From 6a5d9064f3c50bc6a002675fdafc804a8468c3d0 Mon Sep 17 00:00:00 2001 From: cnlimiter Date: Wed, 18 Mar 2026 16:22:32 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(tempmail):=20=E4=BC=98=E5=85=88?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=94=A8=E6=88=B7=E9=82=AE=E7=AE=B1api?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E9=82=AE=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/temp_mail.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py index dcd6e33..801ed7d 100644 --- a/src/services/temp_mail.py +++ b/src/services/temp_mail.py @@ -202,16 +202,27 @@ class TempMailService(BaseEmailService): start_time = time.time() seen_mail_ids: set = set() + # 优先使用用户级 JWT,回退到 admin API + cached = self._email_cache.get(email, {}) + jwt = cached.get("jwt") + while time.time() - start_time < timeout: try: - # 使用 admin API 查询邮件,通过 address 参数过滤 - response = self._make_request( - "GET", - "/admin/mails", - params={"limit": 20, "offset": 0, "address": email}, - ) + if jwt: + response = self._make_request( + "GET", + "/user_api/mails", + params={"limit": 20, "offset": 0}, + headers={"x-user-token": jwt, "Content-Type": "application/json", "Accept": "application/json"}, + ) + else: + response = self._make_request( + "GET", + "/admin/mails", + params={"limit": 20, "offset": 0, "address": email}, + ) - # admin/mails 返回格式: {"results": [...], "total": N} + # /user_api/mails 和 /admin/mails 返回格式相同: {"results": [...], "total": N} mails = response.get("results", []) if not isinstance(mails, list): time.sleep(3) From ffd3a81a386d8d937319882eab909c8bcd9b47dd Mon Sep 17 00:00:00 2001 From: cnlimiter Date: Wed, 18 Mar 2026 18:23:04 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(upload):=20#13=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E8=87=B3sub2api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/database/crud.py | 66 +++++++++++++- src/database/models.py | 14 +++ src/web/routes/__init__.py | 2 + src/web/routes/accounts.py | 51 +++++++++++ static/js/accounts.js | 107 ++++++++++++++++++++++ static/js/settings.js | 180 +++++++++++++++++++++++++++++++++++++ templates/accounts.html | 23 +++++ templates/settings.html | 153 ++++++++++++++++++++++--------- 8 files changed, 551 insertions(+), 45 deletions(-) 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 @@ + + + diff --git a/templates/settings.html b/templates/settings.html index 109d52a..eaaeac2 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -38,8 +38,7 @@
- - + @@ -202,12 +201,12 @@
- -
+ +
-
+
-

CPA 服务管理

+

☁️ CPA 服务

@@ -229,6 +228,110 @@
+ + +
+
+

🔗 Sub2API 服务

+ +
+
+
+ + + + + + + + + + + + + +
名称API URL状态优先级操作
加载中...
+
+
+
+ + +
+
+

🚀 Team Manager

+ 配置 Team Manager 账号导入功能 +
+
+
+
+ +

启用后可在账号管理页面将账号导入 Team Manager 平台

+
+
+ + +

Team Manager 平台的 API 地址

+
+
+ + +

Team Manager 平台的认证 Key

+
+
+ + +
+
+
+
+
+ + + @@ -276,44 +379,6 @@
- -
-
-
-

Team Manager 配置

- 配置 Team Manager 账号导入功能 -
-
-
-
- -

启用后可在账号管理页面将账号导入 Team Manager 平台

-
- -
- - -

Team Manager 平台的 API 地址

-
- -
- - -

Team Manager 平台的认证 Key

