Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
- 多个 CPA 账号打包为 `.zip`,每个账号一个独立文件
- Sub2API 格式所有账号合并为单个 JSON
- 上传目标(直连不走代理):
- **CPA**:支持多服务配置,上传时选择目标服务
- **CPA**:支持多服务配置,上传时选择目标服务,可按服务开关将账号实际代理写入 auth file 的 `proxy_url`
- **Sub2API**:支持多服务配置,标准 sub2api-data 格式
- **Team Manager**:支持多服务配置

Expand Down Expand Up @@ -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 无痕模式
Expand Down
26 changes: 22 additions & 4 deletions src/core/upload/cpa_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "",
Expand All @@ -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,
Expand Down Expand Up @@ -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:
包含成功/失败统计和详情的字典
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/database/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 服务配置"""
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/database/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def migrate_tables(self):
("accounts", "subscription_at", "DATETIME"),
("accounts", "cookies", "TEXT"),
("proxies", "is_default", "BOOLEAN DEFAULT 0"),
("cpa_services", "include_proxy_url", "BOOLEAN DEFAULT 0"),
]

# 确保新表存在(create_tables 已处理,此处兜底)
Expand Down
22 changes: 18 additions & 4 deletions src/web/routes/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,46 +724,56 @@ 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)
if not svc:
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(
db, request.ids, request.select_all,
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


@router.post("/{account_id}/upload-cpa")
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)
if not svc:
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)
Expand All @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion src/web/routes/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
# 未指定则取所有启用的服务
Expand All @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions src/web/routes/upload/cpa_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class CpaServiceCreate(BaseModel):
api_url: str
api_token: str
enabled: bool = True
include_proxy_url: bool = False
priority: int = 0


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


Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
}

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

Expand Down
9 changes: 6 additions & 3 deletions static/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;">
Expand All @@ -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');
}
Expand All @@ -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 不能为空');
Expand All @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion templates/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,14 @@ <h3>☁️ CPA 服务</h3>
<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>
Expand Down Expand Up @@ -406,6 +407,10 @@ <h3 id="cpa-service-modal-title">添加 CPA 服务</h3>
<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">
Expand Down
40 changes: 40 additions & 0 deletions tests/test_cpa_upload.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from types import SimpleNamespace

from src.core.upload import cpa_upload


Expand Down Expand Up @@ -108,3 +110,41 @@ def fake_get(url, **kwargs):
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"
Loading