From fbf7e41b256953dbef8e4c59adec788172d8545c Mon Sep 17 00:00:00 2001 From: shiuing Date: Fri, 20 Mar 2026 17:29:49 +0800 Subject: [PATCH] feat: support proxy_url in CPA auth files --- README.md | 4 +-- src/core/upload/cpa_upload.py | 26 ++++++++++++++--- src/database/crud.py | 2 ++ src/database/models.py | 1 + src/database/session.py | 1 + src/web/routes/accounts.py | 22 ++++++++++++--- src/web/routes/registration.py | 5 +++- src/web/routes/upload/cpa_services.py | 8 ++++++ static/js/settings.js | 9 ++++-- templates/settings.html | 7 ++++- tests/test_cpa_upload.py | 40 +++++++++++++++++++++++++++ 11 files changed, 110 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 272428f..0c291a4 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ - 多个 CPA 账号打包为 `.zip`,每个账号一个独立文件 - Sub2API 格式所有账号合并为单个 JSON - 上传目标(直连不走代理): - - **CPA**:支持多服务配置,上传时选择目标服务 + - **CPA**:支持多服务配置,上传时选择目标服务,可按服务开关将账号实际代理写入 auth file 的 `proxy_url` - **Sub2API**:支持多服务配置,标准 sub2api-data 格式 - **Team Manager**:支持多服务配置 @@ -367,7 +367,7 @@ docker-compose build --no-cache - 所有账号和设置数据存储在 `data/register.db` - 日志文件写入 `logs/` 目录 - 代理优先级:动态代理 > 代理列表(随机/默认) > 直连 -- CPA / Sub2API / Team Manager 上传始终直连,不走代理 +- CPA / Sub2API / Team Manager 上传始终直连,不走代理;其中 CPA 可选把账号记录的代理写入 auth file 的 `proxy_url` - 注册时自动随机生成用户名和生日(年龄范围 18-45 岁) - 支付链接生成使用账号 access_token 鉴权,走全局代理配置 - 无痕浏览器优先使用 playwright(注入 cookie 直达支付页);未安装时降级为系统 Chrome/Edge 无痕模式 diff --git a/src/core/upload/cpa_upload.py b/src/core/upload/cpa_upload.py index 900cff6..7c5741f 100644 --- a/src/core/upload/cpa_upload.py +++ b/src/core/upload/cpa_upload.py @@ -89,17 +89,23 @@ def _post_cpa_auth_file_raw_json(upload_url: str, filename: str, file_content: b ) -def generate_token_json(account: Account) -> dict: +def generate_token_json( + account: Account, + include_proxy_url: bool = False, + proxy_url: Optional[str] = None, +) -> dict: """ 生成 CPA 格式的 Token JSON Args: account: 账号模型实例 + include_proxy_url: 是否将账号代理写入 auth file 的 proxy_url 字段 + proxy_url: 当账号本身没有记录代理时使用的兜底代理 URL Returns: CPA 格式的 Token 字典 """ - return { + token_data = { "type": "codex", "email": account.email, "expired": account.expires_at.strftime("%Y-%m-%dT%H:%M:%S+08:00") if account.expires_at else "", @@ -110,6 +116,12 @@ def generate_token_json(account: Account) -> dict: "refresh_token": account.refresh_token or "", } + resolved_proxy_url = (getattr(account, "proxy_used", None) or proxy_url or "").strip() + if include_proxy_url and resolved_proxy_url: + token_data["proxy_url"] = resolved_proxy_url + + return token_data + def upload_to_cpa( token_data: dict, @@ -185,15 +197,17 @@ def batch_upload_to_cpa( proxy: str = None, api_url: str = None, api_token: str = None, + include_proxy_url: bool = False, ) -> dict: """ 批量上传账号到 CPA 管理平台 Args: account_ids: 账号 ID 列表 - proxy: 可选的代理 URL + proxy: 可选的代理 URL(用于 auth file proxy_url 的兜底值) api_url: 指定 CPA API URL(优先于全局配置) api_token: 指定 CPA API Token(优先于全局配置) + include_proxy_url: 是否将账号代理写入 auth file 的 proxy_url 字段 Returns: 包含成功/失败统计和详情的字典 @@ -231,7 +245,11 @@ def batch_upload_to_cpa( continue # 生成 Token JSON - token_data = generate_token_json(account) + token_data = generate_token_json( + account, + include_proxy_url=include_proxy_url, + proxy_url=proxy, + ) # 上传 success, message = upload_to_cpa(token_data, proxy, api_url=api_url, api_token=api_token) diff --git a/src/database/crud.py b/src/database/crud.py index 4750969..67d827e 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -527,6 +527,7 @@ def create_cpa_service( api_url: str, api_token: str, enabled: bool = True, + include_proxy_url: bool = False, priority: int = 0 ) -> CpaService: """创建 CPA 服务配置""" @@ -535,6 +536,7 @@ def create_cpa_service( api_url=api_url, api_token=api_token, enabled=enabled, + include_proxy_url=include_proxy_url, priority=priority ) db.add(db_service) diff --git a/src/database/models.py b/src/database/models.py index f662917..216f7d8 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -139,6 +139,7 @@ class CpaService(Base): api_url = Column(String(500), nullable=False) # API URL api_token = Column(Text, nullable=False) # API Token enabled = Column(Boolean, default=True) + include_proxy_url = Column(Boolean, default=False) # 是否将账号代理写入 auth file priority = Column(Integer, default=0) # 优先级 created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/src/database/session.py b/src/database/session.py index c16541b..bb45334 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -111,6 +111,7 @@ class DatabaseSessionManager: ("accounts", "subscription_at", "DATETIME"), ("accounts", "cookies", "TEXT"), ("proxies", "is_default", "BOOLEAN DEFAULT 0"), + ("cpa_services", "include_proxy_url", "BOOLEAN DEFAULT 0"), ] # 确保新表存在(create_tables 已处理,此处兜底) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index a6a597f..7b4aa6f 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -724,11 +724,12 @@ class BatchCPAUploadRequest(BaseModel): async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): """批量上传账号到 CPA""" - proxy = request.proxy if request.proxy else get_settings().proxy_url + proxy = request.proxy # 解析指定的 CPA 服务 cpa_api_url = None cpa_api_token = None + include_proxy_url = False if request.cpa_service_id: with get_db() as db: svc = crud.get_cpa_service_by_id(db, request.cpa_service_id) @@ -736,6 +737,7 @@ async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): raise HTTPException(status_code=404, detail="指定的 CPA 服务不存在") cpa_api_url = svc.api_url cpa_api_token = svc.api_token + include_proxy_url = bool(svc.include_proxy_url) with get_db() as db: ids = resolve_account_ids( @@ -743,7 +745,13 @@ async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): request.status_filter, request.email_service_filter, request.search_filter ) - results = batch_upload_to_cpa(ids, proxy, api_url=cpa_api_url, api_token=cpa_api_token) + results = batch_upload_to_cpa( + ids, + proxy, + api_url=cpa_api_url, + api_token=cpa_api_token, + include_proxy_url=include_proxy_url, + ) return results @@ -751,12 +759,13 @@ async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): async def upload_account_to_cpa(account_id: int, request: Optional[CPAUploadRequest] = Body(default=None)): """上传单个账号到 CPA""" - proxy = request.proxy if request and request.proxy else get_settings().proxy_url + proxy = request.proxy if request else None cpa_service_id = request.cpa_service_id if request else None # 解析指定的 CPA 服务 cpa_api_url = None cpa_api_token = None + include_proxy_url = False if cpa_service_id: with get_db() as db: svc = crud.get_cpa_service_by_id(db, cpa_service_id) @@ -764,6 +773,7 @@ async def upload_account_to_cpa(account_id: int, request: Optional[CPAUploadRequ raise HTTPException(status_code=404, detail="指定的 CPA 服务不存在") cpa_api_url = svc.api_url cpa_api_token = svc.api_token + include_proxy_url = bool(svc.include_proxy_url) with get_db() as db: account = crud.get_account_by_id(db, account_id) @@ -777,7 +787,11 @@ async def upload_account_to_cpa(account_id: int, request: Optional[CPAUploadRequ } # 生成 Token JSON - token_data = generate_token_json(account) + token_data = generate_token_json( + account, + include_proxy_url=include_proxy_url, + proxy_url=proxy, + ) # 上传 success, message = upload_to_cpa(token_data, proxy, api_url=cpa_api_url, api_token=cpa_api_token) diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index be6051b..6f5896f 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -418,7 +418,6 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: from ...database.models import Account as AccountModel saved_account = db.query(AccountModel).filter_by(email=result.email).first() if saved_account and saved_account.access_token: - token_data = generate_token_json(saved_account) _cpa_ids = cpa_service_ids or [] if not _cpa_ids: # 未指定则取所有启用的服务 @@ -430,6 +429,10 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: _svc = crud.get_cpa_service_by_id(db, _sid) if not _svc: continue + token_data = generate_token_json( + saved_account, + include_proxy_url=bool(_svc.include_proxy_url), + ) log_callback(f"[CPA] 上传到服务: {_svc.name}") _ok, _msg = upload_to_cpa(token_data, api_url=_svc.api_url, api_token=_svc.api_token) if _ok: diff --git a/src/web/routes/upload/cpa_services.py b/src/web/routes/upload/cpa_services.py index f98ec2f..51f8c49 100644 --- a/src/web/routes/upload/cpa_services.py +++ b/src/web/routes/upload/cpa_services.py @@ -20,6 +20,7 @@ class CpaServiceCreate(BaseModel): api_url: str api_token: str enabled: bool = True + include_proxy_url: bool = False priority: int = 0 @@ -28,6 +29,7 @@ class CpaServiceUpdate(BaseModel): api_url: Optional[str] = None api_token: Optional[str] = None enabled: Optional[bool] = None + include_proxy_url: Optional[bool] = None priority: Optional[int] = None @@ -37,6 +39,7 @@ class CpaServiceResponse(BaseModel): api_url: str has_token: bool enabled: bool + include_proxy_url: bool priority: int created_at: Optional[str] = None updated_at: Optional[str] = None @@ -57,6 +60,7 @@ def _to_response(svc) -> CpaServiceResponse: api_url=svc.api_url, has_token=bool(svc.api_token), enabled=svc.enabled, + include_proxy_url=bool(svc.include_proxy_url), priority=svc.priority, created_at=svc.created_at.isoformat() if svc.created_at else None, updated_at=svc.updated_at.isoformat() if svc.updated_at else None, @@ -83,6 +87,7 @@ async def create_cpa_service(request: CpaServiceCreate): api_url=request.api_url, api_token=request.api_token, enabled=request.enabled, + include_proxy_url=request.include_proxy_url, priority=request.priority, ) return _to_response(service) @@ -111,6 +116,7 @@ async def get_cpa_service_full(service_id: int): "api_url": service.api_url, "api_token": service.api_token, "enabled": service.enabled, + "include_proxy_url": bool(service.include_proxy_url), "priority": service.priority, } @@ -133,6 +139,8 @@ async def update_cpa_service(service_id: int, request: CpaServiceUpdate): update_data["api_token"] = request.api_token if request.enabled is not None: update_data["enabled"] = request.enabled + if request.include_proxy_url is not None: + update_data["include_proxy_url"] = request.include_proxy_url if request.priority is not None: update_data["priority"] = request.priority diff --git a/static/js/settings.js b/static/js/settings.js index 595f038..7835098 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -1224,19 +1224,20 @@ async function loadCpaServices() { const services = await api.get('/cpa-services'); renderCpaServicesTable(services); } catch (e) { - elements.cpaServicesTable.innerHTML = `${e.message}`; + elements.cpaServicesTable.innerHTML = `${e.message}`; } } function renderCpaServicesTable(services) { if (!services || services.length === 0) { - elements.cpaServicesTable.innerHTML = '暂无 CPA 服务,点击「添加服务」新增'; + elements.cpaServicesTable.innerHTML = '暂无 CPA 服务,点击「添加服务」新增'; return; } elements.cpaServicesTable.innerHTML = services.map(s => ` ${escapeHtml(s.name)} ${escapeHtml(s.api_url)} + ${s.include_proxy_url ? '🟢' : '⚪'} ${s.enabled ? '✅' : '⭕'} ${s.priority} @@ -1255,6 +1256,7 @@ function openCpaServiceModal(service = null) { document.getElementById('cpa-service-token').value = ''; document.getElementById('cpa-service-priority').value = service ? service.priority : 0; document.getElementById('cpa-service-enabled').checked = service ? service.enabled : true; + document.getElementById('cpa-service-include-proxy-url').checked = service ? !!service.include_proxy_url : false; elements.cpaServiceModalTitle.textContent = service ? '编辑 CPA 服务' : '添加 CPA 服务'; elements.cpaServiceEditModal.classList.add('active'); } @@ -1280,6 +1282,7 @@ async function handleSaveCpaService(e) { const apiToken = document.getElementById('cpa-service-token').value.trim(); const priority = parseInt(document.getElementById('cpa-service-priority').value) || 0; const enabled = document.getElementById('cpa-service-enabled').checked; + const includeProxyUrl = document.getElementById('cpa-service-include-proxy-url').checked; if (!name || !apiUrl) { toast.error('名称和 API URL 不能为空'); @@ -1291,7 +1294,7 @@ async function handleSaveCpaService(e) { } try { - const payload = { name, api_url: apiUrl, priority, enabled }; + const payload = { name, api_url: apiUrl, priority, enabled, include_proxy_url: includeProxyUrl }; if (apiToken) payload.api_token = apiToken; if (id) { diff --git a/templates/settings.html b/templates/settings.html index e925ce3..7b18e55 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -216,13 +216,14 @@ 名称 API URL + 代理写入 状态 优先级 操作 - 加载中... + 加载中... @@ -406,6 +407,10 @@ + +

开启后,若账号记录了实际使用代理(含动态代理),上传到 CPA 时会一并写入。

diff --git a/tests/test_cpa_upload.py b/tests/test_cpa_upload.py index 82125bc..0b9035f 100644 --- a/tests/test_cpa_upload.py +++ b/tests/test_cpa_upload.py @@ -1,3 +1,5 @@ +from types import SimpleNamespace + from src.core.upload import cpa_upload @@ -108,3 +110,41 @@ def test_test_cpa_connection_uses_get_and_normalized_url(monkeypatch): assert message == "CPA 连接测试成功" assert calls[0]["url"] == "https://cpa.example.com/v0/management/auth-files" assert calls[0]["kwargs"]["headers"]["Authorization"] == "Bearer token-123" + + +def test_generate_token_json_includes_account_proxy_url_when_enabled(): + account = SimpleNamespace( + email="tester@example.com", + expires_at=None, + id_token="id-token", + account_id="acct-1", + access_token="access-token", + last_refresh=None, + refresh_token="refresh-token", + proxy_used="socks5://127.0.0.1:1080", + ) + + token_data = cpa_upload.generate_token_json(account, include_proxy_url=True) + + assert token_data["proxy_url"] == "socks5://127.0.0.1:1080" + + +def test_generate_token_json_uses_fallback_proxy_when_account_proxy_missing(): + account = SimpleNamespace( + email="tester@example.com", + expires_at=None, + id_token="id-token", + account_id="acct-1", + access_token="access-token", + last_refresh=None, + refresh_token="refresh-token", + proxy_used=None, + ) + + token_data = cpa_upload.generate_token_json( + account, + include_proxy_url=True, + proxy_url="http://proxy.example.com:8080", + ) + + assert token_data["proxy_url"] == "http://proxy.example.com:8080"