-
- -
- - -
-
-
-
-
-
From ff2d15ff144b06adc365254bb727b47bab1ed234 Mon Sep 17 00:00:00 2001 From: cnlimiter Date: Wed, 18 Mar 2026 18:34:28 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(config):=20=E5=90=88=E5=B9=B6=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E9=85=8D=E7=BD=AE=E5=B9=B6=E4=BF=AE=E5=A4=8Ddebug?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8B=E6=95=B0=E6=8D=AE=E5=BA=93=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E6=8F=90=E7=A4=BA=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/settings.py | 9 +- src/database/crud.py | 64 ++++++++++ src/database/models.py | 14 +++ src/web/routes/__init__.py | 2 + src/web/routes/payment.py | 41 +++++-- static/js/settings.js | 233 +++++++++++++++++++++++++++---------- templates/settings.html | 74 +++++++++--- 7 files changed, 344 insertions(+), 93 deletions(-) diff --git a/src/config/settings.py b/src/config/settings.py index 5bf4064..d533db9 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -514,7 +514,8 @@ def init_default_settings() -> None: ) print(f"[Settings] 初始化默认设置: {defn.db_key} = {default_value if not defn.is_secret else '***'}") except Exception as e: - print(f"[Settings] 初始化默认设置失败: {e}") + if "未初始化" not in str(e): + print(f"[Settings] 初始化默认设置失败: {e}") def _load_settings_from_db() -> Dict[str, Any]: @@ -549,7 +550,8 @@ def _load_settings_from_db() -> Dict[str, Any]: settings_dict["webui_access_password"] = env_password return settings_dict except Exception as e: - print(f"[Settings] 从数据库加载设置失败: {e},使用默认值") + if "未初始化" not in str(e): + print(f"[Settings] 从数据库加载设置失败: {e},使用默认值") return {name: defn.default_value for name, defn in SETTING_DEFINITIONS.items()} @@ -572,7 +574,8 @@ def _save_settings_to_db(**kwargs) -> None: description=defn.description ) except Exception as e: - print(f"[Settings] 保存设置到数据库失败: {e}") + if "未初始化" not in str(e): + print(f"[Settings] 保存设置到数据库失败: {e}") class Settings(BaseModel): diff --git a/src/database/crud.py b/src/database/crud.py index 98f8761..4750969 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -647,4 +647,68 @@ def delete_sub2api_service(db: Session, service_id: int) -> bool: return False db.delete(svc) db.commit() + return True + + +# ============================================================================ +# Team Manager 服务 CRUD +# ============================================================================ + +def create_tm_service( + db: Session, + name: str, + api_url: str, + api_key: str, + enabled: bool = True, + priority: int = 0, +): + """创建 Team Manager 服务配置""" + from .models import TeamManagerService + svc = TeamManagerService( + 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_tm_service_by_id(db: Session, service_id: int): + """按 ID 获取 Team Manager 服务""" + from .models import TeamManagerService + return db.query(TeamManagerService).filter(TeamManagerService.id == service_id).first() + + +def get_tm_services(db: Session, enabled=None): + """获取 Team Manager 服务列表""" + from .models import TeamManagerService + q = db.query(TeamManagerService) + if enabled is not None: + q = q.filter(TeamManagerService.enabled == enabled) + return q.order_by(TeamManagerService.priority.asc(), TeamManagerService.id.asc()).all() + + +def update_tm_service(db: Session, service_id: int, **kwargs): + """更新 Team Manager 服务配置""" + svc = get_tm_service_by_id(db, service_id) + if not svc: + return None + for k, v in kwargs.items(): + setattr(svc, k, v) + db.commit() + db.refresh(svc) + return svc + + +def delete_tm_service(db: Session, service_id: int) -> bool: + """删除 Team Manager 服务配置""" + svc = get_tm_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 53a7eab..4f871de 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -158,6 +158,20 @@ class Sub2ApiService(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +class TeamManagerService(Base): + """Team Manager 服务配置表""" + __tablename__ = 'tm_services' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) # 服务名称 + api_url = Column(String(500), nullable=False) # API URL + 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 631e2f7..cffe794 100644 --- a/src/web/routes/__init__.py +++ b/src/web/routes/__init__.py @@ -11,6 +11,7 @@ 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 +from .tm_services import router as tm_services_router api_router = APIRouter() @@ -22,3 +23,4 @@ api_router.include_router(email_services_router, prefix="/email-services", tags= 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"]) +api_router.include_router(tm_services_router, prefix="/tm-services", tags=["tm-services"]) diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index 94f5015..049946d 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -11,6 +11,7 @@ from pydantic import BaseModel from ...database.session import get_db from ...database.models import Account +from ...database import crud from ...config.settings import get_settings from .accounts import resolve_account_ids from ...core.payment import ( @@ -61,12 +62,14 @@ class BatchCheckSubscriptionRequest(BaseModel): class UploadTMRequest(BaseModel): proxy: Optional[str] = None # 保留,TM 上传不走代理 + service_id: Optional[int] = None # 指定 TM 服务 ID,不传则使用第一个启用的 class BatchUploadTMRequest(BaseModel): ids: List[int] = [] select_all: bool = False status_filter: Optional[str] = None + service_id: Optional[int] = None # 指定 TM 服务 ID,不传则使用第一个启用的 email_service_filter: Optional[str] = None search_filter: Optional[str] = None @@ -200,14 +203,21 @@ def batch_check_subscription(request: BatchCheckSubscriptionRequest): @router.post("/accounts/{account_id}/upload-tm") def upload_account_tm(account_id: int, request: UploadTMRequest = None): """上传单账号到 Team Manager""" - settings = get_settings() - if not settings.tm_enabled: - raise HTTPException(status_code=400, detail="Team Manager 上传未启用") - - api_url = settings.tm_api_url - api_key = settings.tm_api_key.get_secret_value() if settings.tm_api_key else "" + service_id = request.service_id if request and hasattr(request, 'service_id') else None with get_db() as db: + if service_id: + svc = crud.get_tm_service_by_id(db, service_id) + else: + svcs = crud.get_tm_services(db, enabled=True) + svc = svcs[0] if svcs else None + + if not svc: + raise HTTPException(status_code=400, detail="未找到可用的 Team Manager 服务,请先在设置中配置") + + api_url = svc.api_url + api_key = svc.api_key + account = db.query(Account).filter(Account.id == account_id).first() if not account: raise HTTPException(status_code=404, detail="账号不存在") @@ -219,14 +229,21 @@ def upload_account_tm(account_id: int, request: UploadTMRequest = None): @router.post("/accounts/batch-upload-tm") def batch_upload_tm(request: BatchUploadTMRequest): """批量上传账号到 Team Manager""" - settings = get_settings() - if not settings.tm_enabled: - raise HTTPException(status_code=400, detail="Team Manager 上传未启用") - - api_url = settings.tm_api_url - api_key = settings.tm_api_key.get_secret_value() if settings.tm_api_key else "" + service_id = request.service_id if hasattr(request, 'service_id') else None with get_db() as db: + if service_id: + svc = crud.get_tm_service_by_id(db, service_id) + else: + svcs = crud.get_tm_services(db, enabled=True) + svc = svcs[0] if svcs else None + + if not svc: + raise HTTPException(status_code=400, detail="未找到可用的 Team Manager 服务,请先在设置中配置") + + api_url = svc.api_url + api_key = svc.api_key + ids = resolve_account_ids( db, request.ids, request.select_all, request.status_filter, request.email_service_filter, request.search_filter diff --git a/static/js/settings.js b/static/js/settings.js index 010a400..47f4468 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -57,9 +57,15 @@ const elements = { 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'), + // Team Manager 服务管理 + addTmServiceBtn: document.getElementById('add-tm-service-btn'), + tmServicesTable: document.getElementById('tm-services-table'), + tmServiceEditModal: document.getElementById('tm-service-edit-modal'), + closeTmServiceModal: document.getElementById('close-tm-service-modal'), + cancelTmServiceBtn: document.getElementById('cancel-tm-service-btn'), + tmServiceForm: document.getElementById('tm-service-form'), + tmServiceModalTitle: document.getElementById('tm-service-modal-title'), + testTmServiceBtn: document.getElementById('test-tm-service-btn'), // 验证码设置 emailCodeForm: document.getElementById('email-code-form'), // Outlook 设置 @@ -80,6 +86,7 @@ document.addEventListener('DOMContentLoaded', () => { loadProxies(); loadCpaServices(); loadSub2ApiServices(); + loadTmServices(); initEventListeners(); }); @@ -236,12 +243,26 @@ function initEventListeners() { if (elements.webuiSettingsForm) { elements.webuiSettingsForm.addEventListener('submit', handleSaveWebuiSettings); } - // Team Manager 设置 - if (elements.tmForm) { - elements.tmForm.addEventListener('submit', handleSaveTm); + // Team Manager 服务管理 + if (elements.addTmServiceBtn) { + elements.addTmServiceBtn.addEventListener('click', () => openTmServiceModal()); } - if (elements.testTmBtn) { - elements.testTmBtn.addEventListener('click', handleTestTm); + if (elements.closeTmServiceModal) { + elements.closeTmServiceModal.addEventListener('click', closeTmServiceModal); + } + if (elements.cancelTmServiceBtn) { + elements.cancelTmServiceBtn.addEventListener('click', closeTmServiceModal); + } + if (elements.tmServiceEditModal) { + elements.tmServiceEditModal.addEventListener('click', (e) => { + if (e.target === elements.tmServiceEditModal) closeTmServiceModal(); + }); + } + if (elements.tmServiceForm) { + elements.tmServiceForm.addEventListener('submit', handleSaveTmService); + } + if (elements.testTmServiceBtn) { + elements.testTmServiceBtn.addEventListener('click', handleTestTmService); } // CPA 服务管理 @@ -315,8 +336,6 @@ async function loadSettings() { // 加载 Outlook 设置 loadOutlookSettings(); - // 加载 Team Manager 设置 - loadTmSettings(); // Web UI 访问密码提示 if (data.webui?.has_access_password) { @@ -1030,73 +1049,167 @@ async function handleTestDynamicProxy() { } } -// ============== Team Manager 设置 ============== +// ============== Team Manager 服务管理 ============== -async function loadTmSettings() { +async function loadTmServices() { + if (!elements.tmServicesTable) return; try { - const data = await api.get('/settings/team-manager'); - document.getElementById('tm-enabled').checked = data.enabled || false; - document.getElementById('tm-api-url').value = data.api_url || ''; - document.getElementById('tm-api-key').value = ''; - document.getElementById('tm-api-key').placeholder = data.has_api_key ? '已配置,留空保持不变' : '请输入 API Key'; - } catch (error) { - console.error('加载 Team Manager 设置失败:', error); + const services = await api.get('/tm-services'); + renderTmServicesTable(services); + } catch (e) { + elements.tmServicesTable.innerHTML = `${e.message}`; } } -async function handleSaveTm(e) { +function renderTmServicesTable(services) { + if (!services || services.length === 0) { + elements.tmServicesTable.innerHTML = '暂无 Team Manager 服务,点击「添加服务」新增'; + return; + } + elements.tmServicesTable.innerHTML = services.map(s => ` + + ${escapeHtml(s.name)} + ${escapeHtml(s.api_url)} + + + ${s.enabled ? '启用' : '禁用'} + + + ${s.priority} + + + + + + + `).join(''); +} + +function openTmServiceModal(service = null) { + document.getElementById('tm-service-id').value = service ? service.id : ''; + document.getElementById('tm-service-name').value = service ? service.name : ''; + document.getElementById('tm-service-url').value = service ? service.api_url : ''; + document.getElementById('tm-service-key').value = ''; + document.getElementById('tm-service-priority').value = service ? service.priority : 0; + document.getElementById('tm-service-enabled').checked = service ? service.enabled : true; + if (service) { + document.getElementById('tm-service-key').placeholder = service.has_key ? '已配置,留空保持不变' : '请输入 API Key'; + } else { + document.getElementById('tm-service-key').placeholder = '请输入 API Key'; + } + elements.tmServiceModalTitle.textContent = service ? '编辑 Team Manager 服务' : '添加 Team Manager 服务'; + elements.tmServiceEditModal.classList.add('active'); +} + +function closeTmServiceModal() { + elements.tmServiceEditModal.classList.remove('active'); +} + +async function editTmService(id) { + try { + const service = await api.get(`/tm-services/${id}`); + openTmServiceModal(service); + } catch (e) { + toast.error('获取服务信息失败: ' + e.message); + } +} + +async function handleSaveTmService(e) { e.preventDefault(); - const data = { - enabled: document.getElementById('tm-enabled').checked, - api_url: document.getElementById('tm-api-url').value, - api_key: document.getElementById('tm-api-key').value || '' - }; - try { - await api.post('/settings/team-manager', data); - toast.success('Team Manager 设置已保存'); - loadTmSettings(); - } catch (error) { - toast.error('保存失败: ' + error.message); + const id = document.getElementById('tm-service-id').value; + const name = document.getElementById('tm-service-name').value.trim(); + const apiUrl = document.getElementById('tm-service-url').value.trim(); + const apiKey = document.getElementById('tm-service-key').value.trim(); + const priority = parseInt(document.getElementById('tm-service-priority').value) || 0; + const enabled = document.getElementById('tm-service-enabled').checked; + + if (!name || !apiUrl) { + toast.error('名称和 API URL 不能为空'); + return; } -} - -async function handleTestTm() { - const apiUrl = document.getElementById('tm-api-url').value; - const apiKey = document.getElementById('tm-api-key').value; - - if (!apiUrl) { - toast.error('请先填写 API URL'); + if (!id && !apiKey) { + toast.error('新增服务时 API Key 不能为空'); return; } - let keyToTest = apiKey; - if (!keyToTest) { - const saved = await api.get('/settings/team-manager'); - if (!saved.has_api_key) { - toast.error('请先填写 API Key'); - return; - } - keyToTest = 'use_saved_key'; - } - - elements.testTmBtn.disabled = true; - elements.testTmBtn.innerHTML = ' 测试中...'; - try { - const result = await api.post('/settings/team-manager/test', { - api_url: apiUrl, - api_key: keyToTest - }); + const payload = { name, api_url: apiUrl, priority, enabled }; + if (apiKey) payload.api_key = apiKey; + + if (id) { + await api.patch(`/tm-services/${id}`, payload); + toast.success('服务已更新'); + } else { + payload.api_key = apiKey; + await api.post('/tm-services', payload); + toast.success('服务已添加'); + } + closeTmServiceModal(); + loadTmServices(); + } catch (e) { + toast.error('保存失败: ' + e.message); + } +} + +async function deleteTmService(id, name) { + const confirmed = await confirm(`确定要删除 Team Manager 服务「${name}」吗?`); + if (!confirmed) return; + try { + await api.delete(`/tm-services/${id}`); + toast.success('已删除'); + loadTmServices(); + } catch (e) { + toast.error('删除失败: ' + e.message); + } +} + +async function testTmServiceById(id) { + try { + const result = await api.post(`/tm-services/${id}/test`); if (result.success) { toast.success(result.message); } else { toast.error(result.message); } - } catch (error) { - toast.error('测试失败: ' + error.message); + } catch (e) { + toast.error('测试失败: ' + e.message); + } +} + +async function handleTestTmService() { + const apiUrl = document.getElementById('tm-service-url').value.trim(); + const apiKey = document.getElementById('tm-service-key').value.trim(); + const id = document.getElementById('tm-service-id').value; + + if (!apiUrl) { + toast.error('请先填写 API URL'); + return; + } + if (!id && !apiKey) { + toast.error('请先填写 API Key'); + return; + } + + elements.testTmServiceBtn.disabled = true; + elements.testTmServiceBtn.textContent = '测试中...'; + + try { + let result; + if (id && !apiKey) { + result = await api.post(`/tm-services/${id}/test`); + } else { + result = await api.post('/tm-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.testTmBtn.disabled = false; - elements.testTmBtn.textContent = '🔌 测试连接'; + elements.testTmServiceBtn.disabled = false; + elements.testTmServiceBtn.textContent = '🔌 测试连接'; } } diff --git a/templates/settings.html b/templates/settings.html index eaaeac2..82a89ed 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -255,34 +255,72 @@
- +
-

🚀 Team Manager

- 配置 Team Manager 账号导入功能 +

🚀 Team Manager 服务

+
-
-
+
+
+ + + + + + + + + + + + + +
名称API URL状态优先级操作
加载中...
+
+
+
+
+ + +