diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md index a37fdc4d..b6b4f46e 100644 --- a/PR_DESCRIPTION.md +++ b/PR_DESCRIPTION.md @@ -1,46 +1,53 @@ -# PR 说明(codex-console2 -> codex-console) - -## 结论摘要 -本次 PR 基于 `K:\github\codex-console2` 对比原仓库 `K:\github\codex-console`,当前实际代码差异为 **1 项**: -- 删除 GitHub Actions 工作流文件:`.github/workflows/docker-publish.yml` - -除上述文件外,其余同名文件内容一致(按全量哈希比对)。 - -## 修改方案 -### 目标 -- 清理不需要的镜像发布流水线配置,保持当前仓库 CI 行为可控。 - -### 实施内容 -- 移除:`.github/workflows/docker-publish.yml` - -## 涉及文件 -- 删除文件:`.github/workflows/docker-publish.yml` - -## 影响范围 -### 直接影响 -- 仓库将不再触发该文件定义的 Docker 发布工作流。 - -### 间接影响 -- 如果团队仍依赖该 workflow 进行镜像发布,发布链路会中断;需改由其它 workflow 或手动流程执行。 - -## 验证结果 -- 已完成目录级全量比对(`K:\github\codex-console2` vs `K:\github\codex-console`): - - 同名文件:108 - - 同名文件内容差异:0 - - 新增文件:0 - - 删除文件:1(即上述 workflow 文件) - -## 回滚方案 -如需回滚本次变更: -1. 从原仓库 `K:\github\codex-console` 恢复 `.github/workflows/docker-publish.yml`。 -2. 提交回滚 commit 并重新触发 CI 验证。 - -## 风险评估 -- 风险等级:低(仅 CI 配置变更) -- 关注点:确认团队当前是否仍需要该 Docker 发布流水线。 - -## 建议的 PR 标题 -- `chore(ci): remove docker-publish workflow` - -## 建议的 Commit Message -- `chore(ci): remove .github/workflows/docker-publish.yml` +# PR Description + +## Summary + +- add async account-management task routes for token refresh, token validation, subscription checks, and overview refresh +- add a dedicated Codex Auth workbench with batch audit, repair, generate, and export flows +- keep the three existing batch action buttons stable in idle state and document their hover help behavior +- fix local CodeRabbit review findings around domain-slot cleanup, DB rollback/session scope, mailbox binding, and review-doc secret handling + +## User-Facing Changes + +- the accounts page now exposes a separate `Codex Auth` entry button that opens a dedicated workbench modal +- the accounts table includes a `Codex Auth` state column +- Codex Auth workbench actions now support: + - batch audit + - batch repair + - batch artifact generation + - batch ZIP export +- async account operations now report task progress through dedicated task endpoints + +## Verification + +```bash +python3 -m py_compile src/web/routes/accounts.py src/web/routes/payment.py src/core/openai/codex_auth_workbench.py +node --check static/js/accounts.js +uv run python -m pytest -q tests/test_codex_auth_workbench.py tests/test_security_and_task_routes.py +``` + +Result: + +```text +12 passed in 6.15s +``` + +## Real Dev Evidence + +- isolated dev container: `codex-console-codex-auth-dev` +- dev web URL: `http://127.0.0.1:16668` +- copied 4 abnormal accounts into `data-dev` only: `53`, `64`, `65`, `71` +- batch audit result: `1 repairable`, `3 blocked by add-phone` +- batch repair result: account `53` repaired successfully; `64`, `65`, `71` remained blocked +- batch export returned a standard managed `auth.json` ZIP containing only the repaired account artifact + +## Local CodeRabbit + +- first pass produced actionable findings on: + - domain-slot cleanup + - pause timeout handling + - SQLAlchemy rollback/session reuse + - mailbox-to-service binding + - review doc secret exposure +- all findings were fixed locally +- second pass result: `0 comments` diff --git a/README.md b/README.md index 0bcb7dc0..75f40e56 100644 --- a/README.md +++ b/README.md @@ -181,12 +181,28 @@ - Web UI 管理注册任务、账号、支付、自检、邮箱服务、卡池、Auto Team 和日志数据 - 支持单任务、批量任务、自动补货、计划任务、任务暂停 / 继续 / 取消 / 重试 +- 账号管理页支持异步批量刷新 Token、批量验证、批量检测订阅、账号总览刷新和任务状态轮询 +- 账号管理页提供独立的 `Codex Auth` 工作台,可对残缺账号执行审计、严格修复、标准 `auth.json` 生成和 ZIP 导出 - 支持多种邮箱服务接码和自部署邮箱接入 - 支持 CPA、Sub2API、Team Manager、New-API 等上传链路 - 支持 SQLite 和远程 PostgreSQL - 支持打包为 Windows / Linux / macOS 可执行文件 - 更适配当前 OpenAI 注册与登录链路 +## 账号管理页补充说明 + +当前 `账号管理` 页除了基础账号表,还包含几组已经落地的运维能力: + +- `刷新Token`、`验证Token`、`检测订阅`、`总览刷新` 均已改为异步任务模式,支持进度、暂停、继续、取消和结果轮询。 +- 三个批量动作按钮使用悬浮说明气泡,按钮空闲文案保持稳定,不再随勾选数量来回变更。 +- 账号表新增 `Codex Auth` 状态列,可直接看到 `健康`、`可修复`、`受阻`、`缺条件` 等状态。 +- 工具栏中的 `Codex Auth` 会打开独立工作台,而不是把修复动作和日常账号运维按钮混在一起。 +- 工作台内支持四个动作: + - `批量审计`:严格探测账号是否还能走完整 Codex Auth 链路。 + - `批量修复`:只在拿到完整 token bundle 后才判定修复成功。 + - `批量生成`:为已完整账号生成标准 managed `auth.json`。 + - `批量导出`:导出兼容官方 Codex 和 `codex-auth` 的 ZIP。 + ## 环境要求 - Python 3.10+ @@ -195,13 +211,18 @@ ## 安装依赖 ```bash -# 使用 uv(推荐) -uv sync - -# 或使用 pip +# 运行环境建议直接安装 requirements.txt pip install -r requirements.txt + +# 使用 uv 做本地开发 / 测试 +uv sync --extra dev ``` +说明: + +- `requirements.txt` 目前覆盖运行所需完整依赖,适合直接启动服务。 +- `uv sync --extra dev` 适合本地维护、测试和补充开发依赖。 + ## 环境变量配置 可选。复制 `.env.example` 为 `.env` 后按需修改: @@ -258,6 +279,19 @@ codex-console.exe --access-password mypassword [http://127.0.0.1:8000](http://127.0.0.1:8000) +## 最小验证命令 + +```bash +# Python 路由与核心模块语法检查 +python3 -m py_compile src/web/routes/accounts.py src/web/routes/payment.py src/core/openai/codex_auth_workbench.py + +# 前端脚本语法检查 +node --check static/js/accounts.js + +# 账号管理与 Codex Auth 相关测试 +uv run python -m pytest -q tests/test_codex_auth_workbench.py tests/test_security_and_task_routes.py +``` + ## Docker 部署 ### 使用 docker-compose diff --git a/docs/reviews/CR-ACCOUNT-BATCH-ACTIONS-2026-04-02.md b/docs/reviews/CR-ACCOUNT-BATCH-ACTIONS-2026-04-02.md new file mode 100644 index 00000000..c352bc32 --- /dev/null +++ b/docs/reviews/CR-ACCOUNT-BATCH-ACTIONS-2026-04-02.md @@ -0,0 +1,99 @@ +# Account Batch Actions Review + +## Scope + +- Branch: `feature/account-batch-action-tooltips` +- Base: `upstream/main` +- Goal: + - fix the three broken batch action routes on the accounts page + - keep button labels stable in idle state + - replace native `title` hints with hover bubbles shown below the buttons + +## Verification + +### Static Check + +Command: + +```bash +python3 -m py_compile src/web/routes/accounts.py src/web/routes/payment.py +``` + +Result: + +```text +exit code 0 +``` + +### Runtime Check + +Isolated instance: + +- URL: `http://127.0.0.1:16667` +- Access password: set `REVIEW_LOGIN_PASSWORD` in the local shell before running the script + +Command: + +```bash +python3 - <<'PY' +import urllib.parse, urllib.request, http.cookiejar, json +import os +jar = http.cookiejar.CookieJar() +opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) +password = os.environ.get('REVIEW_LOGIN_PASSWORD', '').strip() +if not password: + raise SystemExit('REVIEW_LOGIN_PASSWORD is required') +login_data = urllib.parse.urlencode({'password': password}).encode() +login_req = urllib.request.Request('http://127.0.0.1:16667/login', data=login_data, method='POST') +login_req.add_header('Content-Type', 'application/x-www-form-urlencoded') +login_resp = opener.open(login_req, timeout=10) +print('login_status', login_resp.status) +accounts_resp = opener.open('http://127.0.0.1:16667/accounts', timeout=10) +print('accounts_status', accounts_resp.status) +for path, poll_prefix in [ + ('/api/accounts/batch-refresh/async', '/api/accounts/tasks/'), + ('/api/accounts/batch-validate/async', '/api/accounts/tasks/'), + ('/api/payment/accounts/batch-check-subscription/async', '/api/payment/ops/tasks/'), +]: + req = urllib.request.Request( + f'http://127.0.0.1:16667{path}', + data=json.dumps({'ids': [], 'select_all': True}).encode(), + method='POST', + headers={'Content-Type': 'application/json'}, + ) + resp = opener.open(req, timeout=20) + payload = json.loads(resp.read().decode() or '{}') + task_id = payload.get('id') or payload.get('task_id') + print(path, resp.status, task_id) + if task_id: + poll = opener.open(f'http://127.0.0.1:16667{poll_prefix}{task_id}', timeout=20) + poll_payload = json.loads(poll.read().decode() or '{}') + print(poll_prefix, poll.status, poll_payload.get('status')) +PY +``` + +Result: + +```text +login_status 200 +accounts_status 200 +/api/accounts/batch-refresh/async 200 accounts-batch-refresh-f0b2d40566ba +/api/accounts/tasks/ 200 running +/api/accounts/batch-validate/async 200 accounts-batch-validate-1d5627590eb7 +/api/accounts/tasks/ 200 completed +/api/payment/accounts/batch-check-subscription/async 200 payment-batch-check-subscription-227ec45d862f +/api/payment/ops/tasks/ 200 completed +``` + +### UI Check + +- Hovering `刷新Token` shows a custom bubble below the button +- Hovering `验证Token` shows a custom bubble below the button +- Hovering `检测订阅` shows a custom bubble below the button +- When selection count changes, these three buttons keep stable idle labels + +## Conclusion + +- The broken batch action routes are fixed on this branch +- Hover help now matches the requested interaction model +- No formal environment deployment was required for this review diff --git a/docs/reviews/CR-CODEX-AUTH-WORKBENCH-2026-04-03.md b/docs/reviews/CR-CODEX-AUTH-WORKBENCH-2026-04-03.md new file mode 100644 index 00000000..3b1bdc27 --- /dev/null +++ b/docs/reviews/CR-CODEX-AUTH-WORKBENCH-2026-04-03.md @@ -0,0 +1,59 @@ +# Codex Auth Workbench Review + +## Scope + +- Branch: `feature/codex-auth-workbench` +- Base: `upstream/main` +- Goal: + - 补齐账号管理页异步批处理和 Codex Auth 工作台文档口径 + - 记录当前分支的真实验证证据 + - 记录本地 CodeRabbit 复核结果 + +## Delivered Behavior + +- 账号管理页的 `刷新Token`、`验证Token`、`检测订阅`、`总览刷新` 使用异步任务模型。 +- `Codex Auth` 通过独立工作台入口打开,不再和常规账号运维按钮混排。 +- 工作台支持 `批量审计`、`批量修复`、`批量生成`、`批量导出` 四个动作。 +- 导出结果为标准 managed `auth.json` ZIP,兼容官方 Codex 和 `codex-auth`。 + +## Verification + +### Static Check + +```bash +python3 -m py_compile src/web/routes/accounts.py src/web/routes/payment.py src/core/openai/codex_auth_workbench.py +node --check static/js/accounts.js +``` + +Result: + +```text +exit code 0 +``` + +### Targeted Tests + +```bash +uv run python -m pytest -q tests/test_codex_auth_workbench.py tests/test_security_and_task_routes.py +``` + +Result: + +```text +12 passed in 6.15s +``` + +### Real Dev Evidence + +- Isolated dev service: `http://127.0.0.1:16668` +- Dev database only: copied 4 abnormal accounts `53 / 64 / 65 / 71` +- Batch audit result: `1 repairable`, `3 blocked by add-phone` +- Batch repair result: account `53` repaired successfully; `64 / 65 / 71` stayed blocked +- Batch export result: ZIP only contained the repaired account artifact + +## Local CodeRabbit + +- First pass: found actionable issues around domain slot release, pause timeout, DB rollback, long-held DB session, mailbox binding, and review doc secret exposure +- Fix status: all findings addressed on branch +- Second pass result: `0 comments` +- Reviewed repository path: `/Volumes/Work/code/codex-console` diff --git a/src/config/constants.py b/src/config/constants.py index fc8f7212..775e826e 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -132,6 +132,9 @@ def account_label_to_role_tag(account_label: str) -> str: OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" OAUTH_REDIRECT_URI = "http://localhost:1455/auth/callback" OAUTH_SCOPE = "openid email profile offline_access" +CODEX_OAUTH_REDIRECT_URI = "http://localhost:1455/auth/callback" +CODEX_OAUTH_SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke" +CODEX_OAUTH_ORIGINATOR = "codex_cli_rs" # OpenAI API 端点 OPENAI_API_ENDPOINTS = { diff --git a/src/core/openai/codex_auth_workbench.py b/src/core/openai/codex_auth_workbench.py new file mode 100644 index 00000000..e78c10b8 --- /dev/null +++ b/src/core/openai/codex_auth_workbench.py @@ -0,0 +1,540 @@ +""" +Codex Auth 工作台核心能力 +""" + +from __future__ import annotations + +import json +import re +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +from ...config.constants import ( + CODEX_OAUTH_ORIGINATOR, + CODEX_OAUTH_REDIRECT_URI, + CODEX_OAUTH_SCOPE, + EmailServiceType, +) +from ...config.settings import get_settings +from ...core.register import RegistrationEngine +from ...core.timezone_utils import utcnow_naive +from ...core.utils import get_data_dir +from ...database.models import Account, EmailService +from ...services import create_email_service + + +CODEX_AUTH_EXTRA_KEY = "codex_auth" +CODEX_AUTH_HEALTHY = "healthy" +CODEX_AUTH_REPAIRABLE = "repairable" +CODEX_AUTH_BLOCKED = "blocked" +CODEX_AUTH_MISSING = "missing_prerequisites" +CODEX_AUTH_UNKNOWN = "unknown" +CODEX_AUTH_ARTIFACT_DIRNAME = "codex_auth" +CODEX_AUTH_ADD_PHONE_KEYWORD = "auth.openai.com/add-phone" + + +@dataclass +class CodexAuthStatus: + health: str + generated: bool + export_ready: bool + complete: bool + label: str + reason: str = "" + generated_at: Optional[str] = None + last_audit_at: Optional[str] = None + last_success_at: Optional[str] = None + last_error: str = "" + last_block_reason: str = "" + artifact_path: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "health": self.health, + "generated": self.generated, + "export_ready": self.export_ready, + "complete": self.complete, + "label": self.label, + "reason": self.reason, + "generated_at": self.generated_at, + "last_audit_at": self.last_audit_at, + "last_success_at": self.last_success_at, + "last_error": self.last_error, + "last_block_reason": self.last_block_reason, + "artifact_path": self.artifact_path, + } + + +@dataclass +class CodexAuthResult: + success: bool + email: str = "" + health: str = CODEX_AUTH_UNKNOWN + workspace_id: str = "" + account_id: str = "" + auth_json: Optional[Dict[str, Any]] = None + error_message: str = "" + block_reason: str = "" + logs: List[str] = field(default_factory=list) + + +class CodexAuthError(RuntimeError): + pass + + +def _utc_iso_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _sanitize_slug(value: str) -> str: + text = re.sub(r"[^a-zA-Z0-9._-]+", "-", str(value or "").strip()).strip("-") + return text or "account" + + +def get_codex_auth_extra(account: Account) -> Dict[str, Any]: + extra_data = dict(getattr(account, "extra_data", None) or {}) + payload = extra_data.get(CODEX_AUTH_EXTRA_KEY) + return dict(payload or {}) if isinstance(payload, dict) else {} + + +def update_codex_auth_extra(account: Account, **fields: Any) -> Dict[str, Any]: + extra_data = dict(getattr(account, "extra_data", None) or {}) + payload = get_codex_auth_extra(account) + payload.update({key: value for key, value in fields.items() if value is not None}) + extra_data[CODEX_AUTH_EXTRA_KEY] = payload + account.extra_data = extra_data + return payload + + +def _has_token(value: Optional[str]) -> bool: + return bool(str(value or "").strip()) + + +def _has_session_material(account: Account) -> bool: + session_token = str(getattr(account, "session_token", "") or "").strip() + cookies_text = str(getattr(account, "cookies", "") or "").strip() + return bool(session_token or cookies_text) + + +def build_managed_auth_json(account: Account) -> Dict[str, Any]: + access_token = str(getattr(account, "access_token", "") or "").strip() + refresh_token = str(getattr(account, "refresh_token", "") or "").strip() + id_token = str(getattr(account, "id_token", "") or "").strip() + account_id = str(getattr(account, "account_id", "") or "").strip() + + missing = [] + if not access_token: + missing.append("access_token") + if not refresh_token: + missing.append("refresh_token") + if not id_token: + missing.append("id_token") + if not account_id: + missing.append("account_id") + if missing: + raise CodexAuthError(f"缺少生成 auth.json 所需字段: {', '.join(missing)}") + + return { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": None, + "tokens": { + "id_token": id_token, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + }, + "last_refresh": _utc_iso_now(), + } + + +def get_codex_auth_artifact_dir() -> Path: + artifact_dir = get_data_dir() / CODEX_AUTH_ARTIFACT_DIRNAME + artifact_dir.mkdir(parents=True, exist_ok=True) + return artifact_dir + + +def get_codex_auth_artifact_path(account: Account) -> Path: + safe_name = _sanitize_slug(f"{account.id}-{account.email}") + return get_codex_auth_artifact_dir() / safe_name / "auth.json" + + +def write_codex_auth_artifact(account: Account, auth_json: Dict[str, Any]) -> Path: + artifact_path = get_codex_auth_artifact_path(account) + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text( + json.dumps(auth_json, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return artifact_path + + +def resolve_codex_auth_status(account: Account) -> CodexAuthStatus: + meta = get_codex_auth_extra(account) + complete = all( + [ + _has_token(getattr(account, "access_token", None)), + _has_token(getattr(account, "refresh_token", None)), + _has_token(getattr(account, "id_token", None)), + _has_token(getattr(account, "account_id", None)), + ] + ) + artifact_path = str(meta.get("artifact_path") or "").strip() + generated = bool(meta.get("generated")) and bool(artifact_path) + export_ready = complete + last_error = str(meta.get("last_error") or "").strip() + last_block_reason = str(meta.get("last_block_reason") or "").strip() + + if complete: + return CodexAuthStatus( + health=CODEX_AUTH_HEALTHY, + generated=generated, + export_ready=export_ready, + complete=True, + label="健康", + reason="完整 Managed Auth 可用", + generated_at=str(meta.get("generated_at") or "") or None, + last_audit_at=str(meta.get("last_audit_at") or "") or None, + last_success_at=str(meta.get("last_success_at") or "") or None, + last_error=last_error, + last_block_reason=last_block_reason, + artifact_path=artifact_path, + ) + + if last_block_reason: + return CodexAuthStatus( + health=CODEX_AUTH_BLOCKED, + generated=False, + export_ready=False, + complete=False, + label="受阻", + reason=last_block_reason, + generated_at=str(meta.get("generated_at") or "") or None, + last_audit_at=str(meta.get("last_audit_at") or "") or None, + last_success_at=str(meta.get("last_success_at") or "") or None, + last_error=last_error, + last_block_reason=last_block_reason, + artifact_path=artifact_path, + ) + + missing = [] + if not _has_token(getattr(account, "password", None)): + missing.append("password") + if not _has_session_material(account): + missing.append("session") + if missing: + return CodexAuthStatus( + health=CODEX_AUTH_MISSING, + generated=False, + export_ready=False, + complete=False, + label="缺条件", + reason=f"缺少前置条件: {', '.join(missing)}", + generated_at=str(meta.get("generated_at") or "") or None, + last_audit_at=str(meta.get("last_audit_at") or "") or None, + last_success_at=str(meta.get("last_success_at") or "") or None, + last_error=last_error, + last_block_reason=last_block_reason, + artifact_path=artifact_path, + ) + + return CodexAuthStatus( + health=CODEX_AUTH_REPAIRABLE, + generated=False, + export_ready=False, + complete=False, + label="可修复", + reason="可尝试严格 Codex Auth 修复", + generated_at=str(meta.get("generated_at") or "") or None, + last_audit_at=str(meta.get("last_audit_at") or "") or None, + last_success_at=str(meta.get("last_success_at") or "") or None, + last_error=last_error, + last_block_reason=last_block_reason, + artifact_path=artifact_path, + ) + + +def resolve_email_service_for_account( + account: Account, + email_service_rows: List[EmailService], +) -> Tuple[Optional[Any], str]: + try: + service_type = EmailServiceType(str(account.email_service or "").strip().lower()) + except Exception: + return None, f"未知邮箱服务类型: {account.email_service}" + + enabled_rows = [ + row for row in email_service_rows + if bool(getattr(row, "enabled", False)) + and str(getattr(row, "service_type", "") or "").strip().lower() == service_type.value + ] + enabled_rows.sort(key=lambda item: (int(getattr(item, "priority", 0) or 0), int(getattr(item, "id", 0) or 0))) + if not enabled_rows: + return None, f"未找到可用邮箱服务配置: {service_type.value}" + + def _normalize_email(value: Any) -> str: + return str(value or "").strip().lower() + + def _lookup_candidates(row: EmailService) -> List[str]: + config = dict(getattr(row, "config", {}) or {}) + return [ + _normalize_email(config.get("email")), + _normalize_email(config.get("username")), + _normalize_email(config.get("mailbox")), + _normalize_email(getattr(row, "name", "")), + ] + + selected = None + target_email = _normalize_email(getattr(account, "email", "")) + if target_email: + for row in enabled_rows: + if target_email in _lookup_candidates(row): + selected = row + break + + if selected is None: + selected = enabled_rows[0] + try: + return create_email_service(service_type, dict(selected.config or {}), selected.name), "" + except Exception as exc: + return None, f"创建邮箱服务失败: {exc}" + + +class CodexAuthEngine(RegistrationEngine): + def __init__( + self, + *, + email: str, + password: str, + email_service: Any, + email_service_id: Optional[str] = None, + proxy_url: Optional[str] = None, + callback_logger: Optional[Callable[[str], None]] = None, + ): + super().__init__( + email_service=email_service, + proxy_url=proxy_url, + callback_logger=callback_logger, + ) + self.email = str(email or "").strip().lower() + self.inbox_email = str(email or "").strip() + self.password = str(password or "").strip() + self.email_info = {"email": self.email} + if email_service_id: + self.email_info["service_id"] = str(email_service_id).strip() + + settings = get_settings() + self.oauth_manager = self.oauth_manager.__class__( + client_id=settings.openai_client_id, + auth_url=settings.openai_auth_url, + token_url=settings.openai_token_url, + redirect_uri=CODEX_OAUTH_REDIRECT_URI, + scope=CODEX_OAUTH_SCOPE, + proxy_url=proxy_url, + originator=CODEX_OAUTH_ORIGINATOR, + ) + + def _strict_workspace_id(self) -> str: + workspace_id = str(self._last_validate_otp_workspace_id or "").strip() + if workspace_id: + self._log(f"使用 OTP 返回的 Workspace ID: {workspace_id}") + return workspace_id + workspace_id = str(self._get_workspace_id() or "").strip() + if workspace_id: + self._log(f"Workspace ID: {workspace_id}") + return workspace_id + return "" + + @staticmethod + def _is_add_phone_url(url: str) -> bool: + return CODEX_AUTH_ADD_PHONE_KEYWORD in str(url or "").strip().lower() + + def _build_auth_json(self, token_info: Dict[str, Any]) -> Dict[str, Any]: + access_token = str(token_info.get("access_token") or "").strip() + refresh_token = str(token_info.get("refresh_token") or "").strip() + id_token = str(token_info.get("id_token") or "").strip() + account_id = str(token_info.get("account_id") or "").strip() + if not all([access_token, refresh_token, id_token, account_id]): + raise CodexAuthError("OAuth 回调未返回完整 token bundle") + return { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": None, + "tokens": { + "id_token": id_token, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + }, + "last_refresh": _utc_iso_now(), + } + + def run(self) -> CodexAuthResult: + result = CodexAuthResult(success=False, email=self.email, logs=self.logs) + try: + did, sen_token = self._prepare_authorize_flow("Codex Auth") + if not did: + result.error_message = "获取 Device ID 失败" + result.health = CODEX_AUTH_MISSING + return result + if not sen_token: + result.error_message = "Sentinel 验证失败" + result.health = CODEX_AUTH_UNKNOWN + return result + + login_start_result = self._submit_login_start(did, sen_token) + if not login_start_result.success: + result.error_message = f"提交登录入口失败: {login_start_result.error_message}" + result.health = CODEX_AUTH_UNKNOWN + return result + + page_type = str(login_start_result.page_type or "").strip() + if page_type == "login_password": + password_result = self._submit_login_password() + if not password_result.success: + result.error_message = f"提交登录密码失败: {password_result.error_message}" + result.health = CODEX_AUTH_UNKNOWN + return result + if not password_result.is_existing_account: + result.error_message = f"未进入邮箱验证码页: {password_result.page_type or 'unknown'}" + result.health = CODEX_AUTH_UNKNOWN + return result + elif page_type != "email_otp_verification": + result.error_message = f"登录入口返回未知页面: {page_type or 'unknown'}" + result.health = CODEX_AUTH_UNKNOWN + return result + + if not self._verify_email_otp_with_retry(stage_label="Codex Auth 验证码", max_attempts=3, fetch_timeout=120): + result.error_message = "验证码校验失败" + result.health = CODEX_AUTH_UNKNOWN + return result + + otp_continue = str(self._last_validate_otp_continue_url or "").strip() + if self._is_add_phone_url(otp_continue): + result.error_message = "OTP 后命中 add-phone 门控" + result.block_reason = "OTP 后进入 add-phone,未放行到 workspace" + result.health = CODEX_AUTH_BLOCKED + return result + + workspace_id = self._strict_workspace_id() + if not workspace_id: + result.error_message = "获取 Workspace ID 失败" + result.block_reason = "OTP 后未获取到 workspace" + result.health = CODEX_AUTH_BLOCKED if self._is_add_phone_url(otp_continue) else CODEX_AUTH_UNKNOWN + return result + result.workspace_id = workspace_id + + continue_url = str(self._select_workspace(workspace_id) or "").strip() + if not continue_url: + continue_url = otp_continue + if continue_url: + self._log("workspace/select 未返回 continue_url,改用 OTP 缓存继续", "warning") + if not continue_url: + result.error_message = "获取 continue_url 失败" + result.health = CODEX_AUTH_UNKNOWN + return result + + callback_url, final_url = self._follow_redirects(continue_url) + if self._is_add_phone_url(final_url): + result.error_message = "重定向阶段命中 add-phone 门控" + result.block_reason = "重定向阶段进入 add-phone,未命中 OAuth callback" + result.health = CODEX_AUTH_BLOCKED + return result + if not callback_url: + result.error_message = "未获取到 OAuth callback" + result.health = CODEX_AUTH_UNKNOWN + return result + + token_info = self._handle_oauth_callback(callback_url) + if not token_info: + result.error_message = "OAuth callback 处理失败" + result.health = CODEX_AUTH_UNKNOWN + return result + + auth_json = self._build_auth_json(token_info) + result.success = True + result.health = CODEX_AUTH_HEALTHY + result.workspace_id = workspace_id + result.account_id = str(token_info.get("account_id") or "").strip() + result.auth_json = auth_json + return result + except Exception as exc: + result.error_message = str(exc) + result.health = CODEX_AUTH_UNKNOWN + return result + finally: + try: + self.http_client.close() + except Exception: + pass + + +def persist_codex_auth_success(account: Account, result: CodexAuthResult) -> Path: + if not result.auth_json: + raise CodexAuthError("缺少 auth.json,无法落盘") + + tokens = result.auth_json.get("tokens") or {} + account.access_token = str(tokens.get("access_token") or "").strip() + account.refresh_token = str(tokens.get("refresh_token") or "").strip() + account.id_token = str(tokens.get("id_token") or "").strip() + account.account_id = str(tokens.get("account_id") or getattr(account, "account_id", "") or "").strip() + if result.workspace_id: + account.workspace_id = result.workspace_id + account.last_refresh = utcnow_naive() + + artifact_path = write_codex_auth_artifact(account, result.auth_json) + update_codex_auth_extra( + account, + health=CODEX_AUTH_HEALTHY, + generated=True, + generated_at=_utc_iso_now(), + last_audit_at=_utc_iso_now(), + last_success_at=_utc_iso_now(), + last_error="", + last_block_reason="", + artifact_path=str(artifact_path), + ) + return artifact_path + + +def persist_codex_auth_generated_artifact(account: Account, auth_json: Dict[str, Any]) -> Path: + artifact_path = write_codex_auth_artifact(account, auth_json) + update_codex_auth_extra( + account, + health=CODEX_AUTH_HEALTHY, + generated=True, + generated_at=_utc_iso_now(), + last_success_at=str(get_codex_auth_extra(account).get("last_success_at") or "") or None, + last_error="", + last_block_reason="", + artifact_path=str(artifact_path), + ) + return artifact_path + + +def persist_codex_auth_audit(account: Account, *, health: str, error_message: str = "", block_reason: str = "") -> None: + update_codex_auth_extra( + account, + health=health, + last_audit_at=_utc_iso_now(), + last_error=str(error_message or "").strip(), + last_block_reason=str(block_reason or "").strip(), + ) + + +def build_codex_auth_zip_entries(accounts: List[Account]) -> List[Tuple[str, bytes]]: + entries: List[Tuple[str, bytes]] = [] + for account in accounts: + try: + auth_json = build_managed_auth_json(account) + except Exception: + continue + filename = f"{_sanitize_slug(account.email)}/auth.json" + entries.append( + ( + filename, + json.dumps(auth_json, ensure_ascii=False, indent=2).encode("utf-8"), + ) + ) + return entries diff --git a/src/core/openai/oauth.py b/src/core/openai/oauth.py index e8dc0fa6..e2bc9282 100644 --- a/src/core/openai/oauth.py +++ b/src/core/openai/oauth.py @@ -190,7 +190,8 @@ def generate_oauth_url( *, redirect_uri: str = OAUTH_REDIRECT_URI, scope: str = OAUTH_SCOPE, - client_id: str = OAUTH_CLIENT_ID + client_id: str = OAUTH_CLIENT_ID, + originator: str = "", ) -> OAuthStart: """ 生成 OAuth 授权 URL @@ -219,6 +220,8 @@ def generate_oauth_url( "id_token_add_organizations": "true", "codex_cli_simplified_flow": "true", } + if str(originator or "").strip(): + params["originator"] = str(originator).strip() auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}" return OAuthStart( auth_url=auth_url, @@ -321,7 +324,8 @@ def __init__( token_url: str = OAUTH_TOKEN_URL, redirect_uri: str = OAUTH_REDIRECT_URI, scope: str = OAUTH_SCOPE, - proxy_url: Optional[str] = None + proxy_url: Optional[str] = None, + originator: str = "", ): self.client_id = client_id self.auth_url = auth_url @@ -329,13 +333,15 @@ def __init__( self.redirect_uri = redirect_uri self.scope = scope self.proxy_url = proxy_url + self.originator = str(originator or "").strip() def start_oauth(self) -> OAuthStart: """开始 OAuth 流程""" return generate_oauth_url( redirect_uri=self.redirect_uri, scope=self.scope, - client_id=self.client_id + client_id=self.client_id, + originator=self.originator, ) def handle_callback( @@ -367,4 +373,4 @@ def extract_account_info(self, id_token: str) -> Dict[str, Any]: "email": email, "account_id": account_id, "claims": claims - } \ No newline at end of file + } diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 104e27a3..42027dbf 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -7,19 +7,35 @@ import logging import re import threading +import time +import uuid import zipfile import base64 from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body from fastapi.responses import StreamingResponse -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import func from ...config.constants import AccountStatus from ...config.settings import get_settings +from ...core.openai.codex_auth_workbench import ( + CODEX_AUTH_BLOCKED, + CODEX_AUTH_HEALTHY, + CODEX_AUTH_REPAIRABLE, + CodexAuthEngine, + build_codex_auth_zip_entries, + build_managed_auth_json, + persist_codex_auth_generated_artifact, + persist_codex_auth_audit, + persist_codex_auth_success, + resolve_codex_auth_status, + resolve_email_service_for_account, + update_codex_auth_extra, +) from ...core.openai.overview import fetch_codex_overview, AccountDeactivatedError from ...core.openai.token_refresh import refresh_account_token as do_refresh from ...core.openai.token_refresh import validate_account_token as do_validate @@ -31,7 +47,7 @@ from ...core.dynamic_proxy import get_proxy_url_for_task from ...core.timezone_utils import utcnow_naive from ...database import crud -from ...database.models import Account +from ...database.models import Account, EmailService from ...database.session import get_db from ..task_manager import task_manager @@ -50,6 +66,995 @@ ) _QUICK_REFRESH_WORKFLOW_LOCK = threading.Lock() +_ACCOUNT_TASK_POLL_INTERVAL_SECONDS = 0.25 + + +def _account_task_id(task_type: str) -> str: + normalized = re.sub(r"[^a-z0-9]+", "-", str(task_type or "").strip().lower()).strip("-") or "task" + return f"accounts-{normalized}-{uuid.uuid4().hex[:12]}" + + +def _get_account_task_or_404(task_id: str) -> Dict[str, Any]: + snapshot = task_manager.get_domain_task("accounts", task_id) + if not snapshot: + raise HTTPException(status_code=404, detail="任务不存在") + return snapshot + + +def _wait_account_task_if_paused(task_id: str) -> bool: + while True: + snapshot = task_manager.get_domain_task("accounts", task_id) or {} + if bool(snapshot.get("cancel_requested")): + return False + if not bool(snapshot.get("pause_requested")): + return True + task_manager.update_domain_task( + "accounts", + task_id, + status="paused", + paused=True, + message="任务已暂停,等待继续", + ) + time.sleep(_ACCOUNT_TASK_POLL_INTERVAL_SECONDS) + + +def _finalize_account_async_task( + task_id: str, + *, + status: str, + message: str, + result: Dict[str, Any], + error: Optional[str] = None, +) -> None: + task_manager.update_domain_task( + "accounts", + task_id, + status=status, + paused=False, + pause_requested=False, + finished_at=datetime.utcnow().isoformat(), + message=message, + error=error, + result=result, + ) + task_manager.release_domain_slot("accounts", task_id) + + +def _submit_account_async_task(task_id: str, runner, payload: Dict[str, Any]) -> None: + try: + task_manager.executor.submit(runner, task_id, payload) + except Exception as exc: + logger.exception("提交 accounts 异步任务失败: task_id=%s error=%s", task_id, exc) + task_manager.update_domain_task( + "accounts", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=f"任务提交失败: {exc}", + error=str(exc), + ) + raise HTTPException(status_code=500, detail="任务提交失败") from exc + + +def _codex_auth_task_id(task_type: str) -> str: + normalized = re.sub(r"[^a-z0-9]+", "-", str(task_type or "").strip().lower()).strip("-") or "task" + return f"codex-auth-{normalized}-{uuid.uuid4().hex[:12]}" + + +def _get_codex_auth_task_or_404(task_id: str) -> Dict[str, Any]: + snapshot = task_manager.get_domain_task("codex_auth", task_id) + if not snapshot: + raise HTTPException(status_code=404, detail="任务不存在") + return snapshot + + +def _wait_codex_auth_task_if_paused(task_id: str) -> bool: + while True: + snapshot = task_manager.get_domain_task("codex_auth", task_id) or {} + if bool(snapshot.get("cancel_requested")): + return False + if not bool(snapshot.get("pause_requested")): + return True + task_manager.update_domain_task( + "codex_auth", + task_id, + status="paused", + paused=True, + message="Codex Auth 任务已暂停,等待继续", + ) + time.sleep(_ACCOUNT_TASK_POLL_INTERVAL_SECONDS) + + +def _finalize_codex_auth_async_task( + task_id: str, + *, + status: str, + message: str, + result: Dict[str, Any], + error: Optional[str] = None, +) -> None: + task_manager.update_domain_task( + "codex_auth", + task_id, + status=status, + paused=False, + pause_requested=False, + finished_at=datetime.utcnow().isoformat(), + message=message, + error=error, + result=result, + ) + task_manager.release_domain_slot("codex_auth", task_id) + + +def _submit_codex_auth_async_task(task_id: str, runner, payload: Dict[str, Any]) -> None: + try: + task_manager.executor.submit(runner, task_id, payload) + except Exception as exc: + logger.exception("提交 codex_auth 异步任务失败: task_id=%s error=%s", task_id, exc) + task_manager.update_domain_task( + "codex_auth", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=f"任务提交失败: {exc}", + error=str(exc), + ) + raise HTTPException(status_code=500, detail="任务提交失败") from exc + + +def _resolve_codex_auth_accounts( + db, + *, + ids: List[int], + select_all: bool, + status_filter: Optional[str], + email_service_filter: Optional[str], + search_filter: Optional[str], +) -> List[Account]: + resolved_ids = resolve_account_ids( + db, + ids, + select_all, + status_filter, + email_service_filter, + search_filter, + ) + if not resolved_ids: + return [] + return db.query(Account).filter(Account.id.in_(resolved_ids)).order_by(Account.created_at.desc()).all() + + +def _resolve_codex_auth_proxy(account: Account, request_proxy: Optional[str]) -> Optional[str]: + if str(request_proxy or "").strip(): + return _get_proxy(request_proxy) + return _get_proxy(str(account.proxy_used or "").strip() or None) + + +def _run_codex_auth_online_probe( + db, + account: Account, + *, + request_proxy: Optional[str] = None, +) -> Tuple[Optional[Dict[str, Any]], str]: + status = resolve_codex_auth_status(account) + if status.complete: + persist_codex_auth_audit(account, health=CODEX_AUTH_HEALTHY) + db.commit() + return ( + { + "id": account.id, + "email": account.email, + "success": True, + "health": CODEX_AUTH_HEALTHY, + "label": status.label, + "reason": status.reason, + "action": "static", + }, + "", + ) + + if status.health not in {CODEX_AUTH_REPAIRABLE, CODEX_AUTH_BLOCKED}: + persist_codex_auth_audit(account, health=status.health, error_message=status.reason, block_reason=status.last_block_reason) + db.commit() + return ( + { + "id": account.id, + "email": account.email, + "success": False, + "health": status.health, + "label": status.label, + "reason": status.reason, + "action": "static", + }, + "", + ) + + email_services = db.query(EmailService).filter(EmailService.enabled.is_(True)).all() + email_service, error_message = resolve_email_service_for_account(account, email_services) + if not email_service: + persist_codex_auth_audit(account, health="missing_prerequisites", error_message=error_message) + db.commit() + return ( + { + "id": account.id, + "email": account.email, + "success": False, + "health": "missing_prerequisites", + "label": "缺条件", + "reason": error_message, + "action": "static", + }, + "", + ) + + engine = CodexAuthEngine( + email=account.email, + password=str(account.password or "").strip(), + email_service=email_service, + email_service_id=str(account.email_service_id or "").strip() or None, + proxy_url=_resolve_codex_auth_proxy(account, request_proxy), + ) + probe_result = engine.run() + if probe_result.success: + persist_codex_auth_audit(account, health=CODEX_AUTH_REPAIRABLE) + db.commit() + return ( + { + "id": account.id, + "email": account.email, + "success": True, + "health": CODEX_AUTH_REPAIRABLE, + "label": "可修复", + "reason": "严格 Codex Auth 探测成功", + "action": "probe", + "workspace_id": probe_result.workspace_id, + "account_id": probe_result.account_id, + }, + "", + ) + + health = probe_result.health or "unknown" + persist_codex_auth_audit( + account, + health=health, + error_message=probe_result.error_message, + block_reason=probe_result.block_reason, + ) + db.commit() + return ( + { + "id": account.id, + "email": account.email, + "success": False, + "health": health, + "label": "受阻" if health == CODEX_AUTH_BLOCKED else "未知", + "reason": probe_result.block_reason or probe_result.error_message or "严格 Codex Auth 探测失败", + "action": "probe", + }, + "", + ) + + +def _run_batch_codex_auth_audit_async(task_id: str, request_data: Dict[str, Any]) -> None: + acquired, running, quota = task_manager.try_acquire_domain_slot("codex_auth", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + task_manager.update_domain_task( + "codex_auth", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=reason, + error=reason, + ) + return + + request = BatchCodexAuthRequest(**request_data) + result = {"success_count": 0, "failed_count": 0, "details": []} + + try: + with get_db() as db: + accounts = _resolve_codex_auth_accounts( + db, + ids=request.ids, + select_all=request.select_all, + status_filter=request.status_filter, + email_service_filter=request.email_service_filter, + search_filter=request.search_filter, + ) + account_ids = [account.id for account in accounts] + + total = len(account_ids) + task_manager.update_domain_task( + "codex_auth", + task_id, + status="running", + started_at=datetime.utcnow().isoformat(), + paused=False, + message="Codex Auth 批量审计执行中", + progress={"completed": 0, "total": total}, + ) + + for index, account_id in enumerate(account_ids, start=1): + if task_manager.is_domain_task_cancel_requested("codex_auth", task_id): + _finalize_codex_auth_async_task(task_id, status="cancelled", message="Codex Auth 审计已取消", result=result) + return + if not _wait_codex_auth_task_if_paused(task_id): + _finalize_codex_auth_async_task(task_id, status="cancelled", message="Codex Auth 审计已取消", result=result) + return + + task_manager.update_domain_task( + "codex_auth", + task_id, + status="running", + paused=False, + message=f"正在审计第 {index}/{total} 个账号", + ) + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + detail = { + "id": account_id, + "success": False, + "health": "unknown", + "reason": "账号不存在", + } + else: + detail, _ = _run_codex_auth_online_probe(db, account, request_proxy=request.proxy) + detail = detail or { + "id": account.id, + "email": account.email, + "success": False, + "health": "unknown", + "reason": "审计执行失败", + } + if detail.get("success"): + result["success_count"] += 1 + else: + result["failed_count"] += 1 + result["details"].append(detail) + task_manager.append_domain_task_detail("codex_auth", task_id, detail) + task_manager.set_domain_task_progress("codex_auth", task_id, completed=index, total=total) + + _finalize_codex_auth_async_task( + task_id, + status="completed", + message=f"Codex Auth 审计完成:可继续 {result['success_count']},失败 {result['failed_count']}", + result=result, + ) + except Exception as exc: + logger.exception("Codex Auth 审计异步任务失败: task_id=%s error=%s", task_id, exc) + _finalize_codex_auth_async_task( + task_id, + status="failed", + message=f"Codex Auth 审计异常: {exc}", + error=str(exc), + result=result, + ) + + +def _run_batch_codex_auth_generate_async(task_id: str, request_data: Dict[str, Any]) -> None: + acquired, running, quota = task_manager.try_acquire_domain_slot("codex_auth", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + task_manager.update_domain_task( + "codex_auth", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=reason, + error=reason, + ) + return + + request = BatchCodexAuthRequest(**request_data) + result = {"success_count": 0, "failed_count": 0, "details": []} + + try: + with get_db() as db: + accounts = _resolve_codex_auth_accounts( + db, + ids=request.ids, + select_all=request.select_all, + status_filter=request.status_filter, + email_service_filter=request.email_service_filter, + search_filter=request.search_filter, + ) + total = len(accounts) + task_manager.update_domain_task( + "codex_auth", + task_id, + status="running", + started_at=datetime.utcnow().isoformat(), + paused=False, + message="Codex Auth 生成任务执行中", + progress={"completed": 0, "total": total}, + ) + + for index, account in enumerate(accounts, start=1): + if task_manager.is_domain_task_cancel_requested("codex_auth", task_id): + _finalize_codex_auth_async_task(task_id, status="cancelled", message="Codex Auth 生成已取消", result=result) + return + if not _wait_codex_auth_task_if_paused(task_id): + _finalize_codex_auth_async_task(task_id, status="cancelled", message="Codex Auth 生成已取消", result=result) + return + + detail: Dict[str, Any] = {"id": account.id, "email": account.email, "success": False} + try: + auth_json = build_managed_auth_json(account) + artifact_path = persist_codex_auth_generated_artifact(account, auth_json) + db.commit() + detail.update( + { + "success": True, + "health": CODEX_AUTH_HEALTHY, + "reason": "标准 auth.json 已生成", + "artifact_path": str(artifact_path), + } + ) + result["success_count"] += 1 + except Exception as exc: + db.rollback() + status = resolve_codex_auth_status(account) + update_codex_auth_extra( + account, + health=status.health, + last_error=str(exc), + ) + db.commit() + detail.update( + { + "health": status.health, + "reason": str(exc), + } + ) + result["failed_count"] += 1 + + result["details"].append(detail) + task_manager.append_domain_task_detail("codex_auth", task_id, detail) + task_manager.set_domain_task_progress("codex_auth", task_id, completed=index, total=total) + + _finalize_codex_auth_async_task( + task_id, + status="completed", + message=f"Codex Auth 生成完成:成功 {result['success_count']},失败 {result['failed_count']}", + result=result, + ) + except Exception as exc: + logger.exception("Codex Auth 生成异步任务失败: task_id=%s error=%s", task_id, exc) + _finalize_codex_auth_async_task( + task_id, + status="failed", + message=f"Codex Auth 生成异常: {exc}", + error=str(exc), + result=result, + ) + + +def _run_batch_codex_auth_repair_async(task_id: str, request_data: Dict[str, Any]) -> None: + acquired, running, quota = task_manager.try_acquire_domain_slot("codex_auth", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + task_manager.update_domain_task( + "codex_auth", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=reason, + error=reason, + ) + return + + request = BatchCodexAuthRequest(**request_data) + result = {"success_count": 0, "failed_count": 0, "details": []} + + try: + with get_db() as db: + accounts = _resolve_codex_auth_accounts( + db, + ids=request.ids, + select_all=request.select_all, + status_filter=request.status_filter, + email_service_filter=request.email_service_filter, + search_filter=request.search_filter, + ) + email_services = db.query(EmailService).filter(EmailService.enabled.is_(True)).all() + total = len(accounts) + task_manager.update_domain_task( + "codex_auth", + task_id, + status="running", + started_at=datetime.utcnow().isoformat(), + paused=False, + message="Codex Auth 严格修复执行中", + progress={"completed": 0, "total": total}, + ) + + for index, account in enumerate(accounts, start=1): + if task_manager.is_domain_task_cancel_requested("codex_auth", task_id): + _finalize_codex_auth_async_task(task_id, status="cancelled", message="Codex Auth 修复已取消", result=result) + return + if not _wait_codex_auth_task_if_paused(task_id): + _finalize_codex_auth_async_task(task_id, status="cancelled", message="Codex Auth 修复已取消", result=result) + return + + task_manager.update_domain_task( + "codex_auth", + task_id, + status="running", + paused=False, + message=f"正在修复第 {index}/{total} 个账号", + ) + + detail: Dict[str, Any] = {"id": account.id, "email": account.email, "success": False} + status = resolve_codex_auth_status(account) + if status.complete: + try: + auth_json = build_managed_auth_json(account) + artifact_path = persist_codex_auth_generated_artifact(account, auth_json) + db.commit() + detail.update( + { + "success": True, + "health": CODEX_AUTH_HEALTHY, + "reason": "账号已完整,已补生成 artifact", + "artifact_path": str(artifact_path), + } + ) + result["success_count"] += 1 + except Exception as exc: + db.rollback() + detail.update({"health": status.health, "reason": str(exc)}) + result["failed_count"] += 1 + result["details"].append(detail) + task_manager.append_domain_task_detail("codex_auth", task_id, detail) + task_manager.set_domain_task_progress("codex_auth", task_id, completed=index, total=total) + continue + + email_service, service_error = resolve_email_service_for_account(account, email_services) + if not email_service: + persist_codex_auth_audit(account, health="missing_prerequisites", error_message=service_error) + db.commit() + detail.update({"health": "missing_prerequisites", "reason": service_error}) + result["failed_count"] += 1 + result["details"].append(detail) + task_manager.append_domain_task_detail("codex_auth", task_id, detail) + task_manager.set_domain_task_progress("codex_auth", task_id, completed=index, total=total) + continue + + engine = CodexAuthEngine( + email=account.email, + password=str(account.password or "").strip(), + email_service=email_service, + email_service_id=str(account.email_service_id or "").strip() or None, + proxy_url=_resolve_codex_auth_proxy(account, request.proxy), + ) + repair_result = engine.run() + if repair_result.success: + artifact_path = persist_codex_auth_success(account, repair_result) + db.commit() + detail.update( + { + "success": True, + "health": CODEX_AUTH_HEALTHY, + "reason": "严格 Codex Auth 修复成功", + "workspace_id": repair_result.workspace_id, + "account_id": repair_result.account_id, + "artifact_path": str(artifact_path), + } + ) + result["success_count"] += 1 + else: + persist_codex_auth_audit( + account, + health=repair_result.health or "unknown", + error_message=repair_result.error_message, + block_reason=repair_result.block_reason, + ) + db.commit() + detail.update( + { + "health": repair_result.health or "unknown", + "reason": repair_result.block_reason or repair_result.error_message or "修复失败", + } + ) + result["failed_count"] += 1 + + result["details"].append(detail) + task_manager.append_domain_task_detail("codex_auth", task_id, detail) + task_manager.set_domain_task_progress("codex_auth", task_id, completed=index, total=total) + + _finalize_codex_auth_async_task( + task_id, + status="completed", + message=f"Codex Auth 修复完成:成功 {result['success_count']},失败 {result['failed_count']}", + result=result, + ) + except Exception as exc: + logger.exception("Codex Auth 修复异步任务失败: task_id=%s error=%s", task_id, exc) + _finalize_codex_auth_async_task( + task_id, + status="failed", + message=f"Codex Auth 修复异常: {exc}", + error=str(exc), + result=result, + ) + + +def _run_batch_refresh_async(task_id: str, request_data: Dict[str, Any]) -> None: + acquired, running, quota = task_manager.try_acquire_domain_slot("accounts", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + task_manager.update_domain_task( + "accounts", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=reason, + error=reason, + ) + return + + result = {"success_count": 0, "failed_count": 0, "errors": [], "details": []} + + try: + request = BatchRefreshRequest(**request_data) + proxy = _get_proxy(request.proxy) + 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 + ) + + total = len(ids) + task_manager.update_domain_task( + "accounts", + task_id, + status="running", + started_at=datetime.utcnow().isoformat(), + paused=False, + message="批量刷新 Token 执行中", + progress={"completed": 0, "total": total}, + ) + + for index, account_id in enumerate(ids, start=1): + if task_manager.is_domain_task_cancel_requested("accounts", task_id): + _finalize_account_async_task( + task_id, + status="cancelled", + message="批量刷新已取消", + result=result, + ) + return + if not _wait_account_task_if_paused(task_id): + _finalize_account_async_task( + task_id, + status="cancelled", + message="批量刷新已取消", + result=result, + ) + return + + task_manager.update_domain_task( + "accounts", + task_id, + status="running", + paused=False, + message=f"正在刷新第 {index}/{total} 个账号", + ) + + detail: Dict[str, Any] = {"id": account_id, "success": False} + try: + refresh_result = do_refresh(account_id, proxy) + if refresh_result.success: + result["success_count"] += 1 + detail["success"] = True + detail["status"] = AccountStatus.ACTIVE.value + else: + result["failed_count"] += 1 + detail["error"] = refresh_result.error_message + detail["status"] = AccountStatus.FAILED.value + result["errors"].append({"id": account_id, "error": refresh_result.error_message}) + except Exception as exc: + result["failed_count"] += 1 + detail["error"] = str(exc) + detail["status"] = AccountStatus.FAILED.value + result["errors"].append({"id": account_id, "error": str(exc)}) + + result["details"].append(detail) + task_manager.append_domain_task_detail("accounts", task_id, detail) + task_manager.set_domain_task_progress("accounts", task_id, completed=index, total=total) + + _finalize_account_async_task( + task_id, + status="completed", + message=f"批量刷新完成:成功 {result['success_count']},失败 {result['failed_count']}", + result=result, + ) + except Exception as exc: + logger.exception("批量刷新异步任务失败: task_id=%s error=%s", task_id, exc) + _finalize_account_async_task( + task_id, + status="failed", + message=f"批量刷新异常: {exc}", + result=result, + error=str(exc), + ) + + +def _run_batch_validate_async(task_id: str, request_data: Dict[str, Any]) -> None: + acquired, running, quota = task_manager.try_acquire_domain_slot("accounts", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + task_manager.update_domain_task( + "accounts", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=reason, + error=reason, + ) + return + + result = {"valid_count": 0, "invalid_count": 0, "details": []} + + try: + request = BatchValidateRequest(**request_data) + proxy = _get_proxy(request.proxy) + 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 + ) + + total = len(ids) + task_manager.update_domain_task( + "accounts", + task_id, + status="running", + started_at=datetime.utcnow().isoformat(), + paused=False, + message="批量验证 Token 执行中", + progress={"completed": 0, "total": total}, + ) + + for index, account_id in enumerate(ids, start=1): + if task_manager.is_domain_task_cancel_requested("accounts", task_id): + _finalize_account_async_task( + task_id, + status="cancelled", + message="批量验证已取消", + result=result, + ) + return + if not _wait_account_task_if_paused(task_id): + _finalize_account_async_task( + task_id, + status="cancelled", + message="批量验证已取消", + result=result, + ) + return + + task_manager.update_domain_task( + "accounts", + task_id, + status="running", + paused=False, + message=f"正在验证第 {index}/{total} 个账号", + ) + + detail: Dict[str, Any] = {"id": account_id, "valid": False, "status": AccountStatus.FAILED.value} + try: + is_valid, error = do_validate(account_id, proxy) + detail["valid"] = bool(is_valid) + detail["error"] = error + detail["status"] = AccountStatus.ACTIVE.value if is_valid else AccountStatus.FAILED.value + if is_valid: + result["valid_count"] += 1 + else: + result["invalid_count"] += 1 + except Exception as exc: + try: + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if account and account.status != AccountStatus.FAILED.value: + crud.update_account(db, account_id, status=AccountStatus.FAILED.value) + except Exception: + logger.debug("异步验证写回 failed 状态失败: account_id=%s", account_id, exc_info=True) + detail["error"] = str(exc) + result["invalid_count"] += 1 + + result["details"].append(detail) + task_manager.append_domain_task_detail("accounts", task_id, detail) + task_manager.set_domain_task_progress("accounts", task_id, completed=index, total=total) + + _finalize_account_async_task( + task_id, + status="completed", + message=f"批量验证完成:有效 {result['valid_count']},无效 {result['invalid_count']}", + result=result, + ) + except Exception as exc: + logger.exception("批量验证异步任务失败: task_id=%s error=%s", task_id, exc) + _finalize_account_async_task( + task_id, + status="failed", + message=f"批量验证异常: {exc}", + result=result, + error=str(exc), + ) + + +def _resolve_overview_account_ids(request: "OverviewRefreshRequest") -> List[int]: + 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, + ) + if ids: + return ids + candidates = db.query(Account).filter( + func.lower(Account.subscription_type).in_(PAID_SUBSCRIPTION_TYPES) + ).order_by(Account.created_at.desc()).all() + return [acc.id for acc in candidates if not _is_overview_card_removed(acc)] + + +def _run_overview_refresh_async(task_id: str, request_data: Dict[str, Any]) -> None: + acquired, running, quota = task_manager.try_acquire_domain_slot("accounts", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + task_manager.update_domain_task( + "accounts", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=reason, + error=reason, + ) + return + + result = {"success_count": 0, "failed_count": 0, "details": []} + + try: + request = OverviewRefreshRequest(**request_data) + proxy = _get_proxy(request.proxy) + ids = _resolve_overview_account_ids(request) + total = len(ids) + task_manager.update_domain_task( + "accounts", + task_id, + status="running", + started_at=datetime.utcnow().isoformat(), + paused=False, + message="账号总览刷新执行中", + progress={"completed": 0, "total": total}, + ) + + for index, account_id in enumerate(ids, start=1): + if task_manager.is_domain_task_cancel_requested("accounts", task_id): + _finalize_account_async_task( + task_id, + status="cancelled", + message="账号总览刷新已取消", + result=result, + ) + return + if not _wait_account_task_if_paused(task_id): + _finalize_account_async_task( + task_id, + status="cancelled", + message="账号总览刷新已取消", + result=result, + ) + return + + task_manager.update_domain_task( + "accounts", + task_id, + status="running", + paused=False, + message=f"正在刷新第 {index}/{total} 个总览", + ) + + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + detail: Dict[str, Any] = {"id": account_id, "success": False} + if not account: + result["failed_count"] += 1 + detail["error"] = "账号不存在" + elif (not _is_paid_subscription(account.subscription_type)) or _is_overview_card_removed(account): + detail["error"] = "账号不在 Codex 卡片范围内,已跳过" + else: + account_proxy = (account.proxy_used or "").strip() or proxy + overview, updated = _get_account_overview_data( + db, + account, + force_refresh=request.force, + proxy=account_proxy, + allow_network=True, + ) + if updated: + db.commit() + if overview.get("hourly_quota", {}).get("status") == "unknown" and overview.get("weekly_quota", {}).get("status") == "unknown": + result["failed_count"] += 1 + detail["error"] = overview.get("error") or "未获取到配额数据" + else: + result["success_count"] += 1 + detail["success"] = True + detail["plan_type"] = overview.get("plan_type") + + result["details"].append(detail) + task_manager.append_domain_task_detail("accounts", task_id, detail) + task_manager.set_domain_task_progress("accounts", task_id, completed=index, total=total) + + _finalize_account_async_task( + task_id, + status="completed", + message=f"账号总览刷新完成:成功 {result['success_count']},失败 {result['failed_count']}", + result=result, + ) + except Exception as exc: + logger.exception("账号总览异步刷新失败: task_id=%s error=%s", task_id, exc) + _finalize_account_async_task( + task_id, + status="failed", + message=f"账号总览刷新异常: {exc}", + result=result, + error=str(exc), + ) + + +def cancel_account_async_task(task_id: str) -> Dict[str, Any]: + _get_account_task_or_404(task_id) + snapshot = task_manager.request_domain_task_cancel("accounts", task_id) + return { + "success": True, + "task_id": task_id, + "status": "cancelling", + "task": snapshot, + } + + +def pause_account_async_task(task_id: str) -> Dict[str, Any]: + _get_account_task_or_404(task_id) + snapshot = task_manager.request_domain_task_pause("accounts", task_id) + return { + "success": True, + "task_id": task_id, + "status": "paused", + "task": snapshot, + } + + +def resume_account_async_task(task_id: str) -> Dict[str, Any]: + _get_account_task_or_404(task_id) + snapshot = task_manager.request_domain_task_resume("accounts", task_id) + return { + "success": True, + "task_id": task_id, + "status": "running", + "task": snapshot, + } + + +def retry_account_async_task(task_id: str) -> Dict[str, Any]: + snapshot = _get_account_task_or_404(task_id) + payload = dict(snapshot.get("payload") or {}) + task_type = str(snapshot.get("task_type") or "").strip().lower() + + if task_type == "batch_refresh": + return create_batch_refresh_async_task(BatchRefreshRequest(**payload)) + if task_type == "batch_validate": + return create_batch_validate_async_task(BatchValidateRequest(**payload)) + if task_type == "overview_refresh": + return create_overview_refresh_async_task(OverviewRefreshRequest(**payload)) + raise HTTPException(status_code=400, detail="当前任务类型不支持重试") def _is_retryable_validate_error(error_message: Optional[str]) -> bool: @@ -154,6 +1159,7 @@ class AccountResponse(BaseModel): cookies: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None + codex_auth: Dict[str, Any] = Field(default_factory=dict) model_config = ConfigDict(from_attributes=True) @@ -292,6 +1298,7 @@ def resolve_account_ids( def account_to_response(account: Account) -> AccountResponse: """转换 Account 模型为响应模型""" + codex_auth = resolve_codex_auth_status(account).to_dict() return AccountResponse( id=account.id, email=account.email, @@ -313,6 +1320,7 @@ def account_to_response(account: Account) -> AccountResponse: cookies=account.cookies, created_at=account.created_at.isoformat() if account.created_at else None, updated_at=account.updated_at.isoformat() if account.updated_at else None, + codex_auth=codex_auth, ) @@ -1340,6 +2348,23 @@ async def refresh_accounts_overview(request: OverviewRefreshRequest): return result +@router.post("/overview/refresh/async") +def create_overview_refresh_async_task(request: OverviewRefreshRequest): + payload = request.model_dump() + ids = _resolve_overview_account_ids(request) + task_id = _account_task_id("overview-refresh") + snapshot = task_manager.register_domain_task( + domain="accounts", + task_id=task_id, + task_type="overview_refresh", + payload=payload, + progress={"completed": 0, "total": len(ids)}, + max_retries=3, + ) + _submit_account_async_task(task_id, _run_overview_refresh_async, payload) + return snapshot + + @router.get("/current") async def get_current_account(): """获取当前已切换的账号""" @@ -1540,6 +2565,16 @@ class BatchExportRequest(BaseModel): search_filter: Optional[str] = None +class BatchCodexAuthRequest(BaseModel): + """Codex Auth 批量请求""" + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + proxy: Optional[str] = None + + @router.post("/export/json") async def export_accounts_json(request: BatchExportRequest): """导出账号为 JSON 格式""" @@ -1948,6 +2983,28 @@ async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: B return results +@router.post("/batch-refresh/async") +def create_batch_refresh_async_task(request: BatchRefreshRequest): + payload = request.model_dump() + 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 + ) + + task_id = _account_task_id("batch-refresh") + snapshot = task_manager.register_domain_task( + domain="accounts", + task_id=task_id, + task_type="batch_refresh", + payload=payload, + progress={"completed": 0, "total": len(ids)}, + max_retries=3, + ) + _submit_account_async_task(task_id, _run_batch_refresh_async, payload) + return snapshot + + @router.post("/{account_id}/refresh") async def refresh_account_token(account_id: int, request: Optional[TokenRefreshRequest] = Body(default=None)): """刷新单个账号的 Token""" @@ -2020,6 +3077,28 @@ async def batch_validate_tokens(request: BatchValidateRequest): return _run_batch_validate_tokens(request) +@router.post("/batch-validate/async") +def create_batch_validate_async_task(request: BatchValidateRequest): + payload = request.model_dump() + 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 + ) + + task_id = _account_task_id("batch-validate") + snapshot = task_manager.register_domain_task( + domain="accounts", + task_id=task_id, + task_type="batch_validate", + payload=payload, + progress={"completed": 0, "total": len(ids)}, + max_retries=3, + ) + _submit_account_async_task(task_id, _run_batch_validate_async, payload) + return snapshot + + def run_quick_refresh_workflow(source: str = "manual") -> Dict[str, Any]: if not _QUICK_REFRESH_WORKFLOW_LOCK.acquire(blocking=False): raise RuntimeError("quick_refresh_workflow_busy") @@ -2097,6 +3176,128 @@ async def validate_account_token(account_id: int, request: Optional[TokenValidat } +@router.get("/tasks/{task_id}") +def get_account_async_task(task_id: str): + return _get_account_task_or_404(task_id) + + +@router.post("/codex-auth/audit/async") +def create_codex_auth_audit_task(request: BatchCodexAuthRequest): + payload = request.model_dump() + with get_db() as db: + accounts = _resolve_codex_auth_accounts( + db, + ids=request.ids, + select_all=request.select_all, + status_filter=request.status_filter, + email_service_filter=request.email_service_filter, + search_filter=request.search_filter, + ) + + task_id = _codex_auth_task_id("audit") + snapshot = task_manager.register_domain_task( + domain="codex_auth", + task_id=task_id, + task_type="codex_auth_audit", + payload=payload, + progress={"completed": 0, "total": len(accounts)}, + max_retries=0, + ) + _submit_codex_auth_async_task(task_id, _run_batch_codex_auth_audit_async, payload) + return snapshot + + +@router.post("/codex-auth/generate/async") +def create_codex_auth_generate_task(request: BatchCodexAuthRequest): + payload = request.model_dump() + with get_db() as db: + accounts = _resolve_codex_auth_accounts( + db, + ids=request.ids, + select_all=request.select_all, + status_filter=request.status_filter, + email_service_filter=request.email_service_filter, + search_filter=request.search_filter, + ) + + task_id = _codex_auth_task_id("generate") + snapshot = task_manager.register_domain_task( + domain="codex_auth", + task_id=task_id, + task_type="codex_auth_generate", + payload=payload, + progress={"completed": 0, "total": len(accounts)}, + max_retries=0, + ) + _submit_codex_auth_async_task(task_id, _run_batch_codex_auth_generate_async, payload) + return snapshot + + +@router.post("/codex-auth/repair/async") +def create_codex_auth_repair_task(request: BatchCodexAuthRequest): + payload = request.model_dump() + with get_db() as db: + accounts = _resolve_codex_auth_accounts( + db, + ids=request.ids, + select_all=request.select_all, + status_filter=request.status_filter, + email_service_filter=request.email_service_filter, + search_filter=request.search_filter, + ) + + task_id = _codex_auth_task_id("repair") + snapshot = task_manager.register_domain_task( + domain="codex_auth", + task_id=task_id, + task_type="codex_auth_repair", + payload=payload, + progress={"completed": 0, "total": len(accounts)}, + max_retries=0, + ) + _submit_codex_auth_async_task(task_id, _run_batch_codex_auth_repair_async, payload) + return snapshot + + +@router.get("/codex-auth/tasks/{task_id}") +def get_codex_auth_async_task(task_id: str): + return _get_codex_auth_task_or_404(task_id) + + +@router.post("/codex-auth/export") +async def export_codex_auth_artifacts(request: BatchCodexAuthRequest): + with get_db() as db: + accounts = _resolve_codex_auth_accounts( + db, + ids=request.ids, + select_all=request.select_all, + status_filter=request.status_filter, + email_service_filter=request.email_service_filter, + search_filter=request.search_filter, + ) + + try: + entries = build_codex_auth_zip_entries(accounts) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not entries: + raise HTTPException(status_code=400, detail="没有可导出的 Codex Auth 账号") + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for filename, content in entries: + zf.writestr(filename, content) + + zip_buffer.seek(0) + filename = f"codex_auth_{timestamp}.zip" + return StreamingResponse( + iter([zip_buffer.getvalue()]), + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + # ============== CPA 上传相关 ============== class CPAUploadRequest(BaseModel): diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index c1dbbcd1..f43a6552 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -6,7 +6,7 @@ import os import re import uuid -from typing import Optional, List +from typing import Optional, List, Dict, Any, Tuple from datetime import datetime import time from urllib.parse import urlparse, urlunparse @@ -36,6 +36,7 @@ from ...core.openai.random_billing import generate_random_billing_profile from ...core.openai.token_refresh import TokenRefreshManager from ...core.dynamic_proxy import get_proxy_url_for_task +from ..task_manager import task_manager logger = logging.getLogger(__name__) router = APIRouter() @@ -64,6 +65,78 @@ "country, region, or territory not supported", "request_forbidden", ) +_PAYMENT_TASK_POLL_INTERVAL_SECONDS = 0.25 +_PAYMENT_TASK_PAUSE_MAX_WAIT_SECONDS = 3600.0 + + +def _payment_task_id(task_type: str) -> str: + normalized = re.sub(r"[^a-z0-9]+", "-", str(task_type or "").strip().lower()).strip("-") or "task" + return f"payment-{normalized}-{uuid.uuid4().hex[:12]}" + + +def _get_payment_task_or_404(task_id: str) -> Dict[str, Any]: + snapshot = task_manager.get_domain_task("payment", task_id) + if not snapshot: + raise HTTPException(status_code=404, detail="任务不存在") + return snapshot + + +def _wait_payment_task_if_paused(task_id: str) -> Tuple[bool, Optional[str]]: + pause_started_at = time.monotonic() + while True: + snapshot = task_manager.get_domain_task("payment", task_id) or {} + if bool(snapshot.get("cancel_requested")): + return False, "cancelled" + if not bool(snapshot.get("pause_requested")): + return True, None + if time.monotonic() - pause_started_at >= _PAYMENT_TASK_PAUSE_MAX_WAIT_SECONDS: + return False, "timeout" + task_manager.update_domain_task( + "payment", + task_id, + status="paused", + paused=True, + message="任务已暂停,等待继续", + ) + time.sleep(_PAYMENT_TASK_POLL_INTERVAL_SECONDS) + + +def _finalize_payment_async_task( + task_id: str, + *, + status: str, + message: str, + result: Dict[str, Any], + error: Optional[str] = None, +) -> None: + task_manager.update_domain_task( + "payment", + task_id, + status=status, + paused=False, + pause_requested=False, + finished_at=datetime.utcnow().isoformat(), + message=message, + error=error, + result=result, + ) + task_manager.release_domain_slot("payment", task_id) + + +def _submit_payment_async_task(task_id: str, runner, payload: Dict[str, Any]) -> None: + try: + task_manager.executor.submit(runner, task_id, payload) + except Exception as exc: + logger.exception("提交 payment 异步任务失败: task_id=%s error=%s", task_id, exc) + task_manager.update_domain_task( + "payment", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=f"任务提交失败: {exc}", + error=str(exc), + ) + raise HTTPException(status_code=500, detail="任务提交失败") from exc def _is_retryable_subscription_check_error(error_message: Optional[str]) -> bool: @@ -3261,6 +3334,188 @@ def delete_bind_card_task(task_id: int): # ============== 订阅状态 ============== + +def _run_batch_check_subscription_async(task_id: str, request_data: Dict[str, Any]) -> None: + acquired, running, quota = task_manager.try_acquire_domain_slot("payment", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + task_manager.update_domain_task( + "payment", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=reason, + error=reason, + ) + return + + result = {"success_count": 0, "failed_count": 0, "details": []} + + try: + request = BatchCheckSubscriptionRequest(**request_data) + explicit_proxy = _normalize_proxy_value(request.proxy) + 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 + ) + + total = len(ids) + task_manager.update_domain_task( + "payment", + task_id, + status="running", + started_at=datetime.utcnow().isoformat(), + paused=False, + message="批量检测订阅执行中", + progress={"completed": 0, "total": total}, + ) + + with get_db() as db: + for index, account_id in enumerate(ids, start=1): + if task_manager.is_domain_task_cancel_requested("payment", task_id): + _finalize_payment_async_task( + task_id, + status="cancelled", + message="批量检测订阅已取消", + result=result, + ) + return + should_continue, pause_exit_reason = _wait_payment_task_if_paused(task_id) + if not should_continue: + if pause_exit_reason == "timeout": + _finalize_payment_async_task( + task_id, + status="failed", + message="批量检测订阅暂停超时,任务已终止", + result=result, + error="任务暂停超时", + ) + return + _finalize_payment_async_task( + task_id, + status="cancelled", + message="批量检测订阅已取消", + result=result, + ) + return + + task_manager.update_domain_task( + "payment", + task_id, + status="running", + paused=False, + message=f"正在检测第 {index}/{total} 个账号的订阅", + ) + + account = db.query(Account).filter(Account.id == account_id).first() + detail: Dict[str, Any] = {"id": account_id, "email": None, "success": False} + if not account: + result["failed_count"] += 1 + detail["error"] = "账号不存在" + else: + detail["email"] = account.email + try: + runtime_proxy = _resolve_runtime_proxy(explicit_proxy, account) + subscription_detail, refreshed = _check_subscription_detail_with_retry( + db=db, + account=account, + proxy=runtime_proxy, + allow_token_refresh=True, + ) + status = str(subscription_detail.get("status") or "free").lower() + confidence = str(subscription_detail.get("confidence") or "low").lower() + + if status in ("plus", "team"): + account.subscription_type = status + account.subscription_at = utcnow_naive() + elif status == "free" and confidence == "high": + account.subscription_type = None + account.subscription_at = None + + db.commit() + result["success_count"] += 1 + detail.update( + { + "success": True, + "subscription_type": status, + "confidence": confidence, + "source": subscription_detail.get("source"), + "token_refreshed": refreshed, + } + ) + except Exception as exc: + db.rollback() + result["failed_count"] += 1 + detail["error"] = str(exc) + + result["details"].append(detail) + task_manager.append_domain_task_detail("payment", task_id, detail) + task_manager.set_domain_task_progress("payment", task_id, completed=index, total=total) + + _finalize_payment_async_task( + task_id, + status="completed", + message=f"批量检测订阅完成:成功 {result['success_count']},失败 {result['failed_count']}", + result=result, + ) + except Exception as exc: + logger.exception("批量检测订阅异步任务失败: task_id=%s error=%s", task_id, exc) + _finalize_payment_async_task( + task_id, + status="failed", + message=f"批量检测订阅异常: {exc}", + result=result, + error=str(exc), + ) + + +def get_payment_op_task(task_id: str) -> Dict[str, Any]: + return _get_payment_task_or_404(task_id) + + +def cancel_payment_op_task(task_id: str) -> Dict[str, Any]: + _get_payment_task_or_404(task_id) + snapshot = task_manager.request_domain_task_cancel("payment", task_id) + return { + "success": True, + "task_id": task_id, + "status": "cancelling", + "task": snapshot, + } + + +def pause_payment_op_task(task_id: str) -> Dict[str, Any]: + _get_payment_task_or_404(task_id) + snapshot = task_manager.request_domain_task_pause("payment", task_id) + return { + "success": True, + "task_id": task_id, + "status": "paused", + "task": snapshot, + } + + +def resume_payment_op_task(task_id: str) -> Dict[str, Any]: + _get_payment_task_or_404(task_id) + snapshot = task_manager.request_domain_task_resume("payment", task_id) + return { + "success": True, + "task_id": task_id, + "status": "running", + "task": snapshot, + } + + +def retry_payment_op_task(task_id: str) -> Dict[str, Any]: + snapshot = _get_payment_task_or_404(task_id) + payload = dict(snapshot.get("payload") or {}) + task_type = str(snapshot.get("task_type") or "").strip().lower() + if task_type == "batch_check_subscription": + return create_batch_check_subscription_async_task(BatchCheckSubscriptionRequest(**payload)) + raise HTTPException(status_code=400, detail="当前任务类型不支持重试") + + @router.post("/accounts/batch-check-subscription") def batch_check_subscription(request: BatchCheckSubscriptionRequest): """批量检测账号订阅状态""" @@ -3322,6 +3577,33 @@ def batch_check_subscription(request: BatchCheckSubscriptionRequest): return results +@router.post("/accounts/batch-check-subscription/async") +def create_batch_check_subscription_async_task(request: BatchCheckSubscriptionRequest): + payload = request.model_dump() + 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 + ) + + task_id = _payment_task_id("batch-check-subscription") + snapshot = task_manager.register_domain_task( + domain="payment", + task_id=task_id, + task_type="batch_check_subscription", + payload=payload, + progress={"completed": 0, "total": len(ids)}, + max_retries=3, + ) + _submit_payment_async_task(task_id, _run_batch_check_subscription_async, payload) + return snapshot + + +@router.get("/ops/tasks/{task_id}") +def get_payment_operation_task(task_id: str): + return get_payment_op_task(task_id) + + @router.post("/accounts/{account_id}/mark-subscription") def mark_subscription(account_id: int, request: MarkSubscriptionRequest): """手动标记账号订阅类型""" diff --git a/src/web/task_manager.py b/src/web/task_manager.py index 82049f37..519b0fab 100644 --- a/src/web/task_manager.py +++ b/src/web/task_manager.py @@ -46,6 +46,7 @@ # 统一任务中心(跨模块任务状态) _DOMAIN_DEFAULT_QUOTAS: Dict[str, int] = { "accounts": 6, + "codex_auth": 1, "payment": 4, "auto_team": 3, "selfcheck": 2, diff --git a/static/css/style.css b/static/css/style.css index 9fc6ad43..0695686d 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1064,6 +1064,66 @@ body { gap: var(--spacing-sm); } +.hover-help { + position: relative; + display: inline-flex; + align-items: center; +} + +.hover-help-bubble { + position: absolute; + top: calc(100% + 8px); + left: 50%; + z-index: 120; + width: 260px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow-lg); + color: var(--text-primary); + font-size: 0.75rem; + line-height: 1.5; + opacity: 0; + visibility: hidden; + transform: translateX(-50%) translateY(-4px); + transition: opacity var(--transition), transform var(--transition), visibility var(--transition); + pointer-events: none; +} + +.hover-help-bubble::before { + content: ''; + position: absolute; + top: -6px; + left: 50%; + width: 10px; + height: 10px; + background: var(--surface); + border-top: 1px solid var(--border); + border-left: 1px solid var(--border); + transform: translateX(-50%) rotate(45deg); +} + +.hover-help-bubble strong { + display: block; + margin-bottom: 4px; + color: var(--text-primary); + font-size: 0.8125rem; + font-weight: 600; +} + +.hover-help-bubble span { + display: block; + color: var(--text-secondary); +} + +.hover-help:hover .hover-help-bubble, +.hover-help:focus-within .hover-help-bubble { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + .form-select, .form-input { padding: 8px 12px; diff --git a/static/js/accounts.js b/static/js/accounts.js index e681e213..5f3972d7 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -14,6 +14,10 @@ let isBatchValidating = false; let isBatchCheckingSubscription = false; let isOverviewRefreshing = false; let isQuickWorkflowRunning = false; +let isCodexAuthAuditing = false; +let isCodexAuthRepairing = false; +let isCodexAuthGenerating = false; +let isCodexAuthExporting = false; let quickWorkflowStepLabel = ''; let selectAllPages = false; // 是否选中了全部页 let currentFilters = { status: '', email_service: '', role_tag: '', search: '' }; // 当前筛选条件 @@ -30,6 +34,12 @@ const activeBatchTasks = { subscription: null, overview: null, }; +const activeCodexAuthTasks = { + audit: null, + repair: null, + generate: null, +}; +let pendingCodexAuthAction = null; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -73,6 +83,20 @@ function getResumableBatchTasks() { return getRunningBatchTasks().filter((task) => Boolean(task.paused)); } +function trackCodexAuthTask(key, taskRef = null) { + if (!Object.prototype.hasOwnProperty.call(activeCodexAuthTasks, key)) return; + activeCodexAuthTasks[key] = taskRef ? normalizeTaskState(taskRef) : null; + updateBatchButtons(); +} + +function patchCodexAuthTask(key, patch = {}) { + if (!Object.prototype.hasOwnProperty.call(activeCodexAuthTasks, key)) return; + const current = activeCodexAuthTasks[key]; + if (!current) return; + activeCodexAuthTasks[key] = normalizeTaskState({ ...current, ...(patch || {}) }); + updateBatchButtons(); +} + async function watchDomainTask(fetchTask, onUpdate, maxWaitMs = 20 * 60 * 1000, options = {}) { const startedAt = Date.now(); const poller = createAdaptivePoller({ @@ -146,6 +170,23 @@ async function watchPaymentTask(taskId, onUpdate, maxWaitMs = 20 * 60 * 1000) { ); } +async function watchCodexAuthTask(taskId, onUpdate, maxWaitMs = 30 * 60 * 1000) { + return watchDomainTask( + () => api.get(`/accounts/codex-auth/tasks/${taskId}`, { + requestKey: `codex-auth:task:${taskId}`, + cancelPrevious: true, + retry: 0, + timeoutMs: 30000, + silentNetworkError: true, + silentTimeoutError: true, + priority: 'low', + }), + onUpdate, + maxWaitMs, + { baseIntervalMs: 1500, maxIntervalMs: 12000 }, + ); +} + function replaceAccountRowStatus(accountId, nextStatus) { const normalizedId = Number(accountId || 0); const normalizedStatus = String(nextStatus || '').trim().toLowerCase(); @@ -262,6 +303,24 @@ const elements = { closeAutoQuickRefreshModalBtn: document.getElementById('close-auto-quick-refresh-modal'), cancelAutoQuickRefreshBtn: document.getElementById('cancel-auto-quick-refresh-btn'), saveAutoQuickRefreshBtn: document.getElementById('save-auto-quick-refresh-btn'), + codexAuthWorkbenchBtn: document.getElementById('codex-auth-workbench-btn'), + codexAuthWorkbenchModal: document.getElementById('codex-auth-workbench-modal'), + closeCodexAuthWorkbenchModalBtn: document.getElementById('close-codex-auth-workbench-modal'), + codexAuthSelectionCount: document.getElementById('codex-auth-selection-count'), + codexAuthAuditBtn: document.getElementById('codex-auth-audit-btn'), + codexAuthRepairBtn: document.getElementById('codex-auth-repair-btn'), + codexAuthGenerateBtn: document.getElementById('codex-auth-generate-btn'), + codexAuthExportBtn: document.getElementById('codex-auth-export-btn'), + codexAuthModal: document.getElementById('codex-auth-modal'), + codexAuthModalTitle: document.getElementById('codex-auth-modal-title'), + codexAuthModalAction: document.getElementById('codex-auth-modal-action'), + codexAuthModalPurpose: document.getElementById('codex-auth-modal-purpose'), + codexAuthModalFlow: document.getElementById('codex-auth-modal-flow'), + codexAuthModalCount: document.getElementById('codex-auth-modal-count'), + codexAuthModalNote: document.getElementById('codex-auth-modal-note'), + closeCodexAuthModalBtn: document.getElementById('close-codex-auth-modal'), + cancelCodexAuthModalBtn: document.getElementById('cancel-codex-auth-modal-btn'), + confirmCodexAuthModalBtn: document.getElementById('confirm-codex-auth-modal-btn'), }; // 初始化 @@ -326,6 +385,11 @@ function initEventListeners() { elements.batchCheckSubBtn.addEventListener('click', handleBatchCheckSubscription); elements.batchPauseBtn?.addEventListener('click', pauseActiveBatchTasks); elements.batchResumeBtn?.addEventListener('click', resumeActiveBatchTasks); + elements.codexAuthWorkbenchBtn?.addEventListener('click', openCodexAuthWorkbenchModal); + elements.codexAuthAuditBtn?.addEventListener('click', () => openCodexAuthModal('audit')); + elements.codexAuthRepairBtn?.addEventListener('click', () => openCodexAuthModal('repair')); + elements.codexAuthGenerateBtn?.addEventListener('click', () => openCodexAuthModal('generate')); + elements.codexAuthExportBtn?.addEventListener('click', () => openCodexAuthModal('export')); // 上传下拉菜单 const uploadMenu = document.getElementById('upload-menu'); @@ -420,6 +484,20 @@ function initEventListeners() { closeAutoQuickRefreshModal(); } }); + elements.closeCodexAuthWorkbenchModalBtn?.addEventListener('click', closeCodexAuthWorkbenchModal); + elements.codexAuthWorkbenchModal?.addEventListener('click', (e) => { + if (e.target === elements.codexAuthWorkbenchModal) { + closeCodexAuthWorkbenchModal(); + } + }); + elements.closeCodexAuthModalBtn?.addEventListener('click', closeCodexAuthModal); + elements.cancelCodexAuthModalBtn?.addEventListener('click', closeCodexAuthModal); + elements.confirmCodexAuthModalBtn?.addEventListener('click', executePendingCodexAuthAction); + elements.codexAuthModal?.addEventListener('click', (e) => { + if (e.target === elements.codexAuthModal) { + closeCodexAuthModal(); + } + }); // 点击其他地方关闭下拉菜单 document.addEventListener('click', () => { @@ -634,7 +712,7 @@ async function loadAccounts() { // 显示加载状态 elements.table.innerHTML = ` - +
@@ -675,7 +753,7 @@ async function loadAccounts() { console.error('加载账号列表失败:', error); elements.table.innerHTML = ` - +
加载失败
@@ -695,7 +773,7 @@ function renderAccounts(accounts) { if (accounts.length === 0) { elements.table.innerHTML = ` - +
📭
暂无数据
@@ -738,6 +816,7 @@ function renderAccounts(accounts) { : ``}
+ ${renderCodexAuthState(account.codex_auth)} ${renderSubscriptionStatus(account.subscription_type)} @@ -847,6 +926,18 @@ function renderSubscriptionStatus(subscriptionType) { `; } +function renderCodexAuthState(codexAuth) { + const health = String(codexAuth?.health || 'unknown').trim().toLowerCase() || 'unknown'; + const label = String(codexAuth?.label || '未知').trim() || '未知'; + const reason = String(codexAuth?.reason || '').trim(); + const title = reason || label; + return ` +
+ ${escapeHtml(label)} +
+ `; +} + // 切换密码显示 function togglePassword(element, password) { if (element.dataset.revealed === 'true') { @@ -1021,26 +1112,267 @@ async function resumeActiveBatchTasks() { } } +function closeCodexAuthModal() { + pendingCodexAuthAction = null; + elements.codexAuthModal?.classList.remove('active'); +} + +function openCodexAuthWorkbenchModal() { + syncCodexAuthWorkbenchState(); + elements.codexAuthWorkbenchModal?.classList.add('active'); +} + +function closeCodexAuthWorkbenchModal() { + elements.codexAuthWorkbenchModal?.classList.remove('active'); +} + +function syncCodexAuthWorkbenchState() { + const count = getEffectiveCount(); + if (elements.codexAuthSelectionCount) { + elements.codexAuthSelectionCount.textContent = String(count); + } +} + +function openCodexAuthModal(action) { + const count = getEffectiveCount(); + if (count === 0) { + toast.warning('请先选择要处理的账号'); + return; + } + + const definitions = { + audit: { + title: 'Codex Auth 批量审计', + action: '批量审计', + purpose: '用严格 Codex Auth 链路判断账号当前是否可修、是否被 add-phone 门控拦截。', + flow: '逐个账号执行严格登录探测;健康账号直接跳过,残缺账号会尝试 OTP、workspace、callback,但不会写回新 token。', + note: '审计不会修复账号,只会更新 Codex Auth 状态。', + confirmText: '开始审计', + }, + repair: { + title: 'Codex Auth 批量修复', + action: '批量修复', + purpose: '对残缺账号执行严格 Codex Auth 登录,成功后补齐 refresh_token、id_token 并生成标准 auth.json。', + flow: '任务按独立 codex_auth 队列低并发执行;每个账号必须走完整 OTP、workspace、callback,缺任一关键 token 都判失败。', + note: '修复使用严格失败语义,不接受 session-only 成功。', + confirmText: '开始修复', + }, + generate: { + title: 'Codex Auth 批量生成', + action: '批量生成', + purpose: '为已有完整 Managed Auth 账号生成标准 auth.json artifact。', + flow: '只处理 refresh_token、id_token、account_id 完整的账号;缺字段账号会直接失败,不会触发登录。', + note: '生成成功后可直接导出给官方 Codex 或 codex-auth 使用。', + confirmText: '开始生成', + }, + export: { + title: 'Codex Auth 批量导出', + action: '批量导出', + purpose: '导出标准 auth.json ZIP 包。', + flow: '按当前勾选或筛选结果打包标准 auth.json;缺少关键 token 的账号不会被导出。', + note: '导出使用标准 managed auth.json 结构。', + confirmText: '开始导出', + }, + }; + const def = definitions[action]; + if (!def) return; + + pendingCodexAuthAction = action; + elements.codexAuthModalTitle.textContent = def.title; + elements.codexAuthModalAction.textContent = def.action; + elements.codexAuthModalPurpose.textContent = def.purpose; + elements.codexAuthModalFlow.textContent = def.flow; + elements.codexAuthModalCount.textContent = String(count); + elements.codexAuthModalNote.textContent = def.note; + elements.confirmCodexAuthModalBtn.textContent = def.confirmText; + syncCodexAuthWorkbenchState(); + elements.codexAuthModal.classList.add('active'); +} + +async function executePendingCodexAuthAction() { + const action = pendingCodexAuthAction; + if (!action) return; + + const originalText = elements.confirmCodexAuthModalBtn.textContent; + elements.confirmCodexAuthModalBtn.disabled = true; + elements.confirmCodexAuthModalBtn.textContent = '执行中...'; + try { + if (action === 'audit') { + await runCodexAuthAsyncTask('audit', '/accounts/codex-auth/audit/async'); + } else if (action === 'repair') { + await runCodexAuthAsyncTask('repair', '/accounts/codex-auth/repair/async'); + } else if (action === 'generate') { + await runCodexAuthAsyncTask('generate', '/accounts/codex-auth/generate/async'); + } else if (action === 'export') { + await exportCodexAuthArtifacts(); + } + closeCodexAuthModal(); + } catch (error) { + toast.error(`Codex Auth 操作失败: ${error.message}`); + } finally { + elements.confirmCodexAuthModalBtn.disabled = false; + elements.confirmCodexAuthModalBtn.textContent = originalText; + } +} + +async function runCodexAuthAsyncTask(key, endpoint) { + const payload = buildBatchPayload(); + const count = getEffectiveCount(); + const stateMap = { + audit: ['isCodexAuthAuditing', 'codexAuthAuditBtn', '批量审计'], + repair: ['isCodexAuthRepairing', 'codexAuthRepairBtn', '批量修复'], + generate: ['isCodexAuthGenerating', 'codexAuthGenerateBtn', '批量生成'], + }; + const state = stateMap[key]; + if (!state) return; + + if (key === 'audit') isCodexAuthAuditing = true; + if (key === 'repair') isCodexAuthRepairing = true; + if (key === 'generate') isCodexAuthGenerating = true; + updateBatchButtons(); + + try { + const task = await api.post(endpoint, payload, { + timeoutMs: 20000, + retry: 0, + cancelPrevious: true, + requestKey: `codex-auth:${key}`, + }); + const taskId = task?.id; + if (!taskId) { + throw new Error('任务创建失败:未返回任务 ID'); + } + trackCodexAuthTask(key, { + id: taskId, + domain: 'codex_auth', + status: task?.status || 'pending', + paused: Boolean(task?.paused), + }); + toast.info(`${state[2]}任务已启动(${taskId.slice(0, 8)})`); + + const button = elements[state[1]]; + const finalTask = await watchCodexAuthTask(taskId, (progressTask) => { + patchCodexAuthTask(key, { + status: progressTask?.status || 'running', + paused: Boolean(progressTask?.paused), + }); + const progress = progressTask?.progress || {}; + const completed = Number(progress.completed || 0); + const total = Number(progress.total || count); + if (button) { + button.textContent = `${state[2]} ${completed}/${total}`; + } + }); + patchCodexAuthTask(key, { + status: finalTask?.status || 'completed', + paused: false, + }); + + const status = String(finalTask?.status || '').toLowerCase(); + const result = finalTask?.result || {}; + if (status === 'completed') { + toast.success(`${state[2]}完成:成功 ${result.success_count || 0},失败 ${result.failed_count || 0}`); + } else if (status === 'cancelled') { + toast.warning(`${state[2]}已取消`); + } else { + toast.error(`${state[2]}失败: ${finalTask?.error || finalTask?.message || '未知错误'}`); + } + await refreshAccountsView({ settleDelayMs: 80 }); + } finally { + if (key === 'audit') isCodexAuthAuditing = false; + if (key === 'repair') isCodexAuthRepairing = false; + if (key === 'generate') isCodexAuthGenerating = false; + trackCodexAuthTask(key, null); + updateBatchButtons(); + } +} + +async function exportCodexAuthArtifacts() { + const count = getEffectiveCount(); + const payload = buildBatchPayload(); + isCodexAuthExporting = true; + updateBatchButtons(); + try { + toast.info(`正在导出 ${count} 个账号的 Codex Auth...`); + const response = await fetch('/api/accounts/codex-auth/export', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${utils.getStorage('access_token') || localStorage.getItem('access_token') || ''}`, + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + let detail = `HTTP ${response.status}`; + try { + const json = await response.json(); + detail = json?.detail || detail; + } catch (error) { + // ignore + } + throw new Error(detail); + } + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition') || ''; + const match = disposition.match(/filename=([^;]+)/i); + const filename = match ? match[1].replace(/"/g, '').trim() : `codex_auth_${Date.now()}.zip`; + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + toast.success('Codex Auth 导出成功'); + } catch (error) { + toast.error('Codex Auth 导出失败: ' + error.message); + } finally { + isCodexAuthExporting = false; + updateBatchButtons(); + } +} + // 更新批量操作按钮 function updateBatchButtons() { const count = getEffectiveCount(); const baseDisabled = count === 0 || isQuickWorkflowRunning || isTaskPausing || isTaskResuming; + syncCodexAuthWorkbenchState(); elements.batchDeleteBtn.disabled = baseDisabled; elements.batchRefreshBtn.disabled = baseDisabled || isBatchRefreshing; elements.batchValidateBtn.disabled = baseDisabled || isBatchValidating; elements.batchUploadBtn.disabled = baseDisabled; elements.batchCheckSubBtn.disabled = baseDisabled || isBatchCheckingSubscription; elements.exportBtn.disabled = count === 0; + if (elements.codexAuthWorkbenchBtn) { + elements.codexAuthWorkbenchBtn.disabled = false; + } + if (elements.codexAuthAuditBtn) { + elements.codexAuthAuditBtn.disabled = count === 0 || isCodexAuthAuditing || isCodexAuthRepairing || isCodexAuthGenerating || isCodexAuthExporting; + elements.codexAuthAuditBtn.textContent = isCodexAuthAuditing ? '审计中...' : '开始审计'; + } + if (elements.codexAuthRepairBtn) { + elements.codexAuthRepairBtn.disabled = count === 0 || isCodexAuthAuditing || isCodexAuthRepairing || isCodexAuthGenerating || isCodexAuthExporting; + elements.codexAuthRepairBtn.textContent = isCodexAuthRepairing ? '修复中...' : '开始修复'; + } + if (elements.codexAuthGenerateBtn) { + elements.codexAuthGenerateBtn.disabled = count === 0 || isCodexAuthAuditing || isCodexAuthRepairing || isCodexAuthGenerating || isCodexAuthExporting; + elements.codexAuthGenerateBtn.textContent = isCodexAuthGenerating ? '生成中...' : '开始生成'; + } + if (elements.codexAuthExportBtn) { + elements.codexAuthExportBtn.disabled = count === 0 || isCodexAuthAuditing || isCodexAuthRepairing || isCodexAuthGenerating || isCodexAuthExporting; + elements.codexAuthExportBtn.textContent = isCodexAuthExporting ? '导出中...' : '开始导出'; + } if (elements.quickRefreshBtn) { elements.quickRefreshBtn.disabled = true; elements.quickRefreshBtn.textContent = '⚡ 一键刷新(已禁用)'; } elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除'; - elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token'; - elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token'; + elements.batchRefreshBtn.textContent = '🔄 刷新Token'; + elements.batchValidateBtn.textContent = '✅ 验证Token'; elements.batchUploadBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传'; - elements.batchCheckSubBtn.textContent = count > 0 ? `🔍 检测 (${count})` : '🔍 检测订阅'; + elements.batchCheckSubBtn.textContent = '🔍 检测订阅'; const pausableCount = getPausableBatchTasks().length; const resumableCount = getResumableBatchTasks().length; diff --git a/templates/accounts.html b/templates/accounts.html index 0ea5dff5..ae99052e 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -126,6 +126,170 @@ .subscription-status.team .label { color: var(--primary-color); } .subscription-status.free .dot { background: var(--danger-color); } .subscription-status.free .label { color: var(--danger-color); } + .codex-auth-workbench-entry { + display: inline-flex; + align-items: center; + gap: 8px; + } + .codex-auth-workbench-entry .btn { + white-space: nowrap; + } + .codex-auth-workbench-modal .modal-content { + max-width: 960px; + } + .codex-auth-workbench-copy { + display: flex; + flex-direction: column; + gap: 6px; + } + .codex-auth-workbench-copy h3 { + font-size: 1.05rem; + font-weight: 700; + color: var(--text-primary); + } + .codex-auth-workbench-copy p { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.55; + } + .codex-auth-workbench-meta { + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: 6px; + color: var(--text-secondary); + font-size: 0.84rem; + } + .codex-auth-workbench-layout { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); + gap: 20px; + } + .codex-auth-workbench-actions { + display: grid; + gap: 12px; + } + .codex-auth-action-card { + display: grid; + gap: 10px; + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-hover); + } + .codex-auth-action-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + .codex-auth-action-card h4 { + margin: 0; + font-size: 0.92rem; + font-weight: 700; + color: var(--text-primary); + } + .codex-auth-action-card p { + margin: 0; + font-size: 0.84rem; + line-height: 1.6; + color: var(--text-secondary); + } + .codex-auth-action-card .btn { + min-width: 108px; + } + .codex-auth-workbench-side { + display: grid; + gap: 12px; + } + .codex-auth-side-card { + display: grid; + gap: 10px; + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + } + .codex-auth-side-card h4 { + margin: 0; + font-size: 0.9rem; + font-weight: 700; + color: var(--text-primary); + } + .codex-auth-side-card p, + .codex-auth-side-card li { + margin: 0; + font-size: 0.84rem; + line-height: 1.6; + color: var(--text-secondary); + } + .codex-auth-side-card ul { + margin: 0; + padding-left: 18px; + display: grid; + gap: 8px; + } + .codex-auth-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: flex-start; + } + .codex-auth-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 58px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 0.72rem; + font-weight: 700; + line-height: 1.4; + letter-spacing: 0.2px; + } + .codex-auth-badge.healthy { + color: #166534; + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.25); + } + .codex-auth-badge.repairable { + color: #1d4ed8; + background: rgba(59, 130, 246, 0.12); + border-color: rgba(59, 130, 246, 0.24); + } + .codex-auth-badge.blocked { + color: #b45309; + background: rgba(245, 158, 11, 0.14); + border-color: rgba(245, 158, 11, 0.3); + } + .codex-auth-badge.missing_prerequisites, + .codex-auth-badge.unknown { + color: var(--text-secondary); + background: var(--surface-hover); + border-color: var(--border); + } + .codex-auth-state { + display: flex; + align-items: center; + justify-content: center; + } + .codex-auth-modal-summary { + display: grid; + gap: 10px; + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.6; + } + .codex-auth-modal-summary strong { + color: var(--text-primary); + } + @media (max-width: 960px) { + .codex-auth-workbench-layout { + grid-template-columns: 1fr; + } + } .account-label-badge { display: inline-flex; align-items: center; @@ -335,15 +499,36 @@

账号管理

- - - +
+ + +
+
+ + +
+
+ + +
@@ -376,6 +561,11 @@

账号管理

导出 Sub2Api 格式
+
+ +
@@ -397,6 +587,7 @@

账号管理

邮箱服务 状态 CPA + Codex Auth 订阅 最后刷新 操作 @@ -431,6 +622,97 @@

账号管理

+ + + +