diff --git a/src/database/crud.py b/src/database/crud.py
index 2e11508..98f8761 100644
--- a/src/database/crud.py
+++ b/src/database/crud.py
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, desc, asc, func
-from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService
+from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService
# ============================================================================
@@ -583,4 +583,68 @@ def delete_cpa_service(db: Session, service_id: int) -> bool:
return False
db.delete(db_service)
db.commit()
+ return True
+
+
+# ============================================================================
+# Sub2API 服务 CRUD
+# ============================================================================
+
+def create_sub2api_service(
+ db: Session,
+ name: str,
+ api_url: str,
+ api_key: str,
+ enabled: bool = True,
+ priority: int = 0
+) -> Sub2ApiService:
+ """创建 Sub2API 服务配置"""
+ svc = Sub2ApiService(
+ name=name,
+ api_url=api_url,
+ api_key=api_key,
+ enabled=enabled,
+ priority=priority,
+ )
+ db.add(svc)
+ db.commit()
+ db.refresh(svc)
+ return svc
+
+
+def get_sub2api_service_by_id(db: Session, service_id: int) -> Optional[Sub2ApiService]:
+ """按 ID 获取 Sub2API 服务"""
+ return db.query(Sub2ApiService).filter(Sub2ApiService.id == service_id).first()
+
+
+def get_sub2api_services(
+ db: Session,
+ enabled: Optional[bool] = None
+) -> List[Sub2ApiService]:
+ """获取 Sub2API 服务列表"""
+ query = db.query(Sub2ApiService)
+ if enabled is not None:
+ query = query.filter(Sub2ApiService.enabled == enabled)
+ return query.order_by(asc(Sub2ApiService.priority), asc(Sub2ApiService.id)).all()
+
+
+def update_sub2api_service(db: Session, service_id: int, **kwargs) -> Optional[Sub2ApiService]:
+ """更新 Sub2API 服务配置"""
+ svc = get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ return None
+ for key, value in kwargs.items():
+ setattr(svc, key, value)
+ db.commit()
+ db.refresh(svc)
+ return svc
+
+
+def delete_sub2api_service(db: Session, service_id: int) -> bool:
+ """删除 Sub2API 服务配置"""
+ svc = get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ return False
+ db.delete(svc)
+ db.commit()
return True
\ No newline at end of file
diff --git a/src/database/models.py b/src/database/models.py
index 7e4f52f..53a7eab 100644
--- a/src/database/models.py
+++ b/src/database/models.py
@@ -144,6 +144,20 @@ class CpaService(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+class Sub2ApiService(Base):
+ """Sub2API 服务配置表"""
+ __tablename__ = 'sub2api_services'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String(100), nullable=False) # 服务名称
+ api_url = Column(String(500), nullable=False) # API URL (host)
+ api_key = Column(Text, nullable=False) # x-api-key
+ enabled = Column(Boolean, default=True)
+ priority = Column(Integer, default=0) # 优先级
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
class Proxy(Base):
"""代理列表表"""
__tablename__ = 'proxies'
diff --git a/src/web/routes/__init__.py b/src/web/routes/__init__.py
index 3ccd6af..631e2f7 100644
--- a/src/web/routes/__init__.py
+++ b/src/web/routes/__init__.py
@@ -10,6 +10,7 @@ from .settings import router as settings_router
from .email_services import router as email_services_router
from .payment import router as payment_router
from .cpa_services import router as cpa_services_router
+from .sub2api_services import router as sub2api_services_router
api_router = APIRouter()
@@ -20,3 +21,4 @@ api_router.include_router(settings_router, prefix="/settings", tags=["settings"]
api_router.include_router(email_services_router, prefix="/email-services", tags=["email-services"])
api_router.include_router(payment_router, prefix="/payment", tags=["payment"])
api_router.include_router(cpa_services_router, prefix="/cpa-services", tags=["cpa-services"])
+api_router.include_router(sub2api_services_router, prefix="/sub2api-services", tags=["sub2api-services"])
diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py
index e89be82..ba8a45b 100644
--- a/src/web/routes/accounts.py
+++ b/src/web/routes/accounts.py
@@ -785,3 +785,54 @@ async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest):
results = batch_upload_to_cpa(ids, proxy, api_url=cpa_api_url, api_token=cpa_api_token)
return results
+
+
+class BatchSub2ApiUploadRequest(BaseModel):
+ """批量 Sub2API 上传请求"""
+ ids: List[int] = []
+ select_all: bool = False
+ status_filter: Optional[str] = None
+ email_service_filter: Optional[str] = None
+ search_filter: Optional[str] = None
+ service_id: Optional[int] = None # 指定 Sub2API 服务 ID,不传则使用第一个启用的
+ concurrency: int = 3
+ priority: int = 50
+
+
+@router.post("/batch-upload-sub2api")
+async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest):
+ """批量上传账号到 Sub2API"""
+ from ...core.sub2api_upload import batch_upload_to_sub2api
+
+ # 解析指定的 Sub2API 服务
+ api_url = None
+ api_key = None
+ if request.service_id:
+ with get_db() as db:
+ svc = crud.get_sub2api_service_by_id(db, request.service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="指定的 Sub2API 服务不存在")
+ api_url = svc.api_url
+ api_key = svc.api_key
+ else:
+ with get_db() as db:
+ svcs = crud.get_sub2api_services(db, enabled=True)
+ if svcs:
+ api_url = svcs[0].api_url
+ api_key = svcs[0].api_key
+
+ if not api_url or not api_key:
+ raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置")
+
+ with get_db() as db:
+ ids = resolve_account_ids(
+ db, request.ids, request.select_all,
+ request.status_filter, request.email_service_filter, request.search_filter
+ )
+
+ results = batch_upload_to_sub2api(
+ ids, api_url, api_key,
+ concurrency=request.concurrency,
+ priority=request.priority,
+ )
+ return results
diff --git a/static/js/accounts.js b/static/js/accounts.js
index e0f6aab..fbb4751 100644
--- a/static/js/accounts.js
+++ b/static/js/accounts.js
@@ -26,6 +26,7 @@ const elements = {
batchRefreshBtn: document.getElementById('batch-refresh-btn'),
batchValidateBtn: document.getElementById('batch-validate-btn'),
batchUploadCpaBtn: document.getElementById('batch-upload-cpa-btn'),
+ batchUploadSub2ApiBtn: document.getElementById('batch-upload-sub2api-btn'),
batchCheckSubBtn: document.getElementById('batch-check-sub-btn'),
batchUploadTmBtn: document.getElementById('batch-upload-tm-btn'),
batchDeleteBtn: document.getElementById('batch-delete-btn'),
@@ -100,6 +101,9 @@ function initEventListeners() {
// 批量检测订阅
elements.batchCheckSubBtn.addEventListener('click', handleBatchCheckSubscription);
+ // 批量上传Sub2API
+ elements.batchUploadSub2ApiBtn.addEventListener('click', handleBatchUploadSub2Api);
+
// 批量上传TM
elements.batchUploadTmBtn.addEventListener('click', handleBatchUploadTm);
@@ -466,6 +470,7 @@ function updateBatchButtons() {
elements.batchRefreshBtn.disabled = count === 0;
elements.batchValidateBtn.disabled = count === 0;
elements.batchUploadCpaBtn.disabled = count === 0;
+ elements.batchUploadSub2ApiBtn.disabled = count === 0;
elements.batchCheckSubBtn.disabled = count === 0;
elements.batchUploadTmBtn.disabled = count === 0;
elements.exportBtn.disabled = count === 0;
@@ -474,6 +479,7 @@ function updateBatchButtons() {
elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token';
elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token';
elements.batchUploadCpaBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传CPA';
+ elements.batchUploadSub2ApiBtn.textContent = count > 0 ? `🔗 Sub2API (${count})` : '🔗 上传Sub2API';
elements.batchCheckSubBtn.textContent = count > 0 ? `🔍 检测 (${count})` : '🔍 检测订阅';
elements.batchUploadTmBtn.textContent = count > 0 ? `🚀 上传TM (${count})` : '🚀 上传TM';
}
@@ -908,6 +914,107 @@ async function handleBatchCheckSubscription() {
}
}
+// ============== Sub2API 上传 ==============
+
+// 弹出 Sub2API 服务选择框,返回 Promise<{service_id: number|null}|null>
+// null 表示用户取消,{service_id: null} 表示自动选择
+function selectSub2ApiService() {
+ return new Promise(async (resolve) => {
+ const modal = document.getElementById('sub2api-service-modal');
+ const listEl = document.getElementById('sub2api-service-list');
+ const closeBtn = document.getElementById('close-sub2api-modal');
+ const cancelBtn = document.getElementById('cancel-sub2api-modal-btn');
+ const autoBtn = document.getElementById('sub2api-use-auto-btn');
+
+ listEl.innerHTML = '
加载中...
';
+ modal.classList.add('active');
+
+ let services = [];
+ try {
+ services = await api.get('/sub2api-services?enabled=true');
+ } catch (e) {
+ services = [];
+ }
+
+ if (services.length === 0) {
+ listEl.innerHTML = '暂无已启用的 Sub2API 服务,将自动选择第一个
';
+ } else {
+ listEl.innerHTML = services.map(s => `
+
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.api_url)}
+
+
选择
+
+ `).join('');
+
+ listEl.querySelectorAll('.sub2api-service-item').forEach(item => {
+ item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)');
+ item.addEventListener('mouseleave', () => item.style.background = '');
+ item.addEventListener('click', () => {
+ cleanup();
+ resolve({ service_id: parseInt(item.dataset.id) });
+ });
+ });
+ }
+
+ function cleanup() {
+ modal.classList.remove('active');
+ closeBtn.removeEventListener('click', onCancel);
+ cancelBtn.removeEventListener('click', onCancel);
+ autoBtn.removeEventListener('click', onAuto);
+ }
+ function onCancel() { cleanup(); resolve(null); }
+ function onAuto() { cleanup(); resolve({ service_id: null }); }
+
+ closeBtn.addEventListener('click', onCancel);
+ cancelBtn.addEventListener('click', onCancel);
+ autoBtn.addEventListener('click', onAuto);
+ });
+}
+
+// 批量上传到 Sub2API
+async function handleBatchUploadSub2Api() {
+ const count = getEffectiveCount();
+ if (count === 0) return;
+
+ const choice = await selectSub2ApiService();
+ if (choice === null) return; // 用户取消
+
+ const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Sub2API 吗?`);
+ if (!confirmed) return;
+
+ elements.batchUploadSub2ApiBtn.disabled = true;
+ elements.batchUploadSub2ApiBtn.textContent = '上传中...';
+
+ try {
+ const payload = buildBatchPayload();
+ if (choice.service_id != null) payload.service_id = choice.service_id;
+ const result = await api.post('/accounts/batch-upload-sub2api', payload);
+
+ let message = `成功: ${result.success_count}`;
+ if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`;
+ if (result.skipped_count > 0) message += `, 跳过: ${result.skipped_count}`;
+
+ toast.success(message);
+ loadAccounts();
+ } catch (error) {
+ toast.error('批量上传失败: ' + error.message);
+ } finally {
+ updateBatchButtons();
+ }
+}
+
// ============== Team Manager 上传 ==============
// 上传单账号到 Team Manager
diff --git a/static/js/settings.js b/static/js/settings.js
index 022ae3a..010a400 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -48,6 +48,15 @@ const elements = {
cpaServiceForm: document.getElementById('cpa-service-form'),
cpaServiceModalTitle: document.getElementById('cpa-service-modal-title'),
testCpaServiceBtn: document.getElementById('test-cpa-service-btn'),
+ // Sub2API 服务管理
+ addSub2ApiServiceBtn: document.getElementById('add-sub2api-service-btn'),
+ sub2ApiServicesTable: document.getElementById('sub2api-services-table'),
+ sub2ApiServiceEditModal: document.getElementById('sub2api-service-edit-modal'),
+ closeSub2ApiServiceModal: document.getElementById('close-sub2api-service-modal'),
+ cancelSub2ApiServiceBtn: document.getElementById('cancel-sub2api-service-btn'),
+ sub2ApiServiceForm: document.getElementById('sub2api-service-form'),
+ sub2ApiServiceModalTitle: document.getElementById('sub2api-service-modal-title'),
+ testSub2ApiServiceBtn: document.getElementById('test-sub2api-service-btn'),
// Team Manager 设置
tmForm: document.getElementById('tm-form'),
testTmBtn: document.getElementById('test-tm-btn'),
@@ -70,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadDatabaseInfo();
loadProxies();
loadCpaServices();
+ loadSub2ApiServices();
initEventListeners();
});
@@ -255,6 +265,28 @@ function initEventListeners() {
if (elements.testCpaServiceBtn) {
elements.testCpaServiceBtn.addEventListener('click', handleTestCpaService);
}
+
+ // Sub2API 服务管理
+ if (elements.addSub2ApiServiceBtn) {
+ elements.addSub2ApiServiceBtn.addEventListener('click', () => openSub2ApiServiceModal());
+ }
+ if (elements.closeSub2ApiServiceModal) {
+ elements.closeSub2ApiServiceModal.addEventListener('click', closeSub2ApiServiceModal);
+ }
+ if (elements.cancelSub2ApiServiceBtn) {
+ elements.cancelSub2ApiServiceBtn.addEventListener('click', closeSub2ApiServiceModal);
+ }
+ if (elements.sub2ApiServiceEditModal) {
+ elements.sub2ApiServiceEditModal.addEventListener('click', (e) => {
+ if (e.target === elements.sub2ApiServiceEditModal) closeSub2ApiServiceModal();
+ });
+ }
+ if (elements.sub2ApiServiceForm) {
+ elements.sub2ApiServiceForm.addEventListener('submit', handleSaveSub2ApiService);
+ }
+ if (elements.testSub2ApiServiceBtn) {
+ elements.testSub2ApiServiceBtn.addEventListener('click', handleTestSub2ApiService);
+ }
}
// 加载设置
@@ -1230,6 +1262,154 @@ async function handleTestCpaService() {
}
}
+// ============================================================================
+// Sub2API 服务管理
+// ============================================================================
+
+let _sub2apiEditingId = null;
+
+async function loadSub2ApiServices() {
+ try {
+ const services = await api.get('/sub2api-services');
+ renderSub2ApiServices(services);
+ } catch (e) {
+ if (elements.sub2ApiServicesTable) {
+ elements.sub2ApiServicesTable.innerHTML = '| 加载失败 |
';
+ }
+ }
+}
+
+function renderSub2ApiServices(services) {
+ if (!elements.sub2ApiServicesTable) return;
+ if (!services || services.length === 0) {
+ elements.sub2ApiServicesTable.innerHTML = '| 暂无服务,点击"添加服务"按钮添加 |
';
+ return;
+ }
+ elements.sub2ApiServicesTable.innerHTML = services.map(s => `
+
+ | ${escapeHtml(s.name)} |
+ ${escapeHtml(s.api_url)} |
+ ${s.enabled ? '已启用' : '已禁用'} |
+ ${s.priority} |
+
+
+
+
+
+ |
+
+ `).join('');
+}
+
+function openSub2ApiServiceModal(svc = null) {
+ _sub2apiEditingId = svc ? svc.id : null;
+ elements.sub2ApiServiceModalTitle.textContent = svc ? '编辑 Sub2API 服务' : '添加 Sub2API 服务';
+ elements.sub2ApiServiceForm.reset();
+ document.getElementById('sub2api-service-id').value = svc ? svc.id : '';
+ if (svc) {
+ document.getElementById('sub2api-service-name').value = svc.name || '';
+ document.getElementById('sub2api-service-url').value = svc.api_url || '';
+ document.getElementById('sub2api-service-priority').value = svc.priority ?? 0;
+ document.getElementById('sub2api-service-enabled').checked = svc.enabled !== false;
+ document.getElementById('sub2api-service-key').placeholder = svc.has_key ? '已配置,留空保持不变' : '请输入 API Key';
+ }
+ elements.sub2ApiServiceEditModal.classList.add('active');
+}
+
+function closeSub2ApiServiceModal() {
+ elements.sub2ApiServiceEditModal.classList.remove('active');
+ elements.sub2ApiServiceForm.reset();
+ _sub2apiEditingId = null;
+}
+
+async function editSub2ApiService(id) {
+ try {
+ const svc = await api.get(`/sub2api-services/${id}`);
+ openSub2ApiServiceModal(svc);
+ } catch (e) {
+ toast.error('加载失败: ' + e.message);
+ }
+}
+
+async function deleteSub2ApiService(id, name) {
+ if (!confirm(`确认删除 Sub2API 服务「${name}」?`)) return;
+ try {
+ await api.delete(`/sub2api-services/${id}`);
+ toast.success('服务已删除');
+ loadSub2ApiServices();
+ } catch (e) {
+ toast.error('删除失败: ' + e.message);
+ }
+}
+
+async function handleSaveSub2ApiService(e) {
+ e.preventDefault();
+ const id = document.getElementById('sub2api-service-id').value;
+ const data = {
+ name: document.getElementById('sub2api-service-name').value,
+ api_url: document.getElementById('sub2api-service-url').value,
+ api_key: document.getElementById('sub2api-service-key').value || undefined,
+ priority: parseInt(document.getElementById('sub2api-service-priority').value) || 0,
+ enabled: document.getElementById('sub2api-service-enabled').checked,
+ };
+ if (!id && !data.api_key) {
+ toast.error('请填写 API Key');
+ return;
+ }
+ if (!data.api_key) delete data.api_key;
+
+ try {
+ if (id) {
+ await api.patch(`/sub2api-services/${id}`, data);
+ toast.success('服务已更新');
+ } else {
+ await api.post('/sub2api-services', data);
+ toast.success('服务已添加');
+ }
+ closeSub2ApiServiceModal();
+ loadSub2ApiServices();
+ } catch (e) {
+ toast.error('保存失败: ' + e.message);
+ }
+}
+
+async function handleTestSub2ApiService() {
+ const apiUrl = document.getElementById('sub2api-service-url').value.trim();
+ const apiKey = document.getElementById('sub2api-service-key').value.trim();
+ const id = document.getElementById('sub2api-service-id').value;
+
+ if (!apiUrl) {
+ toast.error('请先填写 API URL');
+ return;
+ }
+ if (!id && !apiKey) {
+ toast.error('请先填写 API Key');
+ return;
+ }
+
+ elements.testSub2ApiServiceBtn.disabled = true;
+ elements.testSub2ApiServiceBtn.textContent = '测试中...';
+
+ try {
+ let result;
+ if (id && !apiKey) {
+ result = await api.post(`/sub2api-services/${id}/test`);
+ } else {
+ result = await api.post('/sub2api-services/test-connection', { api_url: apiUrl, api_key: apiKey });
+ }
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (e) {
+ toast.error('测试失败: ' + e.message);
+ } finally {
+ elements.testSub2ApiServiceBtn.disabled = false;
+ elements.testSub2ApiServiceBtn.textContent = '🔌 测试连接';
+ }
+}
+
function escapeHtml(text) {
if (!text) return '';
const d = document.createElement('div');
diff --git a/templates/accounts.html b/templates/accounts.html
index c4705d9..6b0523e 100644
--- a/templates/accounts.html
+++ b/templates/accounts.html
@@ -131,6 +131,9 @@
+
@@ -233,6 +236,26 @@
+
+
+
+
+
+
选择要上传到的 Sub2API 服务,或自动选择第一个启用的服务。
+
+
+
+
+
+