feat: support proxy_url in CPA auth files

This commit is contained in:
shiuing
2026-03-20 17:29:49 +08:00
parent 0059cf97bd
commit fbf7e41b25
11 changed files with 110 additions and 15 deletions

View File

@@ -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 无痕模式

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 已处理,此处兜底)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -1224,19 +1224,20 @@ async function loadCpaServices() {
const services = await api.get('/cpa-services');
renderCpaServicesTable(services);
} catch (e) {
elements.cpaServicesTable.innerHTML = `<tr><td colspan="5" style="text-align:center;color:var(--danger-color);">${e.message}</td></tr>`;
elements.cpaServicesTable.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--danger-color);">${e.message}</td></tr>`;
}
}
function renderCpaServicesTable(services) {
if (!services || services.length === 0) {
elements.cpaServicesTable.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">暂无 CPA 服务,点击「添加服务」新增</td></tr>';
elements.cpaServicesTable.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-muted);padding:20px;">暂无 CPA 服务,点击「添加服务」新增</td></tr>';
return;
}
elements.cpaServicesTable.innerHTML = services.map(s => `
<tr>
<td>${escapeHtml(s.name)}</td>
<td style="font-size:0.85rem;color:var(--text-muted);">${escapeHtml(s.api_url)}</td>
<td style="text-align:center;">${s.include_proxy_url ? '🟢' : '⚪'}</td>
<td style="text-align:center;" title="${s.enabled ? '已启用' : '已禁用'}">${s.enabled ? '✅' : '⭕'}</td>
<td style="text-align:center;">${s.priority}</td>
<td style="white-space:nowrap;">
@@ -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) {

View File

@@ -216,13 +216,14 @@
<tr>
<th style="width:150px;">名称</th>
<th>API URL</th>
<th style="width:90px;">代理写入</th>
<th style="width:80px;">状态</th>
<th style="width:60px;text-align:center;">优先级</th>
<th style="width:220px;">操作</th>
</tr>
</thead>
<tbody id="cpa-services-table">
<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">加载中...</td></tr>
<tr><td colspan="6" style="text-align:center;color:var(--text-muted);padding:20px;">加载中...</td></tr>
</tbody>
</table>
</div>
@@ -406,6 +407,10 @@
<label style="margin-top:10px;display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="cpa-service-enabled" checked> 启用
</label>
<label style="margin-top:10px;display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="cpa-service-include-proxy-url"> 写入 auth file 的 <code>proxy_url</code>
</label>
<p class="hint">开启后,若账号记录了实际使用代理(含动态代理),上传到 CPA 时会一并写入。</p>
</div>
</div>
<div class="form-actions">

View File

@@ -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"