diff --git a/.env.example b/.env.example index 821fd168..c16511c9 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,10 @@ # 第三方绑卡 API Key(可留空,后端会先尝试无鉴权) # BIND_CARD_API_KEY=your_api_key_here + +# ── 卡商 EFun(aimizy bindcard)接口版(可选) ─────────────── +# vendor_efun 模式接口地址(默认 https://card.aimizy.com/api/v1/bindcard) +# VENDOR_BINDCARD_API_URL=https://card.aimizy.com/api/v1/bindcard + +# vendor_efun 模式接口 Bearer Token +# VENDOR_BINDCARD_API_KEY=your_vendor_api_key_here diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..321eb064 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: Python Tests + +on: + push: + branches: ["main", "master"] + pull_request: + branches: ["main", "master"] + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest httpx + + - name: Run tests + run: | + pytest -q diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 00000000..a37fdc4d --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,46 @@ +# 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` diff --git a/README.md b/README.md index ff4f0dc7..86c7c6fa 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,9 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/) -- GitHub Repo: [https://github.com/dou-jiang/codex-console](https://github.com/dou-jiang/codex-console) - ## QQ群 -- 交流群: [291638849(点击加群)](https://qm.qq.com/q/4TETC3mWco) -- Telegram 频道: [codex_console](https://t.me/codex_console) +- 交流群: https://qm.qq.com/q/ZTCKxawxeo ## 致谢 @@ -20,9 +17,9 @@ 本仓库是在原项目思路和结构之上进行兼容性修复、流程调整和体验优化,适合作为一个“当前可用的修复维护版”继续使用。 -## 版本更新 +## 这个分支修了什么 -### v1.0 +为适配当前注册链路,这个分支重点补了下面几个问题: 1. 新增 Sentinel POW 求解逻辑 OpenAI 现在会强制校验 Sentinel POW,原先直接传空值已经不行了,这里补上了实际求解流程。 @@ -41,52 +38,6 @@ 5. 优化终端和 Web UI 提示文案 保留可读性的前提下,把一些提示改得更友好一点,出错时至少不至于像在挨骂。 -### v1.1 - -1. 修复注册流程中的问题,解决 Outlook 和临时邮箱收不到邮件导致注册卡住、无法完成注册的问题。 - -2. 修复无法检查订阅状态的问题,提升订阅识别和状态检查的可用性。 - -3. 新增绑卡半自动模式,支持自动随机地址;3DS 无法跳过,需按实际流程完成验证。 - -4. 新增已订阅账号管理功能,支持查看和管理账号额度。 - -5. 新增后台日志功能,并补充数据导出与导入能力,方便排查问题和迁移数据。 - -6. 优化部分 UI 细节与交互体验,减少页面操作时的割裂感。 - -7. 补充细节稳定性处理,尽量减少注册、订阅检测和账号管理过程中出现卡住或误判的情况。 - -### v1.1.1 - -1. 新增 `CloudMail` 邮箱服务实现,并完成服务注册、配置接入、邮件轮询、验证码提取和基础收件处理能力。 - -2. 新增上传目标 `newApi` 支持,可根据配置选择不同导入目标类型。 - -3. 新增 `Codex` 账号导出格式,支持后续登录、迁移和导入使用。 - -4. 新增 `CPA` 认证文件 `proxy_url` 支持,现可在 CPA 服务配置中保存和使用代理地址。 - -5. 优化 OAuth token 刷新兼容逻辑,完善异常返回与一次性令牌场景处理,降低刷新报错概率。 - -6. 优化批量验证流程,改为受控并发执行,减少长时间阻塞和卡死问题。 - -7. 修复模板渲染兼容问题,提升不同 Starlette 版本下页面渲染稳定性。 - -8. 修复六位数字误判为 OTP 的问题,避免邮箱域名或无关文本中的六位数字被错误识别为验证码。 - -9. 新增 Outlook 账户“注册状态”识别与展示功能,可直接看到“已注册/未注册”,并支持显示关联账号编号(如“已注册 #1”)。 - -10. 修复 Outlook 邮箱匹配大小写问题,避免 Outlook.com 因大小写差异被误判为未注册。 - -11. 修复 Outlook 列表列错位、乱码和占位文案问题,恢复中文显示并优化列表信息布局。 - -12. 优化 WebUI 端口冲突处理,默认端口占用时自动切换可用端口。 - -13. 增加启动时轻量字段迁移逻辑,自动补齐新增字段,提升旧数据升级兼容性。 - -14. 批量注册上限由 `100` 提升至 `1000`(前后端同步)。 - ## 核心能力 - Web UI 管理注册任务和账号数据 @@ -262,10 +213,25 @@ dist/codex-console-windows-X64.exe `codex-console` +## 安全基线说明(新增) + +- `/api/*` 与 `/api/ws/*` 已统一接入登录鉴权。 +- 首次启动检测到默认口令或默认密钥时,会强制跳转到 `/setup-password` 完成改密。 +- 支付相关 API Key 不再使用代码内硬编码默认值,需通过环境变量或配置显式提供。 + +## 数据库迁移(Alembic) + +```bash +alembic revision --autogenerate -m "your_change" +alembic upgrade head +``` + +初始化与更多说明见: + +- `alembic/README.md` + ## 免责声明 本项目仅供学习、研究和技术交流使用,请遵守相关平台和服务条款,不要用于违规、滥用或非法用途。 因使用本项目产生的任何风险和后果,由使用者自行承担。 - - diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 00000000..79c302a7 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///data/database.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README.md b/alembic/README.md new file mode 100644 index 00000000..9c3d6589 --- /dev/null +++ b/alembic/README.md @@ -0,0 +1,25 @@ +# Alembic Migration Guide + +## Initialize current schema baseline + +```bash +alembic revision --autogenerate -m "baseline" +alembic upgrade head +``` + +## Create new migration + +```bash +alembic revision --autogenerate -m "add_xxx" +``` + +## Upgrade / Downgrade + +```bash +alembic upgrade head +alembic downgrade -1 +``` + +Notes: +- The DB URL is read from `alembic.ini` first. +- If not set, Alembic falls back to `src.config.settings.get_database_url()`. diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 00000000..21a25a26 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from src.config.settings import get_database_url +from src.database.models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _resolve_database_url() -> str: + configured = str(config.get_main_option("sqlalchemy.url") or "").strip() + if configured: + return configured + try: + return get_database_url() + except Exception: + return "sqlite:///data/database.db" + + +def run_migrations_offline() -> None: + url = _resolve_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + section = config.get_section(config.config_ini_section) or {} + section["sqlalchemy.url"] = _resolve_database_url() + connectable = engine_from_config( + section, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 00000000..17acf761 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/alembic/versions/.gitkeep @@ -0,0 +1 @@ + diff --git a/codex_register.spec b/codex_register.spec index 92d30f11..40af3d7a 100644 --- a/codex_register.spec +++ b/codex_register.spec @@ -84,7 +84,6 @@ a = Analysis( 'src.core.utils', 'src.services.base', 'src.services.tempmail', - 'src.services.yyds_mail', 'src.services.moe_mail', 'src.services.outlook', 'src.services.outlook.account', diff --git a/pyproject.toml b/pyproject.toml index bc382c90..2bd705e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-console" -version = "1.1.1" +version = "1.0.4" description = "OpenAI account management console" requires-python = ">=3.10" dependencies = [ @@ -12,6 +12,7 @@ dependencies = [ "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "sqlalchemy>=2.0.0", + "alembic>=1.13.0", "aiosqlite>=0.19.0", "psycopg[binary]>=3.1.18", "websockets>=16.0", diff --git a/release/qa-phase3-2026-03-26.md b/release/qa-phase3-2026-03-26.md new file mode 100644 index 00000000..af65d3c0 --- /dev/null +++ b/release/qa-phase3-2026-03-26.md @@ -0,0 +1,52 @@ +# Phase-3 Regression QA (2026-03-26) + +## Scope +- Network weak/unstable behavior for frequent refresh actions. +- UI freeze risk while long-running actions are in progress. +- Payment bind-task list refresh responsiveness. +- Theme consistency (purple/blue tones). + +## Environment Status +- Runner date: 2026-03-26 +- Workspace: `K:\github\codex-console2` +- Runtime limitation in this agent sandbox: + - Cannot execute `cmd.exe` / `.bat` launcher. + - Cannot execute `python` / `node`. + - Local HTTP probing tools are unavailable in this sandbox. + +## Validation Method (Executed) +1. Static code-path regression checks for all modified files. +2. Consistency checks for anti-overlap polling guards and request dedup logic. +3. Style token scan to ensure green tones are removed from targeted UI surfaces. +4. Bundle sync and hash verification to `codex-console2-win-test`. + +## Findings +- PASS: Global API client now supports: + - Concurrency queue (`maxConcurrentRequests=6`) + - Priority scheduling (`high/normal/low`) + - Offline-aware low-priority short-circuit + - Toast throttling for network warnings +- PASS: Polling overlap protection added: + - registration task logs + - batch status + - outlook batch status +- PASS: Background polling reduced when page is hidden: + - payment bind-task auto refresh + - accounts overview auto refresh +- PASS: Bind-task list refresh changed to single-flight; duplicate clicks no longer create request pileups. +- PASS: Silent auto-refresh failures no longer overwrite visible table content. +- PASS: Purple/blue palette unification for the modified views. + +## Fixes Applied During QA +- Corrected `try/catch/finally` structure after anti-noise logging patch in `static/js/app.js`. +- Kept background errors quiet for expected abort/offline cases. + +## Risk Notes +- Full runtime E2E click-flow validation is still required on real Windows host due sandbox restrictions. +- No backend API contract change in this phase; risk is mainly frontend behavior regressions. + +## Suggested Manual Spot Checks (on local Windows) +1. Open payment page, spam-click "刷新" and switch tabs rapidly; UI should remain interactive. +2. Trigger long verify task ("我已完成支付"), then operate other tabs; no full-page freeze. +3. Disconnect/reconnect network; polling should degrade gracefully and recover. +4. Verify tone consistency on index/payment/auto-team pages. diff --git a/release/release-lock-phase3-2026-03-26.json b/release/release-lock-phase3-2026-03-26.json new file mode 100644 index 00000000..29ea7fae --- /dev/null +++ b/release/release-lock-phase3-2026-03-26.json @@ -0,0 +1,41 @@ +{ + "release_tag": "phase3_net_ui_20260326", + "created_at": "2026-03-26 10:37:30 +08:00", + "source_repo": "K:\\\\github\\\\codex-console2", + "target_bundle": "K:\\\\github\\\\codex-console2-win-test", + "summary": "Phase-3 network-resilience, anti-freeze polling, and purple-blue UI unification", + "files": [ + { + "path": "static/js/utils.js", + "sha256": "E55A66BC898FF0882066612401F25E95DEAD095F26B1023D05A20BE3EF480B50" + }, + { + "path": "static/js/app.js", + "sha256": "199E2545F721F69D6A572D0262D3881E8C8EDC74115A6CF75F40B43B76793070" + }, + { + "path": "static/js/payment.js", + "sha256": "78B28B0DA417CD57D1A20DA834C7E4BD08E5231AE30F0EE10FC3E31FD40393DD" + }, + { + "path": "static/js/accounts_overview.js", + "sha256": "366A74C2B53D62A351D712A4416959C74099326B4434285434BB4FE0AE5B1B30" + }, + { + "path": "templates/index.html", + "sha256": "C6D88A37CF4C40C5CCFF11D35AB7E4D33C479E746C7B07E91629F2DEE4D2DAC9" + }, + { + "path": "templates/payment.html", + "sha256": "E862A5C1F450077AA4ACC74F216D9C8237465DD9AA06EB0E81252BDA4A654D3F" + }, + { + "path": "templates/auto_team.html", + "sha256": "9D13B3C940D2761A186C360A6897FE51D4DB7AB13F78AF3BAB60EEC4F4102D5A" + }, + { + "path": "release/qa-phase3-2026-03-26.md", + "sha256": "BA3060B0189700F3B08DD260A819E720346560AB2CF777A6C7F16D6B9DAD5089" + } + ] +} diff --git a/requirements.txt b/requirements.txt index 6131f37f..93b8ba34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ uvicorn[standard]>=0.23.0 jinja2>=3.1.0 python-multipart>=0.0.6 sqlalchemy>=2.0.0 +alembic>=1.13.0 aiosqlite>=0.19.0 psycopg[binary]>=3.1.18 # 自动绑卡(local_auto)依赖 diff --git a/src/config/constants.py b/src/config/constants.py index 7e9a7a38..22a9f0e5 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -20,6 +20,27 @@ class AccountStatus(str, Enum): FAILED = "failed" +class AccountLabel(str, Enum): + """账号标签(注册类型)""" + NONE = "none" + MOTHER = "mother" + CHILD = "child" + + +class RoleTag(str, Enum): + """账号角色标签(增强版)""" + NONE = "none" + PARENT = "parent" + CHILD = "child" + + +class PoolState(str, Enum): + """账号池状态""" + TEAM_POOL = "team_pool" + CANDIDATE_POOL = "candidate_pool" + BLOCKED = "blocked" + + class TaskStatus(str, Enum): """任务状态""" PENDING = "pending" @@ -32,7 +53,6 @@ class TaskStatus(str, Enum): class EmailServiceType(str, Enum): """邮箱服务类型""" TEMPMAIL = "tempmail" - YYDS_MAIL = "yyds_mail" OUTLOOK = "outlook" MOE_MAIL = "moe_mail" TEMP_MAIL = "temp_mail" @@ -42,12 +62,62 @@ class EmailServiceType(str, Enum): CLOUDMAIL = "cloudmail" +def normalize_account_label(value: str) -> str: + """标准化账号标签,未知值降级为 none。""" + text = str(value or "").strip().lower() + if text in (AccountLabel.MOTHER.value, "parent", "manager", "母号"): + return AccountLabel.MOTHER.value + if text in (AccountLabel.CHILD.value, "member", "子号"): + return AccountLabel.CHILD.value + return AccountLabel.NONE.value + + +def normalize_role_tag(value: str) -> str: + """标准化角色标签,未知值降级为 none。""" + text = str(value or "").strip().lower() + if text in (RoleTag.PARENT.value, "mother", "manager", "母号"): + return RoleTag.PARENT.value + if text in (RoleTag.CHILD.value, "member", "子号"): + return RoleTag.CHILD.value + return RoleTag.NONE.value + + +def normalize_pool_state(value: str) -> str: + """标准化池状态,未知值降级为 candidate_pool。""" + text = str(value or "").strip().lower() + if text == PoolState.TEAM_POOL.value: + return PoolState.TEAM_POOL.value + if text == PoolState.BLOCKED.value: + return PoolState.BLOCKED.value + return PoolState.CANDIDATE_POOL.value + + +def role_tag_to_account_label(role_tag: str) -> str: + """role_tag -> account_label 兼容映射。""" + normalized = normalize_role_tag(role_tag) + if normalized == RoleTag.PARENT.value: + return AccountLabel.MOTHER.value + if normalized == RoleTag.CHILD.value: + return AccountLabel.CHILD.value + return AccountLabel.NONE.value + + +def account_label_to_role_tag(account_label: str) -> str: + """account_label -> role_tag 兼容映射。""" + normalized = normalize_account_label(account_label) + if normalized == AccountLabel.MOTHER.value: + return RoleTag.PARENT.value + if normalized == AccountLabel.CHILD.value: + return RoleTag.CHILD.value + return RoleTag.NONE.value + + # ============================================================================ # 应用常量 # ============================================================================ APP_NAME = "OpenAI/Codex CLI 自动注册系统" -APP_VERSION = "1.1.1" +APP_VERSION = "2.0.0" APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统" # ============================================================================ @@ -107,13 +177,6 @@ class EmailServiceType(str, Enum): "timeout": 30, "max_retries": 3, }, - "yyds_mail": { - "base_url": "https://maliapi.215.im/v1", - "api_key": "", - "default_domain": "", - "timeout": 30, - "max_retries": 3, - }, "outlook": { "imap_server": "outlook.office365.com", "imap_port": 993, diff --git a/src/config/settings.py b/src/config/settings.py index f137153b..3babe832 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -48,7 +48,7 @@ class SettingDefinition: ), "app_version": SettingDefinition( db_key="app.version", - default_value="1.1.1", + default_value="2.0.0", category=SettingCategory.GENERAL, description="应用版本" ), @@ -94,6 +94,66 @@ class SettingDefinition: description="Web UI 访问密码", is_secret=True ), + "auto_quick_refresh_enabled": SettingDefinition( + db_key="webui.auto_quick_refresh.enabled", + default_value=False, + category=SettingCategory.WEBUI, + description="是否启用账号管理自动一键刷新" + ), + "auto_quick_refresh_interval_minutes": SettingDefinition( + db_key="webui.auto_quick_refresh.interval_minutes", + default_value=30, + category=SettingCategory.WEBUI, + description="账号管理自动一键刷新间隔(分钟)" + ), + "auto_quick_refresh_retry_limit": SettingDefinition( + db_key="webui.auto_quick_refresh.retry_limit", + default_value=2, + category=SettingCategory.WEBUI, + description="账号管理自动一键刷新失败重试次数" + ), + "selfcheck_auto_enabled": SettingDefinition( + db_key="webui.selfcheck.auto_enabled", + default_value=False, + category=SettingCategory.WEBUI, + description="是否启用系统自检定时巡检" + ), + "selfcheck_interval_minutes": SettingDefinition( + db_key="webui.selfcheck.interval_minutes", + default_value=15, + category=SettingCategory.WEBUI, + description="系统自检定时巡检间隔(分钟)" + ), + "selfcheck_mode": SettingDefinition( + db_key="webui.selfcheck.mode", + default_value="quick", + category=SettingCategory.WEBUI, + description="系统自检定时巡检模式(quick/full)" + ), + "circuit_breaker_enabled": SettingDefinition( + db_key="runtime.circuit_breaker.enabled", + default_value=True, + category=SettingCategory.WEBUI, + description="是否启用失败熔断器" + ), + "circuit_breaker_failure_threshold": SettingDefinition( + db_key="runtime.circuit_breaker.failure_threshold", + default_value=5, + category=SettingCategory.WEBUI, + description="熔断触发连续失败次数阈值" + ), + "circuit_breaker_cooldown_seconds": SettingDefinition( + db_key="runtime.circuit_breaker.cooldown_seconds", + default_value=180, + category=SettingCategory.WEBUI, + description="熔断冷却时长(秒)" + ), + "circuit_breaker_probe_interval_seconds": SettingDefinition( + db_key="runtime.circuit_breaker.probe_interval_seconds", + default_value=30, + category=SettingCategory.WEBUI, + description="冷却结束后自动探活最小间隔(秒)" + ), # 日志配置 "log_level": SettingDefinition( @@ -258,18 +318,12 @@ class SettingDefinition: # 邮箱服务配置 "email_service_priority": SettingDefinition( db_key="email.service_priority", - default_value={"tempmail": 0, "yyds_mail": 1, "outlook": 2, "moe_mail": 3}, + default_value={"tempmail": 0, "outlook": 1, "moe_mail": 2}, category=SettingCategory.EMAIL, description="邮箱服务优先级" ), # Tempmail.lol 配置 - "tempmail_enabled": SettingDefinition( - db_key="tempmail.enabled", - default_value=True, - category=SettingCategory.TEMPMAIL, - description="是否启用 Tempmail 渠道" - ), "tempmail_base_url": SettingDefinition( db_key="tempmail.base_url", default_value="https://api.tempmail.lol/v2", @@ -288,43 +342,6 @@ class SettingDefinition: category=SettingCategory.TEMPMAIL, description="Tempmail 最大重试次数" ), - "yyds_mail_enabled": SettingDefinition( - db_key="yyds_mail.enabled", - default_value=False, - category=SettingCategory.TEMPMAIL, - description="是否启用 YYDS Mail 渠道" - ), - "yyds_mail_base_url": SettingDefinition( - db_key="yyds_mail.base_url", - default_value="https://maliapi.215.im/v1", - category=SettingCategory.TEMPMAIL, - description="YYDS Mail API 地址" - ), - "yyds_mail_api_key": SettingDefinition( - db_key="yyds_mail.api_key", - default_value="", - category=SettingCategory.TEMPMAIL, - description="YYDS Mail API Key", - is_secret=True - ), - "yyds_mail_default_domain": SettingDefinition( - db_key="yyds_mail.default_domain", - default_value="", - category=SettingCategory.TEMPMAIL, - description="YYDS Mail 默认域名" - ), - "yyds_mail_timeout": SettingDefinition( - db_key="yyds_mail.timeout", - default_value=30, - category=SettingCategory.TEMPMAIL, - description="YYDS Mail 超时时间(秒)" - ), - "yyds_mail_max_retries": SettingDefinition( - db_key="yyds_mail.max_retries", - default_value=3, - category=SettingCategory.TEMPMAIL, - description="YYDS Mail 最大重试次数" - ), # 自定义域名邮箱配置 "custom_domain_base_url": SettingDefinition( @@ -444,6 +461,16 @@ class SettingDefinition: "proxy_enabled": bool, "proxy_port": int, "proxy_dynamic_enabled": bool, + "auto_quick_refresh_enabled": bool, + "auto_quick_refresh_interval_minutes": int, + "auto_quick_refresh_retry_limit": int, + "selfcheck_auto_enabled": bool, + "selfcheck_interval_minutes": int, + "selfcheck_mode": str, + "circuit_breaker_enabled": bool, + "circuit_breaker_failure_threshold": int, + "circuit_breaker_cooldown_seconds": int, + "circuit_breaker_probe_interval_seconds": int, "registration_max_retries": int, "registration_timeout": int, "registration_default_password_length": int, @@ -451,12 +478,8 @@ class SettingDefinition: "registration_sleep_max": int, "registration_entry_flow": str, "email_service_priority": dict, - "tempmail_enabled": bool, "tempmail_timeout": int, "tempmail_max_retries": int, - "yyds_mail_enabled": bool, - "yyds_mail_timeout": int, - "yyds_mail_max_retries": int, "tm_enabled": bool, "cpa_enabled": bool, "email_code_timeout": int, @@ -639,7 +662,7 @@ class Settings(BaseModel): # 应用信息 app_name: str = "OpenAI/Codex CLI 自动注册系统" - app_version: str = "1.1.1" + app_version: str = "2.0.0" debug: bool = False # 数据库配置 @@ -666,6 +689,16 @@ def validate_database_url(cls, v): webui_port: int = 8000 webui_secret_key: SecretStr = SecretStr("your-secret-key-change-in-production") webui_access_password: SecretStr = SecretStr("admin123") + auto_quick_refresh_enabled: bool = False + auto_quick_refresh_interval_minutes: int = 30 + auto_quick_refresh_retry_limit: int = 2 + selfcheck_auto_enabled: bool = False + selfcheck_interval_minutes: int = 15 + selfcheck_mode: str = "quick" + circuit_breaker_enabled: bool = True + circuit_breaker_failure_threshold: int = 5 + circuit_breaker_cooldown_seconds: int = 180 + circuit_breaker_probe_interval_seconds: int = 30 # 日志配置 log_level: str = "INFO" @@ -720,19 +753,12 @@ def proxy_url(self) -> Optional[str]: registration_entry_flow: str = "native" # 邮箱服务配置 - email_service_priority: Dict[str, int] = {"tempmail": 0, "yyds_mail": 1, "outlook": 2, "moe_mail": 3} + email_service_priority: Dict[str, int] = {"tempmail": 0, "outlook": 1, "moe_mail": 2} # Tempmail.lol 配置 - tempmail_enabled: bool = True tempmail_base_url: str = "https://api.tempmail.lol/v2" tempmail_timeout: int = 30 tempmail_max_retries: int = 3 - yyds_mail_enabled: bool = False - yyds_mail_base_url: str = "https://maliapi.215.im/v1" - yyds_mail_api_key: Optional[SecretStr] = None - yyds_mail_default_domain: str = "" - yyds_mail_timeout: int = 30 - yyds_mail_max_retries: int = 3 # 自定义域名邮箱配置 custom_domain_base_url: str = "" diff --git a/src/core/circuit_breaker.py b/src/core/circuit_breaker.py new file mode 100644 index 00000000..4109590c --- /dev/null +++ b/src/core/circuit_breaker.py @@ -0,0 +1,211 @@ +""" +轻量级失败熔断器(DB 持久化 + 自动冷却探活恢复)。 +""" + +from __future__ import annotations + +import json +import threading +from datetime import datetime, timedelta +from typing import Any, Dict, Optional, Tuple + +from ..config.settings import get_settings +from ..database import crud +from ..database.session import get_db + +BREAKER_SETTING_KEY = "runtime.circuit_breaker.v1" +BREAKER_CHANNELS = ("proxy_runtime", "subscription_check", "team_invite") +_CACHE_TTL_SECONDS = 2.0 + +_state_lock = threading.Lock() +_state_cache: Dict[str, Any] = {"loaded_ts": 0.0, "data": {}} + + +def _utc_now() -> datetime: + return datetime.utcnow() + + +def _now_iso() -> str: + return _utc_now().isoformat() + + +def _parse_dt(value: Any) -> Optional[datetime]: + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None) + except Exception: + return None + + +def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return int(default) + + +def _settings_config() -> Dict[str, Any]: + settings = get_settings() + enabled = bool(getattr(settings, "circuit_breaker_enabled", True)) + threshold = max(1, _safe_int(getattr(settings, "circuit_breaker_failure_threshold", 5), 5)) + cooldown_seconds = max(10, _safe_int(getattr(settings, "circuit_breaker_cooldown_seconds", 180), 180)) + probe_interval_seconds = max(3, _safe_int(getattr(settings, "circuit_breaker_probe_interval_seconds", 30), 30)) + return { + "enabled": enabled, + "failure_threshold": threshold, + "cooldown_seconds": cooldown_seconds, + "probe_interval_seconds": probe_interval_seconds, + } + + +def _default_entry() -> Dict[str, Any]: + return { + "consecutive_fail": 0, + "opened_until": None, + "last_failure_at": None, + "last_success_at": None, + "last_error": None, + "last_probe_at": None, + "open_count": 0, + } + + +def _normalize_state(raw: Any) -> Dict[str, Dict[str, Any]]: + state = raw if isinstance(raw, dict) else {} + result: Dict[str, Dict[str, Any]] = {} + for channel in BREAKER_CHANNELS: + entry = state.get(channel) + merged = _default_entry() + if isinstance(entry, dict): + merged.update(entry) + result[channel] = merged + return result + + +def _load_state(force: bool = False) -> Dict[str, Dict[str, Any]]: + now_ts = _utc_now().timestamp() + with _state_lock: + if (not force) and (now_ts - float(_state_cache.get("loaded_ts") or 0.0) <= _CACHE_TTL_SECONDS): + return _normalize_state(_state_cache.get("data")) + + with get_db() as db: + setting = crud.get_setting(db, BREAKER_SETTING_KEY) + raw_text = str(getattr(setting, "value", "") or "").strip() + try: + parsed = json.loads(raw_text) if raw_text else {} + except Exception: + parsed = {} + normalized = _normalize_state(parsed) + _state_cache["loaded_ts"] = now_ts + _state_cache["data"] = normalized + return _normalize_state(normalized) + + +def _save_state(state: Dict[str, Dict[str, Any]]) -> None: + safe_state = _normalize_state(state) + payload = json.dumps(safe_state, ensure_ascii=False) + with _state_lock: + with get_db() as db: + crud.set_setting( + db, + key=BREAKER_SETTING_KEY, + value=payload, + description="失败熔断器运行时状态", + category="runtime", + ) + _state_cache["loaded_ts"] = _utc_now().timestamp() + _state_cache["data"] = safe_state + + +def _ensure_channel(channel: str) -> str: + name = str(channel or "").strip().lower() + if name not in BREAKER_CHANNELS: + raise ValueError(f"unsupported breaker channel: {channel}") + return name + + +def allow_request(channel: str) -> Tuple[bool, Dict[str, Any]]: + name = _ensure_channel(channel) + cfg = _settings_config() + if not cfg["enabled"]: + return True, {"state": "disabled"} + + state = _load_state() + entry = state[name] + now = _utc_now() + opened_until = _parse_dt(entry.get("opened_until")) + if opened_until and opened_until > now: + return False, { + "state": "open", + "opened_until": opened_until.isoformat(), + "consecutive_fail": _safe_int(entry.get("consecutive_fail"), 0), + } + + if opened_until and opened_until <= now: + last_probe = _parse_dt(entry.get("last_probe_at")) + if last_probe and (now - last_probe).total_seconds() < float(cfg["probe_interval_seconds"]): + next_probe_at = last_probe + timedelta(seconds=float(cfg["probe_interval_seconds"])) + return False, { + "state": "half_open_wait", + "opened_until": opened_until.isoformat(), + "next_probe_at": next_probe_at.isoformat(), + "consecutive_fail": _safe_int(entry.get("consecutive_fail"), 0), + } + entry["last_probe_at"] = _now_iso() + state[name] = entry + _save_state(state) + return True, {"state": "half_open_probe", "opened_until": opened_until.isoformat()} + + return True, {"state": "closed"} + + +def record_success(channel: str) -> Dict[str, Any]: + name = _ensure_channel(channel) + state = _load_state() + entry = state[name] + entry["consecutive_fail"] = 0 + entry["opened_until"] = None + entry["last_success_at"] = _now_iso() + entry["last_error"] = None + entry["last_probe_at"] = None + state[name] = entry + _save_state(state) + return dict(entry) + + +def record_failure(channel: str, error_message: Optional[str] = None) -> Dict[str, Any]: + name = _ensure_channel(channel) + cfg = _settings_config() + state = _load_state() + entry = state[name] + now = _utc_now() + consecutive = _safe_int(entry.get("consecutive_fail"), 0) + 1 + entry["consecutive_fail"] = consecutive + entry["last_failure_at"] = now.isoformat() + entry["last_error"] = str(error_message or "").strip()[:500] or None + + if cfg["enabled"] and consecutive >= int(cfg["failure_threshold"]): + entry["opened_until"] = (now + timedelta(seconds=int(cfg["cooldown_seconds"]))).isoformat() + entry["open_count"] = _safe_int(entry.get("open_count"), 0) + 1 + + state[name] = entry + _save_state(state) + return dict(entry) + + +def reset_channel(channel: str) -> Dict[str, Any]: + name = _ensure_channel(channel) + state = _load_state() + state[name] = _default_entry() + state[name]["last_success_at"] = _now_iso() + _save_state(state) + return dict(state[name]) + + +def snapshot() -> Dict[str, Any]: + return { + "config": _settings_config(), + "channels": _load_state(), + } diff --git a/src/core/openai/overview.py b/src/core/openai/overview.py index 640c2b20..90f60cce 100644 --- a/src/core/openai/overview.py +++ b/src/core/openai/overview.py @@ -8,6 +8,8 @@ import json import logging import re +import time +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple @@ -42,6 +44,11 @@ _RESET_IN_KEYS = ("reset_in", "time_to_reset", "ttl_seconds", "remaining_seconds", "seconds_to_reset") _HOURLY_WINDOW_MAX_SECONDS = 12 * 60 * 60 _WEEKLY_WINDOW_MIN_SECONDS = 5 * 24 * 60 * 60 +_OVERVIEW_HTTP_TIMEOUT_SECONDS = 14 +_OVERVIEW_HTTP_MAX_WORKERS = 3 +_OVERVIEW_HTTP_REQUIRED_RETRY = 2 +_OVERVIEW_HTTP_OPTIONAL_RETRY = 1 +_OVERVIEW_HTTP_RETRY_BASE_DELAY_SECONDS = 0.8 def _build_proxies(proxy: Optional[str]) -> Optional[dict]: @@ -152,12 +159,17 @@ def _build_headers(account: Account) -> Dict[str, str]: return headers -def _request_json(url: str, headers: Dict[str, str], proxy: Optional[str]) -> Dict[str, Any]: +def _request_json( + url: str, + headers: Dict[str, str], + proxy: Optional[str], + timeout_seconds: int = _OVERVIEW_HTTP_TIMEOUT_SECONDS, +) -> Dict[str, Any]: resp = cffi_requests.get( url, headers=headers, proxies=_build_proxies(proxy), - timeout=20, + timeout=max(5, int(timeout_seconds or _OVERVIEW_HTTP_TIMEOUT_SECONDS)), impersonate="chrome110", ) resp.raise_for_status() @@ -182,12 +194,17 @@ def _extract_http_status(exc: Exception) -> Optional[int]: return None -def _request_json_with_proxy_fallback(url: str, headers: Dict[str, str], proxy: Optional[str]) -> Dict[str, Any]: +def _request_json_with_proxy_fallback( + url: str, + headers: Dict[str, str], + proxy: Optional[str], + timeout_seconds: int = _OVERVIEW_HTTP_TIMEOUT_SECONDS, +) -> Dict[str, Any]: """ 配额抓取优先按当前代理请求;若代理异常则回退直连重试一次。 """ try: - return _request_json(url, headers, proxy) + return _request_json(url, headers, proxy, timeout_seconds=timeout_seconds) except Exception as proxy_exc: if not proxy: raise @@ -197,7 +214,46 @@ def _request_json_with_proxy_fallback(url: str, headers: Dict[str, str], proxy: logger.debug("概览请求代理回退直连: url=%s status=%s err=%s", url, status, proxy_exc) else: logger.warning("概览请求代理失败,回退直连重试: url=%s err=%s", url, proxy_exc) - return _request_json(url, headers, None) + return _request_json(url, headers, None, timeout_seconds=timeout_seconds) + + +def _is_retryable_overview_request_error(exc: Exception) -> bool: + status = _extract_http_status(exc) + if status is None: + return True + if status in (408, 429): + return True + return 500 <= int(status) <= 599 + + +def _request_json_with_retry( + *, + url: str, + headers: Dict[str, str], + proxy: Optional[str], + timeout_seconds: int, + attempts: int, +) -> Dict[str, Any]: + max_attempts = max(1, int(attempts or 1)) + last_error: Optional[Exception] = None + for attempt in range(1, max_attempts + 1): + try: + return _request_json_with_proxy_fallback( + url=url, + headers=headers, + proxy=proxy, + timeout_seconds=timeout_seconds, + ) + except Exception as exc: + last_error = exc + can_retry = attempt < max_attempts and _is_retryable_overview_request_error(exc) + if can_retry: + time.sleep(_OVERVIEW_HTTP_RETRY_BASE_DELAY_SECONDS * attempt) + continue + raise + if last_error: + raise last_error + raise RuntimeError("overview request failed") def _to_float(value: Any) -> Optional[float]: @@ -736,16 +792,32 @@ def fetch_codex_overview(account: Account, proxy: Optional[str] = None) -> Dict[ payloads: Dict[str, Dict[str, Any]] = {} errors: List[str] = [] - for source_name, url, required in _USAGE_ENDPOINTS: - try: - payloads[source_name] = _request_json_with_proxy_fallback(url, headers, proxy) - except Exception as exc: - status = _extract_http_status(exc) - # 可选端点在 401/403/404 场景静默降级,避免影响总览刷新日志可读性。 - if not required and status in (401, 403, 404): - logger.debug("概览可选端点降级跳过: source=%s status=%s", source_name, status) - continue - errors.append(f"{source_name}: {exc}") + worker_count = min(_OVERVIEW_HTTP_MAX_WORKERS, max(1, len(_USAGE_ENDPOINTS))) + with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="overview_fetch") as pool: + future_map = {} + for source_name, url, required in _USAGE_ENDPOINTS: + retry_attempts = _OVERVIEW_HTTP_REQUIRED_RETRY if required else _OVERVIEW_HTTP_OPTIONAL_RETRY + future = pool.submit( + _request_json_with_retry, + url=url, + headers=headers, + proxy=proxy, + timeout_seconds=_OVERVIEW_HTTP_TIMEOUT_SECONDS, + attempts=retry_attempts, + ) + future_map[future] = (source_name, bool(required)) + + for future in as_completed(future_map): + source_name, required = future_map[future] + try: + payloads[source_name] = future.result() + except Exception as exc: + status = _extract_http_status(exc) + # 可选端点在 401/403/404 场景静默降级,避免影响总览刷新日志可读性。 + if not required and status in (401, 403, 404): + logger.debug("概览可选端点降级跳过: source=%s status=%s", source_name, status) + continue + errors.append(f"{source_name}: {exc}") if not payloads: raise RuntimeError("所有概览接口请求失败") diff --git a/src/core/openai/token_refresh.py b/src/core/openai/token_refresh.py index 13e8ec8c..b9582550 100644 --- a/src/core/openai/token_refresh.py +++ b/src/core/openai/token_refresh.py @@ -14,7 +14,7 @@ from curl_cffi import requests as cffi_requests from ...config.settings import get_settings -from ...config.constants import AccountStatus +from ...config.constants import AccountStatus, OAUTH_CLIENT_ID from ...database.session import get_db from ...database import crud from ...database.models import Account @@ -78,12 +78,19 @@ def _create_direct_session(self) -> cffi_requests.Session: """创建直连会话(不走代理)。""" return cffi_requests.Session(impersonate="chrome120") - def refresh_by_session_token(self, session_token: str) -> TokenRefreshResult: + def refresh_by_session_token( + self, + session_token: str, + refresh_token: Optional[str] = None, + client_id: Optional[str] = None, + ) -> TokenRefreshResult: """ 使用 Session Token 刷新 Args: session_token: 会话令牌 + refresh_token: 可选 OAuth Refresh Token(用于一次性会话令牌场景兜底) + client_id: 可选 OAuth Client ID Returns: TokenRefreshResult: 刷新结果 @@ -110,6 +117,7 @@ def _request_once(session: cffi_requests.Session): session = self._create_session() response = _request_once(session) + # 某些一次性/异常 session_token 链路会返回 grant 错误,若提供了 refresh_token 则兜底走 OAuth 刷新 if response.status_code >= 400: try: error_payload = response.json() @@ -117,16 +125,19 @@ def _request_once(session: cffi_requests.Session): error_payload = {} error_text = str(error_payload.get("error") or "").lower() - error_description = str(error_payload.get("error_description") or response.text or "") - if response.status_code == 400 and error_text in {"invalid_grant", "unsupported_grant_type", "invalid_request"}: - fallback_data = { - "client_id": client_id, - "grant_type": "refresh_token", - "refresh_token": refresh_token, - } + if ( + response.status_code == 400 + and refresh_token + and error_text in {"invalid_grant", "unsupported_grant_type", "invalid_request"} + ): + oauth_client_id = client_id or self.settings.openai_client_id or OAUTH_CLIENT_ID response = session.post( self.TOKEN_URL, - data=fallback_data, + data={ + "client_id": oauth_client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, headers={ "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", @@ -282,7 +293,11 @@ def refresh_account(self, account: Account) -> TokenRefreshResult: # 优先尝试 Session Token if account.session_token: logger.info(f"尝试使用 Session Token 刷新账号 {account.email}") - result = self.refresh_by_session_token(account.session_token) + result = self.refresh_by_session_token( + account.session_token, + refresh_token=account.refresh_token, + client_id=account.client_id + ) if result.success: return result logger.warning(f"Session Token 刷新失败,尝试 OAuth 刷新") @@ -291,7 +306,11 @@ def refresh_account(self, account: Account) -> TokenRefreshResult: cookie_session_token = self._extract_session_token_from_cookies(getattr(account, "cookies", None)) if cookie_session_token: logger.info(f"尝试使用 Cookies 中的 Session Token 刷新账号 {account.email}") - result = self.refresh_by_session_token(cookie_session_token) + result = self.refresh_by_session_token( + cookie_session_token, + refresh_token=account.refresh_token, + client_id=account.client_id + ) if result.success: return result logger.warning("Cookies Session Token 刷新失败,尝试 OAuth 刷新") @@ -311,12 +330,13 @@ def refresh_account(self, account: Account) -> TokenRefreshResult: error_message="账号没有可用的刷新方式(缺少 session_token 和 refresh_token)" ) - def validate_token(self, access_token: str) -> Tuple[bool, Optional[str]]: + def validate_token(self, access_token: str, timeout_seconds: int = 30) -> Tuple[bool, Optional[str]]: """ 验证 Access Token 是否有效 Args: access_token: 访问令牌 + timeout_seconds: 请求超时(秒) Returns: Tuple[bool, Optional[str]]: (是否有效, 错误信息) @@ -331,15 +351,18 @@ def validate_token(self, access_token: str) -> Tuple[bool, Optional[str]]: "authorization": f"Bearer {access_token}", "accept": "application/json" }, - timeout=30 + timeout=max(5, int(timeout_seconds or 30)) ) if response.status_code == 200: return True, None elif response.status_code == 401: - return False, "Token 无效或已过期" + return False, "Token 无效(401)" + elif response.status_code == 402: + return False, "订阅受限(402)" elif response.status_code == 403: - return False, "账号可能被封禁" + # 403 在当前业务里通常代表工作区/权限受限,但账号可继续使用。 + return True, None else: return False, f"验证失败: HTTP {response.status_code}" @@ -384,13 +407,18 @@ def refresh_account_token(account_id: int, proxy_url: Optional[str] = None) -> T return result -def validate_account_token(account_id: int, proxy_url: Optional[str] = None) -> Tuple[bool, Optional[str]]: +def validate_account_token( + account_id: int, + proxy_url: Optional[str] = None, + timeout_seconds: int = 30, +) -> Tuple[bool, Optional[str]]: """ 验证指定账号的 Token 是否有效 Args: account_id: 账号 ID proxy_url: 代理 URL + timeout_seconds: 验证请求超时(秒) Returns: Tuple[bool, Optional[str]]: (是否有效, 错误信息) @@ -401,30 +429,41 @@ def validate_account_token(account_id: int, proxy_url: Optional[str] = None) -> return False, "账号不存在" if not account.access_token: - # 无 Token 直接归类为 failed,便于账号管理按“失败”筛选定位问题账号。 + # 无 Token 直接标记为 failed,便于账号管理筛选定位。 if account.status != AccountStatus.FAILED.value: crud.update_account(db, account_id, status=AccountStatus.FAILED.value) return False, "账号没有 access_token" manager = TokenRefreshManager(proxy_url=proxy_url) - is_valid, error = manager.validate_token(account.access_token) + is_valid, error = manager.validate_token( + account.access_token, + timeout_seconds=max(5, int(timeout_seconds or 30)), + ) - # 验证后回写账号状态,确保前端筛选(active/expired/banned/failed)与验证结果一致。 + # 验证后回写账号状态,确保列表状态与验证结果一致。 error_text = str(error or "").lower() if is_valid: next_status = AccountStatus.ACTIVE.value elif ( - "过期" in error_text - or "expired" in error_text - or "401" in error_text - or "invalid" in error_text + "402" in error_text + or "payment required" in error_text + or "订阅受限" in error_text ): + # 402 -> 黄色(expired) next_status = AccountStatus.EXPIRED.value + elif ( + "401" in error_text + or "invalid" in error_text + or "unauthorized" in error_text + or "过期" in error_text + or "expired" in error_text + ): + # 401 -> 红色(failed) + next_status = AccountStatus.FAILED.value elif ( "封禁" in error_text or "banned" in error_text or "forbidden" in error_text - or "403" in error_text ): next_status = AccountStatus.BANNED.value else: diff --git a/src/core/register.py b/src/core/register.py index f4a098c9..08ed3783 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -2771,7 +2771,12 @@ def run(self) -> RegistrationResult: result.error_message = str(e) return result - def save_to_database(self, result: RegistrationResult) -> bool: + def save_to_database( + self, + result: RegistrationResult, + account_label: Optional[str] = None, + role_tag: Optional[str] = None, + ) -> bool: """ 保存注册结果到数据库 @@ -2806,7 +2811,9 @@ def save_to_database(self, result: RegistrationResult) -> bool: id_token=result.id_token, proxy_used=self.proxy_url, extra_data=result.metadata, - source=result.source + source=result.source, + account_label=account_label, + role_tag=role_tag, ) self._log(f"账户已存进数据库,落袋为安,ID: {account.id}") diff --git a/src/core/system_selfcheck.py b/src/core/system_selfcheck.py new file mode 100644 index 00000000..cbc5d219 --- /dev/null +++ b/src/core/system_selfcheck.py @@ -0,0 +1,1311 @@ +""" +系统自检核心模块 +""" + +from __future__ import annotations + +import importlib.util +import logging +import os +import platform +import tempfile +import time +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta +from typing import Any, Callable, Dict, List, Optional, Set + +from curl_cffi import requests as cffi_requests + +from ..config.constants import AccountStatus +from ..config.settings import get_settings +from ..core.dynamic_proxy import get_proxy_url_for_task +from ..core.timezone_utils import to_shanghai_iso +from ..database import crud +from ..database.models import Account, BindCardTask, SelfCheckRun +from ..database.session import get_db + +logger = logging.getLogger(__name__) + +CHECK_STATUS_PASS = "pass" +CHECK_STATUS_WARN = "warn" +CHECK_STATUS_FAIL = "fail" +CHECK_STATUS_SKIP = "skip" + +RUN_STATUS_PENDING = "pending" +RUN_STATUS_RUNNING = "running" +RUN_STATUS_COMPLETED = "completed" +RUN_STATUS_FAILED = "failed" +RUN_STATUS_CANCELLED = "cancelled" + +PAID_TYPES = {"team", "plus"} +INVALID_ACCOUNT_STATUSES = { + AccountStatus.FAILED.value, + AccountStatus.EXPIRED.value, + AccountStatus.BANNED.value, +} + +OVERVIEW_EXTRA_DATA_KEY = "codex_overview" +OVERVIEW_CARD_REMOVED_KEY = "codex_overview_card_removed" + +REPAIR_CATALOG: Dict[str, Dict[str, str]] = { + "repair_team_pool": { + "name": "清理 Team 池无效账号", + "description": "将 Team 池中非 plus/team 或无效状态账号移回候选池", + }, + "repair_clear_overview_cache": { + "name": "重建账号总览缓存", + "description": "清除旧总览缓存,触发下次按新逻辑重算", + }, + "repair_mark_stuck_bind_tasks": { + "name": "清理超时绑卡任务", + "description": "将长时间未结束的绑卡任务标记失败,避免队列堆积", + }, + "repair_fill_orphan_task_email": { + "name": "补齐孤儿绑卡任务邮箱快照", + "description": "账号删除后残留任务若缺邮箱快照,则自动补齐文本展示", + }, + "repair_downgrade_402_to_free": { + "name": "402 账号订阅降级为 free", + "description": "将本次自检识别到 HTTP 402 的账号订阅状态改为 free", + }, +} +REPAIR_CENTER_STORE_KEY = "selfcheck.repair_center.store.v1" +REPAIR_CENTER_MAX_ROLLBACKS = 20 + + +def _utc_now() -> datetime: + return datetime.utcnow() + + +def _now_iso() -> str: + return _utc_now().isoformat() + + +def _parse_dt(value: Any) -> Optional[datetime]: + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None) + except Exception: + return None + + +def _clamp_int(value: Any, min_value: int, max_value: int, default: int) -> int: + try: + parsed = int(value) + except Exception: + parsed = int(default) + return max(min_value, min(max_value, parsed)) + + +def _safe_dict(value: Any) -> Dict[str, Any]: + return dict(value) if isinstance(value, dict) else {} + + +def _resolve_selfcheck_proxy_url() -> Optional[str]: + """ + 为系统自检解析代理 URL。 + 优先级与业务任务保持一致:代理列表默认项 -> 动态代理/静态代理配置。 + """ + # 1) 代理列表(优先默认代理,否则首个可用代理) + try: + with get_db() as db: + proxy = crud.get_random_proxy(db) + if proxy and str(proxy.proxy_url or "").strip(): + try: + crud.update_proxy_last_used(db, int(proxy.id)) + except Exception: + logger.debug("更新自检代理 last_used 失败: proxy_id=%s", getattr(proxy, "id", None), exc_info=True) + return str(proxy.proxy_url).strip() + except Exception: + logger.debug("从代理列表解析自检代理失败", exc_info=True) + + # 2) 动态代理 / 静态代理 + return get_proxy_url_for_task() or get_settings().proxy_url + + +def _serialize_run(run: SelfCheckRun) -> Dict[str, Any]: + payload = run.to_dict() + payload["created_at"] = to_shanghai_iso(run.created_at) + payload["started_at"] = to_shanghai_iso(run.started_at) + payload["finished_at"] = to_shanghai_iso(run.finished_at) + payload["updated_at"] = to_shanghai_iso(run.updated_at) + return payload + + +def _build_http_session(proxy_url: Optional[str]) -> cffi_requests.Session: + kwargs: Dict[str, Any] = {"impersonate": "chrome120"} + if proxy_url: + kwargs["proxy"] = proxy_url + return cffi_requests.Session(**kwargs) + + +def _probe_endpoint( + *, + name: str, + url: str, + method: str = "GET", + headers: Optional[Dict[str, str]] = None, + json_body: Optional[Dict[str, Any]] = None, + timeout_seconds: int = 12, + expected_codes: Optional[List[int]] = None, + proxy_url: Optional[str] = None, + allow_direct_fallback: bool = True, + critical: bool = False, +) -> Dict[str, Any]: + expected = set(expected_codes or [200]) + endpoint_result: Dict[str, Any] = { + "name": name, + "url": url, + "method": method.upper(), + "critical": bool(critical), + "expected_codes": sorted(expected), + "ok": False, + "via": None, + "http_status": None, + "error": "", + "proxy": {"used": bool(proxy_url), "status": None, "error": ""}, + "direct": {"status": None, "error": ""}, + } + + def _request_once(use_proxy: bool) -> Dict[str, Any]: + session = _build_http_session(proxy_url if use_proxy else None) + started = time.perf_counter() + req_kwargs: Dict[str, Any] = { + "url": url, + "headers": headers or {}, + "timeout": timeout_seconds, + } + if json_body is not None: + req_kwargs["json"] = json_body + if method.upper() == "POST": + resp = session.post(**req_kwargs) + else: + resp = session.get(**req_kwargs) + cost = int((time.perf_counter() - started) * 1000) + return {"status": int(resp.status_code), "elapsed_ms": cost} + + if proxy_url: + try: + proxy_out = _request_once(True) + endpoint_result["proxy"]["status"] = proxy_out["status"] + endpoint_result["proxy"]["elapsed_ms"] = proxy_out["elapsed_ms"] + endpoint_result["via"] = "proxy" + endpoint_result["http_status"] = proxy_out["status"] + endpoint_result["ok"] = proxy_out["status"] in expected + if endpoint_result["ok"]: + return endpoint_result + if not allow_direct_fallback: + endpoint_result["error"] = f"unexpected_status:{proxy_out['status']}" + return endpoint_result + except Exception as exc: + endpoint_result["proxy"]["error"] = str(exc) + endpoint_result["via"] = "proxy" + if not allow_direct_fallback: + endpoint_result["error"] = str(exc) + return endpoint_result + + try: + direct_out = _request_once(False) + endpoint_result["direct"]["status"] = direct_out["status"] + endpoint_result["direct"]["elapsed_ms"] = direct_out["elapsed_ms"] + endpoint_result["http_status"] = direct_out["status"] + endpoint_result["via"] = "direct" + endpoint_result["ok"] = direct_out["status"] in expected + if not endpoint_result["ok"]: + endpoint_result["error"] = f"unexpected_status:{direct_out['status']}" + except Exception as exc: + endpoint_result["direct"]["error"] = str(exc) + endpoint_result["error"] = str(exc) + + return endpoint_result + + +def _build_check( + *, + key: str, + name: str, + status: str, + message: str, + details: Optional[Dict[str, Any]] = None, + fixes: Optional[List[str]] = None, + duration_ms: int = 0, +) -> Dict[str, Any]: + return { + "key": key, + "name": name, + "status": status, + "message": message, + "duration_ms": int(duration_ms or 0), + "details": details or {}, + "fixes": fixes or [], + } + +def _check_environment() -> Dict[str, Any]: + started = time.perf_counter() + warnings: List[str] = [] + failures: List[str] = [] + + settings = get_settings() + tz_name = str(datetime.now().astimezone().tzinfo or "") + if "shanghai" not in tz_name.lower() and "+08" not in tz_name: + warnings.append(f"当前进程时区={tz_name},建议使用 Asia/Shanghai") + + playwright_ready = importlib.util.find_spec("playwright") is not None + if not playwright_ready: + warnings.append("未检测到 playwright,涉及本地可视化自动绑卡时会受限") + + cffi_ready = importlib.util.find_spec("curl_cffi") is not None + if not cffi_ready: + failures.append("未检测到 curl_cffi,核心网络链路不可用") + + db_url = str(settings.database_url or "") + db_path = "" + if db_url.startswith("sqlite:///"): + db_path = db_url.replace("sqlite:///", "", 1) + elif db_url and "://" not in db_url: + db_path = db_url + + if db_path: + try: + data_dir = os.path.dirname(os.path.abspath(db_path)) + os.makedirs(data_dir, exist_ok=True) + with tempfile.NamedTemporaryFile(prefix="selfcheck_", suffix=".tmp", dir=data_dir, delete=True): + pass + except Exception as exc: + failures.append(f"数据库目录写入失败: {exc}") + + logs_dir = os.path.abspath("logs") + try: + os.makedirs(logs_dir, exist_ok=True) + with tempfile.NamedTemporaryFile(prefix="selfcheck_", suffix=".tmp", dir=logs_dir, delete=True): + pass + except Exception as exc: + failures.append(f"日志目录写入失败: {exc}") + + if failures: + status = CHECK_STATUS_FAIL + message = "环境检查失败:" + ";".join(failures[:2]) + elif warnings: + status = CHECK_STATUS_WARN + message = "环境检查通过(存在建议项)" + else: + status = CHECK_STATUS_PASS + message = "环境检查通过" + + return _build_check( + key="environment", + name="环境与依赖", + status=status, + message=message, + duration_ms=int((time.perf_counter() - started) * 1000), + details={ + "python_version": platform.python_version(), + "platform": platform.platform(), + "timezone": tz_name, + "playwright_installed": playwright_ready, + "curl_cffi_installed": cffi_ready, + "warnings": warnings, + "failures": failures, + }, + ) + + +def _check_network(proxy_url: Optional[str]) -> Dict[str, Any]: + started = time.perf_counter() + settings = get_settings() + timeout_seconds = _clamp_int(getattr(settings, "registration_timeout", 120), 5, 30, 12) + proxy_only_mode = bool(str(proxy_url or "").strip()) + + tempmail_base = str(getattr(settings, "tempmail_base_url", "") or "").strip() + endpoints: List[Dict[str, Any]] = [ + { + "name": "chatgpt_me", + "url": "https://chatgpt.com/backend-api/me", + "method": "GET", + "expected_codes": [200, 401, 403], + "critical": True, + }, + { + "name": "openai_auth", + "url": "https://auth.openai.com/", + "method": "GET", + "expected_codes": [200, 301, 302, 307, 308, 403] if proxy_only_mode else [200, 301, 302, 307, 308], + "critical": True, + }, + { + "name": "sentinel", + "url": "https://sentinel.openai.com/backend-api/sentinel/req", + "method": "POST", + "json_body": {}, + "expected_codes": [200, 400, 401, 403, 405], + "critical": False, + }, + ] + if tempmail_base: + endpoints.append( + { + "name": "tempmail", + "url": tempmail_base, + "method": "GET", + "expected_codes": [200, 301, 302, 401, 403, 404], + "critical": False, + } + ) + + def _run_endpoint(item: Dict[str, Any]) -> Dict[str, Any]: + return _probe_endpoint( + name=item["name"], + url=item["url"], + method=item.get("method", "GET"), + json_body=item.get("json_body"), + expected_codes=item.get("expected_codes"), + timeout_seconds=timeout_seconds, + proxy_url=proxy_url, + allow_direct_fallback=not proxy_only_mode, + critical=item.get("critical", False), + ) + + checks_indexed: List[Dict[str, Any]] = [] + worker_count = min(4, max(1, len(endpoints))) + with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="selfcheck_network") as pool: + future_map = {pool.submit(_run_endpoint, item): idx for idx, item in enumerate(endpoints)} + for future in as_completed(future_map): + idx = future_map[future] + try: + checks_indexed.append({"idx": idx, "item": future.result()}) + except Exception as exc: + item = endpoints[idx] + checks_indexed.append( + { + "idx": idx, + "item": { + "name": item.get("name") or f"endpoint_{idx}", + "url": item.get("url") or "", + "method": str(item.get("method") or "GET").upper(), + "critical": bool(item.get("critical", False)), + "expected_codes": item.get("expected_codes") or [200], + "ok": False, + "via": "proxy" if proxy_only_mode else "direct", + "http_status": None, + "error": str(exc), + "proxy": {"used": bool(proxy_url), "status": None, "error": str(exc) if proxy_only_mode else ""}, + "direct": {"status": None, "error": str(exc) if not proxy_only_mode else ""}, + }, + } + ) + checks = [row["item"] for row in sorted(checks_indexed, key=lambda row: int(row.get("idx", 0)))] + + critical_failures = [item for item in checks if item["critical"] and not item["ok"]] + minor_failures = [item for item in checks if (not item["critical"]) and not item["ok"]] + + if critical_failures: + status = CHECK_STATUS_FAIL + message = f"关键网络不可达 {len(critical_failures)} 项" + elif minor_failures: + status = CHECK_STATUS_WARN + message = f"网络可用,但有 {len(minor_failures)} 项异常" + else: + status = CHECK_STATUS_PASS + message = "网络连通性正常" + + return _build_check( + key="network", + name="网络连通性", + status=status, + message=message, + duration_ms=int((time.perf_counter() - started) * 1000), + details={ + "proxy_preferred": bool(proxy_url), + "proxy_mode": "proxy_only" if proxy_only_mode else "direct_or_fallback", + "checks": checks, + }, + ) + + +def _probe_account_status(account: Account, fallback_proxy: Optional[str]) -> Dict[str, Any]: + account_id = int(account.id) + email = str(account.email or "") + token = str(account.access_token or "") + proxy = str(account.proxy_used or "").strip() or fallback_proxy + if not token: + return { + "id": account_id, + "email": email, + "http_status": None, + "state": "missing_token", + "severity": CHECK_STATUS_WARN, + "message": "缺少 access_token", + } + + probe = _probe_endpoint( + name=f"account_{account_id}", + url="https://chatgpt.com/backend-api/me", + method="GET", + headers={"authorization": f"Bearer {token}", "accept": "application/json"}, + expected_codes=[200, 401, 402, 403], + timeout_seconds=15, + proxy_url=proxy, + critical=False, + ) + code = probe.get("http_status") + if code == 200: + state, severity, message = "ok", CHECK_STATUS_PASS, "可用" + elif code == 401: + state, severity, message = "unauthorized", CHECK_STATUS_FAIL, "Token 失效(401)" + elif code == 402: + state, severity, message = "payment_required", CHECK_STATUS_WARN, "订阅不可用(402)" + elif code == 403: + state, severity, message = "workspace_restricted", CHECK_STATUS_PASS, "工作区受限(403),账号仍可用" + else: + state, severity, message = "unknown", CHECK_STATUS_WARN, f"未知状态({code or 'n/a'})" + + return { + "id": account_id, + "email": email, + "http_status": code, + "state": state, + "severity": severity, + "message": message, + "via": probe.get("via"), + } + + +def _check_accounts_auth(mode: str, proxy_url: Optional[str]) -> Dict[str, Any]: + started = time.perf_counter() + with get_db() as db: + all_accounts = db.query(Account).order_by(Account.id.desc()).all() + + if not all_accounts: + return _build_check( + key="accounts_auth", + name="账号鉴权抽检", + status=CHECK_STATUS_WARN, + message="当前无账号,跳过鉴权抽检", + duration_ms=int((time.perf_counter() - started) * 1000), + details={"checked": 0, "total": 0, "accounts": []}, + ) + + sample_limit = 8 if mode == "quick" else 40 + sample_accounts = all_accounts[: min(sample_limit, len(all_accounts))] + details: List[Dict[str, Any]] = [] + with ThreadPoolExecutor(max_workers=min(8, max(1, len(sample_accounts)))) as pool: + futures = [pool.submit(_probe_account_status, account, proxy_url) for account in sample_accounts] + for future in as_completed(futures): + try: + details.append(future.result()) + except Exception as exc: + details.append({"id": 0, "email": "-", "http_status": None, "state": "probe_exception", "severity": CHECK_STATUS_WARN, "message": str(exc)}) + + details.sort(key=lambda item: int(item.get("id") or 0), reverse=True) + fail_count = sum(1 for item in details if item.get("severity") == CHECK_STATUS_FAIL) + warn_count = sum(1 for item in details if item.get("severity") == CHECK_STATUS_WARN) + pass_count = sum(1 for item in details if item.get("severity") == CHECK_STATUS_PASS) + + fixes: List[str] = [] + if any(int(item.get("http_status") or 0) == 402 for item in details): + fixes.append("repair_downgrade_402_to_free") + if fail_count > 0: + fixes.append("repair_team_pool") + + if fail_count > 0: + status = CHECK_STATUS_FAIL + message = f"抽检 {len(details)} 个账号:失败 {fail_count},警告 {warn_count}" + elif warn_count > 0: + status = CHECK_STATUS_WARN + message = f"抽检 {len(details)} 个账号:警告 {warn_count}" + else: + status = CHECK_STATUS_PASS + message = f"抽检 {len(details)} 个账号全部可用" + + return _build_check( + key="accounts_auth", + name="账号鉴权抽检", + status=status, + message=message, + duration_ms=int((time.perf_counter() - started) * 1000), + fixes=fixes, + details={ + "checked": len(details), + "total": len(all_accounts), + "pass_count": pass_count, + "warn_count": warn_count, + "fail_count": fail_count, + "accounts": details, + }, + ) + +def _check_team_pool() -> Dict[str, Any]: + started = time.perf_counter() + with get_db() as db: + rows = db.query(Account).filter(Account.pool_state == "team_pool").all() + + if not rows: + return _build_check( + key="team_pool", + name="Team 池一致性", + status=CHECK_STATUS_WARN, + message="当前 Team 池为空", + duration_ms=int((time.perf_counter() - started) * 1000), + details={"total": 0, "invalid": 0, "items": []}, + ) + + invalid_items: List[Dict[str, Any]] = [] + for account in rows: + sub = str(account.subscription_type or "").strip().lower() + status = str(account.status or "").strip().lower() + invalid_reason = "" + if sub not in PAID_TYPES: + invalid_reason = f"subscription={sub or '-'}" + elif status in INVALID_ACCOUNT_STATUSES: + invalid_reason = f"status={status}" + if invalid_reason: + invalid_items.append({"id": int(account.id), "email": str(account.email or ""), "reason": invalid_reason}) + + if invalid_items: + status = CHECK_STATUS_FAIL + message = f"Team 池存在 {len(invalid_items)} 个无效账号" + fixes = ["repair_team_pool"] + else: + status = CHECK_STATUS_PASS + message = "Team 池一致性正常" + fixes = [] + + return _build_check( + key="team_pool", + name="Team 池一致性", + status=status, + message=message, + duration_ms=int((time.perf_counter() - started) * 1000), + fixes=fixes, + details={"total": len(rows), "invalid": len(invalid_items), "items": invalid_items}, + ) + + +def _check_payment_pipeline() -> Dict[str, Any]: + started = time.perf_counter() + now = _utc_now() + stale_cutoff = now - timedelta(minutes=30) + stale_statuses = {"link_ready", "opened", "waiting_user_action", "verifying"} + + with get_db() as db: + stale_tasks = ( + db.query(BindCardTask) + .filter(BindCardTask.status.in_(list(stale_statuses)), BindCardTask.created_at < stale_cutoff) + .order_by(BindCardTask.created_at.asc()) + .all() + ) + + playwright_ready = importlib.util.find_spec("playwright") is not None + stale_count = len(stale_tasks) + fixes: List[str] = [] + if stale_count > 0: + fixes.append("repair_mark_stuck_bind_tasks") + + if stale_count >= 8: + status, message = CHECK_STATUS_FAIL, f"检测到 {stale_count} 个超时绑卡任务" + elif stale_count > 0 or (not playwright_ready): + status = CHECK_STATUS_WARN + if stale_count > 0 and not playwright_ready: + message = f"存在 {stale_count} 个超时任务,且缺少 playwright" + elif stale_count > 0: + message = f"存在 {stale_count} 个超时任务" + else: + message = "未检测到 playwright,本地全自动模式受限" + else: + status, message = CHECK_STATUS_PASS, "支付链路基础状态正常" + + return _build_check( + key="payment_pipeline", + name="支付链路健康", + status=status, + message=message, + duration_ms=int((time.perf_counter() - started) * 1000), + fixes=fixes, + details={ + "playwright_installed": playwright_ready, + "stale_count": stale_count, + "stale_tasks": [ + { + "id": int(task.id), + "status": str(task.status or ""), + "account_email": str(task.account_email or ""), + "created_at": to_shanghai_iso(task.created_at), + } + for task in stale_tasks[:50] + ], + }, + ) + + +def _check_data_consistency() -> Dict[str, Any]: + started = time.perf_counter() + with get_db() as db: + orphan_tasks = ( + db.query(BindCardTask) + .filter(BindCardTask.account_id.is_(None), (BindCardTask.account_email.is_(None) | (BindCardTask.account_email == ""))) + .all() + ) + accounts = db.query(Account).all() + + cached_count = 0 + malformed_count = 0 + for account in accounts: + extra = _safe_dict(account.extra_data) + if OVERVIEW_EXTRA_DATA_KEY in extra or OVERVIEW_CARD_REMOVED_KEY in extra: + cached_count += 1 + elif account.extra_data is not None and not isinstance(account.extra_data, dict): + malformed_count += 1 + + fixes: List[str] = [] + if orphan_tasks: + fixes.append("repair_fill_orphan_task_email") + if cached_count > 0: + fixes.append("repair_clear_overview_cache") + + if orphan_tasks: + status, message = CHECK_STATUS_FAIL, f"存在 {len(orphan_tasks)} 条缺邮箱快照的孤儿绑卡任务" + elif malformed_count > 0: + status, message = CHECK_STATUS_WARN, f"发现 {malformed_count} 条额外数据结构异常" + else: + status, message = CHECK_STATUS_PASS, "数据一致性正常" + + return _build_check( + key="data_consistency", + name="数据一致性", + status=status, + message=message, + duration_ms=int((time.perf_counter() - started) * 1000), + fixes=fixes, + details={ + "orphan_bind_tasks": len(orphan_tasks), + "cached_overview_accounts": cached_count, + "malformed_extra_data_accounts": malformed_count, + }, + ) + + +def _compute_score(checks: List[Dict[str, Any]]) -> Dict[str, int]: + passed = sum(1 for item in checks if item.get("status") == CHECK_STATUS_PASS) + warns = sum(1 for item in checks if item.get("status") == CHECK_STATUS_WARN) + failed = sum(1 for item in checks if item.get("status") == CHECK_STATUS_FAIL) + total = len(checks) + score = 0 if total <= 0 else int(round((passed * 100 + warns * 60) / total)) + return {"score": score, "total": total, "passed": passed, "warns": warns, "failed": failed} + + +def create_selfcheck_run(mode: str = "quick", source: str = "manual") -> Dict[str, Any]: + mode_text = "full" if str(mode or "").strip().lower() == "full" else "quick" + source_text = str(source or "manual").strip().lower() or "manual" + with get_db() as db: + run = SelfCheckRun( + run_uuid=str(uuid.uuid4()), + mode=mode_text, + source=source_text, + status=RUN_STATUS_PENDING, + result_data={"checks": [], "repairs": [], "progress": {"completed": 0, "total": 0}}, + ) + db.add(run) + db.commit() + db.refresh(run) + return _serialize_run(run) + + +def has_running_selfcheck_run() -> bool: + with get_db() as db: + running = db.query(SelfCheckRun).filter(SelfCheckRun.status.in_([RUN_STATUS_PENDING, RUN_STATUS_RUNNING])).count() + return int(running or 0) > 0 + + +def execute_selfcheck_run( + run_id: int, + *, + mode: Optional[str] = None, + source: Optional[str] = None, + progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None, + cancel_checker: Optional[Callable[[], bool]] = None, +) -> Dict[str, Any]: + with get_db() as db: + run = db.query(SelfCheckRun).filter(SelfCheckRun.id == int(run_id)).first() + if not run: + raise ValueError("自检任务不存在") + mode_text = "full" if str(mode or run.mode or "").strip().lower() == "full" else "quick" + source_text = str(source or run.source or "manual").strip().lower() or "manual" + run.mode = mode_text + run.source = source_text + run.status = RUN_STATUS_RUNNING + run.started_at = _utc_now() + run.finished_at = None + run.error_message = None + run.result_data = {"checks": [], "repairs": [], "progress": {"completed": 0, "total": 0}} + run.summary = "系统自检运行中" + db.commit() + + started = time.perf_counter() + checks: List[Dict[str, Any]] = [] + proxy_url = _resolve_selfcheck_proxy_url() + check_funcs: List[Callable[[], Dict[str, Any]]] = [_check_environment, lambda: _check_network(proxy_url), lambda: _check_accounts_auth(mode_text, proxy_url)] + if mode_text == "full": + check_funcs.extend([_check_team_pool, _check_payment_pipeline, _check_data_consistency]) + + total = len(check_funcs) + failed_error = "" + try: + for idx, func in enumerate(check_funcs, start=1): + if cancel_checker and cancel_checker(): + score_info = _compute_score(checks) + elapsed_ms = int((time.perf_counter() - started) * 1000) + with get_db() as db: + run = db.query(SelfCheckRun).filter(SelfCheckRun.id == int(run_id)).first() + if not run: + raise ValueError("自检任务不存在") + run.status = RUN_STATUS_CANCELLED + run.total_checks = score_info["total"] + run.passed_checks = score_info["passed"] + run.warning_checks = score_info["warns"] + run.failed_checks = score_info["failed"] + run.score = score_info["score"] + run.duration_ms = elapsed_ms + run.summary = f"任务已取消(已完成 {len(checks)}/{total})" + run.error_message = None + run.finished_at = _utc_now() + data = _safe_dict(run.result_data) + data["checks"] = checks + data["progress"] = {"completed": len(checks), "total": total} + run.result_data = data + db.commit() + db.refresh(run) + return _serialize_run(run) + item_started = time.perf_counter() + try: + item = func() + except Exception as exc: + logger.exception("自检项执行异常: run_id=%s index=%s error=%s", run_id, idx, exc) + item = _build_check(key=f"check_{idx}", name=f"检查项 #{idx}", status=CHECK_STATUS_FAIL, message=f"执行异常: {exc}", duration_ms=int((time.perf_counter() - item_started) * 1000)) + checks.append(item) + + score_info = _compute_score(checks) + partial_payload = {"checks": checks, "repairs": [], "progress": {"completed": idx, "total": total}} + with get_db() as db: + run = db.query(SelfCheckRun).filter(SelfCheckRun.id == int(run_id)).first() + if run: + run.result_data = partial_payload + run.total_checks = total + run.passed_checks = score_info["passed"] + run.warning_checks = score_info["warns"] + run.failed_checks = score_info["failed"] + run.score = score_info["score"] + run.duration_ms = int((time.perf_counter() - started) * 1000) + run.updated_at = _utc_now() + db.commit() + if progress_callback: + progress_callback(_serialize_run(run)) + + score_info = _compute_score(checks) + elapsed_ms = int((time.perf_counter() - started) * 1000) + summary = f"完成 {score_info['total']} 项:通过 {score_info['passed']},警告 {score_info['warns']},失败 {score_info['failed']}" + with get_db() as db: + run = db.query(SelfCheckRun).filter(SelfCheckRun.id == int(run_id)).first() + if not run: + raise ValueError("自检任务不存在") + result_data = _safe_dict(run.result_data) + result_data["checks"] = checks + result_data["progress"] = {"completed": total, "total": total} + run.result_data = result_data + run.status = RUN_STATUS_COMPLETED if score_info["failed"] == 0 else RUN_STATUS_FAILED + run.total_checks = score_info["total"] + run.passed_checks = score_info["passed"] + run.warning_checks = score_info["warns"] + run.failed_checks = score_info["failed"] + run.score = score_info["score"] + run.duration_ms = elapsed_ms + run.summary = summary + run.error_message = None if run.status == RUN_STATUS_COMPLETED else "存在失败检查项" + run.finished_at = _utc_now() + db.commit() + db.refresh(run) + return _serialize_run(run) + except Exception as exc: + failed_error = str(exc) + logger.exception("自检流程执行失败: run_id=%s error=%s", run_id, exc) + + with get_db() as db: + run = db.query(SelfCheckRun).filter(SelfCheckRun.id == int(run_id)).first() + if not run: + raise ValueError("自检任务不存在") + run.status = RUN_STATUS_FAILED + run.error_message = failed_error or "自检流程失败" + run.finished_at = _utc_now() + run.duration_ms = int((time.perf_counter() - started) * 1000) + db.commit() + db.refresh(run) + return _serialize_run(run) + + +def list_selfcheck_runs(limit: int = 20) -> List[Dict[str, Any]]: + safe_limit = _clamp_int(limit, 1, 200, 20) + with get_db() as db: + rows = db.query(SelfCheckRun).order_by(SelfCheckRun.id.desc()).limit(safe_limit).all() + return [_serialize_run(item) for item in rows] + + +def get_selfcheck_run(run_id: int) -> Optional[Dict[str, Any]]: + with get_db() as db: + run = db.query(SelfCheckRun).filter(SelfCheckRun.id == int(run_id)).first() + return _serialize_run(run) if run else None + +def _repair_team_pool() -> Dict[str, Any]: + moved = 0 + checked = 0 + with get_db() as db: + rows = db.query(Account).filter(Account.pool_state == "team_pool").all() + for account in rows: + checked += 1 + sub = str(account.subscription_type or "").strip().lower() + status = str(account.status or "").strip().lower() + if sub in PAID_TYPES and status not in INVALID_ACCOUNT_STATUSES: + continue + account.pool_state = "candidate_pool" + account.last_pool_sync_at = _utc_now() + moved += 1 + db.commit() + return {"checked": checked, "moved_to_candidate_pool": moved} + + +def _repair_clear_overview_cache() -> Dict[str, Any]: + affected = 0 + with get_db() as db: + rows = db.query(Account).all() + for account in rows: + extra = _safe_dict(account.extra_data) + touched = False + if OVERVIEW_EXTRA_DATA_KEY in extra: + extra.pop(OVERVIEW_EXTRA_DATA_KEY, None) + touched = True + if OVERVIEW_CARD_REMOVED_KEY in extra: + extra.pop(OVERVIEW_CARD_REMOVED_KEY, None) + touched = True + if touched: + account.extra_data = extra + affected += 1 + db.commit() + return {"affected_accounts": affected} + + +def _repair_mark_stuck_bind_tasks() -> Dict[str, Any]: + updated = 0 + cutoff = _utc_now() - timedelta(minutes=30) + stale_statuses = {"link_ready", "opened", "waiting_user_action", "verifying"} + with get_db() as db: + rows = db.query(BindCardTask).filter(BindCardTask.status.in_(list(stale_statuses)), BindCardTask.created_at < cutoff).all() + for task in rows: + task.status = "failed" + origin = str(task.last_error or "").strip() + suffix = "系统自检修复:超时任务自动置为 failed" + task.last_error = f"{origin} | {suffix}" if origin else suffix + task.updated_at = _utc_now() + updated += 1 + db.commit() + return {"updated_tasks": updated} + + +def _repair_fill_orphan_task_email() -> Dict[str, Any]: + fixed = 0 + with get_db() as db: + rows = db.query(BindCardTask).filter(BindCardTask.account_id.is_(None), (BindCardTask.account_email.is_(None) | (BindCardTask.account_email == ""))).all() + for task in rows: + task.account_email = f"deleted-account-{task.id}@history.local" + task.updated_at = _utc_now() + fixed += 1 + db.commit() + return {"fixed_tasks": fixed} + + +def _collect_402_target_ids(run_id: int) -> List[int]: + with get_db() as db: + run = db.query(SelfCheckRun).filter(SelfCheckRun.id == int(run_id)).first() + if not run: + return [] + result = _safe_dict(run.result_data) + checks = result.get("checks") or [] + target_ids: List[int] = [] + for check in checks: + if str(check.get("key")) != "accounts_auth": + continue + details = _safe_dict(check.get("details")) + for item in details.get("accounts") or []: + code = int(item.get("http_status") or 0) + account_id = int(item.get("id") or 0) + if code == 402 and account_id > 0: + target_ids.append(account_id) + return sorted(set(target_ids)) + + +def _load_repair_center_store() -> Dict[str, Any]: + with get_db() as db: + setting = crud.get_setting(db, REPAIR_CENTER_STORE_KEY) + raw = str(getattr(setting, "value", "") or "").strip() + if not raw: + return {"rollbacks": []} + try: + data = json.loads(raw) + except Exception: + return {"rollbacks": []} + if not isinstance(data, dict): + return {"rollbacks": []} + rollbacks = data.get("rollbacks") + if not isinstance(rollbacks, list): + rollbacks = [] + return {"rollbacks": rollbacks} + + +def _save_repair_center_store(store: Dict[str, Any]) -> None: + payload = json.dumps(store or {"rollbacks": []}, ensure_ascii=False) + with get_db() as db: + crud.set_setting( + db, + key=REPAIR_CENTER_STORE_KEY, + value=payload, + description="系统自检修复中心回滚点", + category="selfcheck", + ) + + +def _build_repair_snapshot(run_id: int, repair_keys: List[str]) -> Dict[str, Any]: + keys = [str(key or "").strip() for key in repair_keys if str(key or "").strip() in REPAIR_CATALOG] + account_ids: Set[int] = set() + bind_task_ids: Set[int] = set() + + with get_db() as db: + if "repair_team_pool" in keys: + ids = [int(row.id) for row in db.query(Account.id).filter(Account.pool_state == "team_pool").all()] + account_ids.update(ids) + if "repair_clear_overview_cache" in keys: + rows = db.query(Account).all() + for account in rows: + extra = _safe_dict(account.extra_data) + if OVERVIEW_EXTRA_DATA_KEY in extra or OVERVIEW_CARD_REMOVED_KEY in extra: + account_ids.add(int(account.id)) + if "repair_downgrade_402_to_free" in keys: + account_ids.update(_collect_402_target_ids(run_id)) + if "repair_mark_stuck_bind_tasks" in keys: + cutoff = _utc_now() - timedelta(minutes=30) + stale_statuses = {"link_ready", "opened", "waiting_user_action", "verifying"} + ids = [ + int(row.id) + for row in db.query(BindCardTask.id) + .filter(BindCardTask.status.in_(list(stale_statuses)), BindCardTask.created_at < cutoff) + .all() + ] + bind_task_ids.update(ids) + if "repair_fill_orphan_task_email" in keys: + ids = [ + int(row.id) + for row in db.query(BindCardTask.id) + .filter(BindCardTask.account_id.is_(None), (BindCardTask.account_email.is_(None) | (BindCardTask.account_email == ""))) + .all() + ] + bind_task_ids.update(ids) + + account_rows = [] + if account_ids: + rows = db.query(Account).filter(Account.id.in_(list(account_ids))).all() + for account in rows: + account_rows.append( + { + "id": int(account.id), + "pool_state": account.pool_state, + "pool_state_manual": account.pool_state_manual, + "last_pool_sync_at": account.last_pool_sync_at.isoformat() if account.last_pool_sync_at else None, + "subscription_type": account.subscription_type, + "subscription_at": account.subscription_at.isoformat() if account.subscription_at else None, + "extra_data": _safe_dict(account.extra_data), + } + ) + + bind_rows = [] + if bind_task_ids: + rows = db.query(BindCardTask).filter(BindCardTask.id.in_(list(bind_task_ids))).all() + for task in rows: + bind_rows.append( + { + "id": int(task.id), + "status": task.status, + "last_error": task.last_error, + "account_email": task.account_email, + "updated_at": task.updated_at.isoformat() if task.updated_at else None, + } + ) + + return { + "accounts": account_rows, + "bind_card_tasks": bind_rows, + } + + +def _append_repair_rollback_entry(entry: Dict[str, Any]) -> None: + store = _load_repair_center_store() + rows = list(store.get("rollbacks") or []) + rows.insert(0, dict(entry or {})) + store["rollbacks"] = rows[:REPAIR_CENTER_MAX_ROLLBACKS] + _save_repair_center_store(store) + + +def _build_preview_item(key: str, run_id: int) -> Dict[str, Any]: + if key == "repair_team_pool": + with get_db() as db: + rows = db.query(Account).filter(Account.pool_state == "team_pool").all() + impact = 0 + for account in rows: + sub = str(account.subscription_type or "").strip().lower() + status = str(account.status or "").strip().lower() + if sub in PAID_TYPES and status not in INVALID_ACCOUNT_STATUSES: + continue + impact += 1 + return {"key": key, "name": REPAIR_CATALOG[key]["name"], "impact_count": impact, "preview": {"checked": len(rows), "will_move": impact}} + + if key == "repair_clear_overview_cache": + with get_db() as db: + rows = db.query(Account).all() + impact = 0 + for account in rows: + extra = _safe_dict(account.extra_data) + if OVERVIEW_EXTRA_DATA_KEY in extra or OVERVIEW_CARD_REMOVED_KEY in extra: + impact += 1 + return {"key": key, "name": REPAIR_CATALOG[key]["name"], "impact_count": impact, "preview": {"affected_accounts": impact}} + + if key == "repair_mark_stuck_bind_tasks": + cutoff = _utc_now() - timedelta(minutes=30) + stale_statuses = {"link_ready", "opened", "waiting_user_action", "verifying"} + with get_db() as db: + count = db.query(BindCardTask).filter(BindCardTask.status.in_(list(stale_statuses)), BindCardTask.created_at < cutoff).count() + return {"key": key, "name": REPAIR_CATALOG[key]["name"], "impact_count": int(count or 0), "preview": {"updated_tasks": int(count or 0)}} + + if key == "repair_fill_orphan_task_email": + with get_db() as db: + count = db.query(BindCardTask).filter(BindCardTask.account_id.is_(None), (BindCardTask.account_email.is_(None) | (BindCardTask.account_email == ""))).count() + return {"key": key, "name": REPAIR_CATALOG[key]["name"], "impact_count": int(count or 0), "preview": {"fixed_tasks": int(count or 0)}} + + if key == "repair_downgrade_402_to_free": + ids = _collect_402_target_ids(run_id) + return {"key": key, "name": REPAIR_CATALOG[key]["name"], "impact_count": len(ids), "preview": {"matched_402_accounts": len(ids), "account_ids": ids[:200]}} + + return {"key": key, "name": REPAIR_CATALOG.get(key, {}).get("name", key), "impact_count": 0, "preview": {}} + + +def _repair_downgrade_402_to_free(run_id: int) -> Dict[str, Any]: + target_ids = _collect_402_target_ids(run_id) + with get_db() as db: + if not target_ids: + return {"updated_accounts": 0, "matched_402_accounts": 0} + + rows = db.query(Account).filter(Account.id.in_(target_ids)).all() + updated = 0 + for account in rows: + account.subscription_type = "free" + account.subscription_at = None + updated += 1 + db.commit() + return {"updated_accounts": updated, "matched_402_accounts": len(target_ids)} + + +def run_repair_action(run_id: int, repair_key: str) -> Dict[str, Any]: + key = str(repair_key or "").strip() + if key not in REPAIR_CATALOG: + raise ValueError("不支持的修复动作") + + started = time.perf_counter() + if key == "repair_team_pool": + detail = _repair_team_pool() + elif key == "repair_clear_overview_cache": + detail = _repair_clear_overview_cache() + elif key == "repair_mark_stuck_bind_tasks": + detail = _repair_mark_stuck_bind_tasks() + elif key == "repair_fill_orphan_task_email": + detail = _repair_fill_orphan_task_email() + elif key == "repair_downgrade_402_to_free": + detail = _repair_downgrade_402_to_free(run_id) + else: + raise ValueError("不支持的修复动作") + + repair_entry = { + "key": key, + "name": REPAIR_CATALOG[key]["name"], + "finished_at": to_shanghai_iso(_utc_now()), + "duration_ms": int((time.perf_counter() - started) * 1000), + "detail": detail, + } + + with get_db() as db: + run = db.query(SelfCheckRun).filter(SelfCheckRun.id == int(run_id)).first() + if run: + data = _safe_dict(run.result_data) + repairs = list(data.get("repairs") or []) + repairs.append(repair_entry) + data["repairs"] = repairs[-200:] + run.result_data = data + run.updated_at = _utc_now() + db.commit() + + return repair_entry + + +def preview_repair_actions(run_id: int, repair_keys: Optional[List[str]] = None) -> Dict[str, Any]: + keys = [str(key or "").strip() for key in (repair_keys or list(REPAIR_CATALOG.keys())) if str(key or "").strip() in REPAIR_CATALOG] + items = [_build_preview_item(key, run_id) for key in keys] + total_impact = sum(int(item.get("impact_count") or 0) for item in items) + return { + "run_id": int(run_id), + "keys": keys, + "total_impact": total_impact, + "items": items, + } + + +def list_repair_rollbacks(limit: int = 20) -> List[Dict[str, Any]]: + safe_limit = _clamp_int(limit, 1, REPAIR_CENTER_MAX_ROLLBACKS, 20) + store = _load_repair_center_store() + rows = list(store.get("rollbacks") or []) + result: List[Dict[str, Any]] = [] + for item in rows[:safe_limit]: + if not isinstance(item, dict): + continue + result.append( + { + "rollback_id": item.get("rollback_id"), + "created_at": item.get("created_at"), + "run_id": item.get("run_id"), + "repair_keys": item.get("repair_keys") or [], + "counts": item.get("counts") or {}, + } + ) + return result + + +def execute_repair_plan(run_id: int, repair_keys: List[str], actor: str = "system") -> Dict[str, Any]: + keys = [str(key or "").strip() for key in (repair_keys or []) if str(key or "").strip() in REPAIR_CATALOG] + if not keys: + raise ValueError("修复计划为空") + + preview = preview_repair_actions(run_id, keys) + rollback_id = str(uuid.uuid4()) + snapshot = _build_repair_snapshot(run_id, keys) + rollback_entry = { + "rollback_id": rollback_id, + "created_at": _now_iso(), + "run_id": int(run_id), + "actor": str(actor or "system"), + "repair_keys": keys, + "counts": { + "accounts": len(snapshot.get("accounts") or []), + "bind_card_tasks": len(snapshot.get("bind_card_tasks") or []), + }, + "snapshot": snapshot, + } + _append_repair_rollback_entry(rollback_entry) + + results: List[Dict[str, Any]] = [] + for key in keys: + results.append(run_repair_action(run_id, key)) + + try: + with get_db() as db: + crud.create_operation_audit_log( + db, + actor=actor, + action="selfcheck.repair_center.execute", + target_type="selfcheck_run", + target_id=run_id, + payload={ + "rollback_id": rollback_id, + "repair_keys": keys, + "preview_total_impact": int(preview.get("total_impact") or 0), + }, + ) + except Exception: + logger.debug("记录修复中心审计日志失败: run_id=%s", run_id, exc_info=True) + + return { + "run_id": int(run_id), + "rollback_id": rollback_id, + "preview": preview, + "results": results, + } + + +def rollback_repair_plan(rollback_id: str) -> Dict[str, Any]: + rollback_key = str(rollback_id or "").strip() + if not rollback_key: + raise ValueError("rollback_id 不能为空") + + store = _load_repair_center_store() + rollbacks = list(store.get("rollbacks") or []) + target: Optional[Dict[str, Any]] = None + for item in rollbacks: + if isinstance(item, dict) and str(item.get("rollback_id") or "").strip() == rollback_key: + target = item + break + if not target: + raise ValueError("回滚点不存在") + + snapshot = _safe_dict(target.get("snapshot")) + account_rows = snapshot.get("accounts") or [] + bind_rows = snapshot.get("bind_card_tasks") or [] + restored_accounts = 0 + restored_bind_tasks = 0 + + with get_db() as db: + if account_rows: + account_ids = [int(item.get("id") or 0) for item in account_rows if int(item.get("id") or 0) > 0] + account_map = {int(row.id): row for row in db.query(Account).filter(Account.id.in_(account_ids)).all()} + for item in account_rows: + account_id = int(item.get("id") or 0) + account = account_map.get(account_id) + if not account: + continue + account.pool_state = item.get("pool_state") + account.pool_state_manual = item.get("pool_state_manual") + account.last_pool_sync_at = _parse_dt(item.get("last_pool_sync_at")) + account.subscription_type = item.get("subscription_type") + account.subscription_at = _parse_dt(item.get("subscription_at")) + account.extra_data = _safe_dict(item.get("extra_data")) + restored_accounts += 1 + + if bind_rows: + bind_ids = [int(item.get("id") or 0) for item in bind_rows if int(item.get("id") or 0) > 0] + bind_map = {int(row.id): row for row in db.query(BindCardTask).filter(BindCardTask.id.in_(bind_ids)).all()} + for item in bind_rows: + bind_id = int(item.get("id") or 0) + task = bind_map.get(bind_id) + if not task: + continue + task.status = item.get("status") + task.last_error = item.get("last_error") + task.account_email = item.get("account_email") + task.updated_at = _parse_dt(item.get("updated_at")) + restored_bind_tasks += 1 + db.commit() + + try: + with get_db() as db: + crud.create_operation_audit_log( + db, + actor="system", + action="selfcheck.repair_center.rollback", + target_type="selfcheck_repair_rollback", + target_id=rollback_key, + payload={ + "restored_accounts": restored_accounts, + "restored_bind_card_tasks": restored_bind_tasks, + }, + ) + except Exception: + logger.debug("记录修复中心回滚审计日志失败: rollback_id=%s", rollback_key, exc_info=True) + + return { + "rollback_id": rollback_key, + "restored_accounts": restored_accounts, + "restored_bind_card_tasks": restored_bind_tasks, + } diff --git a/src/database/__init__.py b/src/database/__init__.py index 1ee05b91..63383aaf 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -2,7 +2,7 @@ 数据库模块 """ -from .models import Base, Account, EmailService, RegistrationTask, Setting +from .models import Base, Account, EmailService, RegistrationTask, Setting, SelfCheckRun from .session import get_db, init_database, get_session_manager, DatabaseSessionManager from . import crud @@ -12,6 +12,7 @@ 'EmailService', 'RegistrationTask', 'Setting', + 'SelfCheckRun', 'get_db', 'init_database', 'get_session_manager', diff --git a/src/database/crud.py b/src/database/crud.py index 3c6c8492..f37b1429 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -7,7 +7,26 @@ from sqlalchemy.orm import Session from sqlalchemy import and_, or_, desc, asc, func -from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService +from ..config.constants import ( + PoolState, + account_label_to_role_tag, + normalize_account_label, + normalize_pool_state, + normalize_role_tag, + role_tag_to_account_label, +) +from .models import ( + Account, + EmailService, + RegistrationTask, + Setting, + Proxy, + CpaService, + Sub2ApiService, + BindCardTask, + TeamInviteRecord, + OperationAuditLog, +) # ============================================================================ @@ -32,9 +51,25 @@ def create_account( expires_at: Optional['datetime'] = None, extra_data: Optional[Dict[str, Any]] = None, status: Optional[str] = None, - source: Optional[str] = None + source: Optional[str] = None, + account_label: Optional[str] = None, + role_tag: Optional[str] = None, + biz_tag: Optional[str] = None, + pool_state: Optional[str] = None, + pool_state_manual: Optional[str] = None, + priority: Optional[int] = None, + last_used_at: Optional['datetime'] = None, ) -> Account: """创建新账户""" + normalized_role_tag = normalize_role_tag( + role_tag if role_tag is not None else account_label_to_role_tag(account_label) + ) + normalized_account_label = role_tag_to_account_label(normalized_role_tag) + normalized_pool_state = normalize_pool_state(pool_state) if pool_state is not None else PoolState.CANDIDATE_POOL.value + normalized_pool_state_manual = ( + normalize_pool_state(pool_state_manual) if pool_state_manual is not None and str(pool_state_manual).strip() else None + ) + db_account = Account( email=email, password=password, @@ -53,6 +88,13 @@ def create_account( extra_data=extra_data or {}, status=status or 'active', source=source or 'register', + account_label=normalized_account_label, + role_tag=normalized_role_tag, + biz_tag=(str(biz_tag).strip() or None) if biz_tag is not None else None, + pool_state=normalized_pool_state, + pool_state_manual=normalized_pool_state_manual, + priority=int(priority) if priority is not None else 50, + last_used_at=last_used_at, registered_at=datetime.utcnow() ) db.add(db_account) @@ -111,9 +153,46 @@ def update_account( return None for key, value in kwargs.items(): + if key == "role_tag" and value is not None: + normalized_role = normalize_role_tag(value) + db_account.role_tag = normalized_role + db_account.account_label = role_tag_to_account_label(normalized_role) + continue + + if key == "account_label" and value is not None: + normalized_label = normalize_account_label(value) + db_account.account_label = normalized_label + db_account.role_tag = account_label_to_role_tag(normalized_label) + continue + + if key in ("pool_state", "pool_state_manual"): + if value is None: + setattr(db_account, key, None) + elif str(value).strip(): + setattr(db_account, key, normalize_pool_state(value)) + else: + setattr(db_account, key, None) + continue + + if key == "biz_tag": + db_account.biz_tag = str(value).strip() or None if value is not None else None + continue + + if key == "priority" and value is not None: + try: + db_account.priority = int(value) + except Exception: + db_account.priority = 50 + continue + if hasattr(db_account, key) and value is not None: setattr(db_account, key, value) + # 兜底双写:保证旧字段 account_label 与 role_tag 始终一致 + role_value = normalize_role_tag(getattr(db_account, "role_tag", None)) + db_account.role_tag = role_value + db_account.account_label = role_tag_to_account_label(role_value) + db.commit() db.refresh(db_account) return db_account @@ -121,20 +200,69 @@ def update_account( def delete_account(db: Session, account_id: int) -> bool: """删除账户""" + def _detach_bind_card_tasks(snapshot_email: str): + linked_tasks = db.query(BindCardTask).filter(BindCardTask.account_id == account_id).all() + for task in linked_tasks: + if not str(getattr(task, "account_email", "") or "").strip(): + task.account_email = snapshot_email + task.account_id = None + + def _detach_team_invite_records(snapshot_email: str): + linked_records = db.query(TeamInviteRecord).filter(TeamInviteRecord.inviter_account_id == account_id).all() + for record in linked_records: + if not str(getattr(record, "inviter_email", "") or "").strip(): + record.inviter_email = snapshot_email + record.inviter_account_id = None + db_account = get_account_by_id(db, account_id) if not db_account: return False - db.delete(db_account) - db.commit() - return True + try: + # 正常路径:保留绑卡任务历史,先解绑再删账号 + _detach_bind_card_tasks(db_account.email) + _detach_team_invite_records(db_account.email) + db.flush() + db.delete(db_account) + db.commit() + return True + except Exception as e: + db.rollback() + err = str(e).lower() + need_retry_with_migration = ( + "bind_card_tasks" in err and + "account_id" in err and + ("not null" in err or "constraint failed" in err or "foreign key" in err) + ) + if not need_retry_with_migration: + raise + + # 旧库结构兜底:先跑迁移,再重试一次删除 + from .session import get_session_manager + get_session_manager().migrate_tables() + + db_account = get_account_by_id(db, account_id) + if not db_account: + return False + try: + _detach_bind_card_tasks(db_account.email) + _detach_team_invite_records(db_account.email) + db.flush() + db.delete(db_account) + db.commit() + return True + except Exception: + db.rollback() + raise def delete_accounts_batch(db: Session, account_ids: List[int]) -> int: """批量删除账户""" - result = db.query(Account).filter(Account.id.in_(account_ids)).delete(synchronize_session=False) - db.commit() - return result + deleted = 0 + for account_id in account_ids: + if delete_account(db, account_id): + deleted += 1 + return deleted def get_accounts_count( @@ -386,10 +514,85 @@ def delete_setting(db: Session, key: str) -> bool: return True +# ============================================================================ +# 操作审计日志 +# ============================================================================ + +def create_operation_audit_log( + db: Session, + *, + actor: Optional[str], + action: str, + target_type: str, + target_id: Optional[Union[str, int]] = None, + target_email: Optional[str] = None, + payload: Optional[Dict[str, Any]] = None, +) -> OperationAuditLog: + row = OperationAuditLog( + actor=(str(actor or "").strip() or "system"), + action=str(action or "").strip() or "unknown_action", + target_type=str(target_type or "").strip() or "unknown_target", + target_id=(str(target_id).strip() if target_id is not None else None), + target_email=(str(target_email or "").strip() or None), + payload=dict(payload or {}), + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def list_operation_audit_logs( + db: Session, + *, + limit: int = 100, + action: Optional[str] = None, + target_type: Optional[str] = None, +) -> List[OperationAuditLog]: + safe_limit = max(1, min(500, int(limit or 100))) + query = db.query(OperationAuditLog) + if action: + query = query.filter(OperationAuditLog.action == str(action).strip()) + if target_type: + query = query.filter(OperationAuditLog.target_type == str(target_type).strip()) + return query.order_by(desc(OperationAuditLog.id)).limit(safe_limit).all() + + # ============================================================================ # 代理 CRUD # ============================================================================ +def _ensure_single_default_proxy(db: Session) -> Optional[Proxy]: + """ + 保证代理表中“有且仅有一个默认代理”: + - 没有默认时,自动使用最早创建(ID 最小)的代理作为默认 + - 有多个默认时,仅保留最早的一个 + """ + proxies = db.query(Proxy).order_by(asc(Proxy.id)).all() + if not proxies: + return None + + default_proxies = [proxy for proxy in proxies if bool(proxy.is_default)] + keeper = default_proxies[0] if default_proxies else proxies[0] + changed = False + + if not keeper.is_default: + keeper.is_default = True + changed = True + + for proxy in proxies: + should_default = proxy.id == keeper.id + if bool(proxy.is_default) != should_default: + proxy.is_default = should_default + changed = True + + if changed: + db.commit() + db.refresh(keeper) + + return keeper + + def create_proxy( db: Session, name: str, @@ -415,6 +618,10 @@ def create_proxy( db.add(db_proxy) db.commit() db.refresh(db_proxy) + + # 统一默认代理策略:首次添加自动默认,多代理保持“第一个默认”直到手动切换。 + _ensure_single_default_proxy(db) + db.refresh(db_proxy) return db_proxy @@ -430,6 +637,7 @@ def get_proxies( limit: int = 100 ) -> List[Proxy]: """获取代理列表""" + _ensure_single_default_proxy(db) query = db.query(Proxy) if enabled is not None: @@ -471,6 +679,7 @@ def delete_proxy(db: Session, proxy_id: int) -> bool: db.delete(db_proxy) db.commit() + _ensure_single_default_proxy(db) return True @@ -486,28 +695,38 @@ def update_proxy_last_used(db: Session, proxy_id: int) -> bool: def get_random_proxy(db: Session) -> Optional[Proxy]: - """随机获取一个启用的代理,优先返回 is_default=True 的代理""" - import random - # 优先返回默认代理 - default_proxy = db.query(Proxy).filter(Proxy.enabled == True, Proxy.is_default == True).first() + """获取一个启用代理:优先默认代理,否则使用最早启用的代理。""" + _ensure_single_default_proxy(db) + + # 优先返回启用状态下的默认代理 + default_proxy = ( + db.query(Proxy) + .filter(Proxy.enabled == True, Proxy.is_default == True) + .order_by(asc(Proxy.id)) + .first() + ) if default_proxy: return default_proxy - proxies = get_enabled_proxies(db) - if not proxies: - return None - return random.choice(proxies) + + # 默认代理不可用时,回退到最早启用的代理(稳定而可预期) + return ( + db.query(Proxy) + .filter(Proxy.enabled == True) + .order_by(asc(Proxy.id)) + .first() + ) def set_proxy_default(db: Session, proxy_id: int) -> Optional[Proxy]: """将指定代理设为默认,同时清除其他代理的默认标记""" - # 清除所有默认标记 - db.query(Proxy).filter(Proxy.is_default == True).update({"is_default": False}) - # 设置新的默认代理 proxy = db.query(Proxy).filter(Proxy.id == proxy_id).first() - if proxy: - proxy.is_default = True - db.commit() - db.refresh(proxy) + if not proxy: + return None + + db.query(Proxy).filter(Proxy.id != proxy_id, Proxy.is_default == True).update({"is_default": False}) + proxy.is_default = True + db.commit() + db.refresh(proxy) return proxy @@ -608,6 +827,7 @@ def create_sub2api_service( name=name, api_url=api_url, api_key=api_key, + target_type=target_type, enabled=enabled, priority=priority, ) diff --git a/src/database/models.py b/src/database/models.py index ff0e4443..d3b1aa7b 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -10,6 +10,8 @@ from sqlalchemy.types import TypeDecorator from sqlalchemy.orm import relationship +from ..config.constants import AccountLabel, PoolState, RoleTag + Base = declarative_base() @@ -53,12 +55,21 @@ class Account(Base): cpa_uploaded = Column(Boolean, default=False) # 是否已上传到 CPA cpa_uploaded_at = Column(DateTime) # 上传时间 source = Column(String(20), default='register') # 'register' 或 'login',区分账号来源 + account_label = Column(String(20), default=AccountLabel.NONE.value) # none / mother / child + role_tag = Column(String(20), default=RoleTag.NONE.value, index=True) # none / parent / child + biz_tag = Column(String(80), index=True) # 业务标签(可选) + pool_state = Column(String(30), default=PoolState.CANDIDATE_POOL.value, index=True) # team_pool / candidate_pool / blocked + pool_state_manual = Column(String(30), index=True) # 手工覆盖池状态(可空) + last_pool_sync_at = Column(DateTime, index=True) # 最近一次池状态同步 + priority = Column(Integer, default=50, index=True) # 账号优先级 + last_used_at = Column(DateTime, index=True) # 最近用于邀请/绑卡等任务的时间 subscription_type = Column(String(20)) # None / 'plus' / 'team' subscription_at = Column(DateTime) # 订阅开通时间 cookies = Column(Text) # 完整 cookie 字符串,用于支付请求 created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) bind_card_tasks = relationship("BindCardTask", back_populates="account") + team_invite_records = relationship("TeamInviteRecord", back_populates="inviter_account") def to_dict(self) -> Dict[str, Any]: """转换为字典""" @@ -78,6 +89,14 @@ def to_dict(self) -> Dict[str, Any]: 'cpa_uploaded': self.cpa_uploaded, 'cpa_uploaded_at': self.cpa_uploaded_at.isoformat() if self.cpa_uploaded_at else None, 'source': self.source, + 'account_label': self.account_label, + 'role_tag': self.role_tag, + 'biz_tag': self.biz_tag, + 'pool_state': self.pool_state, + 'pool_state_manual': self.pool_state_manual, + 'last_pool_sync_at': self.last_pool_sync_at.isoformat() if self.last_pool_sync_at else None, + 'priority': self.priority, + 'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None, 'subscription_type': self.subscription_type, 'subscription_at': self.subscription_at.isoformat() if self.subscription_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None, @@ -125,7 +144,9 @@ class BindCardTask(Base): __tablename__ = "bind_card_tasks" id = Column(Integer, primary_key=True, autoincrement=True) - account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False, index=True) + # 允许账号删除后保留任务记录:删除账号时将 account_id 置空,邮箱使用快照字段展示 + account_id = Column(Integer, ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=True) + account_email = Column(String(255)) # 账号邮箱快照(历史记录展示用) plan_type = Column(String(20), nullable=False) # plus / team workspace_name = Column(String(255)) price_interval = Column(String(20)) @@ -150,6 +171,27 @@ class BindCardTask(Base): account = relationship("Account", back_populates="bind_card_tasks") +class TeamInviteRecord(Base): + """Team 邀请记录表(用于目标邮箱候选去重与异步状态收敛)""" + __tablename__ = "team_invite_records" + + id = Column(Integer, primary_key=True, autoincrement=True) + inviter_account_id = Column(Integer, ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=True) + inviter_email = Column(String(255)) # 邀请人邮箱快照(账号删除后仍可追溯) + target_email = Column(String(255), nullable=False, index=True) + workspace_id = Column(String(255), index=True) + state = Column(String(20), default="pending", index=True) # pending / invited / joined / expired / failed + invite_attempts = Column(Integer, default=1) + last_error = Column(Text) + invited_at = Column(DateTime, default=datetime.utcnow) + accepted_at = Column(DateTime) + expires_at = Column(DateTime) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + inviter_account = relationship("Account", back_populates="team_invite_records") + + class AppLog(Base): """应用日志表(后台日志监控)""" __tablename__ = "app_logs" @@ -178,6 +220,78 @@ def to_dict(self) -> Dict[str, Any]: } +class OperationAuditLog(Base): + """操作审计日志(记录关键管理动作)""" + __tablename__ = "operation_audit_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + actor = Column(String(120), index=True) # 谁触发(admin/api/system) + action = Column(String(120), nullable=False, index=True) # 做了什么 + target_type = Column(String(80), nullable=False, index=True) # 作用对象类型 + target_id = Column(String(120), index=True) # 作用对象 ID(字符串兼容多类型) + target_email = Column(String(255), index=True) # 账号邮箱快照 + payload = Column(JSONEncodedDict) # 变更前后与附加信息 + created_at = Column(DateTime, default=datetime.utcnow, index=True) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "actor": self.actor, + "action": self.action, + "target_type": self.target_type, + "target_id": self.target_id, + "target_email": self.target_email, + "payload": self.payload or {}, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + +class SelfCheckRun(Base): + """系统自检运行记录""" + __tablename__ = "selfcheck_runs" + + id = Column(Integer, primary_key=True, autoincrement=True) + run_uuid = Column(String(64), unique=True, nullable=False, index=True) + mode = Column(String(20), nullable=False, default="quick") # quick / full + source = Column(String(40), nullable=False, default="manual") # manual / scheduler / api + status = Column(String(20), nullable=False, default="pending") # pending / running / completed / failed / cancelled + score = Column(Integer, default=0) + total_checks = Column(Integer, default=0) + passed_checks = Column(Integer, default=0) + warning_checks = Column(Integer, default=0) + failed_checks = Column(Integer, default=0) + duration_ms = Column(Integer, default=0) + summary = Column(String(500)) + error_message = Column(Text) + result_data = Column(JSONEncodedDict) # 结构化明细(检查项/修复项/统计) + created_at = Column(DateTime, default=datetime.utcnow, index=True) + started_at = Column(DateTime) + finished_at = Column(DateTime) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "run_uuid": self.run_uuid, + "mode": self.mode, + "source": self.source, + "status": self.status, + "score": int(self.score or 0), + "total_checks": int(self.total_checks or 0), + "passed_checks": int(self.passed_checks or 0), + "warning_checks": int(self.warning_checks or 0), + "failed_checks": int(self.failed_checks or 0), + "duration_ms": int(self.duration_ms or 0), + "summary": self.summary, + "error_message": self.error_message, + "result_data": self.result_data or {}, + "created_at": self.created_at.isoformat() if self.created_at else None, + "started_at": self.started_at.isoformat() if self.started_at else None, + "finished_at": self.finished_at.isoformat() if self.finished_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + class Setting(Base): """系统设置表""" __tablename__ = 'settings' @@ -197,7 +311,7 @@ class CpaService(Base): name = Column(String(100), nullable=False) # 服务名称 api_url = Column(String(500), nullable=False) # API URL api_token = Column(Text, nullable=False) # API Token - proxy_url = Column(String(1000)) # ?? URL + proxy_url = Column(String(1000)) # 代理 URL enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 created_at = Column(DateTime, default=datetime.utcnow) diff --git a/src/database/session.py b/src/database/session.py index 7aa8a490..be54ef2e 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -92,6 +92,114 @@ def drop_tables(self): """删除所有表(谨慎使用)""" Base.metadata.drop_all(bind=self.engine) + def _migrate_bind_card_tasks_keep_history_on_account_delete(self, conn): + """ + 迁移 bind_card_tasks: + - account_id 允许为空(账号删除后保留任务历史) + - 增加 account_email 快照字段用于历史展示 + """ + try: + table_exists = conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table' AND name='bind_card_tasks'") + ).fetchone() + if not table_exists: + return + + table_info = conn.execute(text("PRAGMA table_info('bind_card_tasks')")).fetchall() + if not table_info: + return + + column_map = {str(row[1]): row for row in table_info} + account_info = column_map.get("account_id") + has_account_email = "account_email" in column_map + account_notnull = int(account_info[3]) if account_info else 0 + + # 已是目标结构:仅做一次邮箱快照补齐 + if has_account_email and account_notnull == 0: + conn.execute(text( + """ + UPDATE bind_card_tasks + SET account_email = COALESCE( + NULLIF(account_email, ''), + (SELECT email FROM accounts WHERE accounts.id = bind_card_tasks.account_id) + ) + WHERE account_email IS NULL OR TRIM(account_email) = '' + """ + )) + conn.commit() + return + + logger.info("迁移 bind_card_tasks:启用账号删除后保留任务历史") + + select_email_expr = ( + "COALESCE(NULLIF(t.account_email, ''), a.email)" + if has_account_email + else "a.email" + ) + + conn.execute(text("PRAGMA foreign_keys=OFF")) + conn.commit() + + conn.execute(text( + """ + CREATE TABLE IF NOT EXISTS bind_card_tasks_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER, + account_email VARCHAR(255), + plan_type VARCHAR(20) NOT NULL, + workspace_name VARCHAR(255), + price_interval VARCHAR(20), + seat_quantity INTEGER, + country VARCHAR(10) DEFAULT 'US', + currency VARCHAR(10) DEFAULT 'USD', + checkout_url TEXT NOT NULL, + checkout_session_id VARCHAR(120), + publishable_key VARCHAR(255), + client_secret TEXT, + checkout_source VARCHAR(50), + bind_mode VARCHAR(30) DEFAULT 'semi_auto', + status VARCHAR(20) DEFAULT 'link_ready', + last_error TEXT, + opened_at DATETIME, + last_checked_at DATETIME, + completed_at DATETIME, + created_at DATETIME, + updated_at DATETIME, + FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE SET NULL + ) + """ + )) + + conn.execute(text(f""" + INSERT INTO bind_card_tasks_new ( + id, account_id, account_email, plan_type, workspace_name, price_interval, + seat_quantity, country, currency, checkout_url, checkout_session_id, + publishable_key, client_secret, checkout_source, bind_mode, status, + last_error, opened_at, last_checked_at, completed_at, created_at, updated_at + ) + SELECT + t.id, t.account_id, {select_email_expr}, t.plan_type, t.workspace_name, t.price_interval, + t.seat_quantity, t.country, t.currency, t.checkout_url, t.checkout_session_id, + t.publishable_key, t.client_secret, t.checkout_source, t.bind_mode, t.status, + t.last_error, t.opened_at, t.last_checked_at, t.completed_at, t.created_at, t.updated_at + FROM bind_card_tasks t + LEFT JOIN accounts a ON a.id = t.account_id + """)) + + conn.execute(text("DROP TABLE bind_card_tasks")) + conn.execute(text("ALTER TABLE bind_card_tasks_new RENAME TO bind_card_tasks")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_bind_card_tasks_account_id ON bind_card_tasks (account_id)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_bind_card_tasks_status ON bind_card_tasks (status)")) + conn.execute(text("PRAGMA foreign_keys=ON")) + conn.commit() + except Exception as e: + try: + conn.execute(text("PRAGMA foreign_keys=ON")) + conn.commit() + except Exception: + pass + logger.warning(f"迁移 bind_card_tasks 历史保留结构时出错: {e}") + def migrate_tables(self): """ 数据库迁移 - 添加缺失的列 @@ -107,6 +215,14 @@ def migrate_tables(self): ("accounts", "cpa_uploaded", "BOOLEAN DEFAULT 0"), ("accounts", "cpa_uploaded_at", "DATETIME"), ("accounts", "source", "VARCHAR(20) DEFAULT 'register'"), + ("accounts", "account_label", "VARCHAR(20) DEFAULT 'none'"), + ("accounts", "role_tag", "VARCHAR(20) DEFAULT 'none'"), + ("accounts", "biz_tag", "VARCHAR(80)"), + ("accounts", "pool_state", "VARCHAR(30) DEFAULT 'candidate_pool'"), + ("accounts", "pool_state_manual", "VARCHAR(30)"), + ("accounts", "last_pool_sync_at", "DATETIME"), + ("accounts", "priority", "INTEGER DEFAULT 50"), + ("accounts", "last_used_at", "DATETIME"), ("accounts", "subscription_type", "VARCHAR(20)"), ("accounts", "subscription_at", "DATETIME"), ("accounts", "cookies", "TEXT"), @@ -117,6 +233,7 @@ def migrate_tables(self): ("bind_card_tasks", "publishable_key", "VARCHAR(255)"), ("bind_card_tasks", "client_secret", "TEXT"), ("bind_card_tasks", "bind_mode", "VARCHAR(30) DEFAULT 'semi_auto'"), + ("bind_card_tasks", "account_email", "VARCHAR(255)"), ] # 确保新表存在(create_tables 已处理,此处兜底) @@ -148,6 +265,64 @@ def migrate_tables(self): except Exception as e: logger.warning(f"迁移列 {table_name}.{column_name} 时出错: {e}") + # 账户标签/池状态回填与索引 + try: + conn.execute(text( + """ + UPDATE accounts + SET role_tag = CASE + WHEN LOWER(COALESCE(account_label, '')) IN ('mother', 'parent', 'manager', '母号') THEN 'parent' + WHEN LOWER(COALESCE(account_label, '')) IN ('child', 'member', '子号') THEN 'child' + ELSE 'none' + END + WHERE role_tag IS NULL OR TRIM(role_tag) = '' OR LOWER(TRIM(role_tag)) = 'none' + """ + )) + conn.execute(text( + """ + UPDATE accounts + SET account_label = CASE + WHEN LOWER(COALESCE(role_tag, '')) = 'parent' THEN 'mother' + WHEN LOWER(COALESCE(role_tag, '')) = 'child' THEN 'child' + ELSE 'none' + END + WHERE account_label IS NULL OR TRIM(account_label) = '' OR LOWER(TRIM(account_label)) = 'none' + """ + )) + conn.execute(text( + "UPDATE accounts SET pool_state='candidate_pool' WHERE pool_state IS NULL OR TRIM(pool_state)=''" + )) + conn.execute(text( + "UPDATE accounts SET priority=50 WHERE priority IS NULL" + )) + conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_accounts_role_tag ON accounts (role_tag)" + )) + conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_accounts_biz_tag ON accounts (biz_tag)" + )) + conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_accounts_pool_state ON accounts (pool_state)" + )) + conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_accounts_pool_state_manual ON accounts (pool_state_manual)" + )) + conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_accounts_priority ON accounts (priority)" + )) + conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_accounts_last_pool_sync_at ON accounts (last_pool_sync_at)" + )) + conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_accounts_last_used_at ON accounts (last_used_at)" + )) + conn.commit() + except Exception as e: + logger.warning(f"迁移账户 role_tag/pool_state 索引时出错: {e}") + + # 最后处理 bind_card_tasks 结构升级(account_id 可空 + account_email 快照) + self._migrate_bind_card_tasks_keep_history_on_account_delete(conn) + # 全局数据库会话管理器实例 _db_manager: DatabaseSessionManager = None diff --git a/src/services/__init__.py b/src/services/__init__.py index 2e6a19f2..575e0050 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -11,7 +11,6 @@ EmailServiceType ) from .tempmail import TempmailService -from .yyds_mail import YYDSMailService from .outlook import OutlookService from .moe_mail import MeoMailEmailService from .temp_mail import TempMailService @@ -22,7 +21,6 @@ # 注册服务 EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) -EmailServiceFactory.register(EmailServiceType.YYDS_MAIL, YYDSMailService) EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService) EmailServiceFactory.register(EmailServiceType.MOE_MAIL, MeoMailEmailService) EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService) @@ -57,7 +55,6 @@ 'EmailServiceType', # 服务类 'TempmailService', - 'YYDSMailService', 'OutlookService', 'MeoMailEmailService', 'TempMailService', diff --git a/src/services/outlook/account.py b/src/services/outlook/account.py index 6f427d59..9120f8a6 100644 --- a/src/services/outlook/account.py +++ b/src/services/outlook/account.py @@ -18,7 +18,7 @@ class OutlookAccount: def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount": """从配置创建账户""" return cls( - email=config.get("email", ""), + email=str(config.get("email", "") or "").strip().lower(), password=config.get("password", ""), client_id=config.get("client_id", ""), refresh_token=config.get("refresh_token", "") diff --git a/src/services/outlook_legacy_mail.py b/src/services/outlook_legacy_mail.py index 3fd6a7df..04cf6398 100644 --- a/src/services/outlook_legacy_mail.py +++ b/src/services/outlook_legacy_mail.py @@ -58,7 +58,7 @@ def __init__( client_id: str = "", refresh_token: str = "" ): - self.email = email + self.email = str(email or "").strip().lower() self.password = password self.client_id = client_id self.refresh_token = refresh_token @@ -67,7 +67,7 @@ def __init__( def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount": """从配置创建账户""" return cls( - email=config.get("email", ""), + email=str(config.get("email", "") or "").strip().lower(), password=config.get("password", ""), client_id=config.get("client_id", ""), refresh_token=config.get("refresh_token", "") @@ -760,4 +760,4 @@ def remove_account(self, email: str) -> bool: self._account_locks.pop(email, None) logger.info(f"移除 Outlook 账户: {email}") return True - return False \ No newline at end of file + return False diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py index 62908a8c..a718fdce 100644 --- a/src/services/temp_mail.py +++ b/src/services/temp_mail.py @@ -21,8 +21,6 @@ from ..core.http_client import HTTPClient, RequestConfig from ..config.constants import OTP_CODE_PATTERN, OTP_CODE_SEMANTIC_PATTERN -OTP_DOMAIN_PATTERN = re.compile(r"@[A-Za-z0-9.-]+\.\d{6}(?!\d)") - logger = logging.getLogger(__name__) @@ -73,6 +71,74 @@ def __init__(self, config: Dict[str, Any] = None, name: str = None): self._email_cache: Dict[str, Dict[str, Any]] = {} # 记录每个邮箱上一次成功使用的邮件 ID,避免重复使用旧验证码 self._last_used_mail_ids: Dict[str, str] = {} + # /admin/mails 接口对 limit 参数较严格,这里统一限制上限,避免 400 Invalid limit + self._admin_mails_limit_max = 50 + + def _normalize_admin_limit(self, value: Any, default: int = 50) -> int: + try: + number = int(value) + except Exception: + number = int(default) + if number <= 0: + number = int(default) + return max(1, min(number, int(self._admin_mails_limit_max))) + + def _normalize_offset(self, value: Any, default: int = 0) -> int: + try: + number = int(value) + except Exception: + number = int(default) + return max(0, number) + + def _request_admin_mails_with_limit_fallback( + self, + *, + offset: int = 0, + extra_params: Optional[Dict[str, Any]] = None, + preferred_limit: Optional[int] = None, + ) -> Dict[str, Any]: + """ + /admin/mails 在不同部署版本对 limit 校验不一致。 + 这里按降级 limit 自动重试,避免直接报 400 Invalid limit。 + """ + offset_value = self._normalize_offset(offset, default=0) + base_params: Dict[str, Any] = dict(extra_params or {}) + candidate_limits: List[int] = [] + if preferred_limit is not None: + candidate_limits.append(self._normalize_admin_limit(preferred_limit, default=50)) + candidate_limits.extend([50, 40, 30, 20, 10, 5, 1]) + # 去重且保持顺序 + seen = set() + limits = [] + for value in candidate_limits: + v = int(max(1, value)) + if v in seen: + continue + seen.add(v) + limits.append(v) + + last_error: Optional[Exception] = None + for index, limit_value in enumerate(limits): + params = dict(base_params) + params["limit"] = limit_value + params["offset"] = offset_value + try: + return self._make_request("GET", "/admin/mails", params=params) + except Exception as e: + last_error = e + err_text = str(e).lower() + retryable_invalid_limit = ("invalid limit" in err_text) and (index < len(limits) - 1) + if retryable_invalid_limit: + logger.debug( + "TempMail /admin/mails limit=%s 不可用,降级重试下一档", + limit_value, + ) + continue + raise + + if last_error: + raise last_error + raise EmailServiceError("admin mails request failed") def _decode_mime_header(self, value: str) -> str: """解码 MIME 头,兼容 RFC 2047 编码主题。""" @@ -180,7 +246,6 @@ def _is_openai_otp_mail(self, sender: str, subject: str, body: str, raw: str) -> return False otp_keywords = ( - "verification", "verification code", "verify", "one-time code", @@ -288,6 +353,7 @@ def _fetch_mails_once(self, email: str, jwt: Optional[str], email_id: Optional[s 4) /admin/mails (不过滤,客户端二次筛选) """ attempts: List[Dict[str, Any]] = [] + admin_limit = self._normalize_admin_limit(self.config.get("admin_mails_limit", 50), default=50) if jwt: attempts.extend([ { @@ -310,30 +376,40 @@ def _fetch_mails_once(self, email: str, jwt: Optional[str], email_id: Optional[s attempts.append({ "path": "/admin/mails", - "params": {"limit": 80, "offset": 0, "address": email}, + "params": {"limit": admin_limit, "offset": 0, "address": email}, "headers": {"Accept": "application/json"}, }) if email_id and email_id != email: attempts.append({ "path": "/admin/mails", - "params": {"limit": 80, "offset": 0, "address": email_id}, + "params": {"limit": admin_limit, "offset": 0, "address": email_id}, "headers": {"Accept": "application/json"}, }) attempts.append({ "path": "/admin/mails", - "params": {"limit": 120, "offset": 0}, + "params": {"limit": admin_limit, "offset": 0}, "headers": {"Accept": "application/json"}, }) for attempt in attempts: path = attempt["path"] try: - response = self._make_request( - "GET", - path, - params=attempt["params"], - headers=attempt["headers"], - ) + if path == "/admin/mails": + raw_params = dict(attempt["params"] or {}) + preferred_limit = raw_params.pop("limit", admin_limit) + offset = raw_params.pop("offset", 0) + response = self._request_admin_mails_with_limit_fallback( + offset=offset, + extra_params=raw_params, + preferred_limit=preferred_limit, + ) + else: + response = self._make_request( + "GET", + path, + params=attempt["params"], + headers=attempt["headers"], + ) mails = self._extract_mails_from_response(response) if mails and "address" not in attempt["params"]: mails = [mail for mail in mails if self._mail_appears_for_email(mail, email)] @@ -749,10 +825,6 @@ def get_verification_code( reverse=True, )[0] code = str(best["code"]) - if OTP_DOMAIN_PATTERN.search(str(best.get("detail_content") or "")) and code in str(best.get("detail_content") or ""): - logger.debug("??????????????????? OTP") - time.sleep(3) - continue self._last_used_mail_ids[email] = str(best["mail_id"]) logger.info( "从 TempMail 邮箱 %s 找到验证码: %s(mail_id=%s ts=%s semantic=%s)", @@ -773,7 +845,7 @@ def get_verification_code( logger.warning(f"等待 TempMail 验证码超时: {email}") return None - def list_emails(self, limit: int = 100, offset: int = 0, **kwargs) -> List[Dict[str, Any]]: + def list_emails(self, limit: int = 50, offset: int = 0, **kwargs) -> List[Dict[str, Any]]: """ 列出邮箱 @@ -785,14 +857,18 @@ def list_emails(self, limit: int = 100, offset: int = 0, **kwargs) -> List[Dict[ Returns: 邮箱列表 """ - params = { - "limit": limit, - "offset": offset, - } - params.update({k: v for k, v in kwargs.items() if v is not None}) + params = {k: v for k, v in kwargs.items() if v is not None} + raw_limit = params.pop("limit", limit) + raw_offset = params.pop("offset", offset) + params["limit"] = self._normalize_admin_limit(raw_limit, default=50) + params["offset"] = self._normalize_offset(raw_offset, default=0) try: - response = self._make_request("GET", "/admin/mails", params=params) + response = self._request_admin_mails_with_limit_fallback( + offset=params["offset"], + extra_params={k: v for k, v in params.items() if k not in ("limit", "offset")}, + preferred_limit=params["limit"], + ) mails = response.get("results", []) if not isinstance(mails, list): raise EmailServiceError(f"API 返回数据格式错误: {response}") diff --git a/src/services/tempmail.py b/src/services/tempmail.py index ecdf6e84..a2320593 100644 --- a/src/services/tempmail.py +++ b/src/services/tempmail.py @@ -7,7 +7,6 @@ import logging from typing import Optional, Dict, Any, List import json -import os from curl_cffi import requests as cffi_requests @@ -49,12 +48,6 @@ def __init__(self, config: Dict[str, Any] = None, name: str = None): self.config = {**default_config, **(config or {})} - # ←←← 最终优化:API Key 完全可选(付费/免费自动切换)↓↓↓ - self.api_key = self.config.get("api_key") or os.getenv("TEMPMail_API_KEY") - if self.api_key: - logger.info(f"✅ Tempmail.lol Plus/Ultra 已加载 API Key: {self.api_key[:30]}...") - # 没 Key 时不打印任何警告,直接走免费模式(符合你的需求) - # 创建 HTTP 客户端 http_config = RequestConfig( timeout=self.config["timeout"], @@ -90,7 +83,6 @@ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: headers={ "Accept": "application/json", "Content-Type": "application/json", - **({"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}), }, json={} ) @@ -171,10 +163,7 @@ def get_verification_code( response = self.http_client.get( f"{self.config['base_url']}/inbox", params={"token": token}, - headers={ - "Accept": "application/json", - **({"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}), - } + headers={"Accept": "application/json"} ) if response.status_code != 200: @@ -408,4 +397,4 @@ def wait_for_verification_code_with_callback( "email": email, "message": "等待验证码超时" }) - return None \ No newline at end of file + return None diff --git a/src/web/app.py b/src/web/app.py index b679341d..23bff0ff 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -6,19 +6,24 @@ import logging import sys import secrets -import hmac -import hashlib from typing import Optional, Dict, Any from pathlib import Path -from fastapi import FastAPI, Request, Form +from fastapi import FastAPI, Request, Form, Depends from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, RedirectResponse -from ..config.settings import get_settings -from ..config.project_notice import PROJECT_NOTICE +from ..config.settings import get_settings, update_settings +from .auth import ( + build_auth_token, + build_login_redirect, + build_setup_password_redirect, + is_default_security_config_active, + is_request_authenticated, + require_api_auth, +) from .routes import api_router from .routes.websocket import router as ws_router from .task_manager import task_manager @@ -83,8 +88,8 @@ def create_app() -> FastAPI: TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) logger.info(f"创建模板目录: {TEMPLATES_DIR}") - # 注册 API 路由 - app.include_router(api_router, prefix="/api") + # 注册 API 路由(统一鉴权) + app.include_router(api_router, prefix="/api", dependencies=[Depends(require_api_auth)]) # 注册 WebSocket 路由 app.include_router(ws_router, prefix="/api") @@ -92,7 +97,6 @@ def create_app() -> FastAPI: # 模板引擎 templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates.env.globals["static_version"] = _build_static_asset_version(STATIC_DIR) - templates.env.globals["project_notice"] = PROJECT_NOTICE def _render_template( request: Request, @@ -123,41 +127,112 @@ def _render_template( status_code=status_code, ) - def _auth_token(password: str) -> str: - secret = get_settings().webui_secret_key.get_secret_value().encode("utf-8") - return hmac.new(secret, password.encode("utf-8"), hashlib.sha256).hexdigest() - - def _is_authenticated(request: Request) -> bool: - cookie = request.cookies.get("webui_auth") - expected = _auth_token(get_settings().webui_access_password.get_secret_value()) - return bool(cookie) and secrets.compare_digest(cookie, expected) - - def _redirect_to_login(request: Request) -> RedirectResponse: - return RedirectResponse(url=f"/login?next={request.url.path}", status_code=302) + def _guard_page_request(request: Request) -> Optional[RedirectResponse]: + if is_default_security_config_active(): + return build_setup_password_redirect() + if not is_request_authenticated(request): + return build_login_redirect(request) + return None @app.get("/login", response_class=HTMLResponse) - async def login_page(request: Request, next: Optional[str] = "/"): + async def login_page(request: Request, next: Optional[str] = "/", notice: Optional[str] = ""): """登录页面""" + if is_default_security_config_active(): + return build_setup_password_redirect() return _render_template( request, "login.html", - {"error": "", "next": next or "/"}, + {"error": "", "next": next or "/", "notice": notice or ""}, ) @app.post("/login") async def login_submit(request: Request, password: str = Form(...), next: Optional[str] = "/"): """处理登录提交""" + if is_default_security_config_active(): + return build_setup_password_redirect() + expected = get_settings().webui_access_password.get_secret_value() if not secrets.compare_digest(password, expected): return _render_template( request, "login.html", - {"error": "密码错误", "next": next or "/"}, + {"error": "密码错误", "next": next or "/", "notice": ""}, status_code=401, ) response = RedirectResponse(url=next or "/", status_code=302) - response.set_cookie("webui_auth", _auth_token(expected), httponly=True, samesite="lax") + auth_cookie = build_auth_token( + expected, + get_settings().webui_secret_key.get_secret_value(), + ) + response.set_cookie("webui_auth", auth_cookie, httponly=True, samesite="lax") + return response + + @app.get("/setup-password", response_class=HTMLResponse) + async def setup_password_page(request: Request): + """首次启动强制改密页面。""" + if not is_default_security_config_active(): + return RedirectResponse(url="/login", status_code=302) + return _render_template( + request, + "setup_password.html", + {"error": "", "message": ""}, + ) + + @app.post("/setup-password", response_class=HTMLResponse) + async def setup_password_submit( + request: Request, + old_password: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), + ): + """首次启动设置访问密码。""" + if not is_default_security_config_active(): + return RedirectResponse(url="/login", status_code=302) + + expected = get_settings().webui_access_password.get_secret_value() + if not secrets.compare_digest(str(old_password or ""), str(expected or "")): + return _render_template( + request, + "setup_password.html", + {"error": "当前密码不正确", "message": ""}, + status_code=400, + ) + + new_value = str(new_password or "").strip() + confirm_value = str(confirm_password or "").strip() + if len(new_value) < 8: + return _render_template( + request, + "setup_password.html", + {"error": "新密码至少 8 位", "message": ""}, + status_code=400, + ) + if new_value != confirm_value: + return _render_template( + request, + "setup_password.html", + {"error": "两次输入的新密码不一致", "message": ""}, + status_code=400, + ) + if new_value == "admin123": + return _render_template( + request, + "setup_password.html", + {"error": "新密码不能继续使用默认口令", "message": ""}, + status_code=400, + ) + + # 首次改密时同时轮换 secret,避免默认 secret 继续生效。 + update_settings( + webui_access_password=new_value, + webui_secret_key=secrets.token_urlsafe(48), + ) + response = RedirectResponse( + url="/login?notice=访问密码已更新,请使用新密码登录", + status_code=302, + ) + response.delete_cookie("webui_auth") return response @app.get("/logout") @@ -170,70 +245,91 @@ async def logout(request: Request, next: Optional[str] = "/login"): @app.get("/", response_class=HTMLResponse) async def index(request: Request): """首页 - 注册页面""" - if not _is_authenticated(request): - return _redirect_to_login(request) + redirect_response = _guard_page_request(request) + if redirect_response: + return redirect_response return _render_template(request, "index.html") @app.get("/accounts", response_class=HTMLResponse) async def accounts_page(request: Request): """账号管理页面""" - if not _is_authenticated(request): - return _redirect_to_login(request) + redirect_response = _guard_page_request(request) + if redirect_response: + return redirect_response return _render_template(request, "accounts.html") @app.get("/accounts-overview", response_class=HTMLResponse) async def accounts_overview_page(request: Request): """账号总览页面""" - if not _is_authenticated(request): - return _redirect_to_login(request) + redirect_response = _guard_page_request(request) + if redirect_response: + return redirect_response return _render_template(request, "accounts_overview.html") @app.get("/email-services", response_class=HTMLResponse) async def email_services_page(request: Request): """邮箱服务管理页面""" - if not _is_authenticated(request): - return _redirect_to_login(request) + redirect_response = _guard_page_request(request) + if redirect_response: + return redirect_response return _render_template(request, "email_services.html") @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): """设置页面""" - if not _is_authenticated(request): - return _redirect_to_login(request) + redirect_response = _guard_page_request(request) + if redirect_response: + return redirect_response return _render_template(request, "settings.html") @app.get("/payment", response_class=HTMLResponse) async def payment_page(request: Request): """支付页面""" + redirect_response = _guard_page_request(request) + if redirect_response: + return redirect_response return _render_template(request, "payment.html") @app.get("/card-pool", response_class=HTMLResponse) async def card_pool_page(request: Request): """卡池页面(占位)""" - if not _is_authenticated(request): - return _redirect_to_login(request) + redirect_response = _guard_page_request(request) + if redirect_response: + return redirect_response return _render_template(request, "card_pool.html") @app.get("/auto-team", response_class=HTMLResponse) async def auto_team_page(request: Request): - """自动进 Team 页面(占位)""" - if not _is_authenticated(request): - return _redirect_to_login(request) + """team 页面(占位)""" + redirect_response = _guard_page_request(request) + if redirect_response: + return redirect_response return _render_template(request, "auto_team.html") @app.get("/logs", response_class=HTMLResponse) async def logs_page(request: Request): """后台日志页面""" - if not _is_authenticated(request): - return _redirect_to_login(request) + redirect_response = _guard_page_request(request) + if redirect_response: + return redirect_response return _render_template(request, "logs.html") + @app.get("/selfcheck", response_class=HTMLResponse) + async def selfcheck_page(request: Request): + """系统自检页面""" + redirect_response = _guard_page_request(request) + if redirect_response: + return redirect_response + return _render_template(request, "selfcheck.html") + @app.on_event("startup") async def startup_event(): """应用启动事件""" import asyncio from ..database.init_db import initialize_database from ..core.db_logs import cleanup_database_logs + from .auto_quick_refresh_scheduler import auto_quick_refresh_scheduler + from .selfcheck_scheduler import selfcheck_scheduler # 确保数据库已初始化(reload 模式下子进程也需要初始化) try: @@ -269,11 +365,15 @@ async def periodic_log_cleanup(): # 启动时先执行一次,再开启定时任务 await run_log_cleanup_once() app.state.log_cleanup_task = asyncio.create_task(periodic_log_cleanup()) + app.state.auto_quick_refresh_task = asyncio.create_task(auto_quick_refresh_scheduler.run_loop()) + app.state.selfcheck_scheduler_task = asyncio.create_task(selfcheck_scheduler.run_loop()) logger.info("=" * 50) logger.info(f"{settings.app_name} v{settings.app_version} 启动中,程序正在伸懒腰...") logger.info(f"调试模式: {settings.debug}") logger.info(f"数据库连接已接好线: {settings.database_url}") + if is_default_security_config_active(): + logger.warning("检测到默认安全配置,已强制进入首次改密流程:请访问 /setup-password") logger.info("=" * 50) @app.on_event("shutdown") @@ -282,6 +382,12 @@ async def shutdown_event(): cleanup_task = getattr(app.state, "log_cleanup_task", None) if cleanup_task: cleanup_task.cancel() + auto_quick_refresh_task = getattr(app.state, "auto_quick_refresh_task", None) + if auto_quick_refresh_task: + auto_quick_refresh_task.cancel() + selfcheck_scheduler_task = getattr(app.state, "selfcheck_scheduler_task", None) + if selfcheck_scheduler_task: + selfcheck_scheduler_task.cancel() logger.info("应用关闭,今天先收摊啦") return app @@ -289,3 +395,4 @@ async def shutdown_event(): # 创建全局应用实例 app = create_app() + diff --git a/src/web/auth.py b/src/web/auth.py new file mode 100644 index 00000000..44ccd140 --- /dev/null +++ b/src/web/auth.py @@ -0,0 +1,95 @@ +""" +Web UI 统一鉴权与安全基线工具。 +""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets +from typing import Tuple +from urllib.parse import quote + +from fastapi import HTTPException, Request, WebSocket +from fastapi.responses import RedirectResponse + +from ..config.settings import get_settings + +DEFAULT_WEBUI_ACCESS_PASSWORD = "admin123" +DEFAULT_WEBUI_SECRET_KEY = "your-secret-key-change-in-production" +# 临时开关:关闭“首次启动强制改密”。 +# 恢复时改为 False 即可重新启用原有逻辑。 +TEMP_DISABLE_SETUP_PASSWORD_ENFORCE = True + + +def _safe_value(value: str) -> str: + return str(value or "").strip() + + +def build_auth_token(password: str, secret_key: str) -> str: + secret = _safe_value(secret_key).encode("utf-8") + pwd = _safe_value(password).encode("utf-8") + return hmac.new(secret, pwd, hashlib.sha256).hexdigest() + + +def get_expected_auth_token() -> str: + settings = get_settings() + password = settings.webui_access_password.get_secret_value() + secret_key = settings.webui_secret_key.get_secret_value() + return build_auth_token(password, secret_key) + + +def is_default_security_config_active() -> bool: + if TEMP_DISABLE_SETUP_PASSWORD_ENFORCE: + return False + + settings = get_settings() + password = _safe_value(settings.webui_access_password.get_secret_value()) + secret_key = _safe_value(settings.webui_secret_key.get_secret_value()) + return ( + not password + or password == DEFAULT_WEBUI_ACCESS_PASSWORD + or not secret_key + or secret_key == DEFAULT_WEBUI_SECRET_KEY + ) + + +def build_setup_password_redirect() -> RedirectResponse: + return RedirectResponse(url="/setup-password", status_code=302) + + +def build_login_redirect(request: Request) -> RedirectResponse: + target = quote(request.url.path or "/", safe="/") + return RedirectResponse(url=f"/login?next={target}", status_code=302) + + +def is_request_authenticated(request: Request) -> bool: + cookie = request.cookies.get("webui_auth") + expected = get_expected_auth_token() + return bool(cookie) and secrets.compare_digest(cookie, expected) + + +def require_api_auth(request: Request) -> bool: + if is_default_security_config_active(): + raise HTTPException( + status_code=423, + detail={ + "code": "password_change_required", + "message": "首次启动请先访问 /setup-password 修改访问密码", + }, + ) + if not is_request_authenticated(request): + raise HTTPException(status_code=401, detail="未登录或登录已失效") + return True + + +def is_websocket_authenticated(websocket: WebSocket) -> bool: + cookie = websocket.cookies.get("webui_auth") + expected = get_expected_auth_token() + return bool(cookie) and secrets.compare_digest(cookie, expected) + + +def websocket_auth_failure() -> Tuple[int, str]: + if is_default_security_config_active(): + return 4403, "password_change_required" + return 4401, "unauthorized" diff --git a/src/web/auto_quick_refresh_scheduler.py b/src/web/auto_quick_refresh_scheduler.py new file mode 100644 index 00000000..b826b93c --- /dev/null +++ b/src/web/auto_quick_refresh_scheduler.py @@ -0,0 +1,304 @@ +""" +账号管理自动一键刷新调度器。 + +目标: +- 按配置定时执行“批量验证 -> 批量订阅检测”两段流程 +- 保持单任务运行,避免并发堆积导致卡顿 +- 弱网/异常场景自动退避重试 +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from ..config.settings import get_settings + +logger = logging.getLogger(__name__) + +AUTO_MIN_INTERVAL_MINUTES = 5 +AUTO_MAX_INTERVAL_MINUTES = 24 * 60 +AUTO_MAX_RETRY_LIMIT = 5 +SCHEDULER_POLL_SECONDS = 5 +SCHEDULER_BUSY_RETRY_SECONDS = 120 +SCHEDULER_FAILURE_BACKOFF_BASE_SECONDS = 30 +SCHEDULER_FAILURE_BACKOFF_MAX_SECONDS = 600 +SCHEDULER_LOG_MAX_ENTRIES = 100 +SCHEDULER_LOG_SNAPSHOT_LIMIT = 40 + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _to_iso(dt: Optional[datetime]) -> Optional[str]: + return dt.isoformat() if dt else None + + +def _clamp_int(value: Any, min_value: int, max_value: int, default: int) -> int: + try: + parsed = int(value) + except Exception: + parsed = int(default) + return max(min_value, min(max_value, parsed)) + + +class AutoQuickRefreshScheduler: + def __init__(self) -> None: + self._lock = threading.Lock() + self._running: bool = False + self._run_now_requested: bool = False + self._next_run_at: Optional[datetime] = None + self._last_started_at: Optional[datetime] = None + self._last_finished_at: Optional[datetime] = None + self._last_status: str = "idle" # idle / running / success / failed / skipped_busy + self._last_reason: str = "" + self._last_error: str = "" + self._last_result: Dict[str, Any] = {} + self._consecutive_failures: int = 0 + self._logs: List[Dict[str, str]] = [] + + def _append_log_locked(self, level: str, message: str, when: Optional[datetime] = None) -> None: + entry = { + "time": _to_iso(when or _utc_now()) or "", + "level": str(level or "info").lower(), + "message": str(message or "").strip(), + } + self._logs.append(entry) + if len(self._logs) > SCHEDULER_LOG_MAX_ENTRIES: + del self._logs[0 : len(self._logs) - SCHEDULER_LOG_MAX_ENTRIES] + + def _append_log(self, level: str, message: str, when: Optional[datetime] = None) -> None: + with self._lock: + self._append_log_locked(level, message, when=when) + + @staticmethod + def _build_summary_text(run_result: Dict[str, Any]) -> str: + summary = (run_result or {}).get("summary") or {} + validate = summary.get("validate") or {} + subscription = summary.get("subscription") or {} + return ( + f"验证 {int(validate.get('valid_count') or 0)}/{int(validate.get('total') or 0)}," + f"订阅 {int(subscription.get('success_count') or 0)}/{int(subscription.get('total') or 0)}" + ) + + def _read_schedule(self) -> Dict[str, Any]: + settings = get_settings() + enabled = bool(getattr(settings, "auto_quick_refresh_enabled", False)) + interval_minutes = _clamp_int( + getattr(settings, "auto_quick_refresh_interval_minutes", 30), + AUTO_MIN_INTERVAL_MINUTES, + AUTO_MAX_INTERVAL_MINUTES, + 30, + ) + retry_limit = _clamp_int( + getattr(settings, "auto_quick_refresh_retry_limit", 2), + 0, + AUTO_MAX_RETRY_LIMIT, + 2, + ) + return { + "enabled": enabled, + "interval_minutes": interval_minutes, + "retry_limit": retry_limit, + } + + def _snapshot_locked(self) -> Dict[str, Any]: + schedule = self._read_schedule() + return { + "enabled": bool(schedule["enabled"]), + "interval_minutes": int(schedule["interval_minutes"]), + "retry_limit": int(schedule["retry_limit"]), + "running": bool(self._running), + "run_now_requested": bool(self._run_now_requested), + "next_run_at": _to_iso(self._next_run_at), + "last_started_at": _to_iso(self._last_started_at), + "last_finished_at": _to_iso(self._last_finished_at), + "last_status": self._last_status, + "last_reason": self._last_reason, + "last_error": self._last_error, + "last_result": self._last_result or {}, + "consecutive_failures": int(self._consecutive_failures), + "logs": list(self._logs[-SCHEDULER_LOG_SNAPSHOT_LIMIT:]), + } + + def snapshot(self) -> Dict[str, Any]: + with self._lock: + return self._snapshot_locked() + + def notify_schedule_updated(self) -> Dict[str, Any]: + now = _utc_now() + schedule = self._read_schedule() + with self._lock: + if not schedule["enabled"] and not self._running: + self._next_run_at = None + self._run_now_requested = False + self._append_log_locked("info", "定时自动一键刷新已禁用", when=now) + elif schedule["enabled"] and (not self._running): + self._next_run_at = now + timedelta(minutes=int(schedule["interval_minutes"])) + self._append_log_locked( + "info", + f"定时自动一键刷新已启用,每 {int(schedule['interval_minutes'])} 分钟执行", + when=now, + ) + return self._snapshot_locked() + + def request_run_now(self, reason: str = "manual") -> Dict[str, Any]: + now = _utc_now() + with self._lock: + self._run_now_requested = True + if not self._running: + self._next_run_at = now + if reason: + self._last_reason = str(reason) + self._append_log_locked("info", "已请求立即执行一次", when=now) + return self._snapshot_locked() + + async def run_loop(self) -> None: + logger.info("自动一键刷新调度器启动") + self._append_log("info", "调度器已启动") + while True: + try: + await self._tick_once() + await asyncio.sleep(SCHEDULER_POLL_SECONDS) + except asyncio.CancelledError: + logger.info("自动一键刷新调度器已停止") + self._append_log("info", "调度器已停止") + break + except Exception as exc: + logger.warning("自动一键刷新调度器异常: %s", exc) + await asyncio.sleep(SCHEDULER_POLL_SECONDS) + + async def _tick_once(self) -> None: + schedule = self._read_schedule() + now = _utc_now() + + should_start = False + reason = "scheduled" + + with self._lock: + if not schedule["enabled"]: + if not self._running: + self._next_run_at = None + self._run_now_requested = False + return + + if self._running: + return + + if self._next_run_at is None: + self._next_run_at = now + timedelta(minutes=int(schedule["interval_minutes"])) + return + + if self._run_now_requested or now >= self._next_run_at: + should_start = True + reason = "manual" if self._run_now_requested else "scheduled" + self._running = True + self._run_now_requested = False + self._last_status = "running" + self._last_reason = reason + self._last_error = "" + self._last_result = {} + self._last_started_at = now + self._last_finished_at = None + self._append_log_locked("info", f"开始执行({reason})", when=now) + + if should_start: + asyncio.create_task(self._run_once(schedule, reason)) + + async def _run_once(self, schedule: Dict[str, Any], reason: str) -> None: + run_status = "failed" + run_error = "" + run_result: Dict[str, Any] = {} + + try: + run_result, run_status, run_error = await self._execute_with_retry(schedule, reason) + except Exception as exc: + run_status = "failed" + run_error = str(exc) + run_result = {} + + now = _utc_now() + interval_minutes = int(schedule["interval_minutes"]) + + with self._lock: + self._running = False + self._last_finished_at = now + self._last_status = run_status + self._last_error = run_error + self._last_result = run_result or {} + + if run_status == "success": + self._consecutive_failures = 0 + self._next_run_at = now + timedelta(minutes=interval_minutes) + self._append_log_locked("success", f"执行完成:{self._build_summary_text(run_result)}", when=now) + elif run_status == "skipped_busy": + self._consecutive_failures = 0 + self._next_run_at = now + timedelta(seconds=SCHEDULER_BUSY_RETRY_SECONDS) + self._append_log_locked("warning", "系统忙,已跳过本次执行并稍后重试", when=now) + else: + self._consecutive_failures += 1 + backoff_seconds = min( + SCHEDULER_FAILURE_BACKOFF_MAX_SECONDS, + SCHEDULER_FAILURE_BACKOFF_BASE_SECONDS * (2 ** max(0, self._consecutive_failures - 1)), + ) + self._next_run_at = now + timedelta(seconds=backoff_seconds) + error_text = str(run_error or "unknown_error") + self._append_log_locked("error", f"执行失败:{error_text}", when=now) + + if run_status == "success": + logger.info("自动一键刷新完成: reason=%s result=%s", reason, run_result) + elif run_status == "skipped_busy": + logger.info("自动一键刷新跳过(系统忙): reason=%s", reason) + else: + logger.warning("自动一键刷新失败: reason=%s error=%s", reason, run_error) + + async def _execute_with_retry(self, schedule: Dict[str, Any], reason: str) -> Tuple[Dict[str, Any], str, str]: + retry_limit = int(schedule.get("retry_limit") or 0) + max_attempts = max(1, retry_limit + 1) + last_error = "" + + for attempt in range(1, max_attempts + 1): + if attempt > 1: + await asyncio.sleep(min(120, 15 * attempt)) + + try: + result = await asyncio.to_thread(self._execute_once, reason, attempt) + if result.get("skipped"): + return result, "skipped_busy", "" + return result, "success", "" + except Exception as exc: + last_error = str(exc) + logger.warning( + "自动一键刷新执行失败: reason=%s attempt=%s/%s error=%s", + reason, + attempt, + max_attempts, + exc, + ) + self._append_log( + "warning", + f"执行重试失败(第 {attempt}/{max_attempts} 次):{last_error}", + ) + + return {}, "failed", last_error or "unknown_error" + + def _execute_once(self, reason: str, attempt: int) -> Dict[str, Any]: + from .routes import accounts as accounts_routes + + if accounts_routes.has_active_batch_operations(): + return {"skipped": True, "reason": "busy", "attempt": attempt} + + summary = accounts_routes.run_quick_refresh_workflow(source=f"auto:{reason}") + return { + "skipped": False, + "reason": reason, + "attempt": attempt, + "summary": summary, + } + + +auto_quick_refresh_scheduler = AutoQuickRefreshScheduler() diff --git a/src/web/repositories/__init__.py b/src/web/repositories/__init__.py new file mode 100644 index 00000000..1389274a --- /dev/null +++ b/src/web/repositories/__init__.py @@ -0,0 +1,4 @@ +""" +Web 数据访问层(Repository)。 +""" + diff --git a/src/web/repositories/account_repository.py b/src/web/repositories/account_repository.py new file mode 100644 index 00000000..4ae65754 --- /dev/null +++ b/src/web/repositories/account_repository.py @@ -0,0 +1,56 @@ +""" +账号仓储层:封装常见查询与聚合。 +""" + +from __future__ import annotations + +from typing import Dict, Iterator + +from sqlalchemy import case, func + +from ...database.models import Account + + +def iter_query_in_batches(query, *, batch_size: int = 200) -> Iterator[Account]: + """ + 分批迭代 ORM Query,避免一次性 all() 全量加载。 + """ + safe_batch = max(50, min(1000, int(batch_size or 200))) + offset = 0 + while True: + rows = query.offset(offset).limit(safe_batch).all() + if not rows: + break + for row in rows: + yield row + if len(rows) < safe_batch: + break + offset += safe_batch + + +def query_role_tag_counts(db) -> Dict[str, int]: + """ + SQL 聚合统计 role_tag/account_label,返回 parent/child/none 计数。 + """ + role_text = func.lower(func.trim(func.coalesce(Account.role_tag, ""))) + label_text = func.lower(func.trim(func.coalesce(Account.account_label, ""))) + resolved_role_expr = case( + (role_text == "parent", "parent"), + (role_text == "child", "child"), + (label_text.in_(["mother", "parent"]), "parent"), + (label_text == "child", "child"), + else_="none", + ) + rows = ( + db.query(resolved_role_expr.label("role"), func.count(Account.id).label("cnt")) + .group_by(resolved_role_expr) + .all() + ) + result = {"parent": 0, "child": 0, "none": 0} + for row in rows: + role_value = str(getattr(row, "role", row[0]) or "none").strip().lower() + count_value = int(getattr(row, "cnt", row[1]) or 0) + if role_value not in result: + role_value = "none" + result[role_value] += count_value + return result diff --git a/src/web/routes/__init__.py b/src/web/routes/__init__.py index 70a2a574..65d9c3e5 100644 --- a/src/web/routes/__init__.py +++ b/src/web/routes/__init__.py @@ -10,9 +10,12 @@ from .email import router as email_services_router from .payment import router as payment_router from .logs import router as logs_router +from .selfcheck import router as selfcheck_router from .upload.cpa_services import router as cpa_services_router from .upload.sub2api_services import router as sub2api_services_router from .upload.tm_services import router as tm_services_router +from .auto_team import router as auto_team_router +from .tasks import router as tasks_router api_router = APIRouter() @@ -23,6 +26,9 @@ api_router.include_router(email_services_router, prefix="/email-services", tags=["email-services"]) api_router.include_router(payment_router, prefix="/payment", tags=["payment"]) api_router.include_router(logs_router, prefix="/logs", tags=["logs"]) +api_router.include_router(selfcheck_router, prefix="/selfcheck", tags=["selfcheck"]) api_router.include_router(cpa_services_router, prefix="/cpa-services", tags=["cpa-services"]) api_router.include_router(sub2api_services_router, prefix="/sub2api-services", tags=["sub2api-services"]) api_router.include_router(tm_services_router, prefix="/tm-services", tags=["tm-services"]) +api_router.include_router(auto_team_router, prefix="/auto-team", tags=["auto-team"]) +api_router.include_router(tasks_router, prefix="/tasks", tags=["tasks"]) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 4024d90e..7ab156b8 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -2,22 +2,34 @@ 账号管理 API 路由 """ import io -import asyncio import json import logging import re import zipfile import base64 +import time +import threading +import uuid +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Iterator, List, Optional -from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body +from fastapi import APIRouter, HTTPException, Query, Body, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel -from sqlalchemy import func - -from ...config.constants import AccountStatus +from sqlalchemy import and_, func, or_ + +from ...config.constants import ( + AccountLabel, + AccountStatus, + RoleTag, + account_label_to_role_tag, + normalize_account_label, + normalize_pool_state, + normalize_role_tag, + role_tag_to_account_label, +) from ...config.settings import get_settings from ...core.openai.overview import fetch_codex_overview from ...core.openai.token_refresh import refresh_account_token as do_refresh @@ -30,6 +42,9 @@ from ...database import crud from ...database.models import Account from ...database.session import get_db +from ..task_manager import task_manager +from ..services.accounts_service import get_role_tag_counts as _service_get_role_tag_counts +from ..services.accounts_service import stream_accounts as _service_stream_accounts logger = logging.getLogger(__name__) router = APIRouter() @@ -45,6 +60,28 @@ AccountStatus.BANNED.value, ) +ACCOUNT_ASYNC_TASK_MAX_KEEP = 300 +ACCOUNT_ASYNC_EXECUTOR_MAX_WORKERS = 6 +ACCOUNT_BATCH_REFRESH_ASYNC_MAX_WORKERS = 8 +ACCOUNT_BATCH_REFRESH_RETRY_ATTEMPTS = 2 +ACCOUNT_BATCH_REFRESH_RETRY_BASE_DELAY_SECONDS = 1.0 +ACCOUNT_BATCH_VALIDATE_ASYNC_MAX_WORKERS = 8 +ACCOUNT_BATCH_VALIDATE_SYNC_MAX_WORKERS = 12 +ACCOUNT_BATCH_VALIDATE_RETRY_ATTEMPTS = 2 +ACCOUNT_BATCH_VALIDATE_RETRY_BASE_DELAY_SECONDS = 0.8 +ACCOUNT_BATCH_VALIDATE_HTTP_TIMEOUT_SECONDS = 18 +ACCOUNT_OVERVIEW_REFRESH_MAX_WORKERS = 8 +ACCOUNT_OVERVIEW_REFRESH_RETRY_ATTEMPTS = 2 +ACCOUNT_OVERVIEW_REFRESH_RETRY_BASE_DELAY_SECONDS = 1.0 +QUICK_REFRESH_TASK_WAIT_TIMEOUT_SECONDS = 40 * 60 +QUICK_REFRESH_TASK_POLL_INTERVAL_SECONDS = 1.2 +_account_async_tasks: Dict[str, Dict[str, Any]] = {} +_account_async_tasks_lock = threading.Lock() +_account_async_executor = ThreadPoolExecutor( + max_workers=ACCOUNT_ASYNC_EXECUTOR_MAX_WORKERS, + thread_name_prefix="account_async", +) + def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]: """获取代理 URL,策略与注册流程一致:代理列表 → 动态代理 → 静态配置""" @@ -74,6 +111,264 @@ def _apply_status_filter(query, status: Optional[str]): return query.filter(Account.status == normalized) +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _resolve_actor(request: Optional[Request]) -> str: + if request is None: + return "system" + header_keys = ("x-operator", "x-user", "x-username") + for key in header_keys: + value = str(request.headers.get(key) or "").strip() + if value: + return value[:120] + client_host = "" + try: + client_host = str(getattr(getattr(request, "client", None), "host", "") or "").strip() + except Exception: + client_host = "" + return f"api@{client_host}" if client_host else "api" + + +def _audit_account_action( + db, + *, + actor: str, + action: str, + account: Optional[Account] = None, + target_id: Optional[int] = None, + target_email: Optional[str] = None, + payload: Optional[Dict[str, Any]] = None, +) -> None: + try: + crud.create_operation_audit_log( + db, + actor=actor, + action=action, + target_type="account", + target_id=target_id if target_id is not None else getattr(account, "id", None), + target_email=target_email if target_email is not None else getattr(account, "email", None), + payload=payload or {}, + ) + except Exception: + logger.warning("写入账号操作审计日志失败: action=%s", action, exc_info=True) + + +def _iter_query_in_batches(query, batch_size: int = 200) -> Iterator[Account]: + """ + 分批迭代查询结果,避免一次性 all() 把全量记录加载进内存。 + """ + yield from _service_stream_accounts(query, batch_size=batch_size) + + +def _cleanup_account_async_tasks_locked(): + """限制内存中的异步任务数量,优先清理已结束的旧任务。""" + total = len(_account_async_tasks) + if total <= ACCOUNT_ASYNC_TASK_MAX_KEEP: + return + + overflow = total - ACCOUNT_ASYNC_TASK_MAX_KEEP + finished_keys = [ + (task_id, _account_async_tasks[task_id].get("_created_ts", 0)) + for task_id in _account_async_tasks + if _account_async_tasks[task_id].get("status") in {"completed", "failed", "cancelled"} + ] + finished_keys.sort(key=lambda item: item[1]) + + removed = 0 + for task_id, _ in finished_keys: + if removed >= overflow: + break + _account_async_tasks.pop(task_id, None) + removed += 1 + + # 如果当前大多是运行中任务,不强制裁剪,避免前端轮询中的任务被提前清理。 + + +def _create_account_async_task(task_type: str, total: int = 0, payload: Optional[dict] = None) -> str: + task_id = str(uuid.uuid4()) + task = { + "id": task_id, + "task_type": task_type, + "status": "pending", + "message": "任务已创建,等待执行", + "created_at": _utc_now_iso(), + "started_at": None, + "finished_at": None, + "cancel_requested": False, + "pause_requested": False, + "paused": False, + "progress": { + "total": max(0, int(total or 0)), + "completed": 0, + "success": 0, + "failed": 0, + }, + "result": None, + "error": None, + "payload": payload or {}, + "details": [], + "_created_ts": time.time(), + } + with _account_async_tasks_lock: + _account_async_tasks[task_id] = task + _cleanup_account_async_tasks_locked() + task_manager.register_domain_task( + domain="accounts", + task_id=task_id, + task_type=task_type, + payload=payload or {}, + progress=task["progress"], + ) + return task_id + + +def _get_account_async_task(task_id: str) -> Optional[Dict[str, Any]]: + with _account_async_tasks_lock: + return _account_async_tasks.get(task_id) + + +def _get_account_async_task_or_404(task_id: str) -> Dict[str, Any]: + task = _get_account_async_task(task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + return task + + +def _build_account_async_task_snapshot(task: Dict[str, Any]) -> Dict[str, Any]: + data = { + "id": task.get("id"), + "task_type": task.get("task_type"), + "status": task.get("status"), + "message": task.get("message"), + "created_at": task.get("created_at"), + "started_at": task.get("started_at"), + "finished_at": task.get("finished_at"), + "cancel_requested": bool(task.get("cancel_requested")), + "pause_requested": bool(task.get("pause_requested")), + "paused": bool(task.get("paused")), + "progress": task.get("progress") or {}, + "payload": task.get("payload") or {}, + "result": task.get("result"), + "error": task.get("error"), + "details": task.get("details") or [], + } + return data + + +def _update_account_async_task(task_id: str, **fields): + with _account_async_tasks_lock: + task = _account_async_tasks.get(task_id) + if not task: + return + task.update(fields) + task_manager.update_domain_task("accounts", task_id, **fields) + + +def _append_account_async_task_detail(task_id: str, detail: dict, max_items: int = 500): + with _account_async_tasks_lock: + task = _account_async_tasks.get(task_id) + if not task: + return + details = task.setdefault("details", []) + details.append(detail) + if len(details) > max_items: + task["details"] = details[-max_items:] + task_manager.append_domain_task_detail("accounts", task_id, detail, max_items=max_items) + + +def _set_account_async_task_progress(task_id: str, *, completed: int, success: int, failed: int, total: Optional[int] = None): + with _account_async_tasks_lock: + task = _account_async_tasks.get(task_id) + if not task: + return + progress = task.setdefault("progress", {}) + if total is not None: + progress["total"] = max(0, int(total)) + progress["completed"] = max(0, int(completed)) + progress["success"] = max(0, int(success)) + progress["failed"] = max(0, int(failed)) + payload = { + "completed": max(0, int(completed)), + "success": max(0, int(success)), + "failed": max(0, int(failed)), + } + if total is not None: + payload["total"] = max(0, int(total)) + task_manager.set_domain_task_progress("accounts", task_id, **payload) + + +def _is_account_async_task_cancel_requested(task_id: str) -> bool: + local_requested = False + with _account_async_tasks_lock: + task = _account_async_tasks.get(task_id) + local_requested = bool(task and task.get("cancel_requested")) + return local_requested or task_manager.is_domain_task_cancel_requested("accounts", task_id) + + +def _is_account_async_task_pause_requested(task_id: str) -> bool: + local_requested = False + with _account_async_tasks_lock: + task = _account_async_tasks.get(task_id) + local_requested = bool(task and task.get("pause_requested")) + return local_requested or task_manager.is_domain_task_pause_requested("accounts", task_id) + + +def _wait_if_account_async_task_paused(task_id: str, running_message: str) -> bool: + paused_once = False + while True: + if _is_account_async_task_cancel_requested(task_id): + return False + if not _is_account_async_task_pause_requested(task_id): + if paused_once: + _update_account_async_task( + task_id, + status="running", + paused=False, + message=running_message, + ) + return True + if not paused_once: + _update_account_async_task( + task_id, + status="paused", + paused=True, + message="任务已暂停,等待继续", + ) + paused_once = True + time.sleep(0.35) + + +def _run_account_async_task_guard(task_id: str, task_type: str, worker, *args): + acquired, running, quota = task_manager.try_acquire_domain_slot("accounts", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + _update_account_async_task( + task_id, + status="failed", + finished_at=_utc_now_iso(), + message=reason, + error=reason, + paused=False, + ) + return + try: + worker(task_id, *args) + except Exception as exc: + logger.exception("异步任务执行失败: task_id=%s type=%s error=%s", task_id, task_type, exc) + _update_account_async_task( + task_id, + status="failed", + finished_at=_utc_now_iso(), + message=f"任务异常: {exc}", + error=str(exc), + paused=False, + ) + finally: + task_manager.release_domain_slot("accounts", task_id) + + # ============== Pydantic Models ============== class AccountResponse(BaseModel): @@ -93,6 +388,14 @@ class AccountResponse(BaseModel): proxy_used: Optional[str] = None cpa_uploaded: bool = False cpa_uploaded_at: Optional[str] = None + account_label: str = AccountLabel.NONE.value + role_tag: str = RoleTag.NONE.value + biz_tag: Optional[str] = None + pool_state: str = "candidate_pool" + pool_state_manual: Optional[str] = None + last_pool_sync_at: Optional[str] = None + priority: int = 50 + last_used_at: Optional[str] = None subscription_type: Optional[str] = None subscription_at: Optional[str] = None cookies: Optional[str] = None @@ -115,6 +418,10 @@ class AccountUpdateRequest(BaseModel): metadata: Optional[dict] = None cookies: Optional[str] = None # 完整 cookie 字符串,用于支付请求 session_token: Optional[str] = None + role_tag: Optional[str] = None + biz_tag: Optional[str] = None + pool_state_manual: Optional[str] = None + priority: Optional[int] = None class ManualAccountCreateRequest(BaseModel): @@ -133,6 +440,10 @@ class ManualAccountCreateRequest(BaseModel): cookies: Optional[str] = None proxy_used: Optional[str] = None source: Optional[str] = "manual" + account_label: Optional[str] = AccountLabel.NONE.value + role_tag: Optional[str] = None + biz_tag: Optional[str] = None + priority: Optional[int] = 50 subscription_type: Optional[str] = None metadata: Optional[dict] = None @@ -153,6 +464,12 @@ class AccountImportItem(BaseModel): cookies: Optional[str] = None proxy_used: Optional[str] = None source: Optional[str] = "import" + account_label: Optional[str] = AccountLabel.NONE.value + role_tag: Optional[str] = None + biz_tag: Optional[str] = None + pool_state: Optional[str] = None + pool_state_manual: Optional[str] = None + priority: Optional[int] = 50 subscription_type: Optional[str] = None plan_type: Optional[str] = None auth_mode: Optional[str] = None @@ -235,6 +552,26 @@ def resolve_account_ids( return [row[0] for row in query.all()] +def _resolve_account_role_tag(account: Account) -> str: + role_value = str(getattr(account, "role_tag", "") or "").strip() + if role_value: + normalized_role = normalize_role_tag(role_value) + if normalized_role != RoleTag.NONE.value: + return normalized_role + return account_label_to_role_tag(getattr(account, "account_label", None)) + + +def _resolve_account_pool_state(account: Account) -> str: + return normalize_pool_state(getattr(account, "pool_state", None)) + + +def _set_account_role_tag(account: Account, role_tag: Optional[str]) -> str: + normalized_role = normalize_role_tag(role_tag) + account.role_tag = normalized_role + account.account_label = role_tag_to_account_label(normalized_role) + return normalized_role + + def account_to_response(account: Account) -> AccountResponse: """转换 Account 模型为响应模型""" return AccountResponse( @@ -253,6 +590,14 @@ def account_to_response(account: Account) -> AccountResponse: proxy_used=account.proxy_used, cpa_uploaded=account.cpa_uploaded or False, cpa_uploaded_at=account.cpa_uploaded_at.isoformat() if account.cpa_uploaded_at else None, + account_label=normalize_account_label(getattr(account, "account_label", None)), + role_tag=_resolve_account_role_tag(account), + biz_tag=(str(getattr(account, "biz_tag", "") or "").strip() or None), + pool_state=_resolve_account_pool_state(account), + pool_state_manual=(str(getattr(account, "pool_state_manual", "") or "").strip() or None), + last_pool_sync_at=account.last_pool_sync_at.isoformat() if getattr(account, "last_pool_sync_at", None) else None, + priority=int(getattr(account, "priority", 50) or 50), + last_used_at=account.last_used_at.isoformat() if getattr(account, "last_used_at", None) else None, subscription_type=account.subscription_type, subscription_at=account.subscription_at.isoformat() if account.subscription_at else None, cookies=account.cookies, @@ -488,6 +833,17 @@ def _is_paid_subscription(value: Optional[str]) -> bool: return normalized in PAID_SUBSCRIPTION_TYPES +def _promote_child_label_if_paid(account: Account, subscription_type: Optional[str], *, reason: str) -> bool: + """ + 历史兼容函数:关闭“付费后自动子号升母号”。 + 账号标签仅允许手动修改或专用业务入口修改,避免 Team 加入后误升母号。 + """ + _ = account + _ = subscription_type + _ = reason + return False + + def _pick_first_text(*values: Any) -> Optional[str]: for value in values: if value is None: @@ -588,6 +944,7 @@ def _get_account_overview_data( if detected_sub and current_sub != detected_sub: account.subscription_type = detected_sub account.subscription_at = datetime.utcnow() if detected_sub else None + _promote_child_label_if_paid(account, detected_sub, reason="overview_detected_paid") updated = True elif not detected_sub and current_sub in PAID_SUBSCRIPTION_TYPES: logger.info( @@ -625,6 +982,10 @@ async def create_manual_account(request: ManualAccountCreateRequest): email_service = (request.email_service or "manual").strip() or "manual" status = request.status or AccountStatus.ACTIVE.value source = (request.source or "manual").strip() or "manual" + role_tag = normalize_role_tag( + request.role_tag if request.role_tag is not None else request.account_label + ) + account_label = role_tag_to_account_label(role_tag) subscription_type = _normalize_subscription_input(request.subscription_type) if not email or "@" not in email: @@ -657,10 +1018,15 @@ async def create_manual_account(request: ManualAccountCreateRequest): cookies=request.cookies, proxy_used=request.proxy_used, extra_data=request.metadata or {}, + account_label=account_label, + role_tag=role_tag, + biz_tag=request.biz_tag, + priority=request.priority if request.priority is not None else 50, ) if subscription_type: account.subscription_type = subscription_type account.subscription_at = datetime.utcnow() + _promote_child_label_if_paid(account, subscription_type, reason="manual_create_paid") db.commit() db.refresh(account) except Exception as exc: @@ -770,6 +1136,24 @@ def _safe_text(value: Optional[str]) -> Optional[str]: email_service = str(item.email_service or "manual").strip() or "manual" source = str(item.source or "import").strip() or "import" + raw_role_tag = _pick_first_text( + item.role_tag, + raw_item.get("role_tag"), + raw_item.get("registration_type"), + item.account_label, + raw_item.get("account_label"), + ) + role_tag = normalize_role_tag(raw_role_tag) if raw_role_tag is not None else RoleTag.NONE.value + account_label = role_tag_to_account_label(role_tag) + biz_tag = _pick_first_text(item.biz_tag, raw_item.get("biz_tag")) + raw_pool_state = _pick_first_text(item.pool_state, raw_item.get("pool_state")) + pool_state = normalize_pool_state(raw_pool_state) if raw_pool_state is not None else None + raw_pool_state_manual = _pick_first_text(item.pool_state_manual, raw_item.get("pool_state_manual")) + pool_state_manual = normalize_pool_state(raw_pool_state_manual) if raw_pool_state_manual is not None else None + try: + priority_value = int(item.priority) if item.priority is not None else 50 + except Exception: + priority_value = 50 subscription_type = ( _normalize_subscription_input(item.subscription_type) or _normalize_subscription_input(item.plan_type) @@ -820,6 +1204,12 @@ def _safe_text(value: Optional[str]) -> Optional[str]: "cookies": item.cookies if item.cookies is not None else None, "proxy_used": _safe_text(item.proxy_used), "source": source, + "account_label": account_label, + "role_tag": role_tag, + "biz_tag": biz_tag, + "pool_state": pool_state, + "pool_state_manual": pool_state_manual, + "priority": priority_value, "extra_data": metadata, "last_refresh": datetime.utcnow(), } @@ -829,6 +1219,7 @@ def _safe_text(value: Optional[str]) -> Optional[str]: raise RuntimeError("更新账号失败") account.subscription_type = subscription_type account.subscription_at = datetime.utcnow() if subscription_type else None + _promote_child_label_if_paid(account, subscription_type, reason="import_overwrite_paid") db.commit() result["updated"] += 1 continue @@ -850,10 +1241,17 @@ def _safe_text(value: Optional[str]) -> Optional[str]: extra_data=metadata, status=status, source=source, + account_label=account_label, + role_tag=role_tag, + biz_tag=biz_tag, + pool_state=pool_state, + pool_state_manual=pool_state_manual, + priority=priority_value, ) if subscription_type: account.subscription_type = subscription_type account.subscription_at = datetime.utcnow() + _promote_child_label_if_paid(account, subscription_type, reason="import_create_paid") db.commit() result["created"] += 1 except Exception as exc: @@ -869,6 +1267,9 @@ async def list_accounts( page_size: int = Query(20, ge=1, le=100, description="每页数量"), status: Optional[str] = Query(None, description="状态筛选"), email_service: Optional[str] = Query(None, description="邮箱服务筛选"), + role_tag: Optional[str] = Query(None, description="角色标签筛选:parent/child/none"), + pool_state: Optional[str] = Query(None, description="池状态筛选:team_pool/candidate_pool/blocked"), + biz_tag: Optional[str] = Query(None, description="业务标签筛选"), search: Optional[str] = Query(None, description="搜索关键词"), ): """ @@ -888,6 +1289,28 @@ async def list_accounts( if email_service: query = query.filter(Account.email_service == email_service) + # 角色标签筛选 + if role_tag: + normalized_role = normalize_role_tag(role_tag) + fallback_label = role_tag_to_account_label(normalized_role) + query = query.filter( + or_( + func.lower(func.coalesce(Account.role_tag, "")) == normalized_role, + and_( + func.trim(func.coalesce(Account.role_tag, "")) == "", + func.lower(func.coalesce(Account.account_label, "")) == fallback_label, + ), + ) + ) + + # 池状态筛选 + if pool_state: + query = query.filter(Account.pool_state == normalize_pool_state(pool_state)) + + # 业务标签筛选 + if biz_tag: + query = query.filter(Account.biz_tag == str(biz_tag).strip()) + # 搜索 if search: search_pattern = f"%{search}%" @@ -932,11 +1355,12 @@ async def list_accounts_overview_cards( if email_service: query = query.filter(Account.email_service == email_service) - accounts = [ - account - for account in query.order_by(Account.created_at.desc()).all() - if not _is_overview_card_removed(account) - ] + ordered_query = query.order_by(Account.created_at.desc()) + accounts = [] + for account in _iter_query_in_batches(ordered_query, batch_size=200): + if _is_overview_card_removed(account): + continue + accounts.append(account) current_account_id = _get_current_account_id(db) global_proxy = _get_proxy(proxy) # 卡片列表接口默认“缓存优先”,避免首次进入或新增卡片后触发全量远端请求造成页面卡死。 @@ -1026,9 +1450,9 @@ async def list_accounts_overview_addable( if email_service: query = query.filter(Account.email_service == email_service) - accounts = query.order_by(Account.created_at.desc()).all() + ordered_query = query.order_by(Account.created_at.desc()) rows = [] - for account in accounts: + for account in _iter_query_in_batches(ordered_query, batch_size=200): if not _is_overview_card_removed(account): continue if not _is_paid_subscription(account.subscription_type): @@ -1068,9 +1492,9 @@ async def list_accounts_overview_selectable( if email_service: query = query.filter(Account.email_service == email_service) - accounts = query.order_by(Account.created_at.desc()).all() + ordered_query = query.order_by(Account.created_at.desc()) rows = [] - for account in accounts: + for account in _iter_query_in_batches(ordered_query, batch_size=200): # 仅返回当前未在卡片中的账号(即已从卡片移除) if not _is_overview_card_removed(account): continue @@ -1167,10 +1591,12 @@ async def attach_accounts_overview_card(account_id: int): @router.post("/overview/refresh") -async def refresh_accounts_overview(request: OverviewRefreshRequest): +def refresh_accounts_overview(request: OverviewRefreshRequest): """ 批量刷新账号总览数据。 + 使用线程池并发执行,避免长时间刷新阻塞其他接口。 """ + started_at = time.monotonic() proxy = _get_proxy(request.proxy) result = {"success_count": 0, "failed_count": 0, "details": []} @@ -1190,87 +1616,68 @@ async def refresh_accounts_overview(request: OverviewRefreshRequest): ).order_by(Account.created_at.desc()).all() ids = [acc.id for acc in candidates if not _is_overview_card_removed(acc)] - logger.info( - "账号总览刷新开始: target_count=%s force=%s select_all=%s proxy=%s", - len(ids), - bool(request.force), - bool(request.select_all), - proxy or "-", - ) + logger.info( + "账号总览刷新开始: target_count=%s force=%s select_all=%s proxy=%s", + len(ids), + bool(request.force), + bool(request.select_all), + proxy or "-", + ) - for account_id in ids: - account = crud.get_account_by_id(db, account_id) - if not account: - result["failed_count"] += 1 - result["details"].append({"id": account_id, "success": False, "error": "账号不存在"}) - logger.warning("账号总览刷新失败: account_id=%s error=账号不存在", account_id) - continue - if (not _is_paid_subscription(account.subscription_type)) or _is_overview_card_removed(account): - result["details"].append( - { - "id": account.id, - "email": account.email, - "success": False, - "error": "账号不在 Codex 卡片范围内,已跳过", - } - ) - continue + if not ids: + return result - 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() + worker_count = min(ACCOUNT_OVERVIEW_REFRESH_MAX_WORKERS, max(1, len(ids))) + with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="overview_refresh") as pool: + future_map = { + pool.submit(_refresh_overview_account_with_retry, account_id, bool(request.force), proxy): account_id + for account_id in ids + } + for future in as_completed(future_map): + account_id = future_map[future] + try: + detail = future.result() + except Exception as exc: + detail = {"id": account_id, "success": False, "error": str(exc)} - if overview.get("hourly_quota", {}).get("status") == "unknown" and overview.get("weekly_quota", {}).get("status") == "unknown": + result["details"].append(detail) + if detail.get("success") is True: + result["success_count"] += 1 + elif not detail.get("skipped"): result["failed_count"] += 1 - result["details"].append( - { - "id": account.id, - "email": account.email, - "success": False, - "error": overview.get("error") or "未获取到配额数据", - } + + if detail.get("success") is True: + logger.info( + "账号总览刷新成功: account_id=%s email=%s plan=%s", + detail.get("id"), + detail.get("email"), + detail.get("plan_type") or "-", ) - logger.warning( - "账号总览刷新失败: account_id=%s email=%s error=%s", - account.id, - account.email, - overview.get("error") or "未获取到配额数据", + elif detail.get("skipped"): + logger.info( + "账号总览刷新跳过: account_id=%s email=%s reason=%s", + detail.get("id"), + detail.get("email"), + detail.get("error"), ) else: - result["success_count"] += 1 - result["details"].append( - { - "id": account.id, - "email": account.email, - "success": True, - "plan_type": overview.get("plan_type"), - } - ) - logger.info( - "账号总览刷新成功: account_id=%s email=%s plan=%s hourly=%s weekly=%s code_review=%s hourly_source=%s weekly_source=%s", - account.id, - account.email, - overview.get("plan_type") or "-", - overview.get("hourly_quota", {}).get("percentage"), - overview.get("weekly_quota", {}).get("percentage"), - overview.get("code_review_quota", {}).get("percentage"), - overview.get("hourly_quota", {}).get("source"), - overview.get("weekly_quota", {}).get("source"), + logger.warning( + "账号总览刷新失败: account_id=%s email=%s error=%s", + detail.get("id"), + detail.get("email"), + detail.get("error"), ) - logger.info( - "账号总览刷新完成: success=%s failed=%s", - result["success_count"], - result["failed_count"], - ) - + result["details"].sort(key=lambda item: int(item.get("id") or 0)) + duration = round(time.monotonic() - started_at, 2) + logger.info( + "账号总览刷新完成: success=%s failed=%s total=%s workers=%s duration=%.2fs", + result["success_count"], + result["failed_count"], + len(ids), + worker_count, + duration, + ) return result @@ -1359,12 +1766,22 @@ async def get_account_tokens(account_id: int): @router.patch("/{account_id}", response_model=AccountResponse) -async def update_account(account_id: int, request: AccountUpdateRequest): +async def update_account(account_id: int, request: AccountUpdateRequest, http_request: Request): """更新账号状态""" with get_db() as db: account = crud.get_account_by_id(db, account_id) if not account: raise HTTPException(status_code=404, detail="账号不存在") + actor = _resolve_actor(http_request) + before_snapshot = { + "status": account.status, + "role_tag": account.role_tag, + "account_label": account.account_label, + "biz_tag": account.biz_tag, + "pool_state_manual": account.pool_state_manual, + "priority": account.priority, + "subscription_type": account.subscription_type, + } update_data = {} if request.status: @@ -1373,9 +1790,9 @@ async def update_account(account_id: int, request: AccountUpdateRequest): update_data["status"] = request.status if request.metadata: - current_metadata = account.metadata or {} + current_metadata = account.extra_data if isinstance(account.extra_data, dict) else {} current_metadata.update(request.metadata) - update_data["metadata"] = current_metadata + update_data["extra_data"] = current_metadata if request.cookies is not None: # 留空则清空,非空则更新 @@ -1386,7 +1803,46 @@ async def update_account(account_id: int, request: AccountUpdateRequest): update_data["session_token"] = request.session_token or None update_data["last_refresh"] = datetime.utcnow() + if request.role_tag is not None: + normalized_role = normalize_role_tag(request.role_tag) + update_data["role_tag"] = normalized_role + update_data["account_label"] = role_tag_to_account_label(normalized_role) + + if request.biz_tag is not None: + update_data["biz_tag"] = str(request.biz_tag).strip() or None + + if request.pool_state_manual is not None: + text = str(request.pool_state_manual or "").strip() + update_data["pool_state_manual"] = normalize_pool_state(text) if text else None + + if request.priority is not None: + try: + update_data["priority"] = max(0, int(request.priority)) + except Exception: + raise HTTPException(status_code=400, detail="priority 必须为整数") + account = crud.update_account(db, account_id, **update_data) + if update_data: + after_snapshot = { + "status": account.status, + "role_tag": account.role_tag, + "account_label": account.account_label, + "biz_tag": account.biz_tag, + "pool_state_manual": account.pool_state_manual, + "priority": account.priority, + "subscription_type": account.subscription_type, + } + _audit_account_action( + db, + actor=actor, + action="account.update", + account=account, + payload={ + "fields": sorted(list(update_data.keys())), + "before": before_snapshot, + "after": after_snapshot, + }, + ) return account_to_response(account) @@ -1408,20 +1864,25 @@ async def delete_account(account_id: int): if not account: raise HTTPException(status_code=404, detail="账号不存在") - crud.delete_account(db, account_id) + try: + crud.delete_account(db, account_id) + except Exception as e: + raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}") return {"success": True, "message": f"账号 {account.email} 已删除"} @router.post("/batch-delete") -async def batch_delete_accounts(request: BatchDeleteRequest): +async def batch_delete_accounts(request: BatchDeleteRequest, http_request: Request): """批量删除账号""" with get_db() as db: + actor = _resolve_actor(http_request) ids = resolve_account_ids( db, request.ids, request.select_all, request.status_filter, request.email_service_filter, request.search_filter ) deleted_count = 0 errors = [] + deleted_ids: List[int] = [] for account_id in ids: try: @@ -1429,9 +1890,25 @@ async def batch_delete_accounts(request: BatchDeleteRequest): if account: crud.delete_account(db, account_id) deleted_count += 1 + deleted_ids.append(int(account_id)) except Exception as e: errors.append(f"ID {account_id}: {str(e)}") + _audit_account_action( + db, + actor=actor, + action="account.batch_delete", + target_id=0, + target_email=None, + payload={ + "requested_ids": [int(item) for item in ids], + "deleted_ids": deleted_ids, + "deleted_count": deleted_count, + "error_count": len(errors), + "errors": errors[:50], + }, + ) + return { "success": True, "deleted_count": deleted_count, @@ -1440,14 +1917,16 @@ async def batch_delete_accounts(request: BatchDeleteRequest): @router.post("/batch-update") -async def batch_update_accounts(request: BatchUpdateRequest): +async def batch_update_accounts(request: BatchUpdateRequest, http_request: Request): """批量更新账号状态""" if request.status not in [e.value for e in AccountStatus]: raise HTTPException(status_code=400, detail="无效的状态值") with get_db() as db: + actor = _resolve_actor(http_request) updated_count = 0 errors = [] + updated_ids: List[int] = [] for account_id in request.ids: try: @@ -1455,9 +1934,26 @@ async def batch_update_accounts(request: BatchUpdateRequest): if account: crud.update_account(db, account_id, status=request.status) updated_count += 1 + updated_ids.append(int(account_id)) except Exception as e: errors.append(f"ID {account_id}: {str(e)}") + _audit_account_action( + db, + actor=actor, + action="account.batch_update_status", + target_id=0, + target_email=None, + payload={ + "status": request.status, + "requested_ids": [int(item) for item in request.ids], + "updated_ids": updated_ids, + "updated_count": updated_count, + "error_count": len(errors), + "errors": errors[:50], + }, + ) + return { "success": True, "updated_count": updated_count, @@ -1497,6 +1993,11 @@ async def export_accounts_json(request: BatchExportRequest): "id_token": acc.id_token, "session_token": acc.session_token, "email_service": acc.email_service, + "account_label": normalize_account_label(getattr(acc, "account_label", None)), + "role_tag": _resolve_account_role_tag(acc), + "biz_tag": str(getattr(acc, "biz_tag", "") or "").strip() or None, + "pool_state": _resolve_account_pool_state(acc), + "priority": int(getattr(acc, "priority", 50) or 50), "registered_at": acc.registered_at.isoformat() if acc.registered_at else None, "last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else None, "expires_at": acc.expires_at.isoformat() if acc.expires_at else None, @@ -1539,7 +2040,8 @@ async def export_accounts_csv(request: BatchExportRequest): "ID", "Email", "Password", "Client ID", "Account ID", "Workspace ID", "Access Token", "Refresh Token", "ID Token", "Session Token", - "Email Service", "Status", "Registered At", "Last Refresh", "Expires At" + "Email Service", "Account Label", "Role Tag", "Biz Tag", "Pool State", "Priority", + "Status", "Registered At", "Last Refresh", "Expires At" ]) # 写入数据 @@ -1556,6 +2058,11 @@ async def export_accounts_csv(request: BatchExportRequest): acc.id_token or "", acc.session_token or "", acc.email_service, + normalize_account_label(getattr(acc, "account_label", None)), + _resolve_account_role_tag(acc), + str(getattr(acc, "biz_tag", "") or "").strip(), + _resolve_account_pool_state(acc), + int(getattr(acc, "priority", 50) or 50), acc.status, acc.registered_at.isoformat() if acc.registered_at else "", acc.last_refresh.isoformat() if acc.last_refresh else "", @@ -1639,7 +2146,7 @@ def make_account_entry(acc) -> dict: @router.post("/export/codex") async def export_accounts_codex(request: BatchExportRequest): - """????? Codex ???????""" + """导出账号为 Codex JSONL 格式(便于迁移/导入)。""" with get_db() as db: ids = resolve_account_ids( db, request.ids, request.select_all, @@ -1735,10 +2242,19 @@ async def get_accounts_stats(): func.count(Account.id) ).group_by(Account.email_service).all() + # 按角色标签统计(Service + Repository 聚合) + role_counts = _service_get_role_tag_counts(db) + return { "total": total, "by_status": {status: count for status, count in status_stats}, - "by_email_service": {service: count for service, count in service_stats} + "by_email_service": {service: count for service, count in service_stats}, + "by_role_tag": role_counts, + "tagged_role_counts": { + "parent": role_counts["parent"], + "child": role_counts["child"], + "total_labeled": role_counts["parent"] + role_counts["child"], + }, } @@ -1818,6 +2334,21 @@ async def get_accounts_overview(): } +@router.get("/audit-logs") +async def list_account_audit_logs(limit: int = Query(100, ge=1, le=500), action: Optional[str] = Query(None)): + with get_db() as db: + rows = crud.list_operation_audit_logs( + db, + limit=limit, + action=action, + target_type="account", + ) + return { + "success": True, + "items": [row.to_dict() for row in rows], + } + + # ============== Token 刷新相关 ============== class TokenRefreshRequest(BaseModel): @@ -1850,9 +2381,1141 @@ class BatchValidateRequest(BaseModel): search_filter: Optional[str] = None +def _wait_account_async_task_finished( + task_id: str, + timeout_seconds: int = QUICK_REFRESH_TASK_WAIT_TIMEOUT_SECONDS, + poll_interval: float = QUICK_REFRESH_TASK_POLL_INTERVAL_SECONDS, +) -> Dict[str, Any]: + started_at = time.monotonic() + while time.monotonic() - started_at < timeout_seconds: + task = _get_account_async_task(task_id) + if task: + snapshot = _build_account_async_task_snapshot(task) + status = str(snapshot.get("status") or "").lower() + if status in {"completed", "failed", "cancelled"}: + return snapshot + time.sleep(max(0.2, float(poll_interval))) + raise TimeoutError(f"等待账号任务超时: {task_id}") + + +def _wait_payment_op_task_finished( + op_task_id: str, + timeout_seconds: int = QUICK_REFRESH_TASK_WAIT_TIMEOUT_SECONDS, + poll_interval: float = QUICK_REFRESH_TASK_POLL_INTERVAL_SECONDS, +) -> Dict[str, Any]: + from . import payment as payment_routes + + started_at = time.monotonic() + while time.monotonic() - started_at < timeout_seconds: + task = payment_routes._get_payment_op_task(op_task_id) + if task: + snapshot = payment_routes._build_payment_op_task_snapshot(task) + status = str(snapshot.get("status") or "").lower() + if status in {"completed", "failed", "cancelled"}: + return snapshot + time.sleep(max(0.2, float(poll_interval))) + raise TimeoutError(f"等待支付任务超时: {op_task_id}") + + +def _task_terminal_error(task_snapshot: Dict[str, Any], default_message: str) -> str: + status = str(task_snapshot.get("status") or "").lower() + if status == "completed": + return "" + if status == "cancelled": + return str(task_snapshot.get("message") or "任务已取消") + return str(task_snapshot.get("error") or task_snapshot.get("message") or default_message) + + +def has_active_batch_operations() -> bool: + active_status = {"pending", "running"} + account_task_types = {"batch_refresh", "batch_validate", "overview_refresh", "quick_refresh"} + with _account_async_tasks_lock: + for task in _account_async_tasks.values(): + status = str(task.get("status") or "").lower() + task_type = str(task.get("task_type") or "").strip().lower() + if status in active_status and task_type in account_task_types: + return True + + try: + from . import payment as payment_routes + + with payment_routes._PAYMENT_OP_TASK_LOCK: + for task in payment_routes._PAYMENT_OP_TASKS.values(): + status = str(task.get("status") or "").lower() + task_type = str(task.get("task_type") or "").strip().lower() + if status in active_status and task_type in {"batch_check_subscription", "quick_refresh"}: + return True + except Exception: + pass + + return False + + +def _compact_refresh_result(result: Dict[str, Any]) -> Dict[str, int]: + return { + "success_count": int(result.get("success_count") or 0), + "failed_count": int(result.get("failed_count") or 0), + "total": int(result.get("total") or 0), + } + + +def _compact_validate_result(result: Dict[str, Any]) -> Dict[str, int]: + return { + "valid_count": int(result.get("valid_count") or 0), + "invalid_count": int(result.get("invalid_count") or 0), + "total": int(result.get("total") or 0), + } + + +def run_quick_refresh_workflow( + *, + source: str = "manual", + proxy: Optional[str] = None, + select_all: bool = True, + status_filter: Optional[str] = None, + email_service_filter: Optional[str] = None, + search_filter: Optional[str] = None, +) -> Dict[str, Any]: + payload = { + "ids": [], + "proxy": proxy, + "select_all": bool(select_all), + "status_filter": status_filter, + "email_service_filter": email_service_filter, + "search_filter": search_filter, + } + + # 1) 批量验证 + validate_task = start_batch_validate_async(BatchValidateRequest(**payload)) + validate_task_id = str(validate_task.get("id") or "") + if not validate_task_id: + raise RuntimeError("创建批量验证任务失败:缺少 task_id") + validate_final = _wait_account_async_task_finished(validate_task_id) + validate_error = _task_terminal_error(validate_final, "批量验证任务失败") + if validate_error: + raise RuntimeError(f"批量验证失败: {validate_error}") + validate_result = _compact_validate_result(validate_final.get("result") or {}) + + # 2) 批量检测订阅 + from . import payment as payment_routes + + subscription_task = payment_routes.start_batch_check_subscription_async( + payment_routes.BatchCheckSubscriptionRequest(**payload) + ) + subscription_task_id = str(subscription_task.get("id") or "") + if not subscription_task_id: + raise RuntimeError("创建批量订阅检测任务失败:缺少 op_task_id") + subscription_final = _wait_payment_op_task_finished(subscription_task_id) + subscription_error = _task_terminal_error(subscription_final, "批量订阅检测任务失败") + if subscription_error: + raise RuntimeError(f"批量检测订阅失败: {subscription_error}") + subscription_raw = subscription_final.get("result") or {} + subscription_result = { + "success_count": int(subscription_raw.get("success_count") or 0), + "failed_count": int(subscription_raw.get("failed_count") or 0), + "total": int(subscription_raw.get("total") or 0), + } + + return { + "source": source, + "finished_at": _utc_now_iso(), + "validate": validate_result, + "subscription": subscription_result, + } + + +def _is_retryable_refresh_error(error_message: Optional[str]) -> bool: + text = str(error_message or "").strip().lower() + if not text: + return False + retry_markers = ( + "network_error", + "network", + "timeout", + "timed out", + "connection", + "temporarily", + "too many requests", + "http 429", + "http 500", + "http 502", + "http 503", + "http 504", + ) + return any(marker in text for marker in retry_markers) + + +def _refresh_one_account_with_retry( + account_id: int, + proxy: Optional[str], + max_attempts: int = ACCOUNT_BATCH_REFRESH_RETRY_ATTEMPTS, +) -> Dict[str, Any]: + attempts = max(1, int(max_attempts or 1)) + last_error = "" + for attempt in range(1, attempts + 1): + try: + refresh_result = do_refresh(account_id, proxy) + except Exception as exc: + refresh_result = None + last_error = str(exc) + if attempt < attempts: + time.sleep(ACCOUNT_BATCH_REFRESH_RETRY_BASE_DELAY_SECONDS * attempt) + continue + return { + "id": account_id, + "success": False, + "error": last_error, + "attempts": attempt, + } + + if refresh_result and refresh_result.success: + return { + "id": account_id, + "success": True, + "attempts": attempt, + } + + last_error = str(getattr(refresh_result, "error_message", "") or "刷新失败") + can_retry = attempt < attempts and _is_retryable_refresh_error(last_error) + if can_retry: + time.sleep(ACCOUNT_BATCH_REFRESH_RETRY_BASE_DELAY_SECONDS * attempt) + continue + return { + "id": account_id, + "success": False, + "error": last_error, + "attempts": attempt, + } + + return { + "id": account_id, + "success": False, + "error": last_error or "刷新失败", + "attempts": attempts, + } + + +def _run_batch_refresh_task(task_id: str, ids: List[int], proxy: Optional[str]): + total = len(ids) + success_count = 0 + failed_count = 0 + completed_count = 0 + + _update_account_async_task( + task_id, + status="running", + started_at=_utc_now_iso(), + message=f"开始刷新 Token,共 {total} 个账号", + paused=False, + ) + + if total <= 0: + _update_account_async_task( + task_id, + status="completed", + finished_at=_utc_now_iso(), + message="没有可刷新的账号", + paused=False, + result={ + "success_count": 0, + "failed_count": 0, + "total": 0, + "cancelled": False, + }, + ) + return + + worker_count = min(ACCOUNT_BATCH_REFRESH_ASYNC_MAX_WORKERS, max(1, total)) + _update_account_async_task(task_id, message=f"处理中 0/{total}(并发 {worker_count})") + + next_index = 0 + running: Dict[Any, int] = {} + cancelled = False + pool = ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="batch_refresh_async") + try: + while completed_count < total: + if not _wait_if_account_async_task_paused( + task_id, + f"处理中 {completed_count}/{total}(并发 {worker_count})", + ): + cancelled = True + break + if _is_account_async_task_cancel_requested(task_id): + cancelled = True + break + + while next_index < total and len(running) < worker_count: + if not _wait_if_account_async_task_paused( + task_id, + f"处理中 {completed_count}/{total}(并发 {worker_count})", + ): + cancelled = True + break + account_id = int(ids[next_index]) + next_index += 1 + future = pool.submit(_refresh_one_account_with_retry, account_id, proxy) + running[future] = account_id + + if cancelled: + break + if not running: + continue + + done, _ = wait(tuple(running.keys()), timeout=0.6, return_when=FIRST_COMPLETED) + if not done: + continue + + for future in done: + account_id = int(running.pop(future, 0) or 0) + if account_id <= 0: + continue + try: + detail = future.result() + except Exception as exc: + detail = { + "id": account_id, + "success": False, + "error": str(exc), + "attempts": 1, + } + + completed_count += 1 + if detail.get("success"): + success_count += 1 + else: + failed_count += 1 + + _append_account_async_task_detail(task_id, detail) + _set_account_async_task_progress( + task_id, + total=total, + completed=completed_count, + success=success_count, + failed=failed_count, + ) + _update_account_async_task( + task_id, + message=f"处理中 {completed_count}/{total}(并发 {worker_count})", + ) + + if cancelled: + for future in list(running.keys()): + future.cancel() + _update_account_async_task( + task_id, + status="cancelled", + finished_at=_utc_now_iso(), + message=f"任务已取消,进度 {completed_count}/{total}", + paused=False, + result={ + "success_count": success_count, + "failed_count": failed_count, + "total": total, + "cancelled": True, + }, + ) + return + finally: + pool.shutdown(wait=False, cancel_futures=True) + + _update_account_async_task( + task_id, + status="completed", + finished_at=_utc_now_iso(), + message=f"刷新完成:成功 {success_count},失败 {failed_count}", + paused=False, + result={ + "success_count": success_count, + "failed_count": failed_count, + "total": total, + "cancelled": False, + }, + ) + + +def _is_retryable_validate_error(error_message: Optional[str]) -> bool: + text = str(error_message or "").strip().lower() + if not text: + return False + retry_markers = ( + "network_error", + "network", + "timeout", + "timed out", + "connection", + "temporarily", + "too many requests", + "http 429", + "http 500", + "http 502", + "http 503", + "http 504", + "rate limit", + ) + return any(marker in text for marker in retry_markers) + + +def _calculate_validate_worker_count(total: int, *, async_mode: bool) -> int: + """计算批量验证并发数:避免过低并发导致慢,也避免线程过多造成抖动。""" + safe_total = max(0, int(total or 0)) + if safe_total <= 0: + return 1 + max_workers = ACCOUNT_BATCH_VALIDATE_ASYNC_MAX_WORKERS if async_mode else ACCOUNT_BATCH_VALIDATE_SYNC_MAX_WORKERS + if safe_total <= 3: + return safe_total + if safe_total <= 10: + return min(max_workers, max(4, safe_total)) + if safe_total <= 40: + return min(max_workers, max(6, safe_total // 2)) + return min(max_workers, max(8, safe_total // 3)) + + +def _derive_account_status_from_validate_result(is_valid: bool, error: Optional[str]) -> str: + """ + 将 Token 验证结果映射为账号状态。 + 这里与 token_refresh.validate_account_token 的回写规则保持一致, + 便于异步任务在进度明细里即时返回最终状态。 + """ + if bool(is_valid): + return AccountStatus.ACTIVE.value + + error_text = str(error or "").lower() + if ( + "402" in error_text + or "payment required" in error_text + or "订阅受限" in error_text + ): + return AccountStatus.EXPIRED.value + if ( + "401" in error_text + or "invalid" in error_text + or "unauthorized" in error_text + or "过期" in error_text + or "expired" in error_text + ): + return AccountStatus.FAILED.value + if ( + "封禁" in error_text + or "banned" in error_text + or "forbidden" in error_text + ): + return AccountStatus.BANNED.value + return AccountStatus.FAILED.value + + +def _validate_one_account_with_retry( + account_id: int, + proxy: Optional[str], + max_attempts: int = ACCOUNT_BATCH_VALIDATE_RETRY_ATTEMPTS, + timeout_seconds: int = ACCOUNT_BATCH_VALIDATE_HTTP_TIMEOUT_SECONDS, +) -> Dict[str, Any]: + attempts = max(1, int(max_attempts or 1)) + timeout_seconds = max(5, int(timeout_seconds or ACCOUNT_BATCH_VALIDATE_HTTP_TIMEOUT_SECONDS)) + last_error = "" + for attempt in range(1, attempts + 1): + try: + is_valid, error = do_validate(account_id, proxy, timeout_seconds=timeout_seconds) + if is_valid: + return { + "id": account_id, + "valid": True, + "status": _derive_account_status_from_validate_result(True, error), + "error": None, + "attempts": attempt, + } + last_error = str(error or "token_invalid") + can_retry = attempt < attempts and _is_retryable_validate_error(last_error) + if can_retry: + time.sleep(ACCOUNT_BATCH_VALIDATE_RETRY_BASE_DELAY_SECONDS * attempt) + continue + return { + "id": account_id, + "valid": False, + "status": _derive_account_status_from_validate_result(False, last_error), + "error": last_error, + "attempts": attempt, + } + except Exception as exc: + last_error = str(exc) + can_retry = attempt < attempts and _is_retryable_validate_error(last_error) + if can_retry: + time.sleep(ACCOUNT_BATCH_VALIDATE_RETRY_BASE_DELAY_SECONDS * attempt) + continue + return { + "id": account_id, + "valid": False, + "status": _derive_account_status_from_validate_result(False, last_error), + "error": last_error, + "attempts": attempt, + } + + return { + "id": account_id, + "valid": False, + "status": _derive_account_status_from_validate_result(False, last_error or "validation_failed"), + "error": last_error or "validation_failed", + "attempts": attempts, + } + + +def _run_batch_validate_task(task_id: str, ids: List[int], proxy: Optional[str]): + started_perf = time.perf_counter() + total = len(ids) + valid_count = 0 + invalid_count = 0 + completed_count = 0 + retry_count = 0 + + _update_account_async_task( + task_id, + status="running", + started_at=_utc_now_iso(), + message=f"开始验证 Token,共 {total} 个账号", + paused=False, + ) + + if total <= 0: + _update_account_async_task( + task_id, + status="completed", + finished_at=_utc_now_iso(), + message="没有可验证的账号", + paused=False, + result={ + "valid_count": 0, + "invalid_count": 0, + "total": 0, + "cancelled": False, + }, + ) + return + + worker_count = _calculate_validate_worker_count(total, async_mode=True) + _update_account_async_task(task_id, message=f"处理中 0/{total}(并发 {worker_count})") + + next_index = 0 + running: Dict[Any, int] = {} + cancelled = False + pool = ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="batch_validate_async") + try: + while completed_count < total: + if not _wait_if_account_async_task_paused( + task_id, + f"处理中 {completed_count}/{total}(并发 {worker_count})", + ): + cancelled = True + break + if _is_account_async_task_cancel_requested(task_id): + cancelled = True + break + + while next_index < total and len(running) < worker_count: + if not _wait_if_account_async_task_paused( + task_id, + f"处理中 {completed_count}/{total}(并发 {worker_count})", + ): + cancelled = True + break + account_id = int(ids[next_index]) + next_index += 1 + future = pool.submit(_validate_one_account_with_retry, account_id, proxy) + running[future] = account_id + + if cancelled: + break + if not running: + continue + + done, _ = wait(tuple(running.keys()), timeout=0.6, return_when=FIRST_COMPLETED) + if not done: + continue + + for future in done: + account_id = int(running.pop(future, 0) or 0) + if account_id <= 0: + continue + try: + detail = future.result() + except Exception as exc: + detail = { + "id": account_id, + "valid": False, + "error": str(exc), + "attempts": 1, + } + + completed_count += 1 + if detail.get("valid"): + valid_count += 1 + else: + invalid_count += 1 + attempts = int(detail.get("attempts") or 1) + retry_count += max(0, attempts - 1) + + _append_account_async_task_detail(task_id, detail) + _set_account_async_task_progress( + task_id, + total=total, + completed=completed_count, + success=valid_count, + failed=invalid_count, + ) + _update_account_async_task( + task_id, + message=f"处理中 {completed_count}/{total}(并发 {worker_count})", + ) + + if detail.get("valid"): + logger.info( + "批量验证结果: task_id=%s account_id=%s valid=true attempts=%s", + task_id, + account_id, + detail.get("attempts"), + ) + else: + logger.warning( + "批量验证结果: task_id=%s account_id=%s valid=false attempts=%s error=%s", + task_id, + account_id, + detail.get("attempts"), + str(detail.get("error") or "")[:220] or "-", + ) + + if cancelled: + for future in list(running.keys()): + future.cancel() + _update_account_async_task( + task_id, + status="cancelled", + finished_at=_utc_now_iso(), + message=f"任务已取消,进度 {completed_count}/{total}", + paused=False, + result={ + "valid_count": valid_count, + "invalid_count": invalid_count, + "total": total, + "cancelled": True, + "worker_count": worker_count, + "retry_count": retry_count, + "duration_ms": int((time.perf_counter() - started_perf) * 1000), + }, + ) + return + finally: + pool.shutdown(wait=False, cancel_futures=True) + + _update_account_async_task( + task_id, + status="completed", + finished_at=_utc_now_iso(), + message=f"验证完成:有效 {valid_count},无效 {invalid_count}", + paused=False, + result={ + "valid_count": valid_count, + "invalid_count": invalid_count, + "total": total, + "cancelled": False, + "worker_count": worker_count, + "retry_count": retry_count, + "duration_ms": int((time.perf_counter() - started_perf) * 1000), + }, + ) + logger.info( + "批量验证完成: task_id=%s total=%s valid=%s invalid=%s proxy=%s", + task_id, + total, + valid_count, + invalid_count, + proxy or "-", + ) + + +def _is_retryable_overview_refresh_error(error_message: Optional[str]) -> bool: + text = str(error_message or "").strip().lower() + if not text: + return False + retry_markers = ( + "network_error", + "network", + "timeout", + "timed out", + "connection", + "temporarily", + "too many requests", + "http 429", + "http 500", + "http 502", + "http 503", + "http 504", + "rate limit", + ) + return any(marker in text for marker in retry_markers) + + +def _refresh_overview_account_once(account_id: int, force_refresh: bool, proxy: Optional[str]) -> Dict[str, Any]: + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + return {"id": account_id, "success": False, "error": "账号不存在"} + + if (not _is_paid_subscription(account.subscription_type)) or _is_overview_card_removed(account): + return { + "id": account.id, + "email": account.email, + "success": False, + "skipped": True, + "error": "账号不在 Codex 卡片范围内,已跳过", + } + + account_proxy = (account.proxy_used or "").strip() or proxy + overview, updated = _get_account_overview_data( + db, + account, + force_refresh=force_refresh, + proxy=account_proxy, + allow_network=True, + ) + if updated: + db.commit() + + hourly_unknown = overview.get("hourly_quota", {}).get("status") == "unknown" + weekly_unknown = overview.get("weekly_quota", {}).get("status") == "unknown" + if hourly_unknown and weekly_unknown: + return { + "id": account.id, + "email": account.email, + "success": False, + "error": overview.get("error") or "未获取到配额数据", + } + + return { + "id": account.id, + "email": account.email, + "success": True, + "plan_type": overview.get("plan_type"), + } + + +def _refresh_overview_account_with_retry( + account_id: int, + force_refresh: bool, + proxy: Optional[str], + max_attempts: int = ACCOUNT_OVERVIEW_REFRESH_RETRY_ATTEMPTS, +) -> Dict[str, Any]: + attempts = max(1, int(max_attempts or 1)) + last_detail: Dict[str, Any] = {"id": int(account_id), "success": False, "error": "刷新失败"} + + for attempt in range(1, attempts + 1): + try: + detail = _refresh_overview_account_once(account_id, force_refresh, proxy) + except Exception as exc: + detail = {"id": int(account_id), "success": False, "error": str(exc)} + + detail["attempts"] = attempt + if detail.get("success") or detail.get("skipped"): + return detail + + last_detail = detail + error_text = str(detail.get("error") or "") + can_retry = attempt < attempts and _is_retryable_overview_refresh_error(error_text) + if can_retry: + time.sleep(ACCOUNT_OVERVIEW_REFRESH_RETRY_BASE_DELAY_SECONDS * attempt) + continue + return detail + + return last_detail + + +def _run_overview_refresh_async_task(task_id: str, ids: List[int], force_refresh: bool, proxy: Optional[str]): + total = len(ids) + success_count = 0 + failed_count = 0 + completed_count = 0 + + _update_account_async_task( + task_id, + status="running", + started_at=_utc_now_iso(), + message=f"开始刷新账号总览,共 {total} 个账号", + paused=False, + ) + + if total <= 0: + _update_account_async_task( + task_id, + status="completed", + finished_at=_utc_now_iso(), + message="没有可刷新的总览账号", + paused=False, + result={ + "success_count": 0, + "failed_count": 0, + "total": 0, + "cancelled": False, + }, + ) + return + + worker_count = min(ACCOUNT_OVERVIEW_REFRESH_MAX_WORKERS, max(1, total)) + _update_account_async_task(task_id, message=f"处理中 0/{total}(并发 {worker_count})") + + next_index = 0 + running: Dict[Any, int] = {} + cancelled = False + pool = ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="overview_refresh_async") + try: + while completed_count < total: + if not _wait_if_account_async_task_paused( + task_id, + f"处理中 {completed_count}/{total}(并发 {worker_count})", + ): + cancelled = True + break + if _is_account_async_task_cancel_requested(task_id): + cancelled = True + break + + while next_index < total and len(running) < worker_count: + if not _wait_if_account_async_task_paused( + task_id, + f"处理中 {completed_count}/{total}(并发 {worker_count})", + ): + cancelled = True + break + account_id = int(ids[next_index]) + next_index += 1 + future = pool.submit(_refresh_overview_account_with_retry, account_id, force_refresh, proxy) + running[future] = account_id + + if cancelled: + break + if not running: + continue + + done, _ = wait(tuple(running.keys()), timeout=0.6, return_when=FIRST_COMPLETED) + if not done: + continue + + for future in done: + account_id = int(running.pop(future, 0) or 0) + if account_id <= 0: + continue + try: + detail = future.result() + except Exception as exc: + detail = {"id": account_id, "success": False, "error": str(exc), "attempts": 1} + + completed_count += 1 + if detail.get("success") is True: + success_count += 1 + elif not detail.get("skipped"): + failed_count += 1 + + _append_account_async_task_detail(task_id, detail) + _set_account_async_task_progress( + task_id, + total=total, + completed=completed_count, + success=success_count, + failed=failed_count, + ) + _update_account_async_task( + task_id, + message=f"处理中 {completed_count}/{total}(并发 {worker_count})", + ) + + if cancelled: + for future in list(running.keys()): + future.cancel() + _update_account_async_task( + task_id, + status="cancelled", + finished_at=_utc_now_iso(), + message=f"任务已取消,进度 {completed_count}/{total}", + paused=False, + result={ + "success_count": success_count, + "failed_count": failed_count, + "total": total, + "cancelled": True, + }, + ) + return + finally: + pool.shutdown(wait=False, cancel_futures=True) + + _update_account_async_task( + task_id, + status="completed", + finished_at=_utc_now_iso(), + message=f"刷新完成:成功 {success_count},失败 {failed_count}", + paused=False, + result={ + "success_count": success_count, + "failed_count": failed_count, + "total": total, + "cancelled": False, + }, + ) + + +@router.get("/tasks/{task_id}") +def get_account_async_task_status(task_id: str): + task = _get_account_async_task_or_404(task_id) + return _build_account_async_task_snapshot(task) + + +@router.post("/tasks/{task_id}/cancel") +def cancel_account_async_task(task_id: str): + with _account_async_tasks_lock: + task = _account_async_tasks.get(task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + if task.get("status") in {"completed", "failed", "cancelled"}: + return { + "success": True, + "task_id": task_id, + "status": task.get("status"), + "message": "任务已结束,无需取消", + } + task["cancel_requested"] = True + task["pause_requested"] = False + task["paused"] = False + task["message"] = "已提交取消请求,等待任务停止" + task_manager.request_domain_task_cancel("accounts", task_id) + return { + "success": True, + "task_id": task_id, + "status": "cancelling", + } + + +@router.post("/tasks/{task_id}/pause") +def pause_account_async_task(task_id: str): + with _account_async_tasks_lock: + task = _account_async_tasks.get(task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + status = str(task.get("status") or "").strip().lower() + if status in {"completed", "failed", "cancelled"}: + return { + "success": True, + "task_id": task_id, + "status": status, + "message": "任务已结束,无法暂停", + } + task["pause_requested"] = True + task["paused"] = True + task["status"] = "paused" + task["message"] = "任务已暂停,等待继续" + task_manager.request_domain_task_pause("accounts", task_id) + return { + "success": True, + "task_id": task_id, + "status": "paused", + "message": "任务已暂停", + } + + +@router.post("/tasks/{task_id}/resume") +def resume_account_async_task(task_id: str): + with _account_async_tasks_lock: + task = _account_async_tasks.get(task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + status = str(task.get("status") or "").strip().lower() + if status in {"completed", "failed", "cancelled"}: + return { + "success": True, + "task_id": task_id, + "status": status, + "message": "任务已结束,无需继续", + } + task["pause_requested"] = False + task["paused"] = False + if status == "paused": + task["status"] = "running" + task["message"] = "任务已继续执行" + task_manager.request_domain_task_resume("accounts", task_id) + return { + "success": True, + "task_id": task_id, + "status": "running", + "message": "任务已继续执行", + } + + +def retry_account_async_task(task_id: str) -> Dict[str, Any]: + task = _get_account_async_task_or_404(task_id) + task_type = str(task.get("task_type") or "").strip().lower() + payload = dict(task.get("payload") or {}) + + ids = payload.get("ids") + if not isinstance(ids, list): + ids = [] + safe_ids = [int(item) for item in ids if str(item).strip().isdigit()] + + common_kwargs = { + "ids": safe_ids, + "proxy": payload.get("proxy"), + "select_all": bool(payload.get("select_all", False)), + "status_filter": payload.get("status_filter"), + "email_service_filter": payload.get("email_service_filter"), + "search_filter": payload.get("search_filter"), + } + + if task_type == "batch_refresh": + return start_batch_refresh_async(BatchRefreshRequest(**common_kwargs)) + if task_type == "batch_validate": + return start_batch_validate_async(BatchValidateRequest(**common_kwargs)) + if task_type == "overview_refresh": + return start_overview_refresh_async( + OverviewRefreshRequest( + **common_kwargs, + force=bool(payload.get("force", False)), + ) + ) + + raise HTTPException(status_code=400, detail=f"不支持重试的任务类型: {task_type or '-'}") + + +@router.post("/batch-refresh/async") +def start_batch_refresh_async(request: BatchRefreshRequest): + 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 + ) + + task_payload = { + "ids": [int(item) for item in ids], + "proxy": proxy, + "select_all": bool(request.select_all), + "status_filter": request.status_filter, + "email_service_filter": request.email_service_filter, + "search_filter": request.search_filter, + } + task_id = _create_account_async_task("batch_refresh", total=len(ids), payload=task_payload) + if not ids: + _update_account_async_task( + task_id, + status="completed", + started_at=_utc_now_iso(), + finished_at=_utc_now_iso(), + message="没有可刷新的账号", + result={"success_count": 0, "failed_count": 0, "total": 0, "cancelled": False}, + ) + else: + _account_async_executor.submit( + _run_account_async_task_guard, + task_id, + "batch_refresh", + _run_batch_refresh_task, + ids, + proxy, + ) + + task = _get_account_async_task_or_404(task_id) + return _build_account_async_task_snapshot(task) + + +@router.post("/batch-validate/async") +def start_batch_validate_async(request: BatchValidateRequest): + 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 + ) + + task_payload = { + "ids": [int(item) for item in ids], + "proxy": proxy, + "select_all": bool(request.select_all), + "status_filter": request.status_filter, + "email_service_filter": request.email_service_filter, + "search_filter": request.search_filter, + } + task_id = _create_account_async_task("batch_validate", total=len(ids), payload=task_payload) + if not ids: + _update_account_async_task( + task_id, + status="completed", + started_at=_utc_now_iso(), + finished_at=_utc_now_iso(), + message="没有可验证的账号", + result={"valid_count": 0, "invalid_count": 0, "total": 0, "cancelled": False}, + ) + else: + _account_async_executor.submit( + _run_account_async_task_guard, + task_id, + "batch_validate", + _run_batch_validate_task, + ids, + proxy, + ) + + task = _get_account_async_task_or_404(task_id) + return _build_account_async_task_snapshot(task) + + +@router.post("/overview/refresh/async") +def start_overview_refresh_async(request: OverviewRefreshRequest): + 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, + ) + if not ids: + candidates = db.query(Account).filter( + func.lower(Account.subscription_type).in_(PAID_SUBSCRIPTION_TYPES) + ).order_by(Account.created_at.desc()).all() + ids = [acc.id for acc in candidates if not _is_overview_card_removed(acc)] + + task_payload = { + "ids": [int(item) for item in ids], + "proxy": proxy, + "force": bool(request.force), + "select_all": bool(request.select_all), + "status_filter": request.status_filter, + "email_service_filter": request.email_service_filter, + "search_filter": request.search_filter, + } + task_id = _create_account_async_task("overview_refresh", total=len(ids), payload=task_payload) + if not ids: + _update_account_async_task( + task_id, + status="completed", + started_at=_utc_now_iso(), + finished_at=_utc_now_iso(), + message="没有可刷新的总览账号", + result={"success_count": 0, "failed_count": 0, "total": 0, "cancelled": False}, + ) + else: + _account_async_executor.submit( + _run_account_async_task_guard, + task_id, + "overview_refresh", + _run_overview_refresh_async_task, + ids, + bool(request.force), + proxy, + ) + + task = _get_account_async_task_or_404(task_id) + return _build_account_async_task_snapshot(task) + + @router.post("/batch-refresh") -async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: BackgroundTasks): - """批量刷新账号 Token""" +def batch_refresh_tokens(request: BatchRefreshRequest): + """批量刷新账号 Token(并发执行,避免长轮询阻塞)。""" proxy = _get_proxy(request.proxy) results = { @@ -1867,23 +3530,34 @@ async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: B request.status_filter, request.email_service_filter, request.search_filter ) - for account_id in ids: + if not ids: + return results + + def _refresh_one(account_id: int) -> dict: try: result = do_refresh(account_id, proxy) if result.success: + return {"id": account_id, "success": True} + return {"id": account_id, "success": False, "error": result.error_message} + except Exception as exc: + return {"id": account_id, "success": False, "error": str(exc)} + + worker_count = min(6, max(1, len(ids))) + with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="batch_refresh") as pool: + future_map = {pool.submit(_refresh_one, account_id): account_id for account_id in ids} + for future in as_completed(future_map): + item = future.result() + if item.get("success"): results["success_count"] += 1 else: results["failed_count"] += 1 - results["errors"].append({"id": account_id, "error": result.error_message}) - except Exception as e: - results["failed_count"] += 1 - results["errors"].append({"id": account_id, "error": str(e)}) + results["errors"].append({"id": item.get("id"), "error": item.get("error")}) return results @router.post("/{account_id}/refresh") -async def refresh_account_token(account_id: int, request: Optional[TokenRefreshRequest] = Body(default=None)): +def refresh_account_token(account_id: int, request: Optional[TokenRefreshRequest] = Body(default=None)): """刷新单个账号的 Token""" proxy = _get_proxy(request.proxy if request else None) result = do_refresh(account_id, proxy) @@ -1902,14 +3576,18 @@ async def refresh_account_token(account_id: int, request: Optional[TokenRefreshR @router.post("/batch-validate") -async def batch_validate_tokens(request: BatchValidateRequest): - """批量验证账号 Token 有效性""" +def batch_validate_tokens(request: BatchValidateRequest): + """批量验证账号 Token 有效性(并发执行)。""" + started = time.perf_counter() proxy = _get_proxy(request.proxy) results = { "valid_count": 0, "invalid_count": 0, - "details": [] + "details": [], + "worker_count": 0, + "retry_count": 0, + "duration_ms": 0, } with get_db() as db: @@ -1918,46 +3596,58 @@ async def batch_validate_tokens(request: BatchValidateRequest): request.status_filter, request.email_service_filter, request.search_filter ) - for account_id in ids: - try: - is_valid, error = do_validate(account_id, proxy) - results["details"].append({ - "id": account_id, - "valid": is_valid, - "error": error - }) - if is_valid: + if not ids: + results["duration_ms"] = int((time.perf_counter() - started) * 1000) + return results + + worker_count = _calculate_validate_worker_count(len(ids), async_mode=False) + results["worker_count"] = worker_count + with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="batch_validate") as pool: + future_map = { + pool.submit( + _validate_one_account_with_retry, + account_id, + proxy, + ACCOUNT_BATCH_VALIDATE_RETRY_ATTEMPTS, + ACCOUNT_BATCH_VALIDATE_HTTP_TIMEOUT_SECONDS, + ): account_id + for account_id in ids + } + for future in as_completed(future_map): + detail = future.result() + results["details"].append(detail) + results["retry_count"] += max(0, int(detail.get("attempts") or 1) - 1) + if detail.get("valid"): results["valid_count"] += 1 else: results["invalid_count"] += 1 - except Exception as e: - # 异常账号兜底打标 failed,保证前端“失败”筛选可见。 - 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: - pass - results["invalid_count"] += 1 - results["details"].append({ - "id": account_id, - "valid": False, - "error": str(e) - }) + results["details"].sort(key=lambda item: int(item.get("id") or 0)) + results["duration_ms"] = int((time.perf_counter() - started) * 1000) + logger.info( + "批量验证(同步)完成: total=%s valid=%s invalid=%s workers=%s retries=%s duration_ms=%s proxy=%s", + len(ids), + results["valid_count"], + results["invalid_count"], + results["worker_count"], + results["retry_count"], + results["duration_ms"], + proxy or "-", + ) return results @router.post("/{account_id}/validate") -async def validate_account_token(account_id: int, request: Optional[TokenValidateRequest] = Body(default=None)): +def validate_account_token(account_id: int, request: Optional[TokenValidateRequest] = Body(default=None)): """验证单个账号的 Token 有效性""" proxy = _get_proxy(request.proxy if request else None) is_valid, error = do_validate(account_id, proxy) + next_status = _derive_account_status_from_validate_result(is_valid, error) return { "id": account_id, "valid": is_valid, + "status": next_status, "error": error } @@ -1997,6 +3687,8 @@ async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): raise HTTPException(status_code=404, detail="指定的 CPA 服务不存在") cpa_api_url = svc.api_url cpa_api_token = svc.api_token + if not request.proxy: + proxy = getattr(svc, "proxy_url", None) or proxy with get_db() as db: ids = resolve_account_ids( @@ -2025,6 +3717,8 @@ async def upload_account_to_cpa(account_id: int, request: Optional[CPAUploadRequ raise HTTPException(status_code=404, detail="指定的 CPA 服务不存在") cpa_api_url = svc.api_url cpa_api_token = svc.api_token + if not (request and request.proxy): + proxy = getattr(svc, "proxy_url", None) or proxy with get_db() as db: account = crud.get_account_by_id(db, account_id) @@ -2078,6 +3772,7 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): # 解析指定的 Sub2API 服务 api_url = None api_key = None + target_type = "sub2api" if request.service_id: with get_db() as db: svc = crud.get_sub2api_service_by_id(db, request.service_id) @@ -2085,12 +3780,14 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): raise HTTPException(status_code=404, detail="指定的 Sub2API 服务不存在") api_url = svc.api_url api_key = svc.api_key + target_type = getattr(svc, "target_type", "sub2api") else: with get_db() as db: svcs = crud.get_sub2api_services(db, enabled=True) if svcs: api_url = svcs[0].api_url api_key = svcs[0].api_key + target_type = getattr(svcs[0], "target_type", "sub2api") if not api_url or not api_key: raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") @@ -2105,7 +3802,7 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): ids, api_url, api_key, concurrency=request.concurrency, priority=request.priority, - target_type=locals().get("target_type", "sub2api"), + target_type=target_type, ) return results @@ -2120,6 +3817,7 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp api_url = None api_key = None + target_type = "sub2api" if service_id: with get_db() as db: svc = crud.get_sub2api_service_by_id(db, service_id) @@ -2127,12 +3825,14 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp raise HTTPException(status_code=404, detail="指定的 Sub2API 服务不存在") api_url = svc.api_url api_key = svc.api_key + target_type = getattr(svc, "target_type", "sub2api") else: with get_db() as db: svcs = crud.get_sub2api_services(db, enabled=True) if svcs: api_url = svcs[0].api_url api_key = svcs[0].api_key + target_type = getattr(svcs[0], "target_type", "sub2api") if not api_url or not api_key: raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") @@ -2147,7 +3847,7 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp success, message = upload_to_sub2api( [account], api_url, api_key, concurrency=concurrency, priority=priority, - target_type=locals().get("target_type", "sub2api") + target_type=target_type ) if success: return {"success": True, "message": message} @@ -2186,7 +3886,6 @@ async def batch_upload_accounts_to_tm(request: BatchUploadTMRequest): api_url = svc.api_url api_key = svc.api_key - target_type = getattr(svc, "target_type", "sub2api") ids = resolve_account_ids( db, request.ids, request.select_all, @@ -2215,7 +3914,6 @@ async def upload_account_to_tm(account_id: int, request: Optional[UploadTMReques api_url = svc.api_url api_key = svc.api_key - target_type = getattr(svc, "target_type", "sub2api") account = crud.get_account_by_id(db, account_id) if not account: @@ -2240,16 +3938,6 @@ def _build_inbox_config(db, service_type, email: str) -> dict: "max_retries": settings.tempmail_max_retries, } - if service_type == EST.YYDS_MAIL: - settings = get_settings() - return { - "base_url": settings.yyds_mail_base_url, - "api_key": settings.yyds_mail_api_key.get_secret_value() if settings.yyds_mail_api_key else "", - "default_domain": settings.yyds_mail_default_domain, - "timeout": settings.yyds_mail_timeout, - "max_retries": settings.yyds_mail_max_retries, - } - if service_type == EST.MOE_MAIL: # 按域名后缀匹配,找不到则取 priority 最小的 domain = email.split("@")[1] if "@" in email else "" diff --git a/src/web/routes/auto_team.py b/src/web/routes/auto_team.py new file mode 100644 index 00000000..3a51afa9 --- /dev/null +++ b/src/web/routes/auto_team.py @@ -0,0 +1,3517 @@ +""" +team API +参考 team-manage-main 的兑换/邀请流程,先提供可用的最小落地版本。 +""" + +import base64 +import copy +import asyncio +import json +import logging +import re +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from curl_cffi import requests as cffi_requests +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from sqlalchemy import desc, func + +from ...config.constants import ( + AccountStatus, + PoolState, + RoleTag, + account_label_to_role_tag, + normalize_account_label, + normalize_pool_state, + normalize_role_tag, + role_tag_to_account_label, +) +from ...config.settings import get_settings +from ...core.circuit_breaker import allow_request as breaker_allow_request +from ...core.circuit_breaker import record_failure as breaker_record_failure +from ...core.circuit_breaker import record_success as breaker_record_success +from ...core.dynamic_proxy import get_proxy_url_for_task +from ...core.openai.token_refresh import refresh_account_token as do_refresh +from ...database import crud +from ...database.models import Account, TeamInviteRecord, EmailService as EmailServiceModel +from ...database.session import get_db +from ...services import EmailServiceFactory, EmailServiceType as ServiceEmailType + +logger = logging.getLogger(__name__) +router = APIRouter() + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +INVITER_POOL_SETTING_KEY = "auto_team_inviter_pool_ids" +BLOCKED_ACCOUNT_STATUSES = { + AccountStatus.FAILED.value, + AccountStatus.BANNED.value, +} +MANAGER_ROLE_KEYWORDS = ( + "owner", + "admin", + "administrator", + "manager", + "billing_admin", + "billing-owner", + "team_admin", +) + +INVITE_LOCK_HOURS = 24 +INVITE_JOINED_LOCK_HOURS = 24 * 7 +INVITE_LOCK_STATES = {"pending", "invited", "joined"} +MANAGER_HEALTH_SETTING_KEY = "auto_team_manager_health_v1" +TEAM_POOL_FALLBACK_SETTING_KEY = "auto_team.pull_by_tag_fallback_to_none" +MANAGER_CONCURRENCY_LIMIT = 1 +MANAGER_BASE_COOLDOWN_SECONDS = 1.2 +MANAGER_AUTH_FREEZE_TRIGGER = 2 +MANAGER_AUTH_FREEZE_MINUTES_BASE = 10 +MANAGER_AUTH_FREEZE_MINUTES_MAX = 30 +MANAGER_FAIL_BLOCK_TRIGGER = 3 +TEAM_MANAGER_VERIFY_CACHE_TTL_SECONDS = 600 +TEAM_INVITER_CACHE_TTL_SECONDS = 300 +TEAM_TEAM_ACCOUNTS_CACHE_TTL_SECONDS = 180 +TEAM_CONSOLE_CACHE_TTL_SECONDS = 180 +TEAM_MANAGER_VERIFY_TIMEOUT_SECONDS = 4 +TEAM_MANAGER_VERIFY_MAX_WORKERS = 4 +TEAM_MANAGER_VERIFY_MAX_PER_CALL = 8 +TEAM_CONSOLE_ROW_MAX_WORKERS = 4 +TEAM_CONSOLE_FETCH_TIMEOUT_SECONDS = 12 +TEAM_MANAGER_MAIL_FALLBACK_CACHE_TTL_SECONDS = 600 +TEAM_MANAGER_MAIL_FALLBACK_LOOKBACK_HOURS = 72 +TEAM_MANAGER_MAIL_FALLBACK_MAX_ROWS = 120 +TEAM_CLASSIFY_CACHE_TTL_SECONDS = 25 +TEAM_CLASSIFY_INCREMENTAL_MAX_ROWS = 120 + +_INVITER_SEMAPHORES: Dict[int, asyncio.Semaphore] = {} +_MANAGER_VERIFY_CACHE: Dict[int, Dict[str, Any]] = {} +_MANAGER_MAIL_FALLBACK_CACHE: Dict[int, Dict[str, Any]] = {} +_INVITER_CACHE: Dict[str, Any] = {"expires_at": None, "normal": [], "frozen": []} +_TEAM_ACCOUNTS_CACHE: Dict[str, Any] = {"expires_at": None, "payload": None} +_TEAM_CONSOLE_CACHE: Dict[str, Any] = {"expires_at": None, "payload": None} +_TEAM_CLASSIFY_CACHE: Dict[str, Any] = { + "expires_at": None, + "payload": None, + "marker": {"team_count": 0, "max_updated_at": None}, +} + + +class AutoTeamPreviewRequest(BaseModel): + target_email: str + inviter_account_id: Optional[int] = None + + +class AutoTeamInviteRequest(BaseModel): + target_email: str + inviter_account_id: Optional[int] = None + proxy: Optional[str] = None + + +class TeamMemberInviteRequest(BaseModel): + email: str + proxy: Optional[str] = None + + +class TeamMemberRevokeRequest(BaseModel): + email: str + proxy: Optional[str] = None + + +class TeamMemberRemoveRequest(BaseModel): + user_id: str + proxy: Optional[str] = None + + +class TeamInviterPoolAddRequest(BaseModel): + account_ids: List[int] + + +class TargetPoolConfigRequest(BaseModel): + fallback_to_none: bool = False + + +def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]: + """获取代理 URL:优先请求参数,其次代理池/动态代理/静态配置。""" + if request_proxy: + return request_proxy + with get_db() as db: + proxy = crud.get_random_proxy(db) + if proxy: + return proxy.proxy_url + dynamic_proxy = get_proxy_url_for_task() + if dynamic_proxy: + return dynamic_proxy + return get_settings().proxy_url + + +def _utc_now() -> datetime: + return datetime.utcnow() + + +def _is_cache_alive(expires_at: Optional[datetime]) -> bool: + return bool(expires_at and expires_at > _utc_now()) + + +def _invalidate_team_runtime_caches() -> None: + _INVITER_CACHE["expires_at"] = None + _INVITER_CACHE["normal"] = [] + _INVITER_CACHE["frozen"] = [] + _TEAM_ACCOUNTS_CACHE["expires_at"] = None + _TEAM_ACCOUNTS_CACHE["payload"] = None + _TEAM_CONSOLE_CACHE["expires_at"] = None + _TEAM_CONSOLE_CACHE["payload"] = None + _TEAM_CLASSIFY_CACHE["expires_at"] = None + _TEAM_CLASSIFY_CACHE["payload"] = None + _TEAM_CLASSIFY_CACHE["marker"] = {"team_count": 0, "max_updated_at": None} + + +def _safe_decode_jwt_payload(token: Optional[str]) -> Dict[str, Any]: + raw = str(token or "").strip() + if not raw: + return {} + try: + parts = raw.split(".") + if len(parts) < 2: + return {} + payload = parts[1] + padding = "=" * ((4 - len(payload) % 4) % 4) + decoded = base64.urlsafe_b64decode((payload + padding).encode("utf-8")) + data = json.loads(decoded.decode("utf-8", errors="ignore")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _normalize_plan(value: Optional[str]) -> str: + text = str(value or "").strip().lower() + if not text: + return "free" + if "team" in text or "enterprise" in text: + return "team" + if "plus" in text: + return "plus" + if "pro" in text: + return "pro" + if "basic" in text or "free" in text: + return "free" + return text + + +def _normalize_role_text(value: Optional[str]) -> str: + return str(value or "").strip().lower() + + +def _is_manager_role(role_text: Optional[str]) -> bool: + role = _normalize_role_text(role_text) + if not role: + return False + return any(keyword in role for keyword in MANAGER_ROLE_KEYWORDS) + + +def _get_cached_manager_verify(account_id: int) -> Optional[Tuple[bool, str]]: + entry = _MANAGER_VERIFY_CACHE.get(int(account_id)) + if not isinstance(entry, dict): + return None + expires_at = entry.get("expires_at") + if not isinstance(expires_at, datetime) or not _is_cache_alive(expires_at): + _MANAGER_VERIFY_CACHE.pop(int(account_id), None) + return None + return bool(entry.get("verified")), str(entry.get("source") or "cache") + + +def _set_cached_manager_verify(account_id: int, verified: bool, source: str) -> None: + _MANAGER_VERIFY_CACHE[int(account_id)] = { + "verified": bool(verified), + "source": str(source or ""), + "expires_at": _utc_now() + timedelta(seconds=TEAM_MANAGER_VERIFY_CACHE_TTL_SECONDS), + } + + +def _cached_verify_needs_realtime(source: str) -> bool: + """ + 对“兜底保留/鉴权失败”来源的缓存结果不直接复用,避免 401 账号长期残留。 + """ + source_lower = str(source or "").strip().lower() + if not source_lower: + return True + if "history_fallback" in source_lower or "stale_fallback" in source_lower: + return True + if "hard_remove_auth" in source_lower: + return True + if "http_401" in source_lower or "http_403" in source_lower: + return True + if _is_token_invalidated_error(source_lower): + return True + return False + + +def _get_cached_manager_mail_fallback(account_id: int) -> Optional[Tuple[bool, str]]: + entry = _MANAGER_MAIL_FALLBACK_CACHE.get(int(account_id)) + if not isinstance(entry, dict): + return None + expires_at = entry.get("expires_at") + if not isinstance(expires_at, datetime) or not _is_cache_alive(expires_at): + _MANAGER_MAIL_FALLBACK_CACHE.pop(int(account_id), None) + return None + return bool(entry.get("blocked")), str(entry.get("source") or "mail_cache") + + +def _set_cached_manager_mail_fallback(account_id: int, blocked: bool, source: str) -> None: + _MANAGER_MAIL_FALLBACK_CACHE[int(account_id)] = { + "blocked": bool(blocked), + "source": str(source or ""), + "expires_at": _utc_now() + timedelta(seconds=TEAM_MANAGER_MAIL_FALLBACK_CACHE_TTL_SECONDS), + } + + +def _is_auth_source_for_mail_fallback(source: str) -> bool: + text = str(source or "").strip().lower() + if not text: + return False + markers = ( + "http_401", + "http_403", + "token invalidated", + "invalid token", + "token expired", + "authentication token has been invalidated", + "please try signing in again", + "after_refresh", + ) + return any(marker in text for marker in markers) + + +def _normalize_iso_datetime(value: Any) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + dt = value + elif isinstance(value, (int, float)): + ts = float(value) + if ts > 10**12: + ts = ts / 1000.0 + if ts <= 0: + return None + dt = datetime.fromtimestamp(ts, tz=timezone.utc) + else: + text = str(value or "").strip() + if not text: + return None + try: + if text.endswith("Z"): + text = text[:-1] + "+00:00" + dt = datetime.fromisoformat(text) + except Exception: + return None + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + +def _resolve_temp_mail_config_for_account(db, account: Account) -> Optional[Dict[str, Any]]: + if str(account.email_service or "").strip().lower() != ServiceEmailType.TEMP_MAIL.value: + return None + + services = ( + db.query(EmailServiceModel) + .filter( + EmailServiceModel.service_type == ServiceEmailType.TEMP_MAIL.value, + EmailServiceModel.enabled == True, + ) + .order_by(EmailServiceModel.priority.asc(), EmailServiceModel.id.asc()) + .all() + ) + if not services: + return None + + account_domain = "" + try: + account_domain = str(account.email or "").split("@", 1)[1].strip().lower() + except Exception: + account_domain = "" + + matched = None + for svc in services: + cfg = dict(svc.config or {}) + cfg_domain = str(cfg.get("domain") or cfg.get("default_domain") or "").strip().lower() + if cfg_domain and account_domain and cfg_domain == account_domain: + matched = svc + break + if matched is None: + matched = services[0] + + cfg = dict((matched.config or {})) + if "api_url" in cfg and "base_url" not in cfg: + cfg["base_url"] = cfg.pop("api_url") + if not cfg.get("base_url") or not cfg.get("admin_password"): + return None + return cfg + + +def _is_openai_deactivated_mail(sender: str, subject: str, body: str) -> bool: + blob = "\n".join([str(sender or ""), str(subject or ""), str(body or "")]).lower() + if "openai" not in blob and "tm1.openai.com" not in blob: + return False + markers = ( + "access deactivated", + "deactivating your access", + "identified activity in chatgpt that is not permitted", + "trustandsafety@tm1.openai.com", + "initiate appeal", + ) + return any(marker in blob for marker in markers) + + +def _scan_deactivation_mail_fallback(account: Account, *, force: bool = False) -> Tuple[bool, str]: + """ + 邮箱兜底:仅在网络鉴权失败时启用。 + 当前优先支持 temp_mail(可直接读取 admin/mails)。 + """ + account_id = int(getattr(account, "id", 0) or 0) + if account_id <= 0: + return False, "mail_fallback_skip:no_account_id" + + if not force: + cached = _get_cached_manager_mail_fallback(account_id) + if cached is not None: + return cached + + service_key = str(getattr(account, "email_service", "") or "").strip().lower() + if service_key != ServiceEmailType.TEMP_MAIL.value: + source = f"mail_fallback_skip:unsupported_service:{service_key or '-'}" + _set_cached_manager_mail_fallback(account_id, False, source) + return False, source + + target_email = str(getattr(account, "email", "") or "").strip().lower() + if not target_email: + source = "mail_fallback_skip:missing_email" + _set_cached_manager_mail_fallback(account_id, False, source) + return False, source + + try: + with get_db() as db: + cfg = _resolve_temp_mail_config_for_account(db, account) + if not cfg: + source = "mail_fallback_skip:config_missing" + _set_cached_manager_mail_fallback(account_id, False, source) + return False, source + + service = EmailServiceFactory.create(ServiceEmailType.TEMP_MAIL, cfg) + rows = service.list_emails(limit=TEAM_MANAGER_MAIL_FALLBACK_MAX_ROWS, offset=0) + if not isinstance(rows, list): + rows = [] + + cutoff = datetime.utcnow() - timedelta(hours=TEAM_MANAGER_MAIL_FALLBACK_LOOKBACK_HOURS) + for row in rows: + if not isinstance(row, dict): + continue + row_email = str(row.get("email") or row.get("address") or "").strip().lower() + if row_email and row_email != target_email: + continue + raw_data = row.get("raw_data") + raw_dict = raw_data if isinstance(raw_data, dict) else {} + sender = str( + row.get("from") + or row.get("source") + or raw_dict.get("source") + or raw_dict.get("from") + or "" + ).strip() + subject = str(row.get("subject") or raw_dict.get("subject") or "").strip() + body = str( + raw_dict.get("text") + or raw_dict.get("body") + or raw_dict.get("content") + or raw_dict.get("html") + or raw_dict.get("raw") + or "" + ) + + created_at = _normalize_iso_datetime( + row.get("created_at") + or row.get("createdAt") + or raw_dict.get("created_at") + or raw_dict.get("createdAt") + or raw_dict.get("date") + ) + if created_at and created_at < cutoff: + continue + + if _is_openai_deactivated_mail(sender, subject, body): + source = "mail_fallback:openai_access_deactivated" + _set_cached_manager_mail_fallback(account_id, True, source) + return True, source + + source = "mail_fallback:no_deactivated_signal" + _set_cached_manager_mail_fallback(account_id, False, source) + return False, source + except Exception as exc: + source = f"mail_fallback:error:{exc}" + logger.warning( + "team管理号邮箱兜底扫描失败: account=%s email=%s err=%s", + account_id, + target_email, + exc, + ) + _set_cached_manager_mail_fallback(account_id, False, source) + return False, source + + +def _get_cached_inviter_accounts(include_frozen: bool) -> Optional[List[Dict[str, Any]]]: + expires_at = _INVITER_CACHE.get("expires_at") + if not isinstance(expires_at, datetime) or not _is_cache_alive(expires_at): + return None + key = "frozen" if include_frozen else "normal" + rows = _INVITER_CACHE.get(key) + if not isinstance(rows, list): + return None + return copy.deepcopy(rows) + + +def _set_cached_inviter_accounts(normal_rows: List[Dict[str, Any]], frozen_rows: List[Dict[str, Any]]) -> None: + _INVITER_CACHE["normal"] = copy.deepcopy(normal_rows) + _INVITER_CACHE["frozen"] = copy.deepcopy(frozen_rows) + _INVITER_CACHE["expires_at"] = _utc_now() + timedelta(seconds=TEAM_INVITER_CACHE_TTL_SECONDS) + + +def _get_cached_payload(cache_bucket: Dict[str, Any]) -> Optional[Dict[str, Any]]: + expires_at = cache_bucket.get("expires_at") + payload = cache_bucket.get("payload") + if not isinstance(expires_at, datetime) or not _is_cache_alive(expires_at): + return None + if not isinstance(payload, dict): + return None + return copy.deepcopy(payload) + + +def _set_cached_payload(cache_bucket: Dict[str, Any], payload: Dict[str, Any], ttl_seconds: int) -> None: + cache_bucket["payload"] = copy.deepcopy(payload) + cache_bucket["expires_at"] = _utc_now() + timedelta(seconds=max(1, int(ttl_seconds))) + + +def _infer_account_plan(account: Account) -> str: + direct = _normalize_plan(getattr(account, "subscription_type", None)) + if direct != "free": + return direct + + for token in (getattr(account, "access_token", None), getattr(account, "id_token", None)): + payload = _safe_decode_jwt_payload(token) + auth = payload.get("https://api.openai.com/auth") + if isinstance(auth, dict): + plan = _normalize_plan(auth.get("chatgpt_plan_type")) + if plan != "free": + return plan + return direct + + +def _resolve_workspace_id(account: Account) -> str: + value = str(getattr(account, "account_id", "") or "").strip() + if value: + return value + value = str(getattr(account, "workspace_id", "") or "").strip() + if value: + return value + for token in (getattr(account, "access_token", None), getattr(account, "id_token", None)): + payload = _safe_decode_jwt_payload(token) + auth = payload.get("https://api.openai.com/auth") + if isinstance(auth, dict): + account_id = str(auth.get("chatgpt_account_id") or "").strip() + if account_id: + return account_id + + extra = getattr(account, "extra_data", None) + if isinstance(extra, dict): + for key in ("workspace_id", "account_id", "chatgpt_account_id"): + value = str(extra.get(key) or "").strip() + if value: + return value + return "" + + +def _resolve_account_role_tag(account: Account) -> str: + role_raw = str(getattr(account, "role_tag", "") or "").strip() + if role_raw: + return normalize_role_tag(role_raw) + return account_label_to_role_tag(getattr(account, "account_label", None)) + + +def _set_account_role_tag(account: Account, role_tag: str) -> str: + normalized = normalize_role_tag(role_tag) + account.role_tag = normalized + account.account_label = role_tag_to_account_label(normalized) + return normalized + + +def _resolve_account_manual_pool_state(account: Account) -> Optional[str]: + text = str(getattr(account, "pool_state_manual", "") or "").strip() + if not text: + return None + return normalize_pool_state(text) + + +def _resolve_account_pool_state(account: Account) -> str: + return normalize_pool_state(getattr(account, "pool_state", None)) + + +def _read_pull_fallback_to_none() -> bool: + with get_db() as db: + setting = crud.get_setting(db, TEAM_POOL_FALLBACK_SETTING_KEY) + if not setting or setting.value is None: + return False + text = str(setting.value).strip().lower() + return text in {"1", "true", "yes", "on"} + + +def _build_account_item(account: Account) -> Dict[str, Any]: + plan = _infer_account_plan(account) + workspace_id = _resolve_workspace_id(account) + account_label = normalize_account_label(getattr(account, "account_label", None)) + role_tag = _resolve_account_role_tag(account) + return { + "id": account.id, + "email": account.email, + "status": account.status, + "plan": plan, + "account_label": account_label, + "role_tag": role_tag, + "biz_tag": str(getattr(account, "biz_tag", "") or "").strip() or None, + "pool_state": _resolve_account_pool_state(account), + "pool_state_manual": _resolve_account_manual_pool_state(account), + "last_pool_sync_at": account.last_pool_sync_at.isoformat() if getattr(account, "last_pool_sync_at", None) else None, + "priority": int(getattr(account, "priority", 50) or 50), + "last_used_at": account.last_used_at.isoformat() if getattr(account, "last_used_at", None) else None, + "workspace_id": workspace_id, + "subscription_type": account.subscription_type, + "last_refresh": account.last_refresh.isoformat() if account.last_refresh else None, + "updated_at": account.updated_at.isoformat() if account.updated_at else None, + } + + +def _resolve_member_snapshot_from_extra(account: Account) -> Tuple[Optional[int], Optional[int]]: + extra = getattr(account, "extra_data", None) + if not isinstance(extra, dict): + return None, None + + current_members: Optional[int] = None + max_members: Optional[int] = None + for key in ( + "team_current_members", + "current_members", + "total_current_members", + "members_count", + "num_members", + ): + if key not in extra: + continue + value = _safe_int(extra.get(key), -1) + if value >= 0: + current_members = value + break + + for key in ( + "team_max_members", + "max_members", + "total_max_members", + "seat_limit", + ): + if key not in extra: + continue + value = _safe_int(extra.get(key), -1) + if value > 0: + max_members = value + break + + return current_members, max_members + + +def _get_cached_team_member_snapshot_map() -> Dict[int, Tuple[int, int]]: + payload = _get_cached_payload(_TEAM_CONSOLE_CACHE) + if not isinstance(payload, dict): + return {} + rows = payload.get("rows") + if not isinstance(rows, list): + return {} + + result: Dict[int, Tuple[int, int]] = {} + for row in rows: + if not isinstance(row, dict): + continue + account_id = _to_int(row.get("id"), 0) + if account_id <= 0: + continue + current_members = _to_int(row.get("current_members"), -1) + max_members = _to_int(row.get("max_members"), 6) + if current_members < 0: + continue + if max_members <= 0: + max_members = 6 + result[account_id] = (current_members, max_members) + return result + + +def _sync_team_member_snapshot_to_accounts(rows: List[Dict[str, Any]]) -> None: + if not rows: + return + + metrics_map: Dict[int, Tuple[int, int]] = {} + for row in rows: + if not isinstance(row, dict): + continue + account_id = _to_int(row.get("id"), 0) + if account_id <= 0: + continue + current_members = _to_int(row.get("current_members"), -1) + if current_members < 0: + continue + max_members = _to_int(row.get("max_members"), 6) + if max_members <= 0: + max_members = 6 + metrics_map[account_id] = (current_members, max_members) + + if not metrics_map: + return + + now_iso = datetime.utcnow().isoformat() + changed = 0 + with get_db() as db: + account_rows = db.query(Account).filter(Account.id.in_(list(metrics_map.keys()))).all() + for account in account_rows: + snapshot = metrics_map.get(int(account.id)) + if not snapshot: + continue + current_members, max_members = snapshot + extra = account.extra_data if isinstance(account.extra_data, dict) else {} + old_current = _safe_int(extra.get("team_current_members"), -1) + old_max = _safe_int(extra.get("team_max_members"), -1) + if old_current == current_members and old_max == max_members: + continue + new_extra = dict(extra) + new_extra["team_current_members"] = current_members + new_extra["team_max_members"] = max_members + new_extra["team_member_ratio"] = f"{current_members}/{max_members}" + new_extra["team_metrics_updated_at"] = now_iso + account.extra_data = new_extra + changed += 1 + if changed > 0: + db.commit() + + +def _team_classify_item_sort_key(item: Dict[str, Any]) -> Tuple[str, int]: + updated_text = str(item.get("updated_at") or "") + account_id = _safe_int(item.get("id"), 0) + return updated_text, account_id + + +def _serialize_dt(value: Optional[datetime]) -> Optional[str]: + if not isinstance(value, datetime): + return None + return value.isoformat() + + +def _audit_pool_state_change( + *, + account_id: int, + account_email: str, + from_state: str, + to_state: str, + reason: str, + manual_state: Optional[str], +) -> None: + try: + with get_db() as db: + crud.create_operation_audit_log( + db, + actor="system", + action="account.pool_state_auto_sync", + target_type="account", + target_id=account_id, + target_email=account_email, + payload={ + "from": from_state, + "to": to_state, + "reason": reason, + "manual_state": manual_state, + }, + ) + except Exception: + logger.debug("记录池状态变更审计日志失败: account_id=%s", account_id, exc_info=True) + + +def _query_team_classify_marker(db) -> Dict[str, Any]: + max_updated_at, team_count = ( + db.query(func.max(Account.updated_at), func.count(Account.id)) + .filter(func.lower(func.coalesce(Account.subscription_type, "")) == "team") + .first() + ) + return { + "team_count": int(team_count or 0), + "max_updated_at": _serialize_dt(max_updated_at), + } + + +def _is_same_team_marker(left: Optional[Dict[str, Any]], right: Optional[Dict[str, Any]]) -> bool: + if not isinstance(left, dict) or not isinstance(right, dict): + return False + left_count = int(left.get("team_count") or 0) + right_count = int(right.get("team_count") or 0) + left_dt = str(left.get("max_updated_at") or "") + right_dt = str(right.get("max_updated_at") or "") + return left_count == right_count and left_dt == right_dt + + +def _set_team_classify_cache(payload: Dict[str, List[Dict[str, Any]]], marker: Dict[str, Any]) -> None: + _TEAM_CLASSIFY_CACHE["payload"] = copy.deepcopy(payload) + _TEAM_CLASSIFY_CACHE["marker"] = { + "team_count": int(marker.get("team_count") or 0), + "max_updated_at": str(marker.get("max_updated_at") or "") or None, + } + _TEAM_CLASSIFY_CACHE["expires_at"] = _utc_now() + timedelta(seconds=TEAM_CLASSIFY_CACHE_TTL_SECONDS) + + +def _classify_team_account_row( + account: Account, + *, + now: datetime, + health_state: Dict[str, Dict[str, Any]], +) -> Tuple[Optional[Dict[str, Any]], Optional[str], int]: + item = _build_account_item(account) + if item["plan"] != "team": + return None, None, 0 + + row_changed = 0 + has_access_token = bool(str(account.access_token or "").strip()) + has_refresh_token = bool(str(account.refresh_token or "").strip()) + has_session_token = bool(str(account.session_token or "").strip()) + has_workspace = bool(str(item.get("workspace_id") or "").strip()) + can_auth = has_access_token or has_refresh_token or has_session_token + role_tag = _resolve_account_role_tag(account) + account_label = role_tag_to_account_label(role_tag) + item["role_tag"] = role_tag + item["account_label"] = account_label + item["has_access_token"] = has_access_token + item["has_refresh_token"] = has_refresh_token + item["has_session_token"] = has_session_token + item["manager_ready"] = bool(has_workspace and can_auth) + + # 兼容同步:role_tag 与 account_label 双写一致 + if str(getattr(account, "account_label", "") or "").strip().lower() != account_label: + account.account_label = account_label + row_changed += 1 + if str(getattr(account, "role_tag", "") or "").strip().lower() != role_tag: + account.role_tag = role_tag + row_changed += 1 + + status_text = str(account.status or "").strip().lower() + health_entry = _get_manager_health_entry(health_state, int(account.id)) + health_consecutive_fail = _safe_int(health_entry.get("consecutive_fail"), 0) + health_frozen = _is_manager_frozen(health_entry, now) + manual_pool_state = _resolve_account_manual_pool_state(account) + old_pool_state = _resolve_account_pool_state(account) + + auto_pool_state = PoolState.CANDIDATE_POOL.value + auto_reason = "candidate_default" + if status_text in BLOCKED_ACCOUNT_STATUSES: + auto_pool_state = PoolState.BLOCKED.value + auto_reason = f"status_blocked:{status_text}" + elif health_consecutive_fail >= MANAGER_FAIL_BLOCK_TRIGGER: + auto_pool_state = PoolState.BLOCKED.value + auto_reason = f"fuse_consecutive_fail:{health_consecutive_fail}" + elif health_frozen: + auto_pool_state = PoolState.BLOCKED.value + auto_reason = "health_frozen" + elif role_tag == RoleTag.PARENT.value and has_workspace and can_auth: + auto_pool_state = PoolState.TEAM_POOL.value + auto_reason = "parent_team_ready" + + effective_pool_state = manual_pool_state or auto_pool_state + item["pool_state_auto"] = auto_pool_state + item["pool_state_manual"] = manual_pool_state + item["pool_state"] = effective_pool_state + + if health_consecutive_fail >= MANAGER_FAIL_BLOCK_TRIGGER and old_pool_state != PoolState.BLOCKED.value: + logger.warning( + "team管理号触发失败熔断: inviter=%s email=%s consecutive_fail=%s threshold=%s", + account.id, + account.email, + health_consecutive_fail, + MANAGER_FAIL_BLOCK_TRIGGER, + ) + + if old_pool_state != effective_pool_state: + logger.info( + "team账号池状态变更: account_id=%s email=%s from=%s to=%s reason=%s manual=%s", + account.id, + account.email, + old_pool_state, + effective_pool_state, + auto_reason, + manual_pool_state or "-", + ) + account.pool_state = effective_pool_state + row_changed += 1 + _audit_pool_state_change( + account_id=int(account.id), + account_email=str(account.email or ""), + from_state=str(old_pool_state or ""), + to_state=str(effective_pool_state or ""), + reason=auto_reason, + manual_state=manual_pool_state, + ) + + previous_sync_at = getattr(account, "last_pool_sync_at", None) + if not previous_sync_at or (now - previous_sync_at).total_seconds() >= 20: + account.last_pool_sync_at = now + row_changed += 1 + item["last_pool_sync_at"] = ( + account.last_pool_sync_at.isoformat() + if getattr(account, "last_pool_sync_at", None) + else now.isoformat() + ) + + if effective_pool_state == PoolState.BLOCKED.value: + item["team_identity"] = "blocked" + return item, "member", row_changed + if effective_pool_state == PoolState.TEAM_POOL.value and has_workspace and can_auth: + item["team_identity"] = "manager" + return item, "manager", row_changed + item["team_identity"] = "member" + return item, "member", row_changed + + +def _normalize_account_ids(raw: Any) -> List[int]: + if isinstance(raw, list): + values = raw + elif isinstance(raw, str): + text = raw.strip() + if not text: + values = [] + else: + try: + parsed = json.loads(text) + values = parsed if isinstance(parsed, list) else [] + except Exception: + values = [x.strip() for x in text.split(",") if x.strip()] + else: + values = [] + + out: List[int] = [] + seen = set() + for value in values: + try: + num = int(value) + except Exception: + continue + if num <= 0 or num in seen: + continue + seen.add(num) + out.append(num) + return out + + +def _load_inviter_pool_ids() -> List[int]: + with get_db() as db: + setting = crud.get_setting(db, INVITER_POOL_SETTING_KEY) + if not setting or not str(setting.value or "").strip(): + return [] + return _normalize_account_ids(setting.value) + + +def _save_inviter_pool_ids(account_ids: List[int]) -> List[int]: + normalized = _normalize_account_ids(account_ids) + with get_db() as db: + crud.set_setting( + db, + key=INVITER_POOL_SETTING_KEY, + value=json.dumps(normalized, ensure_ascii=False), + description="team邀请手动入池账号ID列表", + category="team", + ) + return normalized + + +def _parse_dt(value: Optional[str]) -> Optional[datetime]: + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text) + except Exception: + return None + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except Exception: + return default + + +def _load_manager_health_state() -> Dict[str, Dict[str, Any]]: + with get_db() as db: + setting = crud.get_setting(db, MANAGER_HEALTH_SETTING_KEY) + raw = str(getattr(setting, "value", "") or "").strip() if setting else "" + if not raw: + return {} + try: + data = json.loads(raw) + if isinstance(data, dict): + return {str(k): v for k, v in data.items() if isinstance(v, dict)} + except Exception: + pass + return {} + + +def _save_manager_health_state(state: Dict[str, Dict[str, Any]]) -> None: + with get_db() as db: + crud.set_setting( + db, + key=MANAGER_HEALTH_SETTING_KEY, + value=json.dumps(state, ensure_ascii=False), + description="Team 邀请管理账号健康度与冻结状态", + category="team", + ) + + +def _get_manager_health_entry(state: Dict[str, Dict[str, Any]], account_id: int) -> Dict[str, Any]: + key = str(int(account_id)) + entry = state.get(key) + if not isinstance(entry, dict): + entry = {} + state[key] = entry + entry.setdefault("success_total", 0) + entry.setdefault("fail_total", 0) + entry.setdefault("consecutive_fail", 0) + entry.setdefault("auth_fail_streak", 0) + entry.setdefault("frozen_until", None) + entry.setdefault("next_allowed_at", None) + entry.setdefault("last_status", None) + entry.setdefault("last_error", None) + entry.setdefault("last_success_at", None) + entry.setdefault("updated_at", None) + return entry + + +def _is_manager_frozen(entry: Dict[str, Any], now: Optional[datetime] = None) -> bool: + current = now or datetime.utcnow() + frozen_until = _parse_dt(entry.get("frozen_until")) + return bool(frozen_until and frozen_until > current) + + +def _manager_wait_seconds(entry: Dict[str, Any], now: Optional[datetime] = None) -> float: + current = now or datetime.utcnow() + next_allowed = _parse_dt(entry.get("next_allowed_at")) + if not next_allowed: + return 0.0 + remain = (next_allowed - current).total_seconds() + return float(remain) if remain > 0 else 0.0 + + +def _set_manager_next_allowed(entry: Dict[str, Any], seconds: float) -> None: + cooldown = max(0.0, float(seconds or 0.0)) + entry["next_allowed_at"] = (datetime.utcnow() + timedelta(seconds=cooldown)).isoformat() + + +def _compute_manager_health_priority(row: Dict[str, Any], entry: Dict[str, Any]) -> int: + success_total = _safe_int(entry.get("success_total"), 0) + fail_total = _safe_int(entry.get("fail_total"), 0) + consecutive_fail = _safe_int(entry.get("consecutive_fail"), 0) + auth_fail_streak = _safe_int(entry.get("auth_fail_streak"), 0) + is_active = str(row.get("status") or "").strip().lower() == AccountStatus.ACTIVE.value + frozen_penalty = 1000 if _is_manager_frozen(entry) else 0 + base = (success_total * 3) - (fail_total * 2) - (consecutive_fail * 8) - (auth_fail_streak * 12) + if not is_active: + base -= 30 + return int(base - frozen_penalty) + + +def _annotate_manager_health(row: Dict[str, Any], entry: Dict[str, Any]) -> None: + now = datetime.utcnow() + success_total = _safe_int(entry.get("success_total"), 0) + fail_total = _safe_int(entry.get("fail_total"), 0) + attempts = max(0, success_total + fail_total) + fail_rate = (float(fail_total) / float(attempts)) if attempts > 0 else 0.5 + row["health_success_total"] = success_total + row["health_fail_total"] = fail_total + row["health_total_attempts"] = attempts + row["health_fail_rate"] = round(fail_rate, 4) + row["health_consecutive_fail"] = _safe_int(entry.get("consecutive_fail"), 0) + row["health_auth_fail_streak"] = _safe_int(entry.get("auth_fail_streak"), 0) + row["health_frozen_until"] = entry.get("frozen_until") + row["health_frozen"] = _is_manager_frozen(entry, now) + row["health_next_allowed_at"] = entry.get("next_allowed_at") + row["health_wait_seconds"] = _manager_wait_seconds(entry, now) + row["health_priority"] = _compute_manager_health_priority(row, entry) + + +def _get_manager_cooldown_seconds(account_id: int) -> float: + state = _load_manager_health_state() + entry = _get_manager_health_entry(state, account_id) + return _manager_wait_seconds(entry) + + +def _update_manager_health_after_invite( + *, + account_id: int, + status_code: int, + error_text: str, + success: bool, +) -> Dict[str, Any]: + state = _load_manager_health_state() + entry = _get_manager_health_entry(state, account_id) + now = datetime.utcnow() + + if success: + entry["success_total"] = _safe_int(entry.get("success_total"), 0) + 1 + entry["consecutive_fail"] = 0 + entry["auth_fail_streak"] = 0 + entry["frozen_until"] = None + entry["last_success_at"] = now.isoformat() + else: + entry["fail_total"] = _safe_int(entry.get("fail_total"), 0) + 1 + entry["consecutive_fail"] = _safe_int(entry.get("consecutive_fail"), 0) + 1 + if int(status_code) in (401, 403): + auth_streak = _safe_int(entry.get("auth_fail_streak"), 0) + 1 + entry["auth_fail_streak"] = auth_streak + if auth_streak >= MANAGER_AUTH_FREEZE_TRIGGER: + freeze_minutes = min( + MANAGER_AUTH_FREEZE_MINUTES_MAX, + MANAGER_AUTH_FREEZE_MINUTES_BASE * (auth_streak - (MANAGER_AUTH_FREEZE_TRIGGER - 1)), + ) + entry["frozen_until"] = (now + timedelta(minutes=freeze_minutes)).isoformat() + logger.warning( + "team管理号进入冻结期: inviter=%s auth_fail_streak=%s freeze=%smin", + account_id, + auth_streak, + freeze_minutes, + ) + elif int(status_code) != 429: + entry["auth_fail_streak"] = 0 + + if int(status_code) == 429: + cooldown = min(20.0, 2.0 * max(1, _safe_int(entry.get("consecutive_fail"), 1))) + else: + cooldown = MANAGER_BASE_COOLDOWN_SECONDS + _set_manager_next_allowed(entry, cooldown) + + entry["last_status"] = int(status_code) + entry["last_error"] = str(error_text or "").strip()[:300] or None + entry["updated_at"] = now.isoformat() + _save_manager_health_state(state) + + # 失败熔断:同一管理号连续失败达到阈值,自动标记 blocked + consecutive_fail = _safe_int(entry.get("consecutive_fail"), 0) + if not success and consecutive_fail >= MANAGER_FAIL_BLOCK_TRIGGER: + with get_db() as db: + account = db.query(Account).filter(Account.id == int(account_id)).first() + if account: + old_pool_state = _resolve_account_pool_state(account) + account.pool_state = PoolState.BLOCKED.value + account.last_pool_sync_at = now + db.commit() + if old_pool_state != PoolState.BLOCKED.value: + logger.warning( + "team管理号自动熔断入 blocked 池: account_id=%s email=%s consecutive_fail=%s threshold=%s", + account.id, + account.email, + consecutive_fail, + MANAGER_FAIL_BLOCK_TRIGGER, + ) + try: + crud.create_operation_audit_log( + db, + actor="system", + action="account.pool_state_fuse_block", + target_type="account", + target_id=account.id, + target_email=account.email, + payload={ + "from": old_pool_state, + "to": PoolState.BLOCKED.value, + "consecutive_fail": consecutive_fail, + "threshold": MANAGER_FAIL_BLOCK_TRIGGER, + "last_error": entry.get("last_error"), + }, + ) + except Exception: + logger.debug("记录熔断审计日志失败: account_id=%s", account.id, exc_info=True) + + _invalidate_team_runtime_caches() + return entry + + +def _get_inviter_semaphore(account_id: int) -> asyncio.Semaphore: + key = int(account_id) + sem = _INVITER_SEMAPHORES.get(key) + if sem is None: + sem = asyncio.Semaphore(MANAGER_CONCURRENCY_LIMIT) + _INVITER_SEMAPHORES[key] = sem + return sem + + +def _classify_team_accounts(force: bool = False) -> Dict[str, List[Dict[str, Any]]]: + """ + Team 账号分类: + - managers: 母号候选(满足基础可邀请条件) + - members: 子号(Team 成员账号) + """ + with get_db() as db: + marker = _query_team_classify_marker(db) + cache_payload = _TEAM_CLASSIFY_CACHE.get("payload") + cache_marker = _TEAM_CLASSIFY_CACHE.get("marker") + cache_expires_at = _TEAM_CLASSIFY_CACHE.get("expires_at") + if ( + not force + and isinstance(cache_payload, dict) + and _is_cache_alive(cache_expires_at) + and _is_same_team_marker(marker, cache_marker) + ): + return copy.deepcopy(cache_payload) + + now = datetime.utcnow() + health_state = _load_manager_health_state() + changed = 0 + managers: List[Dict[str, Any]] = [] + members: List[Dict[str, Any]] = [] + + # 增量路径:仅处理自上次快照以来发生变更的账号,减少常规刷新成本。 + incremental_used = False + old_marker_dt = _parse_dt((cache_marker or {}).get("max_updated_at")) if isinstance(cache_marker, dict) else None + old_team_count = int((cache_marker or {}).get("team_count") or 0) if isinstance(cache_marker, dict) else 0 + new_team_count = int(marker.get("team_count") or 0) + if ( + not force + and isinstance(cache_payload, dict) + and old_marker_dt + and old_team_count == new_team_count + ): + changed_rows = ( + db.query(Account) + .filter(Account.updated_at.isnot(None)) + .filter(Account.updated_at >= old_marker_dt) + .order_by(desc(Account.updated_at), desc(Account.id)) + .all() + ) + if changed_rows and len(changed_rows) <= TEAM_CLASSIFY_INCREMENTAL_MAX_ROWS: + manager_map: Dict[int, Dict[str, Any]] = { + _safe_int(item.get("id"), 0): dict(item) + for item in (cache_payload.get("managers") or []) + if _safe_int(item.get("id"), 0) > 0 + } + member_map: Dict[int, Dict[str, Any]] = { + _safe_int(item.get("id"), 0): dict(item) + for item in (cache_payload.get("members") or []) + if _safe_int(item.get("id"), 0) > 0 + } + + for account in changed_rows: + account_id = int(getattr(account, "id", 0) or 0) + if account_id <= 0: + continue + manager_map.pop(account_id, None) + member_map.pop(account_id, None) + + item, bucket, row_changed = _classify_team_account_row( + account, + now=now, + health_state=health_state, + ) + changed += int(row_changed or 0) + if not item or not bucket: + continue + if bucket == "manager": + manager_map[account_id] = item + else: + member_map[account_id] = item + + managers = sorted(manager_map.values(), key=_team_classify_item_sort_key, reverse=True) + members = sorted(member_map.values(), key=_team_classify_item_sort_key, reverse=True) + incremental_used = True + + if not incremental_used: + rows = ( + db.query(Account) + .order_by(desc(Account.updated_at), desc(Account.id)) + .all() + ) + for account in rows: + item, bucket, row_changed = _classify_team_account_row( + account, + now=now, + health_state=health_state, + ) + changed += int(row_changed or 0) + if not item or not bucket: + continue + if bucket == "manager": + managers.append(item) + else: + members.append(item) + + if changed > 0: + db.commit() + + payload = {"managers": managers, "members": members} + _set_team_classify_cache(payload, marker) + if incremental_used: + logger.info( + "team分类增量刷新完成: managers=%s members=%s marker=%s", + len(managers), + len(members), + marker, + ) + return payload + + +def _list_team_inviter_candidates() -> List[Dict[str, Any]]: + grouped = _classify_team_accounts() + result: List[Dict[str, Any]] = [] + for item in grouped.get("managers", []): + row = dict(item) + row["in_pool"] = True + result.append(row) + return result + + +def _list_team_inviter_accounts_local(force: bool = False) -> List[Dict[str, Any]]: + """ + 本地快速入池(不依赖网络): + - plan=team + - status=active(绿色) + - role_tag=parent(母号标签) + - current_members < 5(优先读取 team-console 缓存,其次账号本地快照) + """ + _ = force + cached_member_snapshots = _get_cached_team_member_snapshot_map() + with get_db() as db: + rows = ( + db.query(Account) + .order_by(desc(Account.updated_at), desc(Account.id)) + .all() + ) + + local_rows: List[Dict[str, Any]] = [] + for account in rows: + item = _build_account_item(account) + if str(item.get("plan") or "").strip().lower() != "team": + continue + if str(item.get("status") or "").strip().lower() != AccountStatus.ACTIVE.value: + continue + if normalize_role_tag(item.get("role_tag")) != RoleTag.PARENT.value: + continue + + account_id = int(item.get("id") or 0) + current_members: Optional[int] = None + max_members: Optional[int] = None + if account_id > 0 and account_id in cached_member_snapshots: + current_members, max_members = cached_member_snapshots[account_id] + else: + current_members, max_members = _resolve_member_snapshot_from_extra(account) + + if current_members is None: + current_members = 0 + if max_members is None or int(max_members) <= 0: + max_members = 6 + if int(current_members) >= 5: + continue + + has_access_token = bool(str(account.access_token or "").strip()) + has_refresh_token = bool(str(account.refresh_token or "").strip()) + has_session_token = bool(str(account.session_token or "").strip()) + has_workspace = bool(str(item.get("workspace_id") or "").strip()) + can_auth = has_access_token or has_refresh_token or has_session_token + + item["has_access_token"] = has_access_token + item["has_refresh_token"] = has_refresh_token + item["has_session_token"] = has_session_token + item["manager_ready"] = bool(has_workspace and can_auth) + item["team_identity"] = "manager" + item["manager_verified"] = True + item["manager_verify_source"] = "local_label_status_plan" + item["manager_verify_realtime"] = False + item["pool_confirmed"] = True + item["fallback_level"] = 0 + item["fallback_note"] = "local_no_network" + item["current_members"] = int(current_members) + item["max_members"] = int(max_members) + item["member_ratio"] = f"{int(current_members)}/{int(max_members)}" + local_rows.append(item) + + local_rows.sort( + key=lambda x: ( + int(x.get("priority") or 50), + -int(x.get("id") or 0), + ) + ) + + return copy.deepcopy(local_rows) + + +def _load_inviter_history_ids() -> set: + """ + 网络异常兜底:曾成功发起过邀请记录的账号可保留在管理列表中。 + """ + with get_db() as db: + rows = ( + db.query(TeamInviteRecord.inviter_account_id) + .filter(TeamInviteRecord.inviter_account_id.isnot(None)) + .filter(TeamInviteRecord.state.in_(["pending", "invited", "joined"])) + .all() + ) + + ids = set() + for row in rows: + value = None + try: + value = row[0] + except Exception: + value = getattr(row, "inviter_account_id", None) + try: + num = int(value) + if num > 0: + ids.add(num) + except Exception: + continue + return ids + + +def _list_team_inviter_accounts(include_frozen: bool = False, force: bool = False) -> List[Dict[str, Any]]: + if not force: + cached = _get_cached_inviter_accounts(include_frozen) + if cached is not None: + cached_ids = [int(item.get("id") or 0) for item in cached if int(item.get("id") or 0) > 0] + if not cached_ids: + return cached + with get_db() as db: + rows = db.query(Account.id, Account.status).filter(Account.id.in_(cached_ids)).all() + blocked_ids = { + int(getattr(row, "id", row[0]) or 0) + for row in rows + if str(getattr(row, "status", row[1]) or "").strip().lower() in BLOCKED_ACCOUNT_STATUSES + } + auth_failed_ids = set() + for item in cached: + item_id = int(item.get("id") or 0) + if item_id <= 0: + continue + source_lower = str(item.get("manager_verify_source") or "").strip().lower() + if ( + "hard_remove_auth" in source_lower + or "http_401" in source_lower + or "http_403" in source_lower + or _is_token_invalidated_error(source_lower) + ): + auth_failed_ids.add(item_id) + remove_ids = blocked_ids | auth_failed_ids + if not remove_ids: + return cached + filtered = [item for item in cached if int(item.get("id") or 0) not in remove_ids] + logger.info( + "team管理号缓存命中剔除失效账号: removed=%s remain=%s", + len(cached) - len(filtered), + len(filtered), + ) + return filtered + + # 自动入池策略: + # 仅“可验证为管理角色(owner/admin/manager)”的 Team 账号才允许入池。 + # 网络抖动时,曾有邀请记录的账号可走历史兜底保留。 + strict_candidates = _list_team_inviter_candidates() + health_state = _load_manager_health_state() + stale_rows: List[Dict[str, Any]] = [] + if not force: + stale_key = "frozen" if include_frozen else "normal" + stale_raw = _INVITER_CACHE.get(stale_key) + if isinstance(stale_raw, list): + stale_rows = copy.deepcopy(stale_raw) + stale_ids = {int(item.get("id") or 0) for item in stale_rows if int(item.get("id") or 0) > 0} + candidate_ids = [int(item.get("id") or 0) for item in strict_candidates if int(item.get("id") or 0) > 0] + account_map: Dict[int, Account] = {} + if candidate_ids: + with get_db() as db: + account_rows = db.query(Account).filter(Account.id.in_(candidate_ids)).all() + account_map = {int(a.id): a for a in account_rows if int(getattr(a, "id", 0) or 0) > 0} + history_ids = _load_inviter_history_ids() + proxy_url = _get_proxy() + all_rows: List[Dict[str, Any]] = [] + verify_results: Dict[int, Tuple[bool, str, bool]] = {} + status_updates: Dict[int, str] = {} + + to_verify_ids: List[int] = [] + for item in strict_candidates: + account_id = int(item.get("id") or 0) + if account_id <= 0: + continue + cached_verify = None if force else _get_cached_manager_verify(account_id) + if cached_verify is not None: + cached_source = str(cached_verify[1] or "unknown") + if (not force) and _cached_verify_needs_realtime(cached_source): + to_verify_ids.append(account_id) + else: + verify_results[account_id] = (bool(cached_verify[0]), f"{cached_source}|cache", False) + continue + to_verify_ids.append(account_id) + + if to_verify_ids: + if (not force) and len(to_verify_ids) > TEAM_MANAGER_VERIFY_MAX_PER_CALL: + logger.info( + "team管理号快速校验限流: candidates=%s verify_now=%s defer=%s", + len(to_verify_ids), + TEAM_MANAGER_VERIFY_MAX_PER_CALL, + len(to_verify_ids) - TEAM_MANAGER_VERIFY_MAX_PER_CALL, + ) + to_verify_ids = to_verify_ids[:TEAM_MANAGER_VERIFY_MAX_PER_CALL] + max_workers = min(max(1, TEAM_MANAGER_VERIFY_MAX_WORKERS), len(to_verify_ids)) + + def _verify_one(verify_account_id: int) -> Tuple[int, bool, str]: + account_obj = account_map.get(verify_account_id) + if not account_obj: + return verify_account_id, False, "account_missing" + try: + ok, source = _is_verified_team_manager( + account=account_obj, + proxy_url=proxy_url, + timeout_seconds=TEAM_MANAGER_VERIFY_TIMEOUT_SECONDS, + ) + return verify_account_id, bool(ok), str(source or "unknown") + except Exception as exc: + return verify_account_id, False, f"verify_exception:{exc}" + + with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="team_mgr_verify") as pool: + future_map = {pool.submit(_verify_one, aid): aid for aid in to_verify_ids} + for future in as_completed(future_map): + aid = int(future_map[future]) + try: + result_id, ok, source = future.result() + except Exception as exc: + result_id, ok, source = aid, False, f"verify_exception:{exc}" + verify_results[int(result_id)] = (bool(ok), str(source or "unknown"), True) + + for item in strict_candidates: + row = dict(item) + account_id = int(row.get("id") or 0) + if account_id <= 0: + continue + + manager_verified = False + manager_source = "unverified" + from_realtime = False + account_obj = account_map.get(account_id) + if account_id in verify_results: + manager_verified, manager_source, from_realtime = verify_results[account_id] + + source_lower = str(manager_source or "").lower() + auth_failed = ("http_401" in source_lower) or ("http_403" in source_lower) or _is_token_invalidated_error(source_lower) + if (not manager_verified) and account_obj is not None and _is_auth_source_for_mail_fallback(manager_source): + blocked_by_mail, mail_source = _scan_deactivation_mail_fallback(account_obj, force=force) + if blocked_by_mail: + auth_failed = True + manager_source = f"{manager_source}|{mail_source}" + source_lower = str(manager_source).lower() + if auth_failed: + manager_verified = False + manager_source = f"{manager_source}|hard_remove_auth" + + # 网络波动兜底: + # 1) 有历史邀请记录时保持在管理列表,避免页面突降为 0/1。 + if (not manager_verified) and (not auth_failed) and (account_id in history_ids): + manager_verified = True + manager_source = "history_fallback" + # 2) 若该账号曾在旧缓存里出现,且当前失败非“明确非管理角色”,允许短暂沿用。 + source_lower = str(manager_source or "").lower() + explicit_non_manager = source_lower.startswith("workspace_role:") and (not _is_manager_role(source_lower)) + soft_network_fail = ( + ("error" in source_lower) + or ("http_401" in source_lower) + or ("http_403" in source_lower) + or ("http_429" in source_lower) + or ("workspace_candidates_http_" in source_lower) + or ("invites_probe_http_" in source_lower) + ) + if ( + (not manager_verified) + and (not auth_failed) + and (account_id in stale_ids) + and (not explicit_non_manager) + and soft_network_fail + ): + manager_verified = True + manager_source = "stale_fallback" + + if account_id in to_verify_ids: + _set_cached_manager_verify(account_id, manager_verified, manager_source) + + if manager_verified: + status_updates[account_id] = AccountStatus.ACTIVE.value + elif auth_failed: + status_updates[account_id] = AccountStatus.FAILED.value + + if not manager_verified: + continue + + row["manager_verified"] = True + row["manager_verify_source"] = manager_source + row["manager_verify_realtime"] = bool(from_realtime) + health_entry = _get_manager_health_entry(health_state, account_id) + _annotate_manager_health(row, health_entry) + row["pool_confirmed"] = True + row["fallback_level"] = 0 + row["fallback_note"] = "auto_manager_pool" + all_rows.append(row) + + all_rows.sort( + key=lambda x: ( + str(x.get("status") or "") != AccountStatus.ACTIVE.value, + bool(x.get("health_frozen")), + 0 if normalize_role_tag(x.get("role_tag")) == RoleTag.PARENT.value else 1, + int(x.get("priority") or 50), + _parse_dt(x.get("last_used_at")) is not None, + _parse_dt(x.get("last_used_at")) or datetime.min, + float(x.get("health_fail_rate") or 0.0), + int(x.get("health_consecutive_fail") or 0), + int(x.get("health_fail_total") or 0), + -int(x.get("health_success_total") or 0), + -int(x.get("health_priority") or 0), + -int(x.get("id") or 0), + ) + ) + normal_rows = [row for row in all_rows if not bool(row.get("health_frozen"))] + + if status_updates: + changed = 0 + with get_db() as db: + for aid, next_status in status_updates.items(): + row = db.query(Account).filter(Account.id == int(aid)).first() + if not row: + continue + curr = str(row.status or "").strip().lower() + if curr == str(next_status or "").strip().lower(): + continue + row.status = str(next_status) + changed += 1 + if changed > 0: + db.commit() + if changed > 0: + logger.info("team管理号刷新状态同步完成: updated=%s", changed) + + _set_cached_inviter_accounts(normal_rows=normal_rows, frozen_rows=all_rows) + return copy.deepcopy(all_rows if include_frozen else normal_rows) + + +def _normalize_email(email: Optional[str]) -> str: + return str(email or "").strip().lower() + + +def _expire_stale_invite_records(db) -> int: + now = datetime.utcnow() + changed = 0 + rows = ( + db.query(TeamInviteRecord) + .filter(TeamInviteRecord.state.in_(["pending", "invited"])) + .all() + ) + for row in rows: + ref_time = row.updated_at or row.invited_at or row.created_at + expired_by_time = bool(ref_time and (now - ref_time) > timedelta(hours=INVITE_LOCK_HOURS)) + expired_by_field = bool(row.expires_at and row.expires_at <= now) + if expired_by_time or expired_by_field: + row.state = "expired" + if not str(row.last_error or "").strip(): + row.last_error = "auto_expired_by_ttl" + row.expires_at = now + changed += 1 + if changed: + db.commit() + return changed + + +def _get_locked_target_email_map(db) -> Dict[str, Dict[str, Any]]: + now = datetime.utcnow() + _expire_stale_invite_records(db) + rows = ( + db.query(TeamInviteRecord) + .filter(TeamInviteRecord.state.in_(list(INVITE_LOCK_STATES))) + .order_by(desc(TeamInviteRecord.updated_at), desc(TeamInviteRecord.id)) + .all() + ) + locked: Dict[str, Dict[str, Any]] = {} + for row in rows: + email = _normalize_email(row.target_email) + if not email or email in locked: + continue + # 邀请状态统一受 TTL 控制,避免永久锁死 + if row.state in ("pending", "invited", "joined"): + ref_time = row.updated_at or row.invited_at or row.created_at + if row.expires_at and row.expires_at <= now: + continue + lock_hours = INVITE_JOINED_LOCK_HOURS if row.state == "joined" else INVITE_LOCK_HOURS + if ref_time and (now - ref_time) > timedelta(hours=lock_hours): + continue + locked[email] = { + "state": str(row.state or "").strip().lower(), + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "inviter_email": row.inviter_email, + } + return locked + + +def _upsert_invite_record( + db, + *, + inviter_account: Optional[Account], + target_email: str, + workspace_id: Optional[str], + state: str, + last_error: Optional[str] = None, + increment_attempt: bool = False, +) -> TeamInviteRecord: + now = datetime.utcnow() + normalized_target = _normalize_email(target_email) + normalized_state = str(state or "").strip().lower() or "pending" + inviter_email = str(getattr(inviter_account, "email", "") or "").strip() or None + inviter_id = getattr(inviter_account, "id", None) + workspace = str(workspace_id or "").strip() or None + + record = ( + db.query(TeamInviteRecord) + .filter(func.lower(TeamInviteRecord.target_email) == normalized_target) + .order_by(desc(TeamInviteRecord.updated_at), desc(TeamInviteRecord.id)) + .first() + ) + + if not record: + record = TeamInviteRecord( + inviter_account_id=inviter_id, + inviter_email=inviter_email, + target_email=normalized_target, + workspace_id=workspace, + state=normalized_state, + invite_attempts=1, + invited_at=now if normalized_state in ("pending", "invited", "joined") else None, + accepted_at=now if normalized_state == "joined" else None, + expires_at=(now + timedelta(hours=INVITE_LOCK_HOURS)) if normalized_state in ("pending", "invited") else None, + last_error=str(last_error or "").strip() or None, + ) + db.add(record) + db.flush() + return record + + if inviter_id: + record.inviter_account_id = inviter_id + if inviter_email: + record.inviter_email = inviter_email + if workspace: + record.workspace_id = workspace + record.target_email = normalized_target + record.state = normalized_state + record.last_error = str(last_error or "").strip() or None + + if increment_attempt: + record.invite_attempts = int(record.invite_attempts or 0) + 1 + elif not record.invite_attempts: + record.invite_attempts = 1 + + if normalized_state in ("pending", "invited"): + record.invited_at = now + record.accepted_at = None + record.expires_at = now + timedelta(hours=INVITE_LOCK_HOURS) + elif normalized_state == "joined": + record.accepted_at = now + record.expires_at = now + timedelta(hours=INVITE_JOINED_LOCK_HOURS) + elif normalized_state in ("failed", "expired"): + record.expires_at = now + + db.flush() + return record + + +def _list_target_email_accounts() -> List[Dict[str, Any]]: + """ + 目标邮箱候选账号(来自账号管理): + - 仅子号标签(child),未命中不回退无标签池 + - 仅 free + - 排除红色状态 failed + - 排除邀请状态池中的 pending/invited/joined(解决订阅状态异步更新窗口期重复入池) + """ + fallback_to_none = _read_pull_fallback_to_none() + with get_db() as db: + locked_map = _get_locked_target_email_map(db) + rows = ( + db.query(Account) + .order_by(desc(Account.updated_at), desc(Account.id)) + .all() + ) + + child_rows: List[Dict[str, Any]] = [] + none_rows: List[Dict[str, Any]] = [] + for account in rows: + if str(account.status or "").strip().lower() == AccountStatus.FAILED.value: + continue + email = str(account.email or "").strip() + if not email: + continue + email_norm = _normalize_email(email) + lock_info = locked_map.get(email_norm) + if lock_info: + continue + + role_tag = _resolve_account_role_tag(account) + plan = _infer_account_plan(account) + if plan != "free": + continue + + row = { + "id": account.id, + "email": email, + "status": account.status, + "plan": plan, + "account_label": role_tag_to_account_label(role_tag), + "role_tag": role_tag, + "biz_tag": str(getattr(account, "biz_tag", "") or "").strip() or None, + "subscription_type": account.subscription_type, + "invite_state": None, + "updated_at": account.updated_at.isoformat() if account.updated_at else None, + } + if role_tag == RoleTag.CHILD.value: + child_rows.append(row) + elif role_tag == RoleTag.NONE.value: + none_rows.append(row) + + if child_rows: + return child_rows + if fallback_to_none: + return none_rows + return [] + + +def _find_selected_inviter(inviter_id: Optional[int]) -> Dict[str, Any]: + candidates = _list_team_inviter_accounts() + if not candidates: + frozen_candidates = _list_team_inviter_accounts(include_frozen=True) + if frozen_candidates: + thaw_times = [ + _parse_dt(item.get("health_frozen_until")) + for item in frozen_candidates + if item.get("health_frozen") + ] + thaw_times = [x for x in thaw_times if x] + earliest = min(thaw_times).strftime("%Y-%m-%d %H:%M:%S") if thaw_times else "稍后" + raise HTTPException( + status_code=429, + detail=f"当前可用 Team 管理账号均处于冻结期,请稍后重试(最早解冻: {earliest} UTC)。", + ) + raise HTTPException( + status_code=404, + detail="当前无可用 Team 管理账号(需 team + 管理角色(owner/admin/manager) + 可用 token/workspace)。", + ) + + if inviter_id is None: + return candidates[0] + + for item in candidates: + if item["id"] == inviter_id: + return item + frozen_candidates = _list_team_inviter_accounts(include_frozen=True) + for item in frozen_candidates: + if int(item.get("id") or 0) == int(inviter_id) and bool(item.get("health_frozen")): + thaw = _parse_dt(item.get("health_frozen_until")) + thaw_text = thaw.strftime("%Y-%m-%d %H:%M:%S") if thaw else "稍后" + raise HTTPException( + status_code=429, + detail=f"指定管理账号当前处于冻结期,请稍后重试(预计解冻: {thaw_text} UTC)。", + ) + raise HTTPException(status_code=404, detail=f"指定邀请账号不存在或不可用: {inviter_id}") + + +def _is_already_member_or_invited(error_text: str) -> bool: + text = str(error_text or "").lower() + return any( + key in text + for key in ( + "already in workspace", + "already in team", + "already a member", + "already invited", + "email already exists", + ) + ) + + +def _safe_json(response) -> Dict[str, Any]: + try: + data = response.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _to_int(value: Any, default: int = 0) -> int: + try: + if value is None: + return default + if isinstance(value, bool): + return int(value) + if isinstance(value, (int, float)): + return int(value) + text = str(value).strip() + if not text: + return default + if "." in text: + return int(float(text)) + return int(text) + except Exception: + return default + + +def _team_api_request( + *, + method: str, + access_token: str, + workspace_id: str, + path: str, + proxy_url: Optional[str], + payload: Optional[Dict[str, Any]] = None, + timeout_seconds: int = 35, +) -> Tuple[int, Dict[str, Any], str]: + try: + url = f"https://chatgpt.com/backend-api/accounts/{workspace_id}{path}" + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + "Origin": "https://chatgpt.com", + "Referer": "https://chatgpt.com/", + "chatgpt-account-id": workspace_id, + } + if method.upper() in ("POST", "PUT", "PATCH", "DELETE"): + headers["Content-Type"] = "application/json" + + session_kwargs: Dict[str, Any] = { + "impersonate": "chrome120", + "timeout": max(3, int(timeout_seconds)), + } + if proxy_url: + session_kwargs["proxy"] = proxy_url + session = cffi_requests.Session(**session_kwargs) + + method_up = method.upper() + if method_up == "GET": + response = session.get(url, headers=headers) + elif method_up == "POST": + response = session.post(url, headers=headers, json=payload or {}) + elif method_up == "DELETE": + if payload: + response = session.delete(url, headers=headers, json=payload) + else: + response = session.delete(url, headers=headers) + else: + raise ValueError(f"unsupported method: {method}") + + body = _safe_json(response) + raw = "" + if not body: + try: + raw = (response.text or "").strip() + except Exception: + raw = "" + return response.status_code, body, raw + except Exception as exc: + logger.warning( + "team_api_request exception: method=%s workspace=%s path=%s proxy=%s err=%s", + method, + workspace_id, + path, + "on" if proxy_url else "off", + exc, + ) + return 599, {}, str(exc) + + +def _send_team_invite_once( + *, + access_token: str, + workspace_id: str, + target_email: str, + proxy_url: Optional[str], +) -> Tuple[int, Dict[str, Any], str]: + url = f"https://chatgpt.com/backend-api/accounts/{workspace_id}/invites" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "Accept": "application/json", + "Origin": "https://chatgpt.com", + "Referer": "https://chatgpt.com/", + "chatgpt-account-id": workspace_id, + } + payload = { + "email_addresses": [target_email], + "role": "standard-user", + "resend_emails": True, + } + + session_kwargs: Dict[str, Any] = { + "impersonate": "chrome120", + "timeout": 35, + } + if proxy_url: + session_kwargs["proxy"] = proxy_url + session = cffi_requests.Session(**session_kwargs) + response = session.post(url, headers=headers, json=payload) + body = _safe_json(response) + raw = "" + if not body: + try: + raw = (response.text or "").strip() + except Exception: + raw = "" + return response.status_code, body, raw + + +async def _send_team_invite_with_backoff( + *, + access_token: str, + workspace_id: str, + target_email: str, + proxy_url: Optional[str], + inviter_account_id: int, + max_attempts: int = 3, +) -> Tuple[int, Dict[str, Any], str]: + status_code = 0 + body: Dict[str, Any] = {} + raw = "" + for attempt in range(1, max_attempts + 1): + status_code, body, raw = _send_team_invite_once( + access_token=access_token, + workspace_id=workspace_id, + target_email=target_email, + proxy_url=proxy_url, + ) + if status_code != 429: + return status_code, body, raw + + wait_seconds = min(18.0, float(2 ** attempt)) + logger.warning( + "team邀请命中 429,自动退避重试: inviter=%s workspace=%s email=%s attempt=%s/%s wait=%.1fs", + inviter_account_id, + workspace_id, + target_email, + attempt, + max_attempts, + wait_seconds, + ) + if attempt < max_attempts: + await asyncio.sleep(wait_seconds) + return status_code, body, raw + + +def _fetch_team_workspace_candidates( + *, + access_token: str, + proxy_url: Optional[str], + timeout_seconds: int = 35, + return_meta: bool = False, +) -> List[Dict[str, Any]] | Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """ + 从 accounts/check 拉取当前 token 可用的 Team workspace 账号。 + 参考 team-manage-main 的账户检测逻辑。 + """ + url = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27" + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + "Origin": "https://chatgpt.com", + "Referer": "https://chatgpt.com/", + } + session_kwargs: Dict[str, Any] = { + "impersonate": "chrome120", + "timeout": max(3, int(timeout_seconds)), + } + if proxy_url: + session_kwargs["proxy"] = proxy_url + try: + session = cffi_requests.Session(**session_kwargs) + resp = session.get(url, headers=headers) + status_code = int(resp.status_code or 0) + except Exception as exc: + logger.warning( + "fetch team workspace candidates failed: proxy=%s err=%s", + "on" if proxy_url else "off", + exc, + ) + if return_meta: + return [], {"status_code": 599, "raw": str(exc)[:180]} + return [] + if status_code != 200: + if return_meta: + raw = "" + try: + raw = str(resp.text or "").strip()[:180] + except Exception: + raw = "" + return [], {"status_code": status_code, "raw": raw} + return [] + + try: + payload = resp.json() or {} + except Exception: + if return_meta: + return [], {"status_code": status_code, "raw": "invalid_json"} + return [] + if not isinstance(payload, dict): + if return_meta: + return [], {"status_code": status_code, "raw": "invalid_payload"} + return [] + + accounts_data = payload.get("accounts") or {} + if not isinstance(accounts_data, dict): + if return_meta: + return [], {"status_code": status_code, "raw": "accounts_missing"} + return [] + + candidates: List[Dict[str, Any]] = [] + for account_id, item in accounts_data.items(): + if not isinstance(item, dict): + continue + account = item.get("account") or {} + entitlement = item.get("entitlement") or {} + if not isinstance(account, dict): + account = {} + if not isinstance(entitlement, dict): + entitlement = {} + + plan = _normalize_plan( + account.get("plan_type") + or entitlement.get("subscription_plan") + or "" + ) + if plan != "team": + continue + + candidates.append( + { + "account_id": str(account_id or "").strip(), + "name": str(account.get("name") or "").strip(), + "is_default": bool(account.get("is_default")), + "role": str(account.get("account_user_role") or "").strip(), + "active": bool(entitlement.get("has_active_subscription")), + "subscription_plan": str( + entitlement.get("subscription_plan") + or account.get("plan_type") + or "" + ).strip(), + "expires_at": str(entitlement.get("expires_at") or "").strip(), + # accounts/check 在不同账号上字段名可能不同,这里做多键兼容 + "current_members": _to_int( + account.get("total_current_members") + or account.get("current_members") + or account.get("members_count") + or account.get("num_members"), + 0, + ), + "max_members": _to_int( + account.get("total_max_members") + or account.get("max_members") + or account.get("seat_limit") + or 6, + 6, + ), + } + ) + + # 排序:默认 + 活跃 + owner 优先 + candidates.sort( + key=lambda x: ( + 0 if x.get("is_default") else 1, + 0 if x.get("active") else 1, + 0 if str(x.get("role") or "").lower() == "owner" else 1, + ) + ) + rows = [x for x in candidates if x.get("account_id")] + if return_meta: + return rows, {"status_code": status_code} + return rows + + +def _pick_workspace_id( + *, + preferred_workspace_id: str, + candidates: List[Dict[str, Any]], +) -> Tuple[str, Optional[Dict[str, Any]]]: + if not candidates: + return preferred_workspace_id, None + + candidate_map = {str(item.get("account_id") or "").strip(): item for item in candidates} + if preferred_workspace_id and preferred_workspace_id in candidate_map: + return preferred_workspace_id, candidate_map[preferred_workspace_id] + + first = candidates[0] + return str(first.get("account_id") or "").strip(), first + + +def _is_verified_team_manager( + *, + account: Account, + proxy_url: Optional[str], + timeout_seconds: int = TEAM_MANAGER_VERIFY_TIMEOUT_SECONDS, +) -> Tuple[bool, str]: + """ + 判定账号是否为 Team 管理号(可邀请): + 1) 优先用 accounts/check 的 role(owner/admin/manager) + 2) role 缺失时降级探测 /invites 权限(200 视为可管理) + """ + max_timeout = max(3, int(timeout_seconds or TEAM_MANAGER_VERIFY_TIMEOUT_SECONDS)) + + def _probe_once(token: str, *, timeout: int) -> Tuple[bool, str, bool]: + if not token: + return False, "no_access_token", False + + preferred_workspace_id = _resolve_workspace_id(account) + try: + candidates_result = _fetch_team_workspace_candidates( + access_token=token, + proxy_url=proxy_url, + timeout_seconds=timeout, + return_meta=True, + ) + candidates, meta = candidates_result # type: ignore[misc] + except Exception as exc: + return False, f"workspace_candidates_error:{exc}", False + + status_code = int((meta or {}).get("status_code") or 0) + raw_meta = str((meta or {}).get("raw") or "").strip() + if not candidates: + if status_code in (401, 403): + return False, f"workspace_candidates_http_{status_code}", True + if status_code and status_code != 200: + return False, f"workspace_candidates_http_{status_code}:{raw_meta[:80]}", False + return False, "workspace_candidates_empty", False + + workspace_id, selected = _pick_workspace_id( + preferred_workspace_id=preferred_workspace_id, + candidates=candidates, + ) + if not workspace_id or not selected: + return False, "workspace_not_selected", False + + role = _normalize_role_text((selected or {}).get("role")) + if _is_manager_role(role): + return True, f"workspace_role:{role}", False + if role: + return False, f"workspace_role:{role}", False + + try: + probe_status, _body, raw = _team_api_request( + method="GET", + access_token=token, + workspace_id=workspace_id, + path="/invites", + proxy_url=proxy_url, + timeout_seconds=timeout, + ) + except Exception as exc: + return False, f"invites_probe_error:{exc}", False + + if probe_status < 400: + return True, "invites_probe_ok", False + if probe_status in (401, 403): + return False, f"invites_probe_http_{probe_status}", True + probe_err = str(raw or "").strip()[:80] + if probe_err: + return False, f"invites_probe_http_{probe_status}:{probe_err}", False + return False, f"invites_probe_http_{probe_status}", False + + access_token = str(getattr(account, "access_token", "") or "").strip() + verified, source, refreshable = _probe_once(access_token, timeout=max_timeout) + if verified: + return True, source + + has_refresh_hint = bool(str(getattr(account, "refresh_token", "") or "").strip()) or bool( + str(getattr(account, "session_token", "") or "").strip() + ) + if refreshable and has_refresh_hint: + try: + refresh_result = do_refresh(account.id, proxy_url=proxy_url) + if refresh_result.success: + with get_db() as db: + latest = db.query(Account).filter(Account.id == account.id).first() + latest_token = str(getattr(latest, "access_token", "") or "").strip() if latest else "" + verified2, source2, _refreshable2 = _probe_once(latest_token, timeout=max_timeout) + if verified2: + return True, f"{source2}|after_refresh" + if source2: + return False, f"{source2}|after_refresh" + except Exception as exc: + logger.warning("管理号校验 refresh 重试异常: account=%s err=%s", account.id, exc) + + return False, source + + +def _fetch_joined_members( + *, + access_token: str, + workspace_id: str, + proxy_url: Optional[str], + timeout_seconds: int = 35, +) -> Tuple[int, List[Dict[str, Any]], str]: + members: List[Dict[str, Any]] = [] + offset = 0 + limit = 50 + while True: + status_code, body, raw = _team_api_request( + method="GET", + access_token=access_token, + workspace_id=workspace_id, + path=f"/users?limit={limit}&offset={offset}", + proxy_url=proxy_url, + timeout_seconds=timeout_seconds, + ) + if status_code >= 400: + return status_code, members, _extract_error_text(status_code, body, raw) + + items = body.get("items") + if not isinstance(items, list): + items = [] + total = _to_int(body.get("total"), len(items)) + + for item in items: + if not isinstance(item, dict): + continue + members.append( + { + "user_id": str(item.get("id") or "").strip() or None, + "email": str(item.get("email") or "").strip(), + "name": str(item.get("name") or "").strip() or None, + "role": str(item.get("role") or "").strip() or "standard-user", + "added_at": str(item.get("created_time") or "").strip() or None, + "status": "joined", + } + ) + + if len(members) >= total or not items: + break + offset += limit + + return 200, members, "" + + +def _fetch_invited_members( + *, + access_token: str, + workspace_id: str, + proxy_url: Optional[str], + timeout_seconds: int = 35, +) -> Tuple[int, List[Dict[str, Any]], str]: + status_code, body, raw = _team_api_request( + method="GET", + access_token=access_token, + workspace_id=workspace_id, + path="/invites", + proxy_url=proxy_url, + timeout_seconds=timeout_seconds, + ) + if status_code >= 400: + return status_code, [], _extract_error_text(status_code, body, raw) + + items = body.get("items") + if not isinstance(items, list): + items = [] + + invites: List[Dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + invites.append( + { + "user_id": None, + "email": str(item.get("email_address") or "").strip(), + "name": None, + "role": str(item.get("role") or "").strip() or "standard-user", + "added_at": str(item.get("created_time") or "").strip() or None, + "status": "invited", + } + ) + return 200, invites, "" + + +def _compute_team_status(account_status: str, current_members: int, max_members: int) -> str: + st = str(account_status or "").strip().lower() + if st in { + AccountStatus.FAILED.value, + AccountStatus.EXPIRED.value, + AccountStatus.BANNED.value, + }: + return st + if max_members > 0 and current_members >= max_members: + return "full" + return AccountStatus.ACTIVE.value + + +def _build_console_row_for_account( + *, + account: Account, + proxy_url: Optional[str], + include_member_counts: bool = True, + request_timeout_seconds: int = 35, +) -> Dict[str, Any]: + base_item = _build_account_item(account) + workspace_id = str(base_item.get("workspace_id") or "").strip() + access_token = str(account.access_token or "").strip() + + selected_workspace: Optional[Dict[str, Any]] = None + candidates: List[Dict[str, Any]] = [] + if access_token: + candidates = _fetch_team_workspace_candidates( + access_token=access_token, + proxy_url=proxy_url, + timeout_seconds=request_timeout_seconds, + ) + workspace_id, selected_workspace = _pick_workspace_id( + preferred_workspace_id=workspace_id, + candidates=candidates, + ) + + team_name = str((selected_workspace or {}).get("name") or "").strip() or "MyTeam" + subscription_plan = str((selected_workspace or {}).get("subscription_plan") or "").strip() or "chatgptteamplan" + expires_at = str((selected_workspace or {}).get("expires_at") or "").strip() or None + + max_members = _to_int((selected_workspace or {}).get("max_members"), 6) + current_members = _to_int((selected_workspace or {}).get("current_members"), 0) + + if include_member_counts and access_token and workspace_id: + joined_status, joined, _joined_err = _fetch_joined_members( + access_token=access_token, + workspace_id=workspace_id, + proxy_url=proxy_url, + timeout_seconds=request_timeout_seconds, + ) + invited_status, invited, _invited_err = _fetch_invited_members( + access_token=access_token, + workspace_id=workspace_id, + proxy_url=proxy_url, + timeout_seconds=request_timeout_seconds, + ) + if joined_status < 400 and invited_status < 400: + current_members = len(joined) + len(invited) + + status = _compute_team_status(str(account.status or ""), current_members, max_members) + plan = _normalize_plan(getattr(account, "subscription_type", None)) or "team" + if plan == "free": + plan = "team" + + return { + "id": account.id, + "email": account.email, + "account_id": workspace_id or str(account.account_id or "").strip() or str(account.workspace_id or "").strip(), + "team_name": team_name, + "current_members": current_members, + "max_members": max_members, + "member_ratio": f"{current_members}/{max_members}", + "subscription_plan": subscription_plan, + "expires_at": expires_at, + "status": status, + "plan": plan, + "role_tag": _resolve_account_role_tag(account), + "pool_state": _resolve_account_pool_state(account), + "priority": int(getattr(account, "priority", 50) or 50), + "last_used_at": account.last_used_at.isoformat() if getattr(account, "last_used_at", None) else None, + "workspace_id": workspace_id, + "updated_at": account.updated_at.isoformat() if account.updated_at else None, + "last_refresh": account.last_refresh.isoformat() if account.last_refresh else None, + } + + +def _build_console_row_fallback(account: Account) -> Dict[str, Any]: + return { + "id": account.id, + "email": account.email, + "account_id": _resolve_workspace_id(account), + "team_name": "MyTeam", + "current_members": 0, + "max_members": 6, + "member_ratio": "0/6", + "subscription_plan": "chatgptteamplan", + "expires_at": None, + "status": str(account.status or "active"), + "plan": "team", + "role_tag": _resolve_account_role_tag(account), + "pool_state": _resolve_account_pool_state(account), + "priority": int(getattr(account, "priority", 50) or 50), + "last_used_at": account.last_used_at.isoformat() if getattr(account, "last_used_at", None) else None, + "workspace_id": _resolve_workspace_id(account), + "updated_at": account.updated_at.isoformat() if account.updated_at else None, + "last_refresh": account.last_refresh.isoformat() if account.last_refresh else None, + } + + +def _build_console_rows_in_parallel( + *, + accounts: List[Account], + proxy_url: Optional[str], + include_member_counts: bool, + request_timeout_seconds: int, +) -> List[Dict[str, Any]]: + if not accounts: + return [] + + max_workers = min(max(1, TEAM_CONSOLE_ROW_MAX_WORKERS), len(accounts)) + + def _build(account: Account) -> Tuple[int, Dict[str, Any]]: + row = _build_console_row_for_account( + account=account, + proxy_url=proxy_url, + include_member_counts=include_member_counts, + request_timeout_seconds=request_timeout_seconds, + ) + return int(account.id), row + + if max_workers <= 1: + rows: List[Dict[str, Any]] = [] + for account in accounts: + try: + _account_id, row = _build(account) + rows.append(row) + except Exception as exc: + logger.warning("构建 Team 控制台行失败: account=%s err=%s", account.email, exc) + rows.append(_build_console_row_fallback(account)) + return rows + + rows_map: Dict[int, Dict[str, Any]] = {} + with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="team_console") as pool: + future_map = {pool.submit(_build, account): account for account in accounts} + for future in as_completed(future_map): + account = future_map[future] + try: + account_id, row = future.result() + rows_map[int(account_id)] = row + except Exception as exc: + logger.warning("构建 Team 控制台行失败: account=%s err=%s", account.email, exc) + rows_map[int(account.id)] = _build_console_row_fallback(account) + + ordered_rows: List[Dict[str, Any]] = [] + for account in accounts: + ordered_rows.append(rows_map.get(int(account.id)) or _build_console_row_fallback(account)) + return ordered_rows + + +def _extract_error_text(status_code: int, body: Dict[str, Any], raw_text: str) -> str: + error_obj = body.get("error") + if isinstance(error_obj, dict): + message = error_obj.get("message") + if message: + return str(message) + detail = body.get("detail") + if detail: + return str(detail) + message = body.get("message") + if message: + return str(message) + if raw_text: + return raw_text[:500] + return f"邀请失败: HTTP {status_code}" + + +def _is_workspace_context_error(error_text: str) -> bool: + text = str(error_text or "").strip().lower() + if not text: + return False + markers = ( + "must use workspace account", + "workspace account", + "workspace", + ) + return any(m in text for m in markers) + + +def _is_token_invalidated_error(error_text: str) -> bool: + text = str(error_text or "").strip().lower() + if not text: + return False + markers = ( + "authentication token has been invalidated", + "token has been invalidated", + "please try signing in again", + "invalid token", + "token expired", + ) + return any(m in text for m in markers) + + +def _looks_like_redeem_gateway_error(error_text: str) -> bool: + """识别“代理网关误返回兑换页”的错误文本。""" + text = str(error_text or "").strip().lower() + if not text: + return False + markers = ( + "请输入兑换码", + "兑换码", + "redeem", + "coupon", + "卡密", + "checkout", + "开始订阅", + ) + return any(m.lower() in text for m in markers) + + +def _get_team_account_by_id_or_raise(account_id: int) -> Account: + with get_db() as db: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail=f"账号不存在: {account_id}") + plan = _infer_account_plan(account) + if plan != "team": + raise HTTPException(status_code=400, detail="仅 Team 账号可执行该操作") + return account + + +def _resolve_workspace_and_candidates( + *, + account: Account, + access_token: str, + proxy_url: Optional[str], +) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: + preferred_workspace = _resolve_workspace_id(account) + candidates = _fetch_team_workspace_candidates( + access_token=access_token, + proxy_url=proxy_url, + ) + workspace_id, selected = _pick_workspace_id( + preferred_workspace_id=preferred_workspace, + candidates=candidates, + ) + return workspace_id, candidates, selected + + +def _retry_with_refresh_on_auth_error( + *, + account: Account, + proxy_url: Optional[str], + executor, +) -> Tuple[int, Dict[str, Any], str, str]: + access_token = str(account.access_token or "").strip() + try: + status_code, body, raw, used_access_token = executor(access_token) + except Exception as exc: + logger.warning("team executor failed before refresh: account=%s err=%s", account.id, exc) + status_code, body, raw, used_access_token = 599, {"detail": str(exc)}, str(exc), access_token + error_text = _extract_error_text( + status_code, + body if isinstance(body, dict) else {}, + raw if isinstance(raw, str) else "", + ) + if status_code not in (401, 403) and not _is_token_invalidated_error(error_text): + return status_code, body, raw, used_access_token + + refresh_result = do_refresh(account.id, proxy_url=proxy_url) + if not refresh_result.success: + return status_code, body, raw, used_access_token + + with get_db() as db: + latest = db.query(Account).filter(Account.id == account.id).first() + if latest: + access_token = str(latest.access_token or "").strip() + if not access_token: + return status_code, body, raw, used_access_token + try: + return (*executor(access_token), access_token) + except Exception as exc: + logger.warning("team executor failed after refresh: account=%s err=%s", account.id, exc) + return 599, {"detail": str(exc)}, str(exc), access_token + + +@router.get("/inviter-accounts") +def list_inviter_accounts(force: bool = False, local_only: bool = True): + """列出可用于发送 Team 邀请的母号账号。""" + if force: + _invalidate_team_runtime_caches() + if local_only: + accounts = _list_team_inviter_accounts_local(force=force) + pool_mode = "local_fast_no_network" + else: + accounts = _list_team_inviter_accounts(force=force) + pool_mode = "hybrid_auto_manual" + return { + "success": True, + "total": len(accounts), + "pool_total": len(accounts), + "pool_mode": pool_mode, + "accounts": accounts, + } + + +@router.get("/inviter-candidates") +def list_inviter_candidates(force: bool = False): + """列出可手动拉入管理池的 Team 候选账号(仅母号/普通)。""" + grouped = _classify_team_accounts(force=bool(force)) + candidates = [ + item + for item in grouped.get("members", []) + if str(item.get("plan") or "").strip().lower() == "team" + and bool(item.get("manager_ready")) + and str(item.get("pool_state") or "").strip().lower() != PoolState.TEAM_POOL.value + and str(item.get("pool_state") or "").strip().lower() != PoolState.BLOCKED.value + and normalize_role_tag(item.get("role_tag")) in {RoleTag.PARENT.value, RoleTag.NONE.value} + ] + candidates.sort( + key=lambda x: ( + str(x.get("status") or "") != AccountStatus.ACTIVE.value, + 0 if normalize_role_tag(x.get("role_tag")) == RoleTag.PARENT.value else 1, + int(x.get("priority") or 50), + -(int(x.get("id") or 0)), + ) + ) + return { + "success": True, + "total": len(candidates), + # 候选拉取仅用于手动入池弹窗,避免这里触发一次昂贵的实时网络校验。 + "pool_total": len(grouped.get("managers", [])), + "pool_mode": "hybrid_auto_manual", + "message": "可手动拉入 Team 管理池(仅母号/普通)。", + "accounts": candidates, + } + + +@router.post("/inviter-pool/add") +def add_inviter_pool(request: TeamInviterPoolAddRequest): + """手动拉入 Team 管理池(设置 pool_state_manual=team_pool)。""" + requested_ids = _normalize_account_ids(request.account_ids) + added: List[int] = [] + skipped: List[int] = [] + invalid: List[int] = [] + + if not requested_ids: + return { + "success": True, + "added": added, + "skipped": skipped, + "invalid": invalid, + "pool_total": len(_list_team_inviter_accounts(force=True)), + "pool_mode": "hybrid_auto_manual", + "message": "未提供可处理账号。", + "accounts": _list_team_inviter_accounts(force=True), + } + + now = datetime.utcnow() + with get_db() as db: + rows = db.query(Account).filter(Account.id.in_(requested_ids)).all() + row_map = {int(row.id): row for row in rows} + for account_id in requested_ids: + account = row_map.get(int(account_id)) + if not account: + invalid.append(int(account_id)) + continue + plan = _infer_account_plan(account) + if plan != "team": + invalid.append(int(account_id)) + continue + role_tag = _resolve_account_role_tag(account) + if role_tag not in {RoleTag.PARENT.value, RoleTag.NONE.value}: + invalid.append(int(account_id)) + continue + has_workspace = bool(str(_resolve_workspace_id(account) or "").strip()) + can_auth = bool( + str(account.access_token or "").strip() + or str(account.refresh_token or "").strip() + or str(account.session_token or "").strip() + ) + if not (has_workspace and can_auth): + invalid.append(int(account_id)) + continue + + old_manual = _resolve_account_manual_pool_state(account) + if old_manual == PoolState.TEAM_POOL.value: + skipped.append(int(account_id)) + continue + + account.pool_state_manual = PoolState.TEAM_POOL.value + account.pool_state = PoolState.TEAM_POOL.value + account.last_pool_sync_at = now + added.append(int(account_id)) + logger.info( + "team管理池手动拉入: account_id=%s email=%s manual=%s->%s", + account.id, + account.email, + old_manual or "-", + PoolState.TEAM_POOL.value, + ) + + db.commit() + + _invalidate_team_runtime_caches() + _classify_team_accounts(force=True) + accounts = _list_team_inviter_accounts(force=True) + return { + "success": True, + "added": added, + "skipped": skipped, + "invalid": invalid, + "pool_total": len(accounts), + "pool_mode": "hybrid_auto_manual", + "message": "手动拉入已执行,系统将持续按规则自动重算池状态。", + "accounts": accounts, + } + + +@router.post("/pool/rebuild") +def rebuild_team_pool(): + """手动重建 Team 池状态(用于脏状态修复)。""" + grouped = _classify_team_accounts(force=True) + inviters = _list_team_inviter_accounts(force=True) + _invalidate_team_runtime_caches() + logger.info( + "team池手动重建完成: manager_candidates=%s member_candidates=%s inviter_pool=%s", + len(grouped.get("managers", [])), + len(grouped.get("members", [])), + len(inviters), + ) + return { + "success": True, + "rebuilt_at": datetime.utcnow().isoformat(), + "manager_candidates_total": len(grouped.get("managers", [])), + "member_candidates_total": len(grouped.get("members", [])), + "pool_total": len(inviters), + } + + +@router.get("/team-console") +def get_team_console( + force: bool = False, + refresh_pool: bool = False, + sync_members: bool = False, +): + """ + Team 管理控制台数据(参考 team-manage-main,去除兑换码统计)。 + """ + use_cache = (not force) and (not sync_members) + if use_cache: + cached_payload = _get_cached_payload(_TEAM_CONSOLE_CACHE) + if cached_payload is not None: + return cached_payload + + started_at = time.perf_counter() + inviters = _list_team_inviter_accounts(force=bool(refresh_pool)) + manager_ids = [int(item["id"]) for item in inviters if item.get("id") is not None] + if not manager_ids: + payload = { + "success": True, + "stats": { + "team_total": 0, + "available_team": 0, + }, + "rows": [], + } + if use_cache: + _set_cached_payload(_TEAM_CONSOLE_CACHE, payload, TEAM_CONSOLE_CACHE_TTL_SECONDS) + return payload + + with get_db() as db: + account_rows = db.query(Account).filter(Account.id.in_(manager_ids)).all() + account_map = {row.id: row for row in account_rows} + ordered_accounts = [account_map[idx] for idx in manager_ids if idx in account_map] + + proxy_url = _get_proxy() + rows = _build_console_rows_in_parallel( + accounts=ordered_accounts, + proxy_url=proxy_url, + include_member_counts=bool(sync_members), + request_timeout_seconds=TEAM_CONSOLE_FETCH_TIMEOUT_SECONDS, + ) + _sync_team_member_snapshot_to_accounts(rows) + + available_team = sum( + 1 for row in rows + if row.get("status") == AccountStatus.ACTIVE.value + and _to_int(row.get("current_members"), 0) < _to_int(row.get("max_members"), 6) + ) + payload = { + "success": True, + "stats": { + "team_total": len(rows), + "available_team": available_team, + }, + "rows": rows, + } + if use_cache: + _set_cached_payload(_TEAM_CONSOLE_CACHE, payload, TEAM_CONSOLE_CACHE_TTL_SECONDS) + logger.info( + "team-console refreshed: rows=%s available=%s sync_members=%s refresh_pool=%s cost=%.2fs", + len(rows), + available_team, + bool(sync_members), + bool(refresh_pool), + time.perf_counter() - started_at, + ) + return payload + + +@router.get("/team-accounts") +def list_team_accounts(force: bool = False): + """列出 Team 母号/子号分类。""" + if not force: + cached_payload = _get_cached_payload(_TEAM_ACCOUNTS_CACHE) + if cached_payload is not None: + return cached_payload + + grouped = _classify_team_accounts(force=bool(force)) + managers = _list_team_inviter_accounts(force=force) + manager_ids = {int(item.get("id") or 0) for item in managers if int(item.get("id") or 0) > 0} + all_team_accounts: List[Dict[str, Any]] = [] + for item in grouped.get("managers", []) + grouped.get("members", []): + account_id = int(item.get("id") or 0) + if account_id <= 0: + continue + if account_id in manager_ids: + continue + row = dict(item) + row["team_identity"] = "member" + all_team_accounts.append(row) + + members = sorted( + all_team_accounts, + key=lambda x: ( + str(x.get("status") or "") != AccountStatus.ACTIVE.value, + -(int(x.get("id") or 0)), + ), + ) + payload = { + "success": True, + "managers_total": len(managers), + "members_total": len(members), + "manager_candidates_total": len(grouped.get("managers", [])), + "managers": managers, + "members": members, + } + _set_cached_payload(_TEAM_ACCOUNTS_CACHE, payload, TEAM_TEAM_ACCOUNTS_CACHE_TTL_SECONDS) + return payload + + +@router.get("/target-accounts") +def list_target_accounts(): + """列出目标邮箱候选账号(按标签拉人,支持配置回退无标签池)。""" + with get_db() as db: + locked_map = _get_locked_target_email_map(db) + fallback_to_none = _read_pull_fallback_to_none() + accounts = _list_target_email_accounts() + pool_label = RoleTag.CHILD.value if accounts else (RoleTag.NONE.value if fallback_to_none else RoleTag.CHILD.value) + return { + "success": True, + "pool_label": pool_label, + "fallback_to_unlabeled": fallback_to_none, + "total": len(accounts), + "locked_total": len(locked_map), + "locked_emails": list(locked_map.keys())[:200], + "accounts": accounts, + } + + +@router.get("/target-pool-config") +def get_target_pool_config(): + fallback_to_none = _read_pull_fallback_to_none() + return { + "success": True, + "fallback_to_unlabeled": fallback_to_none, + "setting_key": TEAM_POOL_FALLBACK_SETTING_KEY, + } + + +@router.post("/target-pool-config") +def update_target_pool_config(request: TargetPoolConfigRequest): + fallback_to_none = bool(request.fallback_to_none) + with get_db() as db: + crud.set_setting( + db, + key=TEAM_POOL_FALLBACK_SETTING_KEY, + value="true" if fallback_to_none else "false", + description="按标签拉人未命中时是否回退到无标签池", + category="team", + ) + _invalidate_team_runtime_caches() + logger.info( + "team目标池配置更新: fallback_to_unlabeled=%s", + fallback_to_none, + ) + return { + "success": True, + "fallback_to_unlabeled": fallback_to_none, + } + + +@router.post("/team-accounts/{account_id}/refresh") +def refresh_team_account(account_id: int, proxy: Optional[str] = None): + """ + 刷新单个 Team 管理账号的控制台行数据。 + """ + account = _get_team_account_by_id_or_raise(account_id) + proxy_url = _get_proxy(proxy) + row = _build_console_row_for_account( + account=account, + proxy_url=proxy_url, + include_member_counts=True, + request_timeout_seconds=TEAM_CONSOLE_FETCH_TIMEOUT_SECONDS, + ) + return { + "success": True, + "row": row, + } + + +@router.get("/team-accounts/{account_id}/members") +def get_team_account_members(account_id: int, proxy: Optional[str] = None): + """ + 读取 Team 已加入成员和待加入成员(邀请中)。 + """ + try: + account = _get_team_account_by_id_or_raise(account_id) + proxy_url = _get_proxy(proxy) + access_token = str(account.access_token or "").strip() + if not access_token: + raise HTTPException(status_code=400, detail="该 Team 账号缺少 access_token") + + workspace_id, candidates, _selected = _resolve_workspace_and_candidates( + account=account, + access_token=access_token, + proxy_url=proxy_url, + ) + candidate_ids = [str(item.get("account_id") or "").strip() for item in candidates if item.get("account_id")] + if workspace_id and workspace_id not in candidate_ids: + candidate_ids.insert(0, workspace_id) + elif workspace_id and workspace_id in candidate_ids: + candidate_ids = [workspace_id] + [cid for cid in candidate_ids if cid != workspace_id] + if not candidate_ids and workspace_id: + candidate_ids = [workspace_id] + if not candidate_ids: + raise HTTPException(status_code=400, detail="未找到可用 Team workspace") + + last_error = "" + for ws_id in candidate_ids: + def _exec(token: str): + joined_status, joined, joined_err = _fetch_joined_members( + access_token=token, + workspace_id=ws_id, + proxy_url=proxy_url, + timeout_seconds=TEAM_CONSOLE_FETCH_TIMEOUT_SECONDS, + ) + if joined_status >= 400: + body = {"detail": joined_err} + return joined_status, body, joined_err, token + invited_status, invited, invited_err = _fetch_invited_members( + access_token=token, + workspace_id=ws_id, + proxy_url=proxy_url, + timeout_seconds=TEAM_CONSOLE_FETCH_TIMEOUT_SECONDS, + ) + if invited_status >= 400: + body = {"detail": invited_err} + return invited_status, body, invited_err, token + return 200, {"joined": joined, "invited": invited}, "", token + + status_code, body, raw, _used_token = _retry_with_refresh_on_auth_error( + account=account, + proxy_url=proxy_url, + executor=_exec, + ) + + if status_code < 400: + joined = body.get("joined") or [] + invited = body.get("invited") or [] + members = list(joined) + list(invited) + return { + "success": True, + "workspace_id": ws_id, + "joined_total": len(joined), + "invited_total": len(invited), + "total": len(members), + "joined_members": joined, + "invited_members": invited, + "members": members, + } + + last_error = _extract_error_text(status_code, body if isinstance(body, dict) else {}, raw) + if _is_workspace_context_error(last_error): + continue + raise HTTPException(status_code=400, detail=last_error) + + raise HTTPException(status_code=400, detail=last_error or "读取 Team 成员失败") + except HTTPException: + raise + except Exception as exc: + logger.exception("读取 Team 成员异常: account_id=%s err=%s", account_id, exc) + raise HTTPException(status_code=400, detail=f"读取 Team 成员失败: {exc}") + + +@router.post("/team-accounts/{account_id}/members/invite") +async def invite_team_member(account_id: int, request: TeamMemberInviteRequest): + """ + 在 Team 管理弹窗中新增成员邀请。 + """ + target_email = str(request.email or "").strip().lower() + if not EMAIL_RE.match(target_email): + raise HTTPException(status_code=400, detail="邮箱格式不正确") + + account = _get_team_account_by_id_or_raise(account_id) + proxy_url = _get_proxy(request.proxy) + + access_token = str(account.access_token or "").strip() + if not access_token: + raise HTTPException(status_code=400, detail="邀请账号缺少 access_token") + + workspace_id, candidates, _selected = _resolve_workspace_and_candidates( + account=account, + access_token=access_token, + proxy_url=proxy_url, + ) + if not workspace_id: + raise HTTPException(status_code=400, detail="邀请账号缺少可用 workspace_id") + + candidate_ids = [str(item.get("account_id") or "").strip() for item in candidates if item.get("account_id")] + if workspace_id and workspace_id in candidate_ids: + candidate_ids = [workspace_id] + [cid for cid in candidate_ids if cid != workspace_id] + elif workspace_id: + candidate_ids = [workspace_id] + candidate_ids + + last_error = "" + for ws_id in candidate_ids: + def _exec(token: str): + status_code, body, raw = _send_team_invite_once( + access_token=token, + workspace_id=ws_id, + target_email=target_email, + proxy_url=proxy_url, + ) + return status_code, body, raw, token + + status_code, body, raw, _used_token = _retry_with_refresh_on_auth_error( + account=account, + proxy_url=proxy_url, + executor=_exec, + ) + error_text = _extract_error_text(status_code, body if isinstance(body, dict) else {}, raw) + if 200 <= status_code < 300: + return { + "success": True, + "message": "邀请已发送", + "workspace_id": ws_id, + "response": body, + } + if status_code in (409, 422) or _is_already_member_or_invited(error_text): + return { + "success": True, + "message": "目标邮箱已在 Team 内或已存在邀请,本次按成功处理。", + "workspace_id": ws_id, + "response": body or {"detail": error_text}, + } + last_error = error_text + if _is_workspace_context_error(error_text): + continue + raise HTTPException(status_code=400, detail=error_text) + + raise HTTPException(status_code=400, detail=last_error or "发送邀请失败") + + +@router.post("/team-accounts/{account_id}/members/revoke") +async def revoke_team_member_invite(account_id: int, request: TeamMemberRevokeRequest): + """ + 撤回 Team 邀请(待加入成员)。 + """ + email = str(request.email or "").strip().lower() + if not EMAIL_RE.match(email): + raise HTTPException(status_code=400, detail="邮箱格式不正确") + + account = _get_team_account_by_id_or_raise(account_id) + proxy_url = _get_proxy(request.proxy) + + access_token = str(account.access_token or "").strip() + if not access_token: + raise HTTPException(status_code=400, detail="账号缺少 access_token") + workspace_id, _candidates, _selected = _resolve_workspace_and_candidates( + account=account, + access_token=access_token, + proxy_url=proxy_url, + ) + if not workspace_id: + raise HTTPException(status_code=400, detail="未找到可用 Team workspace") + + def _exec(token: str): + status_code, body, raw = _team_api_request( + method="DELETE", + access_token=token, + workspace_id=workspace_id, + path="/invites", + proxy_url=proxy_url, + payload={"email_address": email}, + ) + return status_code, body, raw, token + + status_code, body, raw, _used_token = _retry_with_refresh_on_auth_error( + account=account, + proxy_url=proxy_url, + executor=_exec, + ) + if 200 <= status_code < 300: + return {"success": True, "message": "邀请已撤回", "response": body} + + error_text = _extract_error_text(status_code, body if isinstance(body, dict) else {}, raw) + raise HTTPException(status_code=400, detail=error_text) + + +@router.post("/team-accounts/{account_id}/members/remove") +async def remove_team_member(account_id: int, request: TeamMemberRemoveRequest): + """ + 移除 Team 已加入成员。 + """ + user_id = str(request.user_id or "").strip() + if not user_id: + raise HTTPException(status_code=400, detail="user_id 不能为空") + + account = _get_team_account_by_id_or_raise(account_id) + proxy_url = _get_proxy(request.proxy) + + access_token = str(account.access_token or "").strip() + if not access_token: + raise HTTPException(status_code=400, detail="账号缺少 access_token") + workspace_id, _candidates, _selected = _resolve_workspace_and_candidates( + account=account, + access_token=access_token, + proxy_url=proxy_url, + ) + if not workspace_id: + raise HTTPException(status_code=400, detail="未找到可用 Team workspace") + + def _exec(token: str): + status_code, body, raw = _team_api_request( + method="DELETE", + access_token=token, + workspace_id=workspace_id, + path=f"/users/{user_id}", + proxy_url=proxy_url, + ) + return status_code, body, raw, token + + status_code, body, raw, _used_token = _retry_with_refresh_on_auth_error( + account=account, + proxy_url=proxy_url, + executor=_exec, + ) + if 200 <= status_code < 300: + return {"success": True, "message": "成员已移除", "response": body} + + error_text = _extract_error_text(status_code, body if isinstance(body, dict) else {}, raw) + raise HTTPException(status_code=400, detail=error_text) + + +@router.post("/preview") +async def preview_auto_team(request: AutoTeamPreviewRequest): + """预检输入与可用邀请账号。""" + target_email = str(request.target_email or "").strip() + + if not EMAIL_RE.match(target_email): + raise HTTPException(status_code=400, detail="目标邮箱格式不正确") + + inviter = _find_selected_inviter(request.inviter_account_id) + return { + "success": True, + "target_email": target_email, + "inviter": inviter, + "tips": "当前为 Team 管理和自动邀请:直接按邮箱执行邀请。", + } + + +@router.post("/invite") +async def execute_auto_team_invite(request: AutoTeamInviteRequest): + """执行team 邀请。""" + target_email = str(request.target_email or "").strip() + + if not EMAIL_RE.match(target_email): + raise HTTPException(status_code=400, detail="目标邮箱格式不正确") + + invite_allowed, invite_breaker = breaker_allow_request("team_invite") + if not invite_allowed: + raise HTTPException(status_code=429, detail=f"team_invite 熔断中,请稍后重试: {invite_breaker}") + + inviter_item = _find_selected_inviter(request.inviter_account_id) + inviter_account_id = int(inviter_item.get("id") or 0) + if inviter_account_id <= 0: + raise HTTPException(status_code=400, detail="邀请账号无效") + + workspace_id = str(inviter_item.get("workspace_id") or "").strip() + original_workspace_id = workspace_id + + proxy_url = _get_proxy(request.proxy) + proxy_explicit = bool(str(request.proxy or "").strip()) + if proxy_url: + proxy_allowed, proxy_breaker = breaker_allow_request("proxy_runtime") + if not proxy_allowed: + logger.warning("team邀请代理通道熔断,自动切换直连: info=%s", proxy_breaker) + proxy_url = None + semaphore = _get_inviter_semaphore(inviter_account_id) + if semaphore.locked(): + logger.info("team邀请进入管理号并发队列: inviter=%s limit=%s", inviter_account_id, MANAGER_CONCURRENCY_LIMIT) + async with semaphore: + waited = _get_manager_cooldown_seconds(inviter_account_id) + if waited > 0: + logger.info( + "team邀请命中管理号速率队列等待: inviter=%s wait=%.2fs", + inviter_account_id, + waited, + ) + await asyncio.sleep(min(waited, 15.0)) + + with get_db() as db: + account = db.query(Account).filter(Account.id == inviter_item["id"]).first() + if not account: + raise HTTPException(status_code=404, detail="邀请账号不存在") + + access_token = str(account.access_token or "").strip() + if not access_token: + refresh_hint = bool(str(account.refresh_token or "").strip()) + session_hint = bool(str(account.session_token or "").strip()) + if refresh_hint or session_hint: + logger.info( + "team邀请账号缺少 access_token,尝试先刷新 token: inviter=%s refresh=%s session=%s", + account.email, + "yes" if refresh_hint else "no", + "yes" if session_hint else "no", + ) + refresh_result = do_refresh(account.id, proxy_url=proxy_url) + if refresh_result.success: + db.expire(account) + db.refresh(account) + access_token = str(account.access_token or "").strip() + + if not access_token: + raise HTTPException(status_code=400, detail="邀请账号缺少可用 access_token(可先在账号管理刷新订阅/令牌)") + + # 先尝试用 token 实时解析 Team workspace,避免账号表里 workspace 过期。 + team_candidates = _fetch_team_workspace_candidates( + access_token=access_token, + proxy_url=proxy_url, + ) + if team_candidates: + candidate_ids = [str(x.get("account_id") or "").strip() for x in team_candidates if x.get("account_id")] + if not workspace_id or workspace_id not in candidate_ids: + workspace_id = candidate_ids[0] + logger.info( + "team邀请使用实时 workspace_id: inviter=%s old=%s new=%s", + account.email, + original_workspace_id or "-", + workspace_id, + ) + + if not workspace_id: + raise HTTPException(status_code=400, detail="邀请账号缺少可用 workspace_id/account_id") + + # 先落一条 pending 记录,避免目标邮箱在“邀请成功但订阅未同步”窗口期重复出现在候选列表 + account.last_used_at = datetime.utcnow() + _upsert_invite_record( + db, + inviter_account=account, + target_email=target_email, + workspace_id=workspace_id, + state="pending", + increment_attempt=True, + ) + db.commit() + + status_code, body, raw = await _send_team_invite_with_backoff( + access_token=access_token, + workspace_id=workspace_id, + target_email=target_email, + proxy_url=proxy_url, + inviter_account_id=account.id, + ) + + error_text = _extract_error_text(status_code, body, raw) + if status_code in (401, 403) or _is_token_invalidated_error(error_text): + logger.info( + "team邀请命中鉴权失效,尝试刷新 token 后重试: inviter=%s email=%s status=%s", + account.email, + target_email, + status_code, + ) + refresh_result = do_refresh(account.id, proxy_url=proxy_url) + if refresh_result.success: + db.expire(account) + db.refresh(account) + access_token = str(account.access_token or "").strip() + if access_token: + status_code, body, raw = await _send_team_invite_with_backoff( + access_token=access_token, + workspace_id=workspace_id, + target_email=target_email, + proxy_url=proxy_url, + inviter_account_id=account.id, + ) + error_text = _extract_error_text(status_code, body, raw) + + # 命中 workspace 上下文错误时,自动换用 candidates 重试。 + if status_code >= 400 and _is_workspace_context_error(error_text): + if not team_candidates: + team_candidates = _fetch_team_workspace_candidates( + access_token=access_token, + proxy_url=proxy_url, + ) + tried = {workspace_id} + switched = False + for item in team_candidates: + candidate_id = str(item.get("account_id") or "").strip() + if not candidate_id or candidate_id in tried: + continue + tried.add(candidate_id) + logger.warning( + "team邀请命中 workspace 错误,自动切换 workspace 重试: inviter=%s from=%s to=%s", + account.email, + workspace_id, + candidate_id, + ) + workspace_id = candidate_id + status_code, body, raw = await _send_team_invite_with_backoff( + access_token=access_token, + workspace_id=workspace_id, + target_email=target_email, + proxy_url=proxy_url, + inviter_account_id=account.id, + ) + error_text = _extract_error_text(status_code, body, raw) + switched = True + if status_code < 400 or not _is_workspace_context_error(error_text): + break + if switched and status_code in (401, 403): + refresh_result = do_refresh(account.id, proxy_url=proxy_url) + if refresh_result.success: + db.expire(account) + db.refresh(account) + access_token = str(account.access_token or "").strip() + if access_token: + status_code, body, raw = await _send_team_invite_with_backoff( + access_token=access_token, + workspace_id=workspace_id, + target_email=target_email, + proxy_url=proxy_url, + inviter_account_id=account.id, + ) + error_text = _extract_error_text(status_code, body, raw) + + # 兜底:部分代理会把 chatgpt 请求劫持到“兑换码/checkout”页面, + # 导致自动邀请误报“请输入兑换码”。非显式代理时自动直连重试一次。 + if ( + proxy_url + and not proxy_explicit + and status_code >= 400 + and _looks_like_redeem_gateway_error(error_text) + ): + logger.warning( + "team邀请疑似命中代理兑换页,自动切换直连重试: inviter=%s email=%s proxy=%s err=%s", + account.email, + target_email, + proxy_url, + error_text[:160], + ) + status_code, body, raw = await _send_team_invite_with_backoff( + access_token=access_token, + workspace_id=workspace_id, + target_email=target_email, + proxy_url=None, + inviter_account_id=account.id, + ) + if status_code in (401, 403): + refresh_result = do_refresh(account.id, proxy_url=None) + if refresh_result.success: + db.expire(account) + db.refresh(account) + access_token = str(account.access_token or "").strip() + if access_token: + status_code, body, raw = await _send_team_invite_with_backoff( + access_token=access_token, + workspace_id=workspace_id, + target_email=target_email, + proxy_url=None, + inviter_account_id=account.id, + ) + error_text = _extract_error_text(status_code, body, raw) + + if 200 <= status_code < 300: + _upsert_invite_record( + db, + inviter_account=account, + target_email=target_email, + workspace_id=workspace_id, + state="invited", + ) + db.commit() + _update_manager_health_after_invite( + account_id=account.id, + status_code=status_code, + error_text="", + success=True, + ) + breaker_record_success("team_invite") + if proxy_url: + breaker_record_success("proxy_runtime") + return { + "success": True, + "message": "邀请已提交,请到目标邮箱查收 Team 邀请邮件。", + "target_email": target_email, + "inviter": inviter_item, + "request_meta": { + "workspace_id": workspace_id, + "workspace_id_original": original_workspace_id or None, + "proxy": "on" if proxy_url else "off", + "http_status": status_code, + }, + "response": body, + } + + if status_code in (409, 422) or _is_already_member_or_invited(error_text): + final_state = "joined" if ("already a member" in str(error_text or "").lower() or "already in workspace" in str(error_text or "").lower()) else "invited" + _upsert_invite_record( + db, + inviter_account=account, + target_email=target_email, + workspace_id=workspace_id, + state=final_state, + last_error=error_text, + ) + db.commit() + _update_manager_health_after_invite( + account_id=account.id, + status_code=status_code, + error_text=error_text, + success=True, + ) + breaker_record_success("team_invite") + if proxy_url: + breaker_record_success("proxy_runtime") + return { + "success": True, + "message": "目标邮箱已在 Team 内或已存在邀请,本次按成功处理。", + "target_email": target_email, + "inviter": inviter_item, + "request_meta": { + "workspace_id": workspace_id, + "workspace_id_original": original_workspace_id or None, + "proxy": "on" if proxy_url else "off", + "http_status": status_code, + }, + "response": body or {"detail": error_text}, + } + + _upsert_invite_record( + db, + inviter_account=account, + target_email=target_email, + workspace_id=workspace_id, + state="failed", + last_error=error_text, + ) + db.commit() + _update_manager_health_after_invite( + account_id=account.id, + status_code=status_code, + error_text=error_text, + success=False, + ) + breaker_record_failure("team_invite", error_text) + if proxy_url: + breaker_record_failure("proxy_runtime", error_text) + raise HTTPException(status_code=400, detail=error_text) + diff --git a/src/web/routes/email.py b/src/web/routes/email.py index 8dd4ef1f..840579ca 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -13,7 +13,6 @@ from ...database.session import get_db from ...database.models import EmailService as EmailServiceModel from ...database.models import Account as AccountModel -from ...config.settings import get_settings from ...services import EmailServiceFactory, EmailServiceType logger = logging.getLogger(__name__) @@ -40,13 +39,13 @@ class EmailServiceUpdate(BaseModel): class EmailServiceResponse(BaseModel): - """??????""" + """邮箱服务响应""" id: int service_type: str name: str enabled: bool priority: int - config: Optional[Dict[str, Any]] = None # ?????????? + config: Optional[Dict[str, Any]] = None # 过滤敏感信息后的配置 registration_status: Optional[str] = None registered_account_id: Optional[int] = None last_used: Optional[str] = None @@ -119,8 +118,19 @@ def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]: return filtered +def _normalize_outlook_email_config(service_type: str, config: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """统一 Outlook 配置中的邮箱大小写,避免和账号库比较不一致。""" + normalized = dict(config or {}) + if str(service_type or "").strip().lower() != "outlook": + return normalized + + if "email" in normalized: + normalized["email"] = str(normalized.get("email") or "").strip().lower() + return normalized + + def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: - """?????????""" + """转换服务模型为响应""" registration_status = None registered_account_id = None if service.service_type == "outlook": @@ -160,6 +170,8 @@ def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: async def get_email_services_stats(): """获取邮箱服务统计信息""" with get_db() as db: + from sqlalchemy import func + # 按类型统计 type_stats = db.query( EmailServiceModel.service_type, @@ -171,25 +183,15 @@ async def get_email_services_stats(): EmailServiceModel.enabled == True ).scalar() - settings = get_settings() - tempmail_enabled = bool(settings.tempmail_enabled) - yyds_enabled = bool( - settings.yyds_mail_enabled - and settings.yyds_mail_api_key - and settings.yyds_mail_api_key.get_secret_value() - ) - stats = { 'outlook_count': 0, 'custom_count': 0, - 'yyds_mail_count': 0, 'temp_mail_count': 0, 'duck_mail_count': 0, 'freemail_count': 0, 'imap_mail_count': 0, 'cloudmail_count': 0, - 'tempmail_available': tempmail_enabled or yyds_enabled, - 'yyds_mail_available': yyds_enabled, + 'tempmail_available': True, # 临时邮箱始终可用 'enabled_count': enabled_count } @@ -198,8 +200,6 @@ async def get_email_services_stats(): stats['outlook_count'] = count elif service_type == 'moe_mail': stats['custom_count'] = count - elif service_type == 'yyds_mail': - stats['yyds_mail_count'] = count elif service_type == 'temp_mail': stats['temp_mail_count'] = count elif service_type == 'duck_mail': @@ -222,23 +222,12 @@ async def get_service_types(): { "value": "tempmail", "label": "Tempmail.lol", - "description": "官方内置临时邮箱渠道,通过全局配置使用", + "description": "临时邮箱服务,无需配置", "config_fields": [ {"name": "base_url", "label": "API 地址", "default": "https://api.tempmail.lol/v2", "required": False}, {"name": "timeout", "label": "超时时间", "default": 30, "required": False}, ] }, - { - "value": "yyds_mail", - "label": "YYDS Mail", - "description": "官方内置临时邮箱渠道,使用 X-API-Key 创建邮箱并轮询消息", - "config_fields": [ - {"name": "base_url", "label": "API 地址", "default": "https://maliapi.215.im/v1", "required": False}, - {"name": "api_key", "label": "API Key", "required": True, "secret": True}, - {"name": "default_domain", "label": "默认域名", "required": False, "placeholder": "public.example.com"}, - {"name": "timeout", "label": "超时时间", "default": 30, "required": False}, - ] - }, { "value": "outlook", "label": "Outlook", @@ -272,6 +261,18 @@ async def get_service_types(): {"name": "enable_prefix", "label": "启用前缀", "required": False, "default": True}, ] }, + { + "value": "cloudmail", + "label": "CloudMail(自部署)", + "description": "CloudMail 自部署邮箱服务(配置与 Temp-Mail 兼容)", + "config_fields": [ + {"name": "base_url", "label": "Worker 地址", "required": True, "placeholder": "https://mail.example.com"}, + {"name": "admin_password", "label": "Admin 密码", "required": True, "secret": True}, + {"name": "custom_auth", "label": "Custom Auth(可选)", "required": False, "secret": True}, + {"name": "domain", "label": "邮箱域名", "required": True, "placeholder": "example.com"}, + {"name": "enable_prefix", "label": "启用前缀", "required": False, "default": True}, + ] + }, { "value": "duck_mail", "label": "DuckMail", @@ -372,16 +373,31 @@ async def create_email_service(request: EmailServiceCreate): except ValueError: raise HTTPException(status_code=400, detail=f"无效的服务类型: {request.service_type}") + normalized_service_type = str(request.service_type or "").strip().lower() + normalized_config = _normalize_outlook_email_config(normalized_service_type, request.config) + normalized_name = str(request.name or "").strip() + if normalized_service_type == "outlook": + normalized_email = str(normalized_config.get("email") or normalized_name).strip().lower() + if normalized_email: + normalized_name = normalized_email + normalized_config["email"] = normalized_email + with get_db() as db: # 检查名称是否重复 - existing = db.query(EmailServiceModel).filter(EmailServiceModel.name == request.name).first() + if normalized_service_type == "outlook": + existing = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "outlook", + func.lower(EmailServiceModel.name) == normalized_name + ).first() + else: + existing = db.query(EmailServiceModel).filter(EmailServiceModel.name == normalized_name).first() if existing: raise HTTPException(status_code=400, detail="服务名称已存在") service = EmailServiceModel( - service_type=request.service_type, - name=request.name, - config=request.config, + service_type=normalized_service_type, + name=normalized_name, + config=normalized_config, enabled=request.enabled, priority=request.priority ) @@ -409,6 +425,14 @@ async def update_email_service(service_id: int, request: EmailServiceUpdate): merged_config = {**current_config, **request.config} # 移除空值 merged_config = {k: v for k, v in merged_config.items() if v} + merged_config = _normalize_outlook_email_config(service.service_type, merged_config) + if ( + str(service.service_type or "").strip().lower() == "outlook" + and request.name is None + ): + normalized_email = str(merged_config.get("email") or "").strip().lower() + if normalized_email: + update_data["name"] = normalized_email update_data["config"] = merged_config if request.enabled is not None: update_data["enabled"] = request.enabled @@ -548,7 +572,7 @@ async def batch_import_outlook(request: OutlookBatchImportRequest): errors.append(f"行 {i+1}: 格式错误,至少需要邮箱和密码") continue - email = parts[0].strip() + email = parts[0].strip().lower() password = parts[1].strip() # 验证邮箱格式 @@ -560,7 +584,7 @@ async def batch_import_outlook(request: OutlookBatchImportRequest): # 检查是否已存在 existing = db.query(EmailServiceModel).filter( EmailServiceModel.service_type == "outlook", - EmailServiceModel.name == email + func.lower(EmailServiceModel.name) == email ).first() if existing: @@ -639,52 +663,29 @@ async def batch_delete_outlook(service_ids: List[int]): class TempmailTestRequest(BaseModel): """临时邮箱测试请求""" - provider: str = "tempmail" api_url: Optional[str] = None - api_key: Optional[str] = None @router.post("/test-tempmail") async def test_tempmail_service(request: TempmailTestRequest): """测试临时邮箱服务是否可用""" try: - settings = get_settings() - provider = str(request.provider or "tempmail").strip().lower() + from ...services import EmailServiceFactory, EmailServiceType + from ...config.settings import get_settings - if provider == "yyds_mail": - base_url = request.api_url or settings.yyds_mail_base_url - api_key = request.api_key - if api_key is None and settings.yyds_mail_api_key: - api_key = settings.yyds_mail_api_key.get_secret_value() + settings = get_settings() + base_url = request.api_url or settings.tempmail_base_url - config = { - "base_url": base_url, - "api_key": api_key or "", - "default_domain": settings.yyds_mail_default_domain, - "timeout": settings.yyds_mail_timeout, - "max_retries": settings.yyds_mail_max_retries, - } - service = EmailServiceFactory.create(EmailServiceType.YYDS_MAIL, config) - success_message = "YYDS Mail 连接正常" - fail_message = "YYDS Mail 连接失败" - else: - base_url = request.api_url or settings.tempmail_base_url - config = { - "base_url": base_url, - "timeout": settings.tempmail_timeout, - "max_retries": settings.tempmail_max_retries, - } - service = EmailServiceFactory.create(EmailServiceType.TEMPMAIL, config) - success_message = "临时邮箱连接正常" - fail_message = "临时邮箱连接失败" + config = {"base_url": base_url} + tempmail = EmailServiceFactory.create(EmailServiceType.TEMPMAIL, config) # 检查服务健康状态 - health = service.check_health() + health = tempmail.check_health() if health: - return {"success": True, "message": success_message} + return {"success": True, "message": "临时邮箱连接正常"} else: - return {"success": False, "message": fail_message} + return {"success": False, "message": "临时邮箱连接失败"} except Exception as e: logger.error(f"测试临时邮箱失败: {e}") diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index 17c30729..0e0d15ee 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -6,21 +6,27 @@ import os import re import uuid -from typing import Optional, List +import threading +from contextlib import contextmanager +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait +from typing import Optional, List, Dict, Any, Tuple, Callable from datetime import datetime import time -from urllib.parse import urlparse, urlunparse +from urllib.parse import urlparse, urlunparse, quote -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel, Field -from sqlalchemy import or_ +from sqlalchemy import func, or_ from sqlalchemy.orm import joinedload from curl_cffi import requests as cffi_requests from ...database.session import get_db +from ...database import crud from ...database.models import Account, BindCardTask, EmailService as EmailServiceModel from ...config.settings import get_settings -from ...config.constants import OPENAI_PAGE_TYPES +from ...config.constants import ( + OPENAI_PAGE_TYPES, +) from ...services import EmailServiceFactory, EmailServiceType from ...core.register import RegistrationEngine from .accounts import resolve_account_ids @@ -35,6 +41,10 @@ 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 ...core.circuit_breaker import allow_request as breaker_allow_request +from ...core.circuit_breaker import record_failure as breaker_record_failure +from ...core.circuit_breaker import record_success as breaker_record_success +from ..task_manager import task_manager logger = logging.getLogger(__name__) router = APIRouter() @@ -81,6 +91,51 @@ "ES": "EUR", "EU": "EUR", } +VENDOR_REDEEM_CODE_REGEX = re.compile(r"^UK(?:-[A-Z0-9]{5}){5}$") +VENDOR_EFUN_FLOW_VERSION = "vendor_efun_flow_20260327d" +VENDOR_BINDCARD_API_URL_ENV = "VENDOR_BINDCARD_API_URL" +VENDOR_BINDCARD_API_KEY_ENV = "VENDOR_BINDCARD_API_KEY" +VENDOR_BINDCARD_API_URL_DEFAULT = "https://card.aimizy.com/api/v1/bindcard" +VENDOR_BINDCARD_API_KEY_DEFAULT = "" +EFUNCARD_BASE_URL_ENV = "EFUNCARD_BASE_URL" +EFUNCARD_API_KEY_ENV = "EFUNCARD_API_KEY" +EFUNCARD_BASE_URL_DEFAULT = "https://card.efuncard.com" +EFUNCARD_API_KEY_DEFAULT = "" +EFUNCARD_CODE_REGEX = re.compile(r"^UK(?:-[A-Z0-9]{5}){5}$") +EFUNCARD_EXPIRY_REGEX = re.compile(r"^\s*(\d{1,2})\s*/\s*(\d{2,4})\s*$") +VENDOR_NODE_INSTRUCTION_SPLIT_REGEX = re.compile(r"\s*,\s*") +_VENDOR_PROGRESS_LOCK = threading.Lock() +_VENDOR_PROGRESS_STATE: Dict[int, Dict[str, Any]] = {} +_VENDOR_PROGRESS_LOG_MAX = 400 +_VENDOR_FORCE_STOP_AFTER_SECONDS = 120 +_PAYMENT_OP_TASK_LOCK = threading.Lock() +_PAYMENT_OP_TASKS: Dict[str, Dict[str, Any]] = {} +_PAYMENT_OP_TASK_MAX_KEEP = 300 +_PAYMENT_OP_TASK_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="payment_op") +TERMINAL_BIND_TASK_STATUSES = ("completed", "failed", "cancelled") +_BIND_TASK_CREATE_LOCK_GUARD = threading.Lock() +_BIND_TASK_CREATE_LOCKS: Dict[int, threading.Lock] = {} +BIND_TASK_CREATE_LOCK_TIMEOUT_SECONDS = 8.0 +PAYMENT_BATCH_SUBSCRIPTION_CHECK_MAX_WORKERS = 8 +PAYMENT_BATCH_SUBSCRIPTION_CHECK_RETRY_ATTEMPTS = 2 +PAYMENT_BATCH_SUBSCRIPTION_CHECK_RETRY_BASE_DELAY_SECONDS = 0.8 +DISABLED_BIND_MODES = ("third_party", "vendor_auto") +ALLOWED_BIND_MODES = ("semi_auto", "local_auto", "vendor_efun") + + +def _resolve_actor(request: Optional[Request]) -> str: + if request is None: + return "system" + for key in ("x-operator", "x-user", "x-username"): + value = str(request.headers.get(key) or "").strip() + if value: + return value[:120] + client_host = "" + try: + client_host = str(getattr(getattr(request, "client", None), "host", "") or "").strip() + except Exception: + client_host = "" + return f"api@{client_host}" if client_host else "api" def _is_official_checkout_link(link: Optional[str]) -> bool: @@ -171,8 +226,446 @@ def _resolve_runtime_proxy(explicit_proxy: Optional[str], account: Optional[Acco return None +def _get_bind_task_create_lock(account_id: int) -> threading.Lock: + key = int(account_id) + with _BIND_TASK_CREATE_LOCK_GUARD: + lock = _BIND_TASK_CREATE_LOCKS.get(key) + if lock is None: + lock = threading.Lock() + _BIND_TASK_CREATE_LOCKS[key] = lock + return lock + + +@contextmanager +def _acquire_bind_task_create_lock(account_id: int): + lock = _get_bind_task_create_lock(account_id) + acquired = lock.acquire(timeout=BIND_TASK_CREATE_LOCK_TIMEOUT_SECONDS) + if not acquired: + raise HTTPException(status_code=429, detail="该账号正在创建绑卡任务,请稍后重试") + try: + yield + finally: + lock.release() + + +def _find_active_bind_task_for_account(db, account_id: int) -> Optional[BindCardTask]: + return ( + db.query(BindCardTask) + .filter(BindCardTask.account_id == int(account_id)) + .filter( + or_( + BindCardTask.status.is_(None), + ~BindCardTask.status.in_(TERMINAL_BIND_TASK_STATUSES), + ) + ) + .order_by(BindCardTask.created_at.desc(), BindCardTask.id.desc()) + .first() + ) + + +def _resolve_efuncard_base_url(request_base_url: Optional[str]) -> str: + raw = ( + str(request_base_url or "").strip() + or str(os.getenv(EFUNCARD_BASE_URL_ENV) or "").strip() + or EFUNCARD_BASE_URL_DEFAULT + ) + if "://" not in raw: + raw = f"https://{raw}" + parsed = urlparse(raw) + if not parsed.scheme or not parsed.netloc: + raise HTTPException(status_code=400, detail="EfunCard base_url 无效") + return urlunparse(parsed._replace(path="", params="", query="", fragment="")).rstrip("/") + + +def _resolve_efuncard_api_key(request_api_key: Optional[str]) -> str: + token = ( + str(request_api_key or "").strip() + or str(os.getenv(EFUNCARD_API_KEY_ENV) or "").strip() + or EFUNCARD_API_KEY_DEFAULT + ) + if not token: + raise HTTPException( + status_code=400, + detail=f"缺少 EfunCard API Key(可设置环境变量 {EFUNCARD_API_KEY_ENV})", + ) + return token + + +def _normalize_efuncard_code(code: Optional[str]) -> str: + text = str(code or "").strip().upper() + if not text: + raise HTTPException(status_code=400, detail="code 不能为空") + compact = re.sub(r"[^A-Z0-9]", "", text) + if not compact.startswith("UK"): + raise HTTPException(status_code=400, detail="code 格式无效") + body = compact[2:] + if len(body) != 25: + raise HTTPException(status_code=400, detail="code 格式无效") + normalized = f"UK-{body[0:5]}-{body[5:10]}-{body[10:15]}-{body[15:20]}-{body[20:25]}" + if not EFUNCARD_CODE_REGEX.fullmatch(normalized): + raise HTTPException(status_code=400, detail="code 格式无效") + return normalized + + +def _parse_efuncard_expiry(expiry_date: Optional[str]) -> Tuple[str, str]: + text = str(expiry_date or "").strip() + if not text: + return "", "" + match = EFUNCARD_EXPIRY_REGEX.match(text) + if not match: + return "", "" + month_raw = str(match.group(1) or "").strip() + year_raw = str(match.group(2) or "").strip() + try: + month_int = int(month_raw) + except Exception: + return "", "" + if month_int < 1 or month_int > 12: + return "", "" + month = f"{month_int:02d}" + if len(year_raw) == 2: + year = f"20{year_raw}" + else: + year = year_raw[:4] + if not year.isdigit(): + return "", "" + return month, year + + +def _efuncard_request( + *, + method: str, + path: str, + api_key: str, + base_url: str, + proxy: Optional[str], + payload: Optional[Dict[str, Any]] = None, + timeout_seconds: int = 35, +) -> Dict[str, Any]: + url = f"{base_url}{path}" + headers = { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "Content-Type": "application/json", + } + session_kwargs: Dict[str, Any] = { + "impersonate": "chrome120", + "timeout": max(5, int(timeout_seconds)), + } + if proxy: + session_kwargs["proxy"] = proxy + session = cffi_requests.Session(**session_kwargs) + try: + # 避免继承宿主机 HTTP(S)_PROXY,EFun 仅使用显式配置的代理 + session.trust_env = False + except Exception: + pass + method_up = str(method or "GET").strip().upper() + if method_up == "POST": + response = session.post(url, headers=headers, json=payload or {}) + elif method_up == "GET": + response = session.get(url, headers=headers) + else: + raise HTTPException(status_code=400, detail=f"不支持的 method: {method_up}") + + raw = "" + try: + body = response.json() + if not isinstance(body, dict): + body = {"success": False, "raw": body} + except Exception: + try: + raw = str(response.text or "").strip() + except Exception: + raw = "" + body = {"success": False, "message": raw or "provider_non_json_response"} + + if response.status_code >= 400: + message = str( + body.get("message") + or body.get("error") + or body.get("msg") + or raw + or f"http_{response.status_code}" + ).strip() + raise HTTPException(status_code=response.status_code, detail=f"EfunCard 请求失败: {message}") + + if body.get("success") is False: + message = str(body.get("message") or body.get("error") or body.get("msg") or "provider_failed").strip() + raise HTTPException(status_code=400, detail=f"EfunCard 返回失败: {message}") + + return body + + +def _resolve_vendor_bindcard_api_url(request_url: Optional[str]) -> str: + raw = ( + str(request_url or "").strip() + or str(os.getenv(VENDOR_BINDCARD_API_URL_ENV) or "").strip() + or VENDOR_BINDCARD_API_URL_DEFAULT + ) + if "://" not in raw: + raw = f"https://{raw}" + parsed = urlparse(raw) + if not parsed.scheme or not parsed.netloc: + raise HTTPException(status_code=400, detail="卡商 bindcard API 地址无效") + path = str(parsed.path or "").strip() + if not path or path == "/": + path = "/api/v1/bindcard" + normalized = parsed._replace(path="/" + path.lstrip("/"), params="", query="", fragment="") + return urlunparse(normalized) + + +def _resolve_vendor_bindcard_api_key(request_api_key: Optional[str]) -> str: + token = ( + str(request_api_key or "").strip() + or str(os.getenv(VENDOR_BINDCARD_API_KEY_ENV) or "").strip() + or VENDOR_BINDCARD_API_KEY_DEFAULT + ) + if not token: + raise HTTPException( + status_code=400, + detail=f"缺少卡商 bindcard API Key(请求传 api_key 或设置环境变量 {VENDOR_BINDCARD_API_KEY_ENV})", + ) + return token + + +def _build_vendor_bindcard_api_candidates(api_url: str) -> List[str]: + text = str(api_url or "").strip() + if not text: + return [] + if "://" not in text: + text = f"https://{text}" + parsed = urlparse(text) + if not parsed.scheme or not parsed.netloc: + return [] + base = f"{parsed.scheme}://{parsed.netloc}" + path = str(parsed.path or "").strip() + if not path or path == "/": + path = "/api/v1/bindcard" + path = "/" + path.lstrip("/") + lower = path.lower() + candidates: List[str] = [] + + def _append(value: str): + item = str(value or "").strip() + if item and item not in candidates: + candidates.append(item) + + _append(base + path) + if lower.endswith("/bindcard"): + _append(base + path[: -len("/bindcard")] + "/bind-card") + elif lower.endswith("/bind-card"): + _append(base + path[: -len("/bind-card")] + "/bindcard") + else: + _append(base + "/api/v1/bindcard") + _append(base + "/api/v1/bind-card") + return candidates + + +def _normalize_vendor_card_payload(redeem_data: Dict[str, Any]) -> Dict[str, str]: + data = redeem_data if isinstance(redeem_data, dict) else {} + card_number = str(data.get("cardNumber") or data.get("card_number") or "").strip() + cvc = str(data.get("cvv") or data.get("cvc") or "").strip() + exp_month = str(data.get("exp_month") or data.get("expiryMonth") or "").strip() + exp_year = str(data.get("exp_year") or data.get("expiryYear") or "").strip() + + if not exp_month or not exp_year: + exp_date = str(data.get("expiryDate") or "").strip() + month_fallback, year_fallback = _parse_efuncard_expiry(exp_date) + exp_month = exp_month or month_fallback + exp_year = exp_year or year_fallback + + try: + exp_month_num = int(str(exp_month or "").strip()) + exp_month = f"{exp_month_num:02d}" + except Exception: + exp_month = "" + + exp_year_text = re.sub(r"[^0-9]", "", str(exp_year or "").strip()) + if len(exp_year_text) == 2: + exp_year_text = f"20{exp_year_text}" + if len(exp_year_text) > 4: + exp_year_text = exp_year_text[:4] + + return { + "number": card_number, + "exp_month": exp_month, + "exp_year": exp_year_text, + "cvc": cvc, + } + + +def _vendor_country_code_from_text(text: Optional[str], fallback: str = "US") -> str: + raw = str(text or "").strip().upper() + if not raw: + return _normalize_checkout_country(fallback) + if len(raw) == 2 and raw.isalpha(): + return _normalize_checkout_country(raw) + + mapping = { + "UNITED KINGDOM": "GB", + "GREAT BRITAIN": "GB", + "BRITAIN": "GB", + "ENGLAND": "GB", + "UK": "GB", + "U.K.": "GB", + "UNITED STATES": "US", + "USA": "US", + "U.S.": "US", + "U.S.A.": "US", + "HONG KONG": "HK", + "JAPAN": "JP", + "SINGAPORE": "SG", + "CANADA": "CA", + "AUSTRALIA": "AU", + "GERMANY": "DE", + "FRANCE": "FR", + "SPAIN": "ES", + "ITALY": "IT", + } + for key, code in mapping.items(): + if key in raw: + return _normalize_checkout_country(code) + return _normalize_checkout_country(fallback) + + +def _vendor_proxy_country_label(country_code: str) -> str: + code = _normalize_checkout_country(country_code) + mapping = { + "US": "美国", + "GB": "英国", + "CA": "加拿大", + "AU": "澳大利亚", + "SG": "新加坡", + "HK": "香港", + "JP": "日本", + "TR": "土耳其", + "IN": "印度", + "BR": "巴西", + "MX": "墨西哥", + "DE": "德国", + "FR": "法国", + "IT": "意大利", + "ES": "西班牙", + } + return mapping.get(code, code) + + +def _parse_vendor_node_instructions(node_text: Optional[str], fallback_country: str) -> Dict[str, str]: + cleaned = re.sub(r"\s+", " ", str(node_text or "")).strip(" ,") + if not cleaned: + return {} + parts = [p.strip() for p in VENDOR_NODE_INSTRUCTION_SPLIT_REGEX.split(cleaned) if p and p.strip()] + if not parts: + return {} + + country = _vendor_country_code_from_text(parts[-1], fallback=fallback_country) + postal = parts[-2] if len(parts) >= 2 else "" + city = parts[-3] if len(parts) >= 3 else "" + line1 = ", ".join(parts[:-3]) if len(parts) >= 4 else (parts[0] if parts else "") + if not line1 and len(parts) >= 2: + line1 = parts[0] + + return { + "country": country, + "line1": str(line1 or "").strip(), + "city": str(city or "").strip(), + "state": "", + "postal_code": str(postal or "").strip(), + "raw": cleaned, + } + + +def _build_vendor_billing_payload( + *, + account: Account, + redeem_data: Dict[str, Any], + country_hint: str, +) -> Tuple[Dict[str, str], str]: + fallback_country = _normalize_checkout_country(country_hint) + node_text = str( + (redeem_data or {}).get("nodeInstructions") + or (redeem_data or {}).get("groupInstructions") + or "" + ).strip() + parsed = _parse_vendor_node_instructions(node_text, fallback_country) + source = "efun.nodeInstructions" if parsed else "random_billing" + + try: + random_profile = generate_random_billing_profile(country=fallback_country, proxy=None) + except Exception: + random_profile = {} + + random_name = str((random_profile or {}).get("billing_name") or "").strip() + random_line1 = str((random_profile or {}).get("address_line1") or "").strip() + random_city = str((random_profile or {}).get("address_city") or "").strip() + random_state = str((random_profile or {}).get("address_state") or "").strip() + random_postal = str((random_profile or {}).get("postal_code") or "").strip() + random_country = _normalize_checkout_country((random_profile or {}).get("country_code") or fallback_country) + + email = str(getattr(account, "email", "") or "").strip() + email_name = str(email.split("@")[0] if "@" in email else email).replace(".", " ").replace("_", " ").strip() + fallback_name = re.sub(r"\s+", " ", email_name).title() if email_name else "Card Holder" + + billing = { + "name": random_name or fallback_name, + "email": email, + "country": _normalize_checkout_country(parsed.get("country") or random_country or fallback_country), + "state": str(parsed.get("state") or random_state or "").strip(), + "city": str(parsed.get("city") or random_city or "").strip(), + "line1": str(parsed.get("line1") or random_line1 or "").strip(), + "postal_code": str(parsed.get("postal_code") or random_postal or "").strip(), + } + return billing, source + + +def _invoke_vendor_bindcard_api( + *, + api_url: str, + api_key: str, + payload: Dict[str, Any], + timeout_seconds: int = 120, +) -> Tuple[Dict[str, Any], str]: + headers = { + "Accept": "*/*", + "Content-Type": "application/json", + "User-Agent": "codex-console2/vendor-efun-bindcard", + "Authorization": f"Bearer {api_key}", + } + candidates = _build_vendor_bindcard_api_candidates(api_url) + if not candidates: + raise RuntimeError("卡商 bindcard API 地址无效") + errors: List[str] = [] + for endpoint in candidates: + try: + session = cffi_requests.Session( + impersonate="chrome120", + timeout=max(20, int(timeout_seconds or 120)), + ) + try: + session.trust_env = False + except Exception: + pass + resp = session.post(endpoint, headers=headers, json=payload) + parsed = _parse_third_party_response(resp) + if resp.status_code >= 400: + body = str((parsed or {}).get("message") or (parsed or {}).get("error") or (resp.text or ""))[:400] + errors.append(f"{endpoint} http={resp.status_code} body={body}") + continue + if not isinstance(parsed, dict): + parsed = {"raw": str(parsed)[:1000]} + parsed["_meta_endpoint"] = endpoint + return parsed, endpoint + except Exception as exc: + errors.append(f"{endpoint} error={exc}") + continue + summary = " | ".join(errors[-4:]) if errors else "unknown_error" + raise RuntimeError(f"卡商 bindcard API 调用失败: {summary}") + + def _serialize_bind_card_task(task: BindCardTask) -> dict: - account_email = task.account.email if task.account else None + # 账号删除后仍展示邮箱快照(纯文本),便于历史任务识别 + account_email = task.account.email if task.account else (str(getattr(task, "account_email", "") or "").strip() or None) return { "id": task.id, "account_id": task.account_id, @@ -737,9 +1230,6 @@ def _normalize_email_service_config_for_session_bootstrap( if service_type == EmailServiceType.MOE_MAIL: if "domain" in normalized and "default_domain" not in normalized: normalized["default_domain"] = normalized.pop("domain") - elif service_type == EmailServiceType.YYDS_MAIL: - if "domain" in normalized and "default_domain" not in normalized: - normalized["default_domain"] = normalized.pop("domain") elif service_type in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): if "default_domain" in normalized and "domain" not in normalized: normalized["domain"] = normalized.pop("default_domain") @@ -793,18 +1283,6 @@ def _resolve_email_service_for_account_session_bootstrap(db, account: Account, p "max_retries": settings.tempmail_max_retries, "proxy_url": proxy, } - elif service_type == EmailServiceType.YYDS_MAIL: - api_key = settings.yyds_mail_api_key.get_secret_value() if settings.yyds_mail_api_key else "" - if not settings.yyds_mail_enabled or not api_key: - raise RuntimeError("YYDS Mail 渠道未启用或未配置 API Key,无法自动获取登录验证码") - config = { - "base_url": settings.yyds_mail_base_url, - "api_key": api_key, - "default_domain": settings.yyds_mail_default_domain, - "timeout": settings.yyds_mail_timeout, - "max_retries": settings.yyds_mail_max_retries, - "proxy_url": proxy, - } else: raise RuntimeError( f"未找到可用邮箱服务配置(type={service_type.value}),无法自动获取登录验证码" @@ -1291,103 +1769,2205 @@ def _mark_task_paid_pending_sync(task: BindCardTask, reason: str) -> None: task.last_error = reason -def _resolve_third_party_bind_api_url(request_url: Optional[str]) -> Optional[str]: - raw = ( - str(request_url or "").strip() - or str(os.getenv(THIRD_PARTY_BIND_API_URL_ENV) or "").strip() - or THIRD_PARTY_BIND_API_DEFAULT - ) - normalized = _normalize_third_party_bind_api_url(raw) - return normalized or None +def _promote_child_account_to_mother(account: Account, *, reason: str) -> bool: + """ + 历史兼容函数:关闭“订阅命中 plus/team 后自动子号升母号”。 + 防止子号加入 Team 后被错误改为母号。 + """ + _ = account + _ = reason + return False -def _resolve_third_party_bind_api_key(request_key: Optional[str]) -> Optional[str]: - token = str(request_key or "").strip() or str(os.getenv(THIRD_PARTY_BIND_API_KEY_ENV) or "").strip() - return token or None +def _apply_subscription_result( + account: Account, + *, + status: str, + checked_at: datetime, + confidence: Optional[str] = None, + promote_reason: str = "subscription_upgrade", +) -> bool: + normalized_status = str(status or "free").strip().lower() + normalized_confidence = str(confidence or "").strip().lower() + + if normalized_status in ("plus", "team"): + account.subscription_type = normalized_status + account.subscription_at = checked_at + return _promote_child_account_to_mother(account, reason=promote_reason) + + if normalized_status == "free" and normalized_confidence == "high": + account.subscription_type = None + account.subscription_at = None + + return False -def _normalize_third_party_bind_api_url(raw_url: Optional[str]) -> Optional[str]: - text = str(raw_url or "").strip() +def _vendor_progress_init(task_id: int) -> None: + now = datetime.now().strftime("%H:%M:%S") + started_at_epoch = int(time.time()) + with _VENDOR_PROGRESS_LOCK: + _VENDOR_PROGRESS_STATE[task_id] = { + "status": "running", + "progress": 0, + "updated_at": now, + "started_at_epoch": started_at_epoch, + "force_stop_after_seconds": _VENDOR_FORCE_STOP_AFTER_SECONDS, + "next_index": 0, + "logs": [], + "stop_requested": False, + } + + +def _vendor_progress_log( + task_id: int, + message: str, + *, + progress: Optional[int] = None, + status: Optional[str] = None, +) -> None: + text = str(message or "").strip() if not text: + return + now = datetime.now().strftime("%H:%M:%S") + with _VENDOR_PROGRESS_LOCK: + state = _VENDOR_PROGRESS_STATE.setdefault( + task_id, + { + "status": "running", + "progress": 0, + "updated_at": now, + "started_at_epoch": int(time.time()), + "force_stop_after_seconds": _VENDOR_FORCE_STOP_AFTER_SECONDS, + "next_index": 0, + "logs": [], + "stop_requested": False, + }, + ) + if progress is not None: + try: + state["progress"] = max(0, min(100, int(progress))) + except Exception: + pass + if status: + state["status"] = status + state["updated_at"] = now + idx = int(state.get("next_index") or 0) + logs = list(state.get("logs") or []) + logs.append({"index": idx, "time": now, "message": text}) + if len(logs) > _VENDOR_PROGRESS_LOG_MAX: + logs = logs[-_VENDOR_PROGRESS_LOG_MAX:] + state["logs"] = logs + state["next_index"] = idx + 1 + _VENDOR_PROGRESS_STATE[task_id] = state + + +def _vendor_progress_snapshot(task_id: int, cursor: int = 0) -> Dict[str, Any]: + with _VENDOR_PROGRESS_LOCK: + state = dict(_VENDOR_PROGRESS_STATE.get(task_id) or {}) + logs = list(state.get("logs") or []) + safe_cursor = max(0, int(cursor or 0)) + new_logs = [item for item in logs if int(item.get("index") or 0) >= safe_cursor] + next_cursor = safe_cursor + if logs: + next_cursor = int(logs[-1].get("index") or 0) + 1 + started_at_epoch = int(state.get("started_at_epoch") or 0) + now_epoch = int(time.time()) + elapsed_seconds = max(0, now_epoch - started_at_epoch) if started_at_epoch > 0 else 0 + force_stop_after_seconds = max(1, int(state.get("force_stop_after_seconds") or _VENDOR_FORCE_STOP_AFTER_SECONDS)) + return { + "status": str(state.get("status") or "idle"), + "progress": int(state.get("progress") or 0), + "updated_at": state.get("updated_at"), + "started_at_epoch": started_at_epoch if started_at_epoch > 0 else None, + "elapsed_seconds": elapsed_seconds, + "force_stop_after_seconds": force_stop_after_seconds, + "can_force_stop": elapsed_seconds >= force_stop_after_seconds, + "logs": new_logs, + "next_cursor": next_cursor, + "stop_requested": bool(state.get("stop_requested")), + } + + +def _vendor_progress_exists(task_id: int) -> bool: + with _VENDOR_PROGRESS_LOCK: + return int(task_id) in _VENDOR_PROGRESS_STATE + + +def _vendor_request_stop(task_id: int) -> bool: + now = datetime.now().strftime("%H:%M:%S") + with _VENDOR_PROGRESS_LOCK: + state = _VENDOR_PROGRESS_STATE.setdefault( + task_id, + { + "status": "running", + "progress": 0, + "updated_at": now, + "started_at_epoch": int(time.time()), + "force_stop_after_seconds": _VENDOR_FORCE_STOP_AFTER_SECONDS, + "next_index": 0, + "logs": [], + "stop_requested": False, + }, + ) + already = bool(state.get("stop_requested")) + state["stop_requested"] = True + state["updated_at"] = now + _VENDOR_PROGRESS_STATE[task_id] = state + return not already + + +def _vendor_should_stop(task_id: int) -> bool: + with _VENDOR_PROGRESS_LOCK: + state = _VENDOR_PROGRESS_STATE.get(task_id) or {} + return bool(state.get("stop_requested")) + + +def _vendor_get_latest_active_task_id() -> Optional[int]: + with _VENDOR_PROGRESS_LOCK: + items = [] + for raw_task_id, raw_state in (_VENDOR_PROGRESS_STATE or {}).items(): + try: + task_id = int(raw_task_id) + except Exception: + continue + state = dict(raw_state or {}) + status = str(state.get("status") or "running").strip().lower() + if status in ("completed", "failed", "cancelled"): + continue + updated_at = str(state.get("updated_at") or "") + items.append((task_id, updated_at)) + if not items: return None - if "://" not in text: - text = "https://" + text - try: - parsed = urlparse(text) - except Exception: - return None - if not parsed.scheme or not parsed.netloc: - return None - path = parsed.path or "" - if not path or path == "/": - path = THIRD_PARTY_BIND_PATH_DEFAULT - path = "/" + path.lstrip("/") - normalized = parsed._replace(path=path, params="", fragment="") - return urlunparse(normalized) + items.sort(key=lambda item: (item[1], item[0]), reverse=True) + return int(items[0][0]) -def _build_third_party_bind_api_candidates(api_url: str) -> List[str]: - """ - 对第三方地址做容错: - - 支持只给根域名(自动补 /api/v1/bind-card) - - 支持给到 /api/v1(自动补 /bind-card) - - 保留原始路径作为首选 - """ - normalized = _normalize_third_party_bind_api_url(api_url) - if not normalized: - return [] +def _payment_now_iso() -> str: + return datetime.utcnow().isoformat() - candidates: List[str] = [] - def _append(url: Optional[str]): - value = str(url or "").strip() - if value and value not in candidates: - candidates.append(value) +def _cleanup_payment_op_tasks_locked() -> None: + total = len(_PAYMENT_OP_TASKS) + if total <= _PAYMENT_OP_TASK_MAX_KEEP: + return - _append(normalized) - parsed = urlparse(normalized) - base = f"{parsed.scheme}://{parsed.netloc}" - path = (parsed.path or "").rstrip("/") - lower = path.lower() + overflow = total - _PAYMENT_OP_TASK_MAX_KEEP + finished = [ + (task_id, _PAYMENT_OP_TASKS[task_id].get("_created_ts", 0)) + for task_id in _PAYMENT_OP_TASKS + if _PAYMENT_OP_TASKS[task_id].get("status") in {"completed", "failed", "cancelled"} + ] + finished.sort(key=lambda item: item[1]) + removed = 0 + for task_id, _ in finished: + if removed >= overflow: + break + _PAYMENT_OP_TASKS.pop(task_id, None) + removed += 1 + + +def _create_payment_op_task(task_type: str, *, bind_task_id: Optional[int] = None, progress: Optional[dict] = None) -> str: + task_id = str(uuid.uuid4()) + task = { + "id": task_id, + "task_type": str(task_type or "unknown"), + "bind_task_id": int(bind_task_id) if bind_task_id else None, + "status": "pending", + "message": "任务已创建,等待执行", + "created_at": _payment_now_iso(), + "started_at": None, + "finished_at": None, + "cancel_requested": False, + "pause_requested": False, + "paused": False, + "progress": progress or {}, + "payload": {"bind_task_id": int(bind_task_id) if bind_task_id else None}, + "result": None, + "error": None, + "details": [], + "_created_ts": time.time(), + } + with _PAYMENT_OP_TASK_LOCK: + _PAYMENT_OP_TASKS[task_id] = task + _cleanup_payment_op_tasks_locked() + task_manager.register_domain_task( + domain="payment", + task_id=task_id, + task_type=task_type, + payload={"bind_task_id": int(bind_task_id) if bind_task_id else None}, + progress=task["progress"], + ) + return task_id - if lower in ("", "/"): - _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) - elif lower.endswith("/api/v1"): - _append(base + path + "/bind-card") - _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) - elif not lower.endswith("/bind-card"): - _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) - return candidates +def _get_payment_op_task(task_id: str) -> Optional[Dict[str, Any]]: + with _PAYMENT_OP_TASK_LOCK: + return _PAYMENT_OP_TASKS.get(task_id) -def _parse_third_party_response(resp) -> dict: - if not (resp.content or b""): - return {"ok": True} +def _get_payment_op_task_or_404(task_id: str) -> Dict[str, Any]: + task = _get_payment_op_task(task_id) + if not task: + raise HTTPException(status_code=404, detail="支付任务不存在") + return task - content_type = (resp.headers.get("content-type") or "").lower() - if "application/json" in content_type: - try: - data = resp.json() - if isinstance(data, dict): - return data - return {"data": data} - except Exception: - pass - raw = str(resp.text or "").strip() - if raw.startswith("{") and raw.endswith("}"): - try: - data = resp.json() - if isinstance(data, dict): - return data - except Exception: - pass - return {"raw": raw[:1000]} +def _update_payment_op_task(task_id: str, **fields) -> None: + with _PAYMENT_OP_TASK_LOCK: + task = _PAYMENT_OP_TASKS.get(task_id) + if not task: + return + task.update(fields) + task_manager.update_domain_task("payment", task_id, **fields) -def _invoke_third_party_bind_api( - *, +def _set_payment_op_task_progress(task_id: str, **progress_fields) -> None: + with _PAYMENT_OP_TASK_LOCK: + task = _PAYMENT_OP_TASKS.get(task_id) + if not task: + return + progress = task.setdefault("progress", {}) + progress.update(progress_fields) + task_manager.set_domain_task_progress("payment", task_id, **(progress_fields or {})) + + +def _append_payment_op_task_detail(task_id: str, detail: dict, max_items: int = 400) -> None: + with _PAYMENT_OP_TASK_LOCK: + task = _PAYMENT_OP_TASKS.get(task_id) + if not task: + return + details = task.setdefault("details", []) + details.append(detail) + if len(details) > max_items: + task["details"] = details[-max_items:] + task_manager.append_domain_task_detail("payment", task_id, detail, max_items=max_items) + + +def _is_payment_op_task_cancel_requested(task_id: str) -> bool: + local_requested = False + with _PAYMENT_OP_TASK_LOCK: + task = _PAYMENT_OP_TASKS.get(task_id) + local_requested = bool(task and task.get("cancel_requested")) + return local_requested or task_manager.is_domain_task_cancel_requested("payment", task_id) + + +def _is_payment_op_task_pause_requested(task_id: str) -> bool: + local_requested = False + with _PAYMENT_OP_TASK_LOCK: + task = _PAYMENT_OP_TASKS.get(task_id) + local_requested = bool(task and task.get("pause_requested")) + return local_requested or task_manager.is_domain_task_pause_requested("payment", task_id) + + +def _wait_if_payment_op_task_paused(task_id: str, running_message: str) -> bool: + paused_once = False + while True: + if _is_payment_op_task_cancel_requested(task_id): + return False + if not _is_payment_op_task_pause_requested(task_id): + if paused_once: + _update_payment_op_task( + task_id, + status="running", + paused=False, + message=running_message, + ) + return True + if not paused_once: + _update_payment_op_task( + task_id, + status="paused", + paused=True, + message="任务已暂停,等待继续", + ) + paused_once = True + time.sleep(0.35) + + +def _build_payment_op_task_snapshot(task: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": task.get("id"), + "task_type": task.get("task_type"), + "bind_task_id": task.get("bind_task_id"), + "status": task.get("status"), + "message": task.get("message"), + "created_at": task.get("created_at"), + "started_at": task.get("started_at"), + "finished_at": task.get("finished_at"), + "cancel_requested": bool(task.get("cancel_requested")), + "pause_requested": bool(task.get("pause_requested")), + "paused": bool(task.get("paused")), + "progress": task.get("progress") or {}, + "payload": task.get("payload") or {}, + "result": task.get("result"), + "error": task.get("error"), + "details": task.get("details") or [], + } + + +def _run_payment_op_task_guard(task_id: str, task_type: str, worker, *args) -> None: + acquired, running, quota = task_manager.try_acquire_domain_slot("payment", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + _update_payment_op_task( + task_id, + status="failed", + finished_at=_payment_now_iso(), + message=reason, + error=reason, + paused=False, + ) + return + try: + worker(task_id, *args) + except Exception as exc: + logger.exception("支付异步任务异常: task_id=%s type=%s error=%s", task_id, task_type, exc) + _update_payment_op_task( + task_id, + status="failed", + finished_at=_payment_now_iso(), + message=f"任务异常: {exc}", + error=str(exc), + paused=False, + ) + finally: + task_manager.release_domain_slot("payment", task_id) + + +def _vendor_fill_input_by_hints(target, hints: List[str], value: str) -> bool: + try: + return bool( + target.evaluate( + """ + ({ hints, value }) => { + const nodes = Array.from( + document.querySelectorAll("input, textarea, [contenteditable='true']") + ); + const loweredHints = (hints || []).map(h => String(h || "").toLowerCase()); + const loweredValue = String(value || "").toLowerCase(); + const isUrlValue = loweredValue.includes("http") || loweredValue.includes("chatgpt.com/checkout") || loweredValue.includes("cs_live"); + const isRedeemValue = /[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/i.test(loweredValue); + + const isVisible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + if (!style) return false; + if (style.display === "none" || style.visibility === "hidden") return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const collectLabelText = (el) => { + const parts = []; + const id = el.getAttribute("id"); + if (id) { + document.querySelectorAll(`label[for="${CSS.escape(id)}"]`).forEach((node) => { + const t = (node.innerText || node.textContent || "").trim(); + if (t) parts.push(t); + }); + } + const parentLabel = el.closest("label"); + if (parentLabel) { + const t = (parentLabel.innerText || parentLabel.textContent || "").trim(); + if (t) parts.push(t); + } + const labelledBy = el.getAttribute("aria-labelledby"); + if (labelledBy) { + labelledBy.split(/\s+/).forEach((idRef) => { + const node = document.getElementById(idRef); + const t = (node?.innerText || node?.textContent || "").trim(); + if (t) parts.push(t); + }); + } + const block = el.closest("div, section, form, article"); + if (block) { + const t = (block.innerText || block.textContent || "").trim(); + if (t) parts.push(t.slice(0, 220)); + } + return parts.join(" "); + }; + + const setNativeValue = (el, val) => { + if (el && el.isContentEditable) { + el.focus(); + el.textContent = val; + } else { + const proto = Object.getPrototypeOf(el); + const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); + if (descriptor && descriptor.set) { + descriptor.set.call(el, val); + } else { + el.value = val; + } + if ("selectionStart" in el && typeof el.value === "string") { + const len = el.value.length; + try { + el.setSelectionRange(len, len); + } catch (_) {} + } + } + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + + const candidates = []; + for (const el of nodes) { + if (!isVisible(el)) continue; + const attrs = [ + el.getAttribute("name"), + el.getAttribute("id"), + el.getAttribute("placeholder"), + el.getAttribute("aria-label"), + el.getAttribute("data-testid"), + el.type, + el.className, + ].filter(Boolean).join(" ").toLowerCase(); + const labels = collectLabelText(el).toLowerCase(); + const haystack = `${attrs} ${labels}`.trim(); + const scoreBase = loweredHints.reduce((acc, h) => acc + (h && haystack.includes(h) ? 2 : 0), 0); + let score = scoreBase; + if (isUrlValue) { + if (/(checkout|url|link|cs_live|支付链接)/i.test(haystack)) score += 4; + if (String(el.getAttribute("type") || "").toLowerCase() === "url") score += 3; + } + if (isRedeemValue) { + if (/(redeem|coupon|code|card|卡密|兑换|卡片|序列号)/i.test(haystack)) score += 4; + const maxLen = parseInt(el.getAttribute("maxlength") || "0", 10); + if (maxLen >= 16 && maxLen <= 32) score += 2; + } + candidates.push({ el, haystack, score }); + } + + candidates.sort((a, b) => b.score - a.score); + for (const item of candidates) { + if (item.score <= 0) continue; + try { + item.el.focus(); + setNativeValue(item.el, value); + return true; + } catch (_) {} + } + + // 关键词不命中时的兜底: + // checkout: 优先 url/link 风格输入框;redeem: 优先长度较短、常见 code 框。 + const fallback = candidates.filter((x) => x.el && !x.el.disabled && !x.el.readOnly); + const rankFallback = (list) => { + return list.sort((a, b) => { + const aa = (a.el.getAttribute("placeholder") || "").toLowerCase(); + const bb = (b.el.getAttribute("placeholder") || "").toLowerCase(); + let as = 0; + let bs = 0; + if (isUrlValue) { + if (/(http|url|link|checkout|cs_)/i.test(aa)) as += 3; + if (/(http|url|link|checkout|cs_)/i.test(bb)) bs += 3; + } else if (isRedeemValue) { + if (/(code|redeem|coupon|card|兑换|卡片)/i.test(aa)) as += 3; + if (/(code|redeem|coupon|card|兑换|卡片)/i.test(bb)) bs += 3; + const am = parseInt(a.el.getAttribute("maxlength") || "0", 10); + const bm = parseInt(b.el.getAttribute("maxlength") || "0", 10); + if (am >= 16 && am <= 32) as += 2; + if (bm >= 16 && bm <= 32) bs += 2; + } + return bs - as; + }); + }; + const ranked = rankFallback(fallback); + for (const item of ranked) { + try { + item.el.focus(); + setNativeValue(item.el, value); + return true; + } catch (_) {} + } + return false; + } + """, + {"hints": hints, "value": value}, + ) + ) + except Exception: + return False + + +def _vendor_fill_input_by_locator(target, hints: List[str], value: str) -> bool: + lowered_hints = [str(h or "").strip().lower() for h in (hints or []) if str(h or "").strip()] + lowered_value = str(value or "").strip().lower() + is_url_value = ( + "http" in lowered_value + or "chatgpt.com/checkout" in lowered_value + or "cs_live" in lowered_value + or "cs_test" in lowered_value + ) + is_redeem_value = bool(re.search(r"^UK(?:-[A-Z0-9]{5}){5}$", str(value or "").strip(), re.IGNORECASE)) + + def _try_fill(item) -> bool: + try: + if not item.is_visible(): + return False + except Exception: + return False + try: + item.scroll_into_view_if_needed(timeout=1200) + except Exception: + pass + try: + item.click(timeout=1200, force=True) + except Exception: + pass + try: + item.fill(str(value or ""), timeout=1500) + return True + except Exception: + pass + try: + item.press("Control+A", timeout=800) + item.type(str(value or ""), delay=0, timeout=1500) + return True + except Exception: + return False + + # 1) 先按关键词定位 placeholder / label + for hint in lowered_hints: + try: + regex = re.compile(re.escape(hint), re.IGNORECASE) + except Exception: + continue + for getter in ("placeholder", "label"): + try: + locator = target.get_by_placeholder(regex) if getter == "placeholder" else target.get_by_label(regex) + total = min(locator.count(), 6) + except Exception: + continue + for idx in range(total): + try: + if _try_fill(locator.nth(idx)): + return True + except Exception: + continue + + # 2) 再遍历输入框做打分 + try: + locator = target.locator("input, textarea") + total = min(locator.count(), 80) + except Exception: + total = 0 + locator = None + + scored = [] + for idx in range(total): + try: + item = locator.nth(idx) + if not item.is_visible(): + continue + meta = item.evaluate( + """ + (el) => { + const low = (v) => String(v || "").toLowerCase(); + const attrs = [ + el.getAttribute("name"), + el.getAttribute("id"), + el.getAttribute("placeholder"), + el.getAttribute("aria-label"), + el.getAttribute("data-testid"), + el.getAttribute("class"), + el.getAttribute("type"), + ].map(low).join(" "); + let blockText = ""; + try { + const b = el.closest("div, section, form, article"); + if (b) blockText = low((b.innerText || b.textContent || "").slice(0, 180)); + } catch (_) {} + const maxlength = parseInt(el.getAttribute("maxlength") || "0", 10) || 0; + const disabled = !!el.disabled; + const readonly = !!el.readOnly; + return { attrs, blockText, maxlength, disabled, readonly }; + } + """ + ) + if not isinstance(meta, dict): + continue + if bool(meta.get("disabled")): + continue + haystack = f"{meta.get('attrs', '')} {meta.get('blockText', '')}".strip() + score = 0 + for hint in lowered_hints: + if hint and hint in haystack: + score += 2 + if is_url_value: + if re.search(r"(checkout|session|token|link|url|cs_live|cs_test|支付链接|结账链接)", haystack, re.IGNORECASE): + score += 4 + if is_redeem_value: + if re.search(r"(redeem|cdk|code|coupon|激活|兑换|卡密|卡片|序列号)", haystack, re.IGNORECASE): + score += 4 + maxlength = int(meta.get("maxlength") or 0) + if 16 <= maxlength <= 40: + score += 2 + scored.append((score, idx)) + except Exception: + continue + + scored.sort(key=lambda x: x[0], reverse=True) + for score, idx in scored: + if score <= 0: + continue + try: + if _try_fill(locator.nth(idx)): + return True + except Exception: + continue + + # 3) 最后兜底:可见输入框依次尝试 + for idx in range(total): + try: + if _try_fill(locator.nth(idx)): + return True + except Exception: + continue + + # 4) contenteditable 兜底 + try: + c_locator = target.locator("[contenteditable='true']") + c_total = min(c_locator.count(), 20) + except Exception: + c_total = 0 + c_locator = None + for idx in range(c_total): + try: + item = c_locator.nth(idx) + if not item.is_visible(): + continue + item.scroll_into_view_if_needed(timeout=1000) + item.click(timeout=1000, force=True) + ok = bool( + item.evaluate( + "(el, val) => { el.textContent = String(val || ''); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; }", + str(value or ""), + ) + ) + if ok: + return True + except Exception: + continue + + return False + + +def _vendor_fill_locator_item(item, value: str) -> bool: + try: + if not item.is_visible(): + return False + except Exception: + return False + try: + item.scroll_into_view_if_needed(timeout=1200) + except Exception: + pass + try: + item.click(timeout=1200, force=True) + except Exception: + pass + try: + item.fill(str(value or ""), timeout=1500) + return True + except Exception: + pass + try: + item.press("Control+A", timeout=800) + item.type(str(value or ""), delay=0, timeout=1500) + return True + except Exception: + return False + + +def _vendor_fill_redeem_input(target, redeem_code: str) -> bool: + selectors = ( + "input[placeholder*='卡密']", + "textarea[placeholder*='卡密']", + "input[placeholder*='激活码']", + "textarea[placeholder*='激活码']", + "input[placeholder*='CODE-']", + "textarea[placeholder*='CODE-']", + "input[placeholder*='US-']", + "textarea[placeholder*='US-']", + "input[name*='code' i]", + "textarea[name*='code' i]", + "input[id*='code' i]", + "textarea[id*='code' i]", + "input[name*='redeem' i]", + "input[id*='redeem' i]", + "input[name*='cdk' i]", + "input[id*='cdk' i]", + ) + for selector in selectors: + try: + locator = target.locator(selector) + total = min(locator.count(), 8) + except Exception: + continue + for idx in range(total): + try: + if _vendor_fill_locator_item(locator.nth(idx), redeem_code): + return True + except Exception: + continue + + # 标签附近输入框兜底(覆盖“卡密 / 激活码”这类文本与输入框分离的结构) + try: + ok = bool( + target.evaluate( + """ + ({ code }) => { + const isVisible = (el) => { + if (!el) return false; + try { + const st = window.getComputedStyle(el); + if (!st) return false; + if (st.display === "none" || st.visibility === "hidden" || Number(st.opacity || 1) === 0) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } catch (_) { return false; } + }; + const all = Array.from(document.querySelectorAll("input, textarea")); + const hit = all.find((el) => { + if (!isVisible(el)) return false; + const attrs = [ + el.getAttribute("placeholder"), + el.getAttribute("aria-label"), + el.getAttribute("name"), + el.getAttribute("id"), + ].join(" ").toLowerCase(); + if (/(卡密|激活码|redeem|cdk|coupon|code)/i.test(attrs)) return true; + const block = el.closest("div, section, form, article"); + const text = String(block?.innerText || "").toLowerCase(); + return /(卡密\\s*\\/\\s*激活码|请输入您的卡密兑换码|验证兑换码)/i.test(text); + }); + if (!hit) return false; + hit.focus(); + try { + const proto = Object.getPrototypeOf(hit); + const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); + if (descriptor && descriptor.set) descriptor.set.call(hit, String(code || "")); + else hit.value = String(code || ""); + } catch (_) { + hit.value = String(code || ""); + } + hit.dispatchEvent(new Event("input", { bubbles: true })); + hit.dispatchEvent(new Event("change", { bubbles: true })); + return true; + } + """, + {"code": str(redeem_code or "")}, + ) + ) + if ok: + return True + except Exception: + pass + return False + + +def _vendor_force_fill_first_text_input(target, value: str) -> bool: + try: + ok = bool( + target.evaluate( + """ + ({ value }) => { + const val = String(value || ""); + const isVisible = (el) => { + if (!el) return false; + try { + const st = window.getComputedStyle(el); + if (!st) return false; + if (st.display === "none" || st.visibility === "hidden" || Number(st.opacity || 1) === 0) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } catch (_) { return false; } + }; + const setVal = (el) => { + try { + if (el.isContentEditable || String(el.getAttribute("contenteditable") || "").toLowerCase() === "true") { + el.focus(); + el.textContent = val; + } else { + const proto = Object.getPrototypeOf(el); + const descriptor = proto ? Object.getOwnPropertyDescriptor(proto, "value") : null; + if (descriptor && descriptor.set) descriptor.set.call(el, val); + else el.value = val; + } + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + return true; + } catch (_) { + return false; + } + }; + + // 1) 优先“卡密/激活码/兑换码”文本锚点附近 + const anchors = Array.from(document.querySelectorAll("label, div, span, p, h1, h2, h3, h4, h5, h6")) + .filter((el) => { + if (!isVisible(el)) return false; + const t = String(el.innerText || el.textContent || "").toLowerCase(); + return /(卡密\\s*\\/\\s*激活码|验证兑换码|请输入您的卡密兑换码|激活码|兑换码|cdk|redeem|coupon code)/i.test(t); + }); + + for (const anchor of anchors.slice(0, 12)) { + const box = anchor.closest("div, section, form, article") || anchor.parentElement || document.body; + if (!box) continue; + const fields = Array.from(box.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']")); + for (const f of fields) { + if (!isVisible(f)) continue; + if (setVal(f)) return true; + } + } + + // 2) 再尝试页面第一个可见文本输入 + const fields = Array.from(document.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']")); + for (const f of fields) { + if (!isVisible(f)) continue; + const type = String(f.getAttribute("type") || "").toLowerCase(); + if (["checkbox", "radio", "button", "submit", "hidden"].includes(type)) continue; + if (setVal(f)) return true; + } + return false; + } + """, + {"value": str(value or "")}, + ) + ) + return ok + except Exception: + return False + + +def _vendor_force_fill_first_text_input_any_frame(page, value: str) -> bool: + try: + if _vendor_force_fill_first_text_input(page, value): + return True + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + if _vendor_force_fill_first_text_input(frame, value): + return True + except Exception: + continue + except Exception: + pass + return False + + +def _vendor_fill_redeem_input_any_frame(page, redeem_code: str) -> bool: + try: + if _vendor_fill_redeem_input(page, redeem_code): + return True + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + if _vendor_fill_redeem_input(frame, redeem_code): + return True + except Exception: + continue + except Exception: + pass + return False + + +def _vendor_has_redeem_input_any_frame(page) -> bool: + selectors = ( + "input[placeholder*='卡密']", + "textarea[placeholder*='卡密']", + "input[placeholder*='激活码']", + "textarea[placeholder*='激活码']", + "input[name*='code' i], textarea[name*='code' i], input[id*='code' i], textarea[id*='code' i]", + "input[name*='redeem' i], input[id*='redeem' i], input[name*='cdk' i], input[id*='cdk' i]", + ) + targets = [page] + try: + targets.extend(list(page.frames or [])) + except Exception: + pass + for target in targets: + for selector in selectors: + try: + locator = target.locator(selector) + total = min(locator.count(), 6) + except Exception: + continue + for idx in range(total): + try: + if locator.nth(idx).is_visible(): + return True + except Exception: + continue + return False + + +def _vendor_fill_input_any_frame(page, hints: List[str], value: str) -> bool: + try: + if _vendor_fill_input_by_locator(page, hints, value): + return True + except Exception: + pass + try: + if _vendor_fill_input_by_hints(page, hints, value): + return True + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + if _vendor_fill_input_by_locator(frame, hints, value): + return True + except Exception: + continue + try: + if _vendor_fill_input_by_hints(frame, hints, value): + return True + except Exception: + continue + except Exception: + pass + return False + + +def _vendor_collect_input_descriptors(target, limit: int = 14) -> List[str]: + # 先走 Playwright 定位器(更稳,兼容动态节点) + try: + out: List[str] = [] + locator = target.locator("input, textarea, [contenteditable='true']") + total = min(locator.count(), max(4, int(limit or 14)) * 3) + for idx in range(total): + try: + item = locator.nth(idx) + if not item.is_visible(): + continue + info = item.evaluate( + """ + (el) => { + const low = (v) => String(v || "").replace(/\\s+/g, " ").trim(); + return [ + String(el.tagName || "").toLowerCase(), + low(el.getAttribute("placeholder")), + low(el.getAttribute("aria-label")), + low(el.getAttribute("name")), + low(el.getAttribute("id")), + low(el.getAttribute("type")), + ].join(":"); + } + """ + ) + text = str(info or "").strip() + if text and text not in out: + out.append(text[:120]) + if len(out) >= max(4, int(limit or 14)): + return out + except Exception: + continue + if out: + return out + except Exception: + pass + + try: + rows = target.evaluate( + """ + ({ limit }) => { + const out = []; + const isVisible = (el) => { + if (!el) return false; + try { + const st = window.getComputedStyle(el); + if (!st) return false; + if (st.display === "none" || st.visibility === "hidden" || Number(st.opacity || 1) === 0) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } catch (_) { return false; } + }; + const nodes = Array.from(document.querySelectorAll("input, textarea, [contenteditable='true']")); + for (const el of nodes) { + if (!isVisible(el)) continue; + const tag = String(el.tagName || "").toLowerCase(); + const desc = [ + el.getAttribute("placeholder") || "", + el.getAttribute("aria-label") || "", + el.getAttribute("name") || "", + el.getAttribute("id") || "", + el.getAttribute("type") || "", + ].join(" ").replace(/\\s+/g, " ").trim(); + const text = `${tag}:${desc || "(no-attrs)"}`.slice(0, 90); + if (text && !out.includes(text)) out.push(text); + if (out.length >= Number(limit || 14)) break; + } + return out; + } + """, + {"limit": max(4, int(limit or 14))}, + ) + if isinstance(rows, list): + return [str(x).strip() for x in rows if str(x or "").strip()] + except Exception: + pass + return [] + + +def _vendor_collect_input_descriptors_any_frame(page, limit: int = 18) -> List[str]: + merged: List[str] = [] + try: + for text in _vendor_collect_input_descriptors(page, limit=limit): + if text not in merged: + merged.append(text) + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + for text in _vendor_collect_input_descriptors(frame, limit=limit): + if text not in merged: + merged.append(text) + if len(merged) >= limit: + return merged[:limit] + except Exception: + continue + except Exception: + pass + return merged[:limit] + + +def _normalize_vendor_redeem_code(code: Optional[str]) -> str: + text = str(code or "").strip().upper() + compact = re.sub(r"[^A-Z0-9]", "", text) + if compact.startswith("UK"): + body = compact[2:] + if len(body) == 25: + return f"UK-{body[0:5]}-{body[5:10]}-{body[10:15]}-{body[15:20]}-{body[20:25]}" + text = re.sub(r"\s+", "", text) + text = re.sub(r"-{2,}", "-", text).strip("-") + return text + + +def _vendor_seed_checkout_context(page, checkout_url: str) -> None: + try: + page.evaluate( + """ + ({ checkoutUrl }) => { + const val = String(checkoutUrl || ""); + if (!val) return false; + const keys = [ + "checkout", + "checkout_url", + "checkoutUrl", + "payment_link", + "paymentLink", + "url", + "link", + ]; + for (const key of keys) { + try { localStorage.setItem(key, val); } catch (_) {} + try { sessionStorage.setItem(key, val); } catch (_) {} + } + + const selectors = [ + "input[name='checkout']", + "input[name='checkout_url']", + "input[id*='checkout']", + "input[placeholder*='checkout' i]", + "textarea[name='checkout']", + "textarea[name='checkout_url']", + "textarea[id*='checkout']", + "textarea[placeholder*='checkout' i]", + ]; + const setNativeValue = (el, v) => { + const proto = Object.getPrototypeOf(el); + const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); + if (descriptor && descriptor.set) { + descriptor.set.call(el, v); + } else { + el.value = v; + } + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + for (const selector of selectors) { + const el = document.querySelector(selector); + if (!el) continue; + try { setNativeValue(el, val); } catch (_) {} + } + + window.__VENDOR_CHECKOUT_URL__ = val; + try { + document.dispatchEvent(new CustomEvent("checkout:prefill", { detail: { checkoutUrl: val } })); + } catch (_) {} + return true; + } + """, + {"checkoutUrl": checkout_url}, + ) + except Exception: + pass + + +def _regenerate_vendor_checkout_for_task( + db, + *, + task: BindCardTask, + account: Account, + proxy: Optional[str], +) -> Optional[str]: + req = CheckoutRequestBase( + account_id=account.id, + plan_type=str(task.plan_type or "plus"), + workspace_name=str(task.workspace_name or "MyTeam"), + price_interval=str(task.price_interval or "month"), + seat_quantity=int(task.seat_quantity or 5), + proxy=proxy, + country=_normalize_checkout_country(task.country), + currency=_normalize_checkout_currency(_normalize_checkout_country(task.country), task.currency), + ) + link, source, _fallback_reason, checkout_session_id, publishable_key, client_secret = _generate_checkout_link_for_account( + account=account, + request=req, + proxy=proxy, + ) + link = str(link or "").strip() + if not link: + return None + + task.checkout_url = link + task.checkout_source = source + task.checkout_session_id = checkout_session_id + task.publishable_key = publishable_key + task.client_secret = client_secret + task.last_checked_at = datetime.utcnow() + db.commit() + db.refresh(task) + return link + + +def _regenerate_vendor_checkout_for_task_with_retry( + db, + *, + task: BindCardTask, + account: Account, + explicit_proxy: Optional[str] = None, +) -> Tuple[str, Optional[str]]: + last_error: Optional[Exception] = None + for proxy_item in _build_proxy_candidates(explicit_proxy, account, include_direct=True): + try: + link = _regenerate_vendor_checkout_for_task( + db, + task=task, + account=account, + proxy=proxy_item, + ) + if link: + return link, proxy_item + except Exception as exc: + last_error = exc + continue + if last_error: + raise last_error + raise RuntimeError("未生成到有效 checkout 链接") + + +def _vendor_click_button_by_hints(target, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: + try: + return bool( + target.evaluate( + """ + ({ hints, excludeHints }) => { + const loweredHints = (hints || []).map(h => String(h || "").toLowerCase()); + const loweredExclude = (excludeHints || []).map(h => String(h || "").toLowerCase()); + const nodes = Array.from( + document.querySelectorAll( + "button, input[type='button'], input[type='submit'], a[role='button'], [role='button'], .btn, .button, .ant-btn, .el-button, [onclick]" + ) + ); + const isDisabled = (el) => { + if (!el) return true; + try { + if (el.disabled) return true; + const ariaDisabled = String(el.getAttribute("aria-disabled") || "").toLowerCase(); + if (ariaDisabled === "true") return true; + const cls = String(el.className || "").toLowerCase(); + if (/(disabled|is-disabled|btn-disabled|ant-btn-disabled)/i.test(cls)) return true; + } catch (_) {} + return false; + }; + const clickNode = (el) => { + if (!el || isDisabled(el)) return false; + try { + el.scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); + } catch (_) {} + try { el.click(); } catch (_) {} + try { el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } catch (_) {} + return true; + }; + for (const el of nodes) { + const text = (el.innerText || el.textContent || el.value || "").trim().toLowerCase(); + if (!text) continue; + if (loweredExclude.some(h => h && text.includes(h))) continue; + if (!loweredHints.some(h => text.includes(h))) continue; + if (clickNode(el)) return true; + } + + // 兜底:部分站点把可点击控件做成 div/span + const loose = Array.from(document.querySelectorAll("div, span, a")); + for (const el of loose) { + const text = (el.innerText || el.textContent || "").trim().toLowerCase(); + if (!text || text.length > 24) continue; + if (loweredExclude.some(h => h && text.includes(h))) continue; + if (!loweredHints.some(h => text.includes(h))) continue; + const cls = String(el.className || "").toLowerCase(); + const role = String(el.getAttribute("role") || "").toLowerCase(); + const styleCursor = (() => { try { return String(window.getComputedStyle(el).cursor || "").toLowerCase(); } catch (_) { return ""; } })(); + if (!(role === "button" || /(btn|button|click|action|submit)/i.test(cls) || styleCursor === "pointer")) { + continue; + } + if (clickNode(el)) return true; + } + return false; + } + """, + {"hints": hints, "excludeHints": exclude_hints or []}, + ) + ) + except Exception: + return False + + +def _vendor_click_button_any_frame(page, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: + if _vendor_click_button_by_locator_any_frame(page, hints, exclude_hints): + return True + try: + if _vendor_click_button_by_hints(page, hints, exclude_hints): + return True + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + if _vendor_click_button_by_hints(frame, hints, exclude_hints): + return True + except Exception: + continue + except Exception: + pass + return False + + +def _vendor_click_button_by_locator(target, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: + lowered_hints = [str(h or "").strip().lower() for h in (hints or []) if str(h or "").strip()] + lowered_excludes = [str(h or "").strip().lower() for h in (exclude_hints or []) if str(h or "").strip()] + if not lowered_hints: + return False + + selectors = ( + "button", + "[role='button']", + "a", + ".btn", + ".button", + ".ant-btn", + ".el-button", + "[onclick]", + "div", + "span", + ) + for hint in lowered_hints: + try: + regex = re.compile(re.escape(hint), re.IGNORECASE) + except Exception: + continue + for selector in selectors: + try: + locator = target.locator(selector, has_text=regex) + total = min(locator.count(), 5) + except Exception: + continue + for idx in range(total): + try: + item = locator.nth(idx) + if not item.is_visible(): + continue + text = str(item.inner_text() or "").strip().lower() + if not text: + continue + if any(ex and ex in text for ex in lowered_excludes): + continue + item.scroll_into_view_if_needed(timeout=1000) + item.click(timeout=1200, force=True) + return True + except Exception: + continue + return False + + +def _vendor_click_button_by_locator_any_frame(page, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: + try: + if _vendor_click_button_by_locator(page, hints, exclude_hints): + return True + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + if _vendor_click_button_by_locator(frame, hints, exclude_hints): + return True + except Exception: + continue + except Exception: + pass + return False + + +def _vendor_click_button_near_input_value( + target, + *, + input_value: str, + hints: List[str], + exclude_hints: Optional[List[str]] = None, +) -> bool: + try: + return bool( + target.evaluate( + """ + ({ inputValue, hints, excludeHints }) => { + const normalized = String(inputValue || "").toLowerCase().replace(/[^a-z0-9]/g, ""); + if (!normalized) return false; + const loweredHints = (hints || []).map(h => String(h || "").toLowerCase()).filter(Boolean); + const loweredExclude = (excludeHints || []).map(h => String(h || "").toLowerCase()).filter(Boolean); + const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); + const txt = (el) => String(el?.innerText || el?.textContent || el?.value || "").trim().toLowerCase(); + const isDisabled = (el) => { + if (!el) return true; + try { + if (el.disabled) return true; + const ad = String(el.getAttribute("aria-disabled") || "").toLowerCase(); + if (ad === "true") return true; + const cls = String(el.className || "").toLowerCase(); + if (/(disabled|is-disabled|btn-disabled|ant-btn-disabled)/i.test(cls)) return true; + } catch (_) {} + return false; + }; + const clickNode = (el) => { + if (!el || isDisabled(el)) return false; + try { el.scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); } catch (_) {} + try { el.click(); } catch (_) {} + try { el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } catch (_) {} + return true; + }; + const matchButtonText = (text) => { + const low = String(text || "").toLowerCase(); + if (!low) return false; + if (loweredExclude.some(h => h && low.includes(h))) return false; + return loweredHints.some(h => h && low.includes(h)); + }; + + const allInputs = Array.from(document.querySelectorAll("input, textarea")); + const nearInputs = allInputs.filter((el) => { + try { + const v = norm(el.value); + return !!v && (v.includes(normalized) || normalized.includes(v)); + } catch (_) { + return false; + } + }); + if (!nearInputs.length) return false; + + for (const inputEl of nearInputs) { + const containers = []; + let node = inputEl; + for (let i = 0; i < 5 && node; i += 1) { + node = node.parentElement; + if (!node) break; + containers.push(node); + if (node.matches && (node.matches("form, section, article") || node.getAttribute("role") === "dialog")) break; + } + for (const box of containers) { + const btns = Array.from( + box.querySelectorAll("button, input[type='button'], input[type='submit'], [role='button'], a, .btn, .button, .ant-btn, .el-button, [onclick]") + ); + for (const btn of btns) { + if (!matchButtonText(txt(btn))) continue; + if (clickNode(btn)) return true; + } + } + } + return false; + } + """, + { + "inputValue": str(input_value or ""), + "hints": hints, + "excludeHints": exclude_hints or [], + }, + ) + ) + except Exception: + return False + + +def _vendor_click_button_near_input_value_any_frame( + page, + *, + input_value: str, + hints: List[str], + exclude_hints: Optional[List[str]] = None, +) -> bool: + try: + if _vendor_click_button_near_input_value( + page, + input_value=input_value, + hints=hints, + exclude_hints=exclude_hints, + ): + return True + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + if _vendor_click_button_near_input_value( + frame, + input_value=input_value, + hints=hints, + exclude_hints=exclude_hints, + ): + return True + except Exception: + continue + except Exception: + pass + return False + + +def _vendor_has_input_by_hints(target, hints: List[str]) -> bool: + try: + return bool( + target.evaluate( + """ + ({ hints }) => { + const loweredHints = (hints || []).map(h => String(h || "").toLowerCase()).filter(Boolean); + const nodes = Array.from(document.querySelectorAll("input, textarea")); + const norm = (v) => String(v || "").toLowerCase(); + for (const el of nodes) { + const txt = [ + el.getAttribute("name"), + el.getAttribute("id"), + el.getAttribute("placeholder"), + el.getAttribute("aria-label"), + el.getAttribute("data-placeholder"), + ].map(norm).join(" "); + if (!txt) continue; + if (loweredHints.some(h => txt.includes(h))) return true; + } + return false; + } + """, + {"hints": hints}, + ) + ) + except Exception: + return False + + +def _vendor_has_input_any_frame(page, hints: List[str]) -> bool: + try: + if _vendor_has_input_by_hints(page, hints): + return True + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + if _vendor_has_input_by_hints(frame, hints): + return True + except Exception: + continue + except Exception: + pass + return False + + +def _vendor_has_button_by_hints(target, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: + try: + return bool( + target.evaluate( + """ + ({ hints, excludeHints }) => { + const loweredHints = (hints || []).map(h => String(h || "").toLowerCase()).filter(Boolean); + const loweredExclude = (excludeHints || []).map(h => String(h || "").toLowerCase()).filter(Boolean); + const nodes = Array.from( + document.querySelectorAll("button, input[type='button'], input[type='submit'], [role='button'], a, .btn, .button, .ant-btn, .el-button, [onclick]") + ); + for (const el of nodes) { + const text = String(el.innerText || el.textContent || el.value || "").trim().toLowerCase(); + if (!text) continue; + if (loweredExclude.some(h => h && text.includes(h))) continue; + if (loweredHints.some(h => text.includes(h))) return true; + } + return false; + } + """, + {"hints": hints, "excludeHints": exclude_hints or []}, + ) + ) + except Exception: + return False + + +def _vendor_has_button_any_frame(page, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: + try: + if _vendor_has_button_by_hints(page, hints, exclude_hints): + return True + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + if _vendor_has_button_by_hints(frame, hints, exclude_hints): + return True + except Exception: + continue + except Exception: + pass + return False + + +def _vendor_has_text_by_locator(target, hints: List[str]) -> bool: + lowered_hints = [str(h or "").strip().lower() for h in (hints or []) if str(h or "").strip()] + if not lowered_hints: + return False + for hint in lowered_hints: + try: + regex = re.compile(re.escape(hint), re.IGNORECASE) + locator = target.get_by_text(regex) + total = min(locator.count(), 5) + for idx in range(total): + try: + if locator.nth(idx).is_visible(): + return True + except Exception: + continue + except Exception: + continue + return False + + +def _vendor_has_text_any_frame_by_locator(page, hints: List[str]) -> bool: + try: + if _vendor_has_text_by_locator(page, hints): + return True + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + if _vendor_has_text_by_locator(frame, hints): + return True + except Exception: + continue + except Exception: + pass + return False + + +def _vendor_collect_button_texts(target, limit: int = 16) -> List[str]: + try: + rows = target.evaluate( + """ + ({ limit }) => { + const isVisible = (el) => { + if (!el) return false; + try { + const st = window.getComputedStyle(el); + if (!st) return false; + if (st.display === "none" || st.visibility === "hidden" || Number(st.opacity || 1) === 0) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } catch (_) { + return false; + } + }; + const nodes = Array.from( + document.querySelectorAll("button, input[type='button'], input[type='submit'], [role='button'], a, .btn, .button, .ant-btn, .el-button, [onclick]") + ); + const out = []; + for (const el of nodes) { + if (!isVisible(el)) continue; + const t = String(el.innerText || el.textContent || el.value || "").replace(/\\s+/g, " ").trim(); + if (!t || t.length > 26) continue; + if (!out.includes(t)) out.push(t); + if (out.length >= Number(limit || 16)) break; + } + return out; + } + """, + {"limit": max(4, int(limit or 16))}, + ) + if isinstance(rows, list): + return [str(x).strip() for x in rows if str(x or "").strip()] + except Exception: + pass + return [] + + +def _vendor_collect_button_texts_any_frame(page, limit: int = 20) -> List[str]: + merged: List[str] = [] + try: + for text in _vendor_collect_button_texts(page, limit=limit): + if text not in merged: + merged.append(text) + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + for text in _vendor_collect_button_texts(frame, limit=limit): + if text not in merged: + merged.append(text) + if len(merged) >= limit: + return merged[:limit] + except Exception: + continue + except Exception: + pass + return merged[:limit] + + +def _vendor_is_stage2_ready_any_frame(page) -> bool: + if _vendor_has_text_any_frame_by_locator( + page, + ["填写测试信息", "获取 checkout session", "直接输入 token", "开始测试", "下一步开始测试"], + ): + return True + + has_checkout_input = _vendor_has_input_any_frame( + page, + ["checkout", "checkout session", "checkout_url", "cs_live", "cs_id", "token", "链接", "支付链接", "结账链接"], + ) + has_stage2_controls = _vendor_has_button_any_frame( + page, + ["直接输入token", "直接输入 token", "access token 生成", "获取token", "获取 token", "获取链接", "开始测试", "下一步开始测试"], + exclude_hints=["验证兑换码", "订单查询", "历史查询"], + ) + return bool(has_checkout_input and has_stage2_controls) + + +def _vendor_detect_stage_by_dom(target) -> int: + try: + value = target.evaluate( + """ + () => { + const textOf = (el) => String((el && (el.innerText || el.textContent)) || "").replace(/\\s+/g, " ").trim().toLowerCase(); + const isVisible = (el) => { + if (!el) return false; + try { + const st = window.getComputedStyle(el); + if (!st) return false; + if (st.display === "none" || st.visibility === "hidden" || Number(st.opacity || 1) === 0) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } catch (_) { + return false; + } + }; + const pickByActiveClass = () => { + const selectors = [ + ".active", ".is-active", ".current", ".is-current", + ".ant-steps-item-process", ".step.active", ".steps .active" + ]; + for (const selector of selectors) { + const nodes = Array.from(document.querySelectorAll(selector)); + for (const node of nodes) { + if (!isVisible(node)) continue; + const t = textOf(node); + if (!t) continue; + if (/(验证兑换码|兑换码|cdk|redeem|step\\s*1|\\b1\\b)/i.test(t)) return 1; + if (/(填写信息|填写测试信息|checkout|token|step\\s*2|\\b2\\b)/i.test(t)) return 2; + if (/(测试处理|处理中|testing|step\\s*3|\\b3\\b)/i.test(t)) return 3; + if (/(完成|success|done|step\\s*4|\\b4\\b)/i.test(t)) return 4; + } + } + return 0; + }; + + const activeStage = pickByActiveClass(); + if (activeStage > 0) return activeStage; + + const body = textOf(document.body); + if (!body) return 0; + if (/(填写测试信息|获取 checkout session|直接输入 token|开始测试)/i.test(body)) return 2; + if (/(测试处理|处理中|执行中|请稍候|正在测试)/i.test(body)) return 3; + if (/(验证兑换码|激活cdk|兑换码验证)/i.test(body)) return 1; + return 0; + } + """ + ) + return int(value or 0) + except Exception: + return 0 + + +def _vendor_detect_stage_by_locator(target) -> int: + try: + if _vendor_has_text_by_locator(target, ["测试处理", "处理中", "执行中", "正在测试"]): + return 3 + if _vendor_has_text_by_locator(target, ["填写测试信息", "获取 checkout session", "直接输入 token", "开始测试"]): + return 2 + if _vendor_has_text_by_locator(target, ["验证兑换码", "请输入您的卡密兑换码", "激活码"]): + return 1 + if _vendor_has_text_by_locator(target, ["完成", "测试完成", "订阅成功"]): + return 4 + except Exception: + pass + return 0 + + +def _vendor_detect_stage_any_frame(page) -> int: + detected = 0 + try: + detected = max(detected, _vendor_detect_stage_by_dom(page)) + except Exception: + pass + try: + detected = max(detected, _vendor_detect_stage_by_locator(page)) + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + detected = max(detected, _vendor_detect_stage_by_dom(frame)) + except Exception: + continue + except Exception: + pass + try: + for frame in list(page.frames or []): + try: + detected = max(detected, _vendor_detect_stage_by_locator(frame)) + except Exception: + continue + except Exception: + pass + return int(detected or 0) + + +def _vendor_wait_stage_any_frame( + page, + expected_stage: int, + *, + timeout_ms: int = 12000, + poll_ms: int = 500, +) -> bool: + deadline = time.monotonic() + max(1.0, float(timeout_ms) / 1000.0) + while time.monotonic() < deadline: + stage = _vendor_detect_stage_any_frame(page) + if int(stage or 0) == int(expected_stage): + return True + try: + page.wait_for_timeout(max(100, int(poll_ms))) + except Exception: + time.sleep(max(0.1, float(poll_ms) / 1000.0)) + return False + + +def _run_vendor_auto_bind_task( + task_id: int, + *, + redeem_code: str, + checkout_override: Optional[str], + api_url: Optional[str], + api_key: Optional[str], + timeout_seconds: int, +) -> None: + _vendor_progress_log(task_id, "卡商模式开始执行", progress=3, status="running") + _vendor_progress_log(task_id, f"流程版本: {VENDOR_EFUN_FLOW_VERSION}", progress=3, status="running") + try: + with get_db() as db: + task = ( + db.query(BindCardTask) + .options(joinedload(BindCardTask.account)) + .filter(BindCardTask.id == task_id) + .first() + ) + if not task: + _vendor_progress_log(task_id, "任务不存在", progress=100, status="failed") + return + account = task.account + if not account: + task.status = "failed" + task.last_error = "任务关联账号不存在" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, "任务关联账号不存在", progress=100, status="failed") + return + + if _vendor_should_stop(task_id): + task.status = "failed" + task.last_error = "用户手动停止卡商订阅任务" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, "任务已停止", progress=100, status="cancelled") + return + + checkout_url = str(checkout_override or "").strip() + checkout_session_id = str(getattr(task, "checkout_session_id", "") or "").strip() + checkout_proxy = _resolve_runtime_proxy(None, account) + if not checkout_proxy: + task.status = "failed" + task.last_error = "未配置可用代理,vendor_efun 模式要求生成 Checkout 阶段走代理" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log( + task_id, + "未配置可用代理,无法生成 Checkout(vendor_efun 要求 checkout 阶段走代理)", + progress=100, + status="failed", + ) + return + + _vendor_progress_log(task_id, "卡商模式:强制按账号生成最新 Checkout", progress=10) + try: + refreshed_link = _regenerate_vendor_checkout_for_task( + db, + task=task, + account=account, + proxy=checkout_proxy, + ) + checkout_url = str(refreshed_link or "").strip() + checkout_session_id = str(getattr(task, "checkout_session_id", "") or "").strip() or _extract_checkout_session_id_from_url(checkout_url) or "" + _vendor_progress_log( + task_id, + "最新 Checkout 已生成并回填(proxy=on,后续步骤走直连)", + progress=16, + ) + except Exception as regen_exc: + task.status = "failed" + task.last_error = f"强制生成 checkout 失败: {regen_exc}" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, f"强制生成 checkout 失败: {regen_exc}", progress=100, status="failed") + return + + if not checkout_url: + task.status = "failed" + task.last_error = "强制生成 checkout 为空" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, "强制生成 checkout 为空", progress=100, status="failed") + return + + if _vendor_should_stop(task_id): + task.status = "failed" + task.last_error = "用户手动停止卡商订阅任务" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, "任务已停止", progress=100, status="cancelled") + return + + access_token = str(getattr(account, "access_token", "") or "").strip() + if not access_token: + task.status = "failed" + task.last_error = "账号缺少 access_token,无法调用卡商 bindcard 接口" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, "账号缺少 access_token,无法执行卡商接口提交", progress=100, status="failed") + return + + _vendor_progress_log(task_id, "调用 EFun 接口开卡", progress=22) + try: + redeem_body = _efuncard_request( + method="POST", + path="/api/external/redeem", + api_key=_resolve_efuncard_api_key(None), + base_url=_resolve_efuncard_base_url(None), + proxy=None, + payload={"code": redeem_code}, + ) + redeem_data = redeem_body.get("data") if isinstance(redeem_body, dict) else {} + if not isinstance(redeem_data, dict): + redeem_data = {} + except Exception as redeem_exc: + task.status = "failed" + task.last_error = f"EFun 开卡失败: {redeem_exc}" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, f"EFun 开卡失败: {redeem_exc}", progress=100, status="failed") + return + + card_payload = _normalize_vendor_card_payload(redeem_data) + if not card_payload.get("number") or not card_payload.get("exp_month") or not card_payload.get("exp_year") or not card_payload.get("cvc"): + task.status = "failed" + task.last_error = "EFun 开卡返回缺少卡号/有效期/CVC" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, "EFun 开卡返回缺少卡号/有效期/CVC", progress=100, status="failed") + return + + masked_card = _mask_card_number(card_payload.get("number")) + _vendor_progress_log(task_id, f"EFun 开卡成功,卡片: {masked_card}", progress=30) + + billing_payload, billing_source = _build_vendor_billing_payload( + account=account, + redeem_data=redeem_data, + country_hint=str(task.country or "US"), + ) + _vendor_progress_log(task_id, f"账单信息已生成(source={billing_source})", progress=36) + + proxy_country = _vendor_proxy_country_label(billing_payload.get("country")) + bind_payload: Dict[str, Any] = { + "acc_token": access_token, + "plan_type": str(task.plan_type or "plus").strip().lower() or "plus", + "card": { + "number": card_payload["number"], + "exp_month": card_payload["exp_month"], + "exp_year": card_payload["exp_year"], + "cvc": card_payload["cvc"], + }, + "billing": { + "name": str(billing_payload.get("name") or "").strip(), + "email": str(billing_payload.get("email") or account.email or "").strip(), + "country": _normalize_checkout_country(billing_payload.get("country")), + "state": str(billing_payload.get("state") or "").strip(), + "city": str(billing_payload.get("city") or "").strip(), + "line1": str(billing_payload.get("line1") or "").strip(), + "postal_code": str(billing_payload.get("postal_code") or "").strip(), + }, + "proxy_mode": "system", + "proxy_country": proxy_country, + } + if checkout_url: + bind_payload["checkout_url"] = checkout_url + if checkout_session_id: + bind_payload["session_id"] = checkout_session_id + + _vendor_progress_log(task_id, "提交卡商 bindcard 接口", progress=43) + try: + resolved_api_url = _resolve_vendor_bindcard_api_url(api_url) + resolved_api_key = _resolve_vendor_bindcard_api_key(api_key) + vendor_response, used_endpoint = _invoke_vendor_bindcard_api( + api_url=resolved_api_url, + api_key=resolved_api_key, + payload=bind_payload, + timeout_seconds=min(max(int(timeout_seconds or 180), 60), 300), + ) + except Exception as submit_exc: + task.status = "failed" + task.last_error = f"卡商接口提交失败: {submit_exc}" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, f"卡商接口提交失败: {submit_exc}", progress=100, status="failed") + return + + assessment = _assess_third_party_submission_result(vendor_response if isinstance(vendor_response, dict) else {}) + assess_state = str(assessment.get("state") or "pending").strip().lower() + assess_reason = str(assessment.get("reason") or "").strip() + snapshot = assessment.get("snapshot") if isinstance(assessment.get("snapshot"), dict) else {} + payment_status = str(snapshot.get("payment_status") or "").strip().lower() + checkout_status = str(snapshot.get("checkout_status") or "").strip().lower() + logger.info( + "卡商EFun接口提交结果: task_id=%s account_id=%s endpoint=%s state=%s payment_status=%s checkout_status=%s reason=%s", + task.id, + account.id, + used_endpoint, + assess_state, + payment_status or "-", + checkout_status or "-", + assess_reason or "-", + ) + if assess_state == "failed": + task.status = "failed" + task.last_error = f"卡商接口返回失败: {assess_reason or 'unknown'}" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, f"卡商接口返回失败: {assess_reason or 'unknown'}", progress=100, status="failed") + return + + _mark_task_paid_pending_sync( + task, + ( + f"卡商接口已受理(state={assess_state}, payment_status={payment_status or '-'}, reason={assess_reason or '-'}),开始同步订阅。" + ), + ) + db.commit() + _vendor_progress_log(task_id, "卡商接口提交成功,开始同步订阅", progress=70) + + if _vendor_should_stop(task_id): + task.status = "failed" + task.last_error = "用户手动停止卡商订阅任务" + task.last_checked_at = datetime.utcnow() + db.commit() + _vendor_progress_log(task_id, "任务已停止", progress=100, status="cancelled") + return + + detail, refreshed = _check_subscription_detail_with_retry( + db=db, + account=account, + proxy=None, + allow_token_refresh=True, + ) + sub_status = str(detail.get("status") or "free").lower() + source = str(detail.get("source") or "unknown") + confidence = str(detail.get("confidence") or "low") + now = datetime.utcnow() + task.last_checked_at = now + + if sub_status in ("plus", "team"): + _apply_subscription_result( + account, + status=sub_status, + checked_at=now, + confidence=confidence, + promote_reason="vendor_sync_paid", + ) + task.status = "completed" + task.completed_at = now + task.last_error = None + db.commit() + _vendor_progress_log( + task_id, + f"订阅同步完成: {sub_status.upper()} (source={source}, confidence={confidence}, token_refreshed={refreshed})", + progress=100, + status="completed", + ) + return + + if assess_state == "pending" and _is_third_party_challenge_pending(assessment): + task.status = "waiting_user_action" + task.last_error = ( + f"卡商接口返回挑战态(reason={assess_reason or 'requires_action'}),请稍后点击“同步订阅”确认。" + ) + task.last_checked_at = now + db.commit() + _vendor_progress_log(task_id, "卡商接口进入挑战态,等待人工完成后同步订阅", progress=100, status="pending") + return + + _mark_task_paid_pending_sync( + task, + ( + f"卡商接口已提交,但当前订阅={sub_status}(source={source}, confidence={confidence})。请稍后点“同步订阅”重试。" + ), + ) + db.commit() + _vendor_progress_log( + task_id, + f"订阅同步未命中付费状态({sub_status}),任务保留在已支付待同步", + progress=100, + status="pending", + ) + return + except Exception as exc: + logger.exception("卡商模式执行异常: task_id=%s error=%s", task_id, exc) + _vendor_progress_log(task_id, f"执行异常: {exc}", progress=100, status="failed") + with get_db() as db: + task = db.query(BindCardTask).filter(BindCardTask.id == task_id).first() + if task: + task.status = "failed" + task.last_error = f"卡商模式执行异常: {exc}" + task.last_checked_at = datetime.utcnow() + db.commit() + +def _resolve_third_party_bind_api_url(request_url: Optional[str]) -> Optional[str]: + raw = ( + str(request_url or "").strip() + or str(os.getenv(THIRD_PARTY_BIND_API_URL_ENV) or "").strip() + or THIRD_PARTY_BIND_API_DEFAULT + ) + normalized = _normalize_third_party_bind_api_url(raw) + return normalized or None + + +def _resolve_third_party_bind_api_key(request_key: Optional[str]) -> Optional[str]: + token = str(request_key or "").strip() or str(os.getenv(THIRD_PARTY_BIND_API_KEY_ENV) or "").strip() + return token or None + + +def _normalize_third_party_bind_api_url(raw_url: Optional[str]) -> Optional[str]: + text = str(raw_url or "").strip() + if not text: + return None + if "://" not in text: + text = "https://" + text + try: + parsed = urlparse(text) + except Exception: + return None + if not parsed.scheme or not parsed.netloc: + return None + path = parsed.path or "" + if not path or path == "/": + path = THIRD_PARTY_BIND_PATH_DEFAULT + path = "/" + path.lstrip("/") + normalized = parsed._replace(path=path, params="", fragment="") + return urlunparse(normalized) + + +def _build_third_party_bind_api_candidates(api_url: str) -> List[str]: + """ + 对第三方地址做容错: + - 支持只给根域名(自动补 /api/v1/bind-card) + - 支持给到 /api/v1(自动补 /bind-card) + - 保留原始路径作为首选 + """ + normalized = _normalize_third_party_bind_api_url(api_url) + if not normalized: + return [] + + candidates: List[str] = [] + + def _append(url: Optional[str]): + value = str(url or "").strip() + if value and value not in candidates: + candidates.append(value) + + _append(normalized) + parsed = urlparse(normalized) + base = f"{parsed.scheme}://{parsed.netloc}" + path = (parsed.path or "").rstrip("/") + lower = path.lower() + + if lower in ("", "/"): + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + elif lower.endswith("/api/v1"): + _append(base + path + "/bind-card") + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + elif not lower.endswith("/bind-card"): + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + + return candidates + + +def _parse_third_party_response(resp) -> dict: + if not (resp.content or b""): + return {"ok": True} + + content_type = (resp.headers.get("content-type") or "").lower() + if "application/json" in content_type: + try: + data = resp.json() + if isinstance(data, dict): + return data + return {"data": data} + except Exception: + pass + + raw = str(resp.text or "").strip() + if raw.startswith("{") and raw.endswith("}"): + try: + data = resp.json() + if isinstance(data, dict): + return data + except Exception: + pass + return {"raw": raw[:1000]} + + +def _invoke_third_party_bind_api( + *, api_url: str, api_key: Optional[str], payload: dict, @@ -1881,6 +4461,322 @@ def _check_subscription_detail_with_retry( return detail, refreshed +def _is_retryable_subscription_check_error(error_message: Optional[str]) -> bool: + text = str(error_message or "").strip().lower() + if not text: + return False + retry_markers = ( + "network_error", + "network", + "timeout", + "timed out", + "connection", + "temporarily", + "too many requests", + "http 429", + "http 500", + "http 502", + "http 503", + "http 504", + "rate limit", + ) + return any(marker in text for marker in retry_markers) + + +def _batch_check_subscription_one( + account_id: int, + explicit_proxy: Optional[str], + max_attempts: int = PAYMENT_BATCH_SUBSCRIPTION_CHECK_RETRY_ATTEMPTS, +) -> Dict[str, Any]: + subscription_allowed, subscription_breaker = breaker_allow_request("subscription_check") + if not subscription_allowed: + return { + "id": account_id, + "email": None, + "success": False, + "error": f"subscription_check 熔断中,稍后重试: {subscription_breaker}", + "attempts": 0, + "breaker": subscription_breaker, + } + + attempts = max(1, int(max_attempts or 1)) + last_error = "" + with get_db() as db: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + return { + "id": account_id, + "email": None, + "success": False, + "error": "账号不存在", + "attempts": 1, + } + + for attempt in range(1, attempts + 1): + runtime_proxy: Optional[str] = None + try: + runtime_proxy = _resolve_runtime_proxy(explicit_proxy, account) + requested_proxy = bool(str(runtime_proxy or "").strip()) + if requested_proxy: + proxy_allowed, _proxy_breaker = breaker_allow_request("proxy_runtime") + if not proxy_allowed: + runtime_proxy = None + using_proxy = bool(str(runtime_proxy or "").strip()) + + detail, refreshed = _check_subscription_detail_with_retry( + db=db, + account=account, + proxy=runtime_proxy, + allow_token_refresh=True, + ) + status = str(detail.get("status") or "free").lower() + confidence = str(detail.get("confidence") or "low").lower() + before_subscription = str(account.subscription_type or "free") + + _apply_subscription_result( + account, + status=status, + checked_at=datetime.utcnow(), + confidence=confidence, + promote_reason="batch_check_subscription", + ) + + db.commit() + if before_subscription != status: + try: + crud.create_operation_audit_log( + db, + actor="system", + action="account.subscription_auto_detect", + target_type="account", + target_id=account.id, + target_email=account.email, + payload={ + "before": before_subscription, + "after": status, + "confidence": confidence, + "source": detail.get("source"), + "task": "batch_check_subscription", + }, + ) + except Exception: + logger.debug("记录订阅自动检测审计日志失败: account_id=%s", account.id, exc_info=True) + breaker_record_success("subscription_check") + if using_proxy: + breaker_record_success("proxy_runtime") + return { + "id": account_id, + "email": account.email, + "success": True, + "subscription_type": status, + "confidence": confidence, + "source": detail.get("source"), + "token_refreshed": refreshed, + "attempts": attempt, + } + except Exception as exc: + db.rollback() + last_error = str(exc) + breaker_record_failure("subscription_check", last_error) + if bool(str(runtime_proxy or explicit_proxy or getattr(account, "proxy_used", "") or "").strip()): + breaker_record_failure("proxy_runtime", last_error) + can_retry = attempt < attempts and _is_retryable_subscription_check_error(last_error) + if can_retry: + time.sleep(PAYMENT_BATCH_SUBSCRIPTION_CHECK_RETRY_BASE_DELAY_SECONDS * attempt) + continue + return { + "id": account_id, + "email": account.email, + "success": False, + "error": last_error, + "attempts": attempt, + } + + return { + "id": account_id, + "email": None, + "success": False, + "error": last_error or "subscription_check_failed", + "attempts": attempts, + } + + +def _run_batch_check_subscription_async_task( + op_task_id: str, + ids: List[int], + explicit_proxy: Optional[str], +) -> None: + total = len(ids) + success_count = 0 + failed_count = 0 + completed_count = 0 + + _update_payment_op_task( + op_task_id, + status="running", + started_at=_payment_now_iso(), + message=f"开始检测订阅,共 {total} 个账号", + paused=False, + ) + _set_payment_op_task_progress( + op_task_id, + total=total, + completed=0, + success=0, + failed=0, + ) + + if total <= 0: + _update_payment_op_task( + op_task_id, + status="completed", + finished_at=_payment_now_iso(), + message="没有可检测的账号", + paused=False, + result={"success_count": 0, "failed_count": 0, "total": 0, "cancelled": False, "details": []}, + ) + return + + worker_count = min(PAYMENT_BATCH_SUBSCRIPTION_CHECK_MAX_WORKERS, max(1, total)) + _update_payment_op_task(op_task_id, message=f"处理中 0/{total}(并发 {worker_count})") + + next_index = 0 + running: Dict[Any, int] = {} + details: List[Dict[str, Any]] = [] + cancelled = False + pool = ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="payment_sub_check_async") + try: + while completed_count < total: + if not _wait_if_payment_op_task_paused( + op_task_id, + f"处理中 {completed_count}/{total}(并发 {worker_count})", + ): + cancelled = True + break + if _is_payment_op_task_cancel_requested(op_task_id): + cancelled = True + break + + while next_index < total and len(running) < worker_count: + if not _wait_if_payment_op_task_paused( + op_task_id, + f"处理中 {completed_count}/{total}(并发 {worker_count})", + ): + cancelled = True + break + account_id = int(ids[next_index]) + next_index += 1 + future = pool.submit(_batch_check_subscription_one, account_id, explicit_proxy) + running[future] = account_id + + if cancelled: + break + if not running: + continue + + done, _ = wait(tuple(running.keys()), timeout=0.6, return_when=FIRST_COMPLETED) + if not done: + continue + + for future in done: + account_id = int(running.pop(future, 0) or 0) + if account_id <= 0: + continue + try: + detail = future.result() + except Exception as exc: + detail = { + "id": account_id, + "email": None, + "success": False, + "error": str(exc), + "attempts": 1, + } + + completed_count += 1 + if detail.get("success"): + success_count += 1 + else: + failed_count += 1 + details.append(detail) + _append_payment_op_task_detail(op_task_id, detail) + _set_payment_op_task_progress( + op_task_id, + total=total, + completed=completed_count, + success=success_count, + failed=failed_count, + ) + _update_payment_op_task( + op_task_id, + message=f"处理中 {completed_count}/{total}(并发 {worker_count})", + ) + + if cancelled: + for future in list(running.keys()): + future.cancel() + _update_payment_op_task( + op_task_id, + status="cancelled", + finished_at=_payment_now_iso(), + message=f"任务已取消,进度 {completed_count}/{total}", + paused=False, + result={ + "success_count": success_count, + "failed_count": failed_count, + "total": total, + "cancelled": True, + "details": details, + }, + ) + with get_db() as db: + crud.create_operation_audit_log( + db, + actor="system", + action="payment.batch_check_subscription.cancelled", + target_type="batch_operation", + target_id=op_task_id, + payload={ + "total": total, + "completed": completed_count, + "success_count": success_count, + "failed_count": failed_count, + }, + ) + return + finally: + pool.shutdown(wait=False, cancel_futures=True) + + _update_payment_op_task( + op_task_id, + status="completed", + finished_at=_payment_now_iso(), + message=f"检测完成:成功 {success_count},失败 {failed_count}", + paused=False, + result={ + "success_count": success_count, + "failed_count": failed_count, + "total": total, + "cancelled": False, + "details": details, + }, + ) + with get_db() as db: + crud.create_operation_audit_log( + db, + actor="system", + action="payment.batch_check_subscription.completed", + target_type="batch_operation", + target_id=op_task_id, + payload={ + "total": total, + "completed": completed_count, + "success_count": success_count, + "failed_count": failed_count, + }, + ) + + def _generate_checkout_link_for_account( account: Account, request: "CheckoutRequestBase", @@ -1969,7 +4865,8 @@ class GenerateLinkRequest(CheckoutRequestBase): class CreateBindCardTaskRequest(CheckoutRequestBase): auto_open: bool = False - bind_mode: str = "semi_auto" # semi_auto / third_party / local_auto + bind_mode: str = "semi_auto" # semi_auto / local_auto / vendor_efun + custom_checkout_url: Optional[str] = None class OpenIncognitoRequest(BaseModel): @@ -2027,6 +4924,25 @@ class LocalAutoBindRequest(BaseModel): profile: ThirdPartyProfileRequest +class VendorAutoBindRequest(BaseModel): + redeem_code: str + checkout_url: Optional[str] = None + api_url: Optional[str] = None + api_key: Optional[str] = None + timeout_seconds: int = Field(default=180, ge=60, le=900) + + +class EfunRequestBase(BaseModel): + code: str + api_key: Optional[str] = None + base_url: Optional[str] = None + proxy: Optional[str] = None + + +class EfunThreeDSVerifyRequest(EfunRequestBase): + minutes: int = Field(default=30, ge=1, le=1440) + + class MarkSubscriptionRequest(BaseModel): subscription_type: str # 'free' / 'plus' / 'team' @@ -2040,6 +4956,10 @@ class BatchCheckSubscriptionRequest(BaseModel): search_filter: Optional[str] = None +class BindTaskFailStatsRequest(BaseModel): + account_ids: List[int] = [] + + class SaveSessionTokenRequest(BaseModel): session_token: str merge_cookie: bool = True @@ -2070,6 +4990,138 @@ def get_random_billing_profile( raise HTTPException(status_code=500, detail=f"随机账单资料生成失败: {exc}") +# ============== EFun 卡商接口 ============== + +@router.post("/efun/redeem") +def efuncard_redeem(request: EfunRequestBase): + base_url = _resolve_efuncard_base_url(request.base_url) + api_key = _resolve_efuncard_api_key(request.api_key) + code = _normalize_efuncard_code(request.code) + proxy = _normalize_proxy_value(request.proxy) or None + + body = _efuncard_request( + method="POST", + path="/api/external/redeem", + api_key=api_key, + base_url=base_url, + proxy=proxy, + payload={"code": code}, + ) + data = body.get("data") if isinstance(body, dict) else {} + if not isinstance(data, dict): + data = {} + + expiry = str(data.get("expiryDate") or "").strip() + exp_month, exp_year = _parse_efuncard_expiry(expiry) + card_number = str(data.get("cardNumber") or "").strip() + cvv = str(data.get("cvv") or "").strip() + masked = _mask_card_number(card_number) if card_number else "-" + + logger.info("EfunCard 开卡成功: code=%s card=%s status=%s", code, masked, data.get("status")) + return { + "success": True, + "provider": "efuncard", + "card": { + "card_number": card_number, + "exp_month": exp_month, + "exp_year": exp_year, + "cvc": cvv, + "expiry_raw": expiry, + "masked": masked, + "code": str(data.get("code") or code).strip(), + "card_id": data.get("cardId"), + "status": str(data.get("status") or "").strip(), + "created_at": data.get("createdAt"), + }, + "raw": data, + } + + +@router.post("/efun/cancel") +def efuncard_cancel(request: EfunRequestBase): + base_url = _resolve_efuncard_base_url(request.base_url) + api_key = _resolve_efuncard_api_key(request.api_key) + code = _normalize_efuncard_code(request.code) + proxy = _normalize_proxy_value(request.proxy) or None + + body = _efuncard_request( + method="POST", + path="/api/external/cards/cancel", + api_key=api_key, + base_url=base_url, + proxy=proxy, + payload={"code": code}, + ) + data = body.get("data") if isinstance(body, dict) else {} + if not isinstance(data, dict): + data = {} + logger.info("EfunCard 销卡完成: code=%s status=%s refund=%s", code, data.get("status"), data.get("refundAmount")) + return {"success": True, "provider": "efuncard", "data": data} + + +@router.post("/efun/query") +def efuncard_query(request: EfunRequestBase): + base_url = _resolve_efuncard_base_url(request.base_url) + api_key = _resolve_efuncard_api_key(request.api_key) + code = _normalize_efuncard_code(request.code) + proxy = _normalize_proxy_value(request.proxy) or None + + encoded_code = quote(code, safe="") + body = _efuncard_request( + method="GET", + path=f"/api/external/cards/query/{encoded_code}", + api_key=api_key, + base_url=base_url, + proxy=proxy, + ) + data = body.get("data") if isinstance(body, dict) else {} + if not isinstance(data, dict): + data = {} + return {"success": True, "provider": "efuncard", "data": data} + + +@router.post("/efun/billing") +def efuncard_billing(request: EfunRequestBase): + base_url = _resolve_efuncard_base_url(request.base_url) + api_key = _resolve_efuncard_api_key(request.api_key) + code = _normalize_efuncard_code(request.code) + proxy = _normalize_proxy_value(request.proxy) or None + + encoded_code = quote(code, safe="") + body = _efuncard_request( + method="GET", + path=f"/api/external/billing/{encoded_code}", + api_key=api_key, + base_url=base_url, + proxy=proxy, + ) + data = body.get("data") if isinstance(body, dict) else {} + if not isinstance(data, dict): + data = {} + return {"success": True, "provider": "efuncard", "data": data} + + +@router.post("/efun/3ds/verify") +def efuncard_verify_3ds(request: EfunThreeDSVerifyRequest): + base_url = _resolve_efuncard_base_url(request.base_url) + api_key = _resolve_efuncard_api_key(request.api_key) + code = _normalize_efuncard_code(request.code) + proxy = _normalize_proxy_value(request.proxy) or None + + body = _efuncard_request( + method="POST", + path="/api/external/3ds/verify", + api_key=api_key, + base_url=base_url, + proxy=proxy, + payload={"code": code, "minutes": int(request.minutes)}, + ) + data = body.get("data") if isinstance(body, dict) else {} + if not isinstance(data, dict): + data = {} + return {"success": True, "provider": "efuncard", "data": data} + + @router.get("/accounts/{account_id}/session-diagnostic") def get_account_session_diagnostic( account_id: int, @@ -2318,83 +5370,113 @@ def open_browser_incognito(request: OpenIncognitoRequest): def create_bind_card_task(request: CreateBindCardTaskRequest): """创建绑卡任务(从账号管理中选择账号)""" bind_mode = str(request.bind_mode or "semi_auto").strip().lower() - if bind_mode not in ("semi_auto", "third_party", "local_auto"): - raise HTTPException(status_code=400, detail="bind_mode 必须为 semi_auto / third_party / local_auto") - - with get_db() as db: - account = db.query(Account).filter(Account.id == request.account_id).first() - if not account: - raise HTTPException(status_code=404, detail="账号不存在") - - logger.info( - "创建绑卡任务: account_id=%s email=%s plan=%s country=%s mode=%s auto_open=%s", - account.id, account.email, request.plan_type, request.country, bind_mode, request.auto_open - ) - - proxy = _resolve_runtime_proxy(request.proxy, account) - try: - link, source, fallback_reason, checkout_session_id, publishable_key, client_secret = _generate_checkout_link_for_account( - account=account, - request=request, - proxy=proxy, - ) - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"创建绑卡任务失败: {e}") - raise HTTPException(status_code=500, detail=f"创建绑卡任务失败: {str(e)}") + if bind_mode in DISABLED_BIND_MODES: + raise HTTPException(status_code=403, detail=f"bind_mode={bind_mode} 已被禁用") + if bind_mode not in ALLOWED_BIND_MODES: + raise HTTPException(status_code=400, detail="bind_mode 必须为 semi_auto / local_auto / vendor_efun") - task = BindCardTask( - account_id=account.id, - plan_type=request.plan_type, - workspace_name=request.workspace_name if request.plan_type == "team" else None, - price_interval=request.price_interval if request.plan_type == "team" else None, - seat_quantity=request.seat_quantity if request.plan_type == "team" else None, - country=_normalize_checkout_country(request.country), - currency=_normalize_checkout_currency(_normalize_checkout_country(request.country), request.currency), - checkout_url=link, - checkout_session_id=checkout_session_id, - publishable_key=publishable_key, - client_secret=client_secret, - checkout_source=source, - bind_mode=bind_mode, - status="link_ready", - ) - db.add(task) - db.commit() - db.refresh(task) + with _acquire_bind_task_create_lock(request.account_id): + with get_db() as db: + account = db.query(Account).filter(Account.id == request.account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") - logger.info( - "绑卡任务已创建: task_id=%s account_id=%s plan=%s source=%s status=%s", - task.id, task.account_id, task.plan_type, source, task.status - ) + active_task = _find_active_bind_task_for_account(db, account.id) + if active_task: + logger.info( + "拒绝重复创建绑卡任务: account_id=%s active_task_id=%s active_status=%s", + account.id, + active_task.id, + active_task.status, + ) + raise HTTPException( + status_code=409, + detail=f"账号已有进行中的绑卡任务(task_id={active_task.id}, status={active_task.status or 'pending'}),请先完成或取消后再创建", + ) - opened = False - if request.auto_open and bind_mode == "semi_auto" and link: - opened = open_url_incognito(link, account.cookies if account else None) - if opened: - task.status = "opened" - task.opened_at = datetime.utcnow() - db.commit() - db.refresh(task) - logger.info("绑卡任务自动打开成功: task_id=%s mode=%s", task.id, bind_mode) + logger.info( + "创建绑卡任务: account_id=%s email=%s plan=%s country=%s mode=%s auto_open=%s", + account.id, account.email, request.plan_type, request.country, bind_mode, request.auto_open + ) + + proxy = _resolve_runtime_proxy(request.proxy, account) + checkout_request = request + custom_checkout = str(request.custom_checkout_url or "").strip() + if bind_mode == "vendor_auto" and custom_checkout: + parsed_custom = urlparse(custom_checkout) + if parsed_custom.scheme not in ("http", "https") or not parsed_custom.netloc: + raise HTTPException(status_code=400, detail="自定义 Checkout 链接格式无效") + link = custom_checkout + source = "vendor_custom" + fallback_reason = None + checkout_session_id = _extract_checkout_session_id_from_url(link) + publishable_key = None + client_secret = None else: - logger.warning("绑卡任务自动打开失败: task_id=%s mode=%s", task.id, bind_mode) + try: + link, source, fallback_reason, checkout_session_id, publishable_key, client_secret = _generate_checkout_link_for_account( + account=account, + request=checkout_request, + proxy=proxy, + ) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"创建绑卡任务失败: {e}") + raise HTTPException(status_code=500, detail=f"创建绑卡任务失败: {str(e)}") + + task = BindCardTask( + account_id=account.id, + account_email=account.email, + plan_type=request.plan_type, + workspace_name=request.workspace_name if request.plan_type == "team" else None, + price_interval=request.price_interval if request.plan_type == "team" else None, + seat_quantity=request.seat_quantity if request.plan_type == "team" else None, + country=_normalize_checkout_country(request.country), + currency=_normalize_checkout_currency(_normalize_checkout_country(request.country), request.currency), + checkout_url=link, + checkout_session_id=checkout_session_id, + publishable_key=publishable_key, + client_secret=client_secret, + checkout_source=source, + bind_mode=bind_mode, + status="link_ready", + ) + db.add(task) + db.commit() + db.refresh(task) - return { - "success": True, - "task": _serialize_bind_card_task(task), - "link": link, - "is_official_checkout": _is_official_checkout_link(link), - "source": source, - "fallback_reason": fallback_reason, - "auto_opened": opened, - "checkout_session_id": checkout_session_id, - "publishable_key": publishable_key, - "has_client_secret": bool(client_secret), - } + logger.info( + "绑卡任务已创建: task_id=%s account_id=%s plan=%s source=%s status=%s", + task.id, task.account_id, task.plan_type, source, task.status + ) + + opened = False + if request.auto_open and bind_mode in ("semi_auto", "vendor_efun") and link: + opened = open_url_incognito(link, account.cookies if account else None) + if opened: + task.status = "opened" + task.opened_at = datetime.utcnow() + db.commit() + db.refresh(task) + logger.info("绑卡任务自动打开成功: task_id=%s mode=%s", task.id, bind_mode) + else: + logger.warning("绑卡任务自动打开失败: task_id=%s mode=%s", task.id, bind_mode) + + return { + "success": True, + "task": _serialize_bind_card_task(task), + "link": link, + "is_official_checkout": _is_official_checkout_link(link), + "source": source, + "fallback_reason": fallback_reason, + "auto_opened": opened, + "checkout_session_id": checkout_session_id, + "publishable_key": publishable_key, + "has_client_secret": bool(client_secret), + } @router.get("/bind-card/tasks") @@ -2411,8 +5493,12 @@ def list_bind_card_tasks( query = query.filter(BindCardTask.status == status) if search: pattern = f"%{search}%" - query = query.join(Account, BindCardTask.account_id == Account.id).filter( - or_(Account.email.ilike(pattern), Account.account_id.ilike(pattern)) + query = query.outerjoin(Account, BindCardTask.account_id == Account.id).filter( + or_( + Account.email.ilike(pattern), + Account.account_id.ilike(pattern), + BindCardTask.account_email.ilike(pattern), + ) ) total = query.count() @@ -2444,6 +5530,48 @@ def list_bind_card_tasks( } +@router.post("/bind-card/tasks/fail-stats") +def get_bind_card_task_fail_stats(request: BindTaskFailStatsRequest): + account_ids = [] + for item in (request.account_ids or []): + try: + account_id = int(item) + except Exception: + continue + if account_id > 0: + account_ids.append(account_id) + + with get_db() as db: + query = ( + db.query( + BindCardTask.account_id.label("account_id"), + func.count(BindCardTask.id).label("fail_count"), + ) + .filter(BindCardTask.account_id.isnot(None)) + .filter(BindCardTask.status.in_(("failed", "cancelled"))) + ) + if account_ids: + query = query.filter(BindCardTask.account_id.in_(account_ids)) + + rows = query.group_by(BindCardTask.account_id).all() + + stats = [] + for row in rows: + try: + aid = int(getattr(row, "account_id", 0) or 0) + except Exception: + aid = 0 + try: + fail_count = int(getattr(row, "fail_count", 0) or 0) + except Exception: + fail_count = 0 + if aid <= 0: + continue + stats.append({"account_id": aid, "fail_count": max(0, fail_count)}) + + return {"success": True, "stats": stats} + + @router.post("/bind-card/tasks/{task_id}/open") def open_bind_card_task(task_id: int): """打开绑卡任务对应的 checkout 链接""" @@ -2480,6 +5608,7 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi A: 三态判定(success/pending/failed) B: 尝试轮询第三方状态接口,能确认 paid 就标记 paid_pending_sync(等待订阅同步) """ + raise HTTPException(status_code=403, detail="第三方自动绑卡模式已禁用") third_party_response_safe: dict = {} api_url_for_log = "" third_party_assessment: dict = {} @@ -2971,6 +6100,167 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): } +@router.post("/bind-card/tasks/{task_id}/auto-bind-vendor") +def auto_bind_bind_card_task_vendor(task_id: int, request: VendorAutoBindRequest): + """ + 卡商全自动模式(接口版): + - 先强制按账号生成最新 checkout(生成阶段走代理) + - 再调用 EFun 开卡接口获取卡信息 + - 提交 card.aimizy.com /api/v1/bindcard + - 最后同步订阅状态 + """ + redeem_code = _normalize_vendor_redeem_code(request.redeem_code) + if not redeem_code: + raise HTTPException(status_code=400, detail="兑换码不能为空") + if not VENDOR_REDEEM_CODE_REGEX.fullmatch(redeem_code): + raise HTTPException(status_code=400, detail="兑换码格式错误") + + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + account = task.account + if not account: + raise HTTPException(status_code=404, detail="任务关联账号不存在") + + checkout_override = str(request.checkout_url or "").strip() + if checkout_override: + parsed = urlparse(checkout_override) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise HTTPException(status_code=400, detail="自定义 Checkout 链接格式无效") + task.checkout_url = checkout_override + task.checkout_session_id = _extract_checkout_session_id_from_url(checkout_override) + + task.bind_mode = "vendor_efun" + task.status = "verifying" + task.last_error = None + task.last_checked_at = datetime.utcnow() + db.commit() + db.refresh(task) + + _vendor_progress_init(task_id) + _vendor_progress_log( + task_id, + "已开始卡商接口订阅流程(EFun 开卡 + bindcard 提交 + 订阅同步)", + progress=8, + status="running", + ) + + thread = threading.Thread( + target=_run_vendor_auto_bind_task, + kwargs={ + "task_id": task_id, + "redeem_code": redeem_code, + "checkout_override": str(request.checkout_url or "").strip() or None, + "api_url": str(request.api_url or "").strip() or None, + "api_key": str(request.api_key or "").strip() or None, + "timeout_seconds": int(request.timeout_seconds), + }, + daemon=True, + ) + thread.start() + + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + return { + "success": True, + "pending": True, + "task": _serialize_bind_card_task(task) if task else {"id": task_id}, + "progress": _vendor_progress_snapshot(task_id, 0), + } + + +@router.get("/bind-card/tasks/{task_id}/vendor-progress") +def get_bind_card_task_vendor_progress(task_id: int, cursor: int = Query(0, ge=0)): + snapshot = _vendor_progress_snapshot(task_id, cursor) + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + if _vendor_progress_exists(task_id): + # 兼容多实例/多数据库文件场景:进度仍在,但任务查询暂未命中时继续返回进度 + return { + "success": True, + "task": { + "id": task_id, + "status": str(snapshot.get("status") or "running"), + "bind_mode": "vendor_efun", + "account_email": None, + }, + "progress": snapshot, + "task_missing": True, + } + raise HTTPException(status_code=404, detail="绑卡任务不存在") + return { + "success": True, + "task": _serialize_bind_card_task(task), + "progress": snapshot, + } + + +@router.post("/bind-card/vendor-stop-active") +def stop_active_bind_card_task_vendor(): + task_id = _vendor_get_latest_active_task_id() + if not task_id: + raise HTTPException(status_code=404, detail="当前没有进行中的卡商任务") + return stop_bind_card_task_vendor(task_id) + + +@router.post("/bind-card/tasks/{task_id}/vendor-stop") +@router.post("/bind-card/tasks/{task_id}/stop-vendor") +@router.post("/bind-card/tasks/{task_id}/stop") +@router.post("/bind-card/tasks/{task_id}/cancel") +def stop_bind_card_task_vendor(task_id: int): + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + if _vendor_progress_exists(task_id): + requested_now = _vendor_request_stop(task_id) + if requested_now: + _vendor_progress_log(task_id, "已收到停止指令,正在停止任务...", progress=99, status="cancelled") + else: + _vendor_progress_log(task_id, "停止指令已存在,等待任务退出", progress=99, status="cancelled") + return { + "success": True, + "stopped": True, + "task_missing": True, + "task": {"id": task_id, "bind_mode": "vendor_efun", "status": "cancelled"}, + "progress": _vendor_progress_snapshot(task_id, 0), + } + raise HTTPException(status_code=404, detail="绑卡任务不存在") + if str(task.bind_mode or "").strip().lower() not in ("vendor_auto", "vendor_efun"): + raise HTTPException(status_code=400, detail="仅卡商模式任务支持停止") + + task_status = str(task.status or "").lower() + if task_status in ("completed", "failed"): + return { + "success": True, + "stopped": False, + "message": "任务已结束,无需停止", + "task": _serialize_bind_card_task(task), + "progress": _vendor_progress_snapshot(task_id, 0), + } + + requested_now = _vendor_request_stop(task_id) + now = datetime.utcnow() + task.status = "failed" + task.last_error = "用户手动停止卡商订阅任务" + task.last_checked_at = now + db.commit() + db.refresh(task) + + if requested_now: + _vendor_progress_log(task_id, "已收到停止指令,正在停止任务...", progress=99, status="cancelled") + else: + _vendor_progress_log(task_id, "停止指令已存在,等待任务退出", progress=99, status="cancelled") + + return { + "success": True, + "stopped": True, + "task": _serialize_bind_card_task(task), + "progress": _vendor_progress_snapshot(task_id, 0), + } + + @router.post("/bind-card/tasks/{task_id}/sync-subscription") def sync_bind_card_task_subscription(task_id: int, request: SyncBindCardTaskRequest): """同步任务账号订阅状态,并回写到账号管理""" @@ -3007,13 +6297,13 @@ def sync_bind_card_task_subscription(task_id: int, request: SyncBindCardTaskRequ raise HTTPException(status_code=500, detail=f"订阅检测失败: {exc}") # 仅在高置信度 free 时清空;低置信度 free 不覆盖已有订阅 - if status in ("plus", "team"): - account.subscription_type = status - account.subscription_at = now - elif status == "free": - if str(detail.get("confidence") or "").lower() == "high": - account.subscription_type = None - account.subscription_at = None + _apply_subscription_result( + account, + status=status, + checked_at=now, + confidence=detail.get("confidence"), + promote_reason="bind_task_sync_subscription", + ) task.last_checked_at = now if status in ("plus", "team"): @@ -3069,13 +6359,12 @@ def sync_bind_card_task_subscription(task_id: int, request: SyncBindCardTaskRequ } -@router.post("/bind-card/tasks/{task_id}/mark-user-action") -def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest): - """ - 用户确认“已完成支付”后,自动轮询订阅状态一段时间: - - 命中 plus/team -> completed - - 超时未命中 -> paid_pending_sync 或 waiting_user_action - """ +def _execute_mark_user_action( + task_id: int, + request: MarkUserActionRequest, + cancel_checker: Optional[Callable[[], bool]] = None, + progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None, +) -> Dict[str, Any]: with get_db() as db: task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() if not task: @@ -3102,9 +6391,31 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest deadline = time.monotonic() + timeout_seconds checks = 0 last_status = "free" + last_detail: Dict[str, Any] = {} token_refresh_used = False while time.monotonic() < deadline: + if callable(cancel_checker) and cancel_checker(): + fallback_status = previous_status if previous_status in ("paid_pending_sync", "waiting_user_action", "link_ready") else "waiting_user_action" + task.status = fallback_status + task.last_error = "用户取消了验证任务" + task.last_checked_at = datetime.utcnow() + task.completed_at = None + db.commit() + db.refresh(task) + return { + "success": True, + "verified": False, + "cancelled": True, + "checks": checks, + "subscription_type": last_status, + "detail": last_detail or None, + "token_refresh_used": token_refresh_used, + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + } + checks += 1 try: detail, refreshed = _check_subscription_detail_with_retry( @@ -3117,6 +6428,7 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest token_refresh_used = True status = str(detail.get("status") or "free").lower() last_status = status + last_detail = detail if isinstance(detail, dict) else {} logger.info( "绑卡任务验证轮询: task_id=%s attempt=%s status=%s source=%s confidence=%s token_refreshed=%s", task.id, checks, status, detail.get("source"), detail.get("confidence"), bool(detail.get("token_refreshed")) @@ -3131,16 +6443,30 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest raise HTTPException(status_code=500, detail=f"订阅检测失败: {exc}") checked_at = datetime.utcnow() - if status in ("plus", "team"): - account.subscription_type = status - account.subscription_at = checked_at - elif status == "free": - # 低置信度 free 不覆盖已有订阅,避免误判清空 - if str(detail.get("confidence") or "").lower() == "high": - account.subscription_type = None - account.subscription_at = None + _apply_subscription_result( + account, + status=status, + checked_at=checked_at, + confidence=detail.get("confidence"), + promote_reason="bind_task_verify_polling", + ) task.last_checked_at = checked_at + if callable(progress_callback): + try: + progress_callback( + { + "checks": checks, + "timeout_seconds": timeout_seconds, + "interval_seconds": interval_seconds, + "status": status, + "source": detail.get("source"), + "confidence": detail.get("confidence"), + } + ) + except Exception: + pass + if status in ("plus", "team"): task.status = "completed" task.completed_at = checked_at @@ -3154,6 +6480,7 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest return { "success": True, "verified": True, + "cancelled": False, "checks": checks, "subscription_type": status, "detail": detail, @@ -3170,18 +6497,18 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest timeout_msg = ( f"在 {timeout_seconds} 秒内未检测到订阅变更(当前: {last_status}," - f"source={detail.get('source') if 'detail' in locals() else 'unknown'}," - f"confidence={detail.get('confidence') if 'detail' in locals() else 'unknown'}," + f"source={last_detail.get('source') or 'unknown'}," + f"confidence={last_detail.get('confidence') or 'unknown'}," f"token_refreshed={token_refresh_used}" + ( - f",note={str(detail.get('note') or '').strip()}" - if "detail" in locals() and detail and detail.get("note") + f",note={str(last_detail.get('note') or '').strip()}" + if last_detail and last_detail.get("note") else "" ) + ")。" ) - timeout_confidence = str((detail if "detail" in locals() else {}).get("confidence") or "unknown").lower() - timeout_source = str((detail if "detail" in locals() else {}).get("source") or "unknown") + timeout_confidence = str(last_detail.get("confidence") or "unknown").lower() + timeout_source = str(last_detail.get("source") or "unknown") should_keep_paid_pending = ( previous_status == "paid_pending_sync" or (last_status == "free" and timeout_confidence != "high") @@ -3209,9 +6536,10 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest return { "success": True, "verified": False, + "cancelled": False, "checks": checks, "subscription_type": last_status, - "detail": detail if "detail" in locals() else None, + "detail": last_detail or None, "token_refresh_used": token_refresh_used, "task": _serialize_bind_card_task(task), "account_id": account.id, @@ -3219,6 +6547,209 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest } +def _run_mark_user_action_async_task(op_task_id: str, bind_task_id: int, payload: Dict[str, Any]) -> None: + timeout_seconds = int(payload.get("timeout_seconds") or 180) + interval_seconds = int(payload.get("interval_seconds") or 10) + _update_payment_op_task( + op_task_id, + status="running", + started_at=_payment_now_iso(), + message=f"开始验证订阅(最长 {timeout_seconds} 秒)", + ) + _set_payment_op_task_progress( + op_task_id, + checks=0, + timeout_seconds=timeout_seconds, + interval_seconds=interval_seconds, + ) + + request = MarkUserActionRequest( + proxy=payload.get("proxy"), + timeout_seconds=timeout_seconds, + interval_seconds=interval_seconds, + ) + + result = _execute_mark_user_action( + bind_task_id, + request, + cancel_checker=lambda: _is_payment_op_task_cancel_requested(op_task_id), + progress_callback=lambda progress: _set_payment_op_task_progress(op_task_id, **(progress or {})), + ) + _append_payment_op_task_detail( + op_task_id, + { + "checked_at": _payment_now_iso(), + "subscription_type": result.get("subscription_type"), + "verified": bool(result.get("verified")), + "cancelled": bool(result.get("cancelled")), + "checks": int(result.get("checks") or 0), + }, + ) + final_status = "cancelled" if result.get("cancelled") else "completed" + _update_payment_op_task( + op_task_id, + status=final_status, + finished_at=_payment_now_iso(), + message=( + "任务已取消" + if final_status == "cancelled" + else ("验证成功" if result.get("verified") else "验证完成,未命中订阅") + ), + result=result, + ) + + +@router.post("/bind-card/tasks/{task_id}/mark-user-action/async") +def start_mark_bind_card_task_user_action_async(task_id: int, request: MarkUserActionRequest): + payload = { + "proxy": request.proxy, + "timeout_seconds": int(request.timeout_seconds), + "interval_seconds": int(request.interval_seconds), + } + op_task_id = _create_payment_op_task( + "mark_user_action", + bind_task_id=task_id, + progress={ + "checks": 0, + "timeout_seconds": int(request.timeout_seconds), + "interval_seconds": int(request.interval_seconds), + }, + ) + task_manager.update_domain_task( + "payment", + op_task_id, + payload={ + "bind_task_id": int(task_id), + "proxy": request.proxy, + "timeout_seconds": int(request.timeout_seconds), + "interval_seconds": int(request.interval_seconds), + }, + ) + _PAYMENT_OP_TASK_EXECUTOR.submit( + _run_payment_op_task_guard, + op_task_id, + "mark_user_action", + _run_mark_user_action_async_task, + task_id, + payload, + ) + return _build_payment_op_task_snapshot(_get_payment_op_task_or_404(op_task_id)) + + +@router.get("/ops/tasks/{op_task_id}") +def get_payment_op_task(op_task_id: str): + return _build_payment_op_task_snapshot(_get_payment_op_task_or_404(op_task_id)) + + +@router.post("/ops/tasks/{op_task_id}/cancel") +def cancel_payment_op_task(op_task_id: str): + with _PAYMENT_OP_TASK_LOCK: + task = _PAYMENT_OP_TASKS.get(op_task_id) + if not task: + raise HTTPException(status_code=404, detail="支付任务不存在") + if task.get("status") in {"completed", "failed", "cancelled"}: + return { + "success": True, + "task_id": op_task_id, + "status": task.get("status"), + "message": "任务已结束,无需取消", + } + task["cancel_requested"] = True + task["pause_requested"] = False + task["paused"] = False + task["message"] = "已提交取消请求,等待任务结束" + task_manager.request_domain_task_cancel("payment", op_task_id) + return {"success": True, "task_id": op_task_id, "status": "cancelling"} + + +@router.post("/ops/tasks/{op_task_id}/pause") +def pause_payment_op_task(op_task_id: str): + with _PAYMENT_OP_TASK_LOCK: + task = _PAYMENT_OP_TASKS.get(op_task_id) + if not task: + raise HTTPException(status_code=404, detail="支付任务不存在") + status = str(task.get("status") or "").strip().lower() + if status in {"completed", "failed", "cancelled"}: + return { + "success": True, + "task_id": op_task_id, + "status": status, + "message": "任务已结束,无法暂停", + } + task["pause_requested"] = True + task["paused"] = True + task["status"] = "paused" + task["message"] = "任务已暂停,等待继续" + task_manager.request_domain_task_pause("payment", op_task_id) + return {"success": True, "task_id": op_task_id, "status": "paused", "message": "任务已暂停"} + + +@router.post("/ops/tasks/{op_task_id}/resume") +def resume_payment_op_task(op_task_id: str): + with _PAYMENT_OP_TASK_LOCK: + task = _PAYMENT_OP_TASKS.get(op_task_id) + if not task: + raise HTTPException(status_code=404, detail="支付任务不存在") + status = str(task.get("status") or "").strip().lower() + if status in {"completed", "failed", "cancelled"}: + return { + "success": True, + "task_id": op_task_id, + "status": status, + "message": "任务已结束,无需继续", + } + task["pause_requested"] = False + task["paused"] = False + if status == "paused": + task["status"] = "running" + task["message"] = "任务已继续执行" + task_manager.request_domain_task_resume("payment", op_task_id) + return {"success": True, "task_id": op_task_id, "status": "running", "message": "任务已继续执行"} + + +def retry_payment_op_task(op_task_id: str) -> Dict[str, Any]: + task = _get_payment_op_task_or_404(op_task_id) + task_type = str(task.get("task_type") or "").strip().lower() + payload = dict(task.get("payload") or {}) + + if task_type == "batch_check_subscription": + return start_batch_check_subscription_async( + BatchCheckSubscriptionRequest( + ids=[int(item) for item in (payload.get("ids") or []) if str(item).strip().isdigit()], + proxy=payload.get("proxy"), + select_all=bool(payload.get("select_all", False)), + status_filter=payload.get("status_filter"), + email_service_filter=payload.get("email_service_filter"), + search_filter=payload.get("search_filter"), + ) + ) + + if task_type == "mark_user_action": + bind_task_id = int(payload.get("bind_task_id") or task.get("bind_task_id") or 0) + if bind_task_id <= 0: + raise HTTPException(status_code=400, detail="mark_user_action 缺少 bind_task_id,无法重试") + return start_mark_bind_card_task_user_action_async( + bind_task_id, + MarkUserActionRequest( + proxy=payload.get("proxy"), + timeout_seconds=int(payload.get("timeout_seconds") or 180), + interval_seconds=int(payload.get("interval_seconds") or 10), + ), + ) + + raise HTTPException(status_code=400, detail=f"不支持重试的支付任务类型: {task_type or '-'}") + + +@router.post("/bind-card/tasks/{task_id}/mark-user-action") +def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest): + """ + 用户确认“已完成支付”后,自动轮询订阅状态一段时间: + - 命中 plus/team -> completed + - 超时未命中 -> paid_pending_sync 或 waiting_user_action + """ + return _execute_mark_user_action(task_id, request) + + @router.delete("/bind-card/tasks/{task_id}") def delete_bind_card_task(task_id: int): """删除绑卡任务""" @@ -3238,65 +6769,90 @@ def delete_bind_card_task(task_id: int): def batch_check_subscription(request: BatchCheckSubscriptionRequest): """批量检测账号订阅状态""" explicit_proxy = _normalize_proxy_value(request.proxy) - - results = {"success_count": 0, "failed_count": 0, "details": []} - 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 ) - for account_id in ids: - account = db.query(Account).filter(Account.id == account_id).first() - if not account: + results = {"success_count": 0, "failed_count": 0, "details": []} + if not ids: + return results + + worker_count = min(PAYMENT_BATCH_SUBSCRIPTION_CHECK_MAX_WORKERS, max(1, len(ids))) + with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="payment_sub_check") as pool: + future_map = { + pool.submit(_batch_check_subscription_one, int(account_id), explicit_proxy): int(account_id) + for account_id in ids + } + for future in as_completed(future_map): + try: + detail = future.result() + except Exception as exc: + detail = { + "id": future_map.get(future), + "email": None, + "success": False, + "error": str(exc), + "attempts": 1, + } + if detail.get("success"): + results["success_count"] += 1 + else: results["failed_count"] += 1 - results["details"].append( - {"id": account_id, "email": None, "success": False, "error": "账号不存在"} - ) - continue + results["details"].append(detail) + return results - try: - runtime_proxy = _resolve_runtime_proxy(explicit_proxy, account) - detail, refreshed = _check_subscription_detail_with_retry( - db=db, - account=account, - proxy=runtime_proxy, - allow_token_refresh=True, - ) - status = str(detail.get("status") or "free").lower() - confidence = str(detail.get("confidence") or "low").lower() - if status in ("plus", "team"): - account.subscription_type = status - account.subscription_at = datetime.utcnow() - elif status == "free" and confidence == "high": - account.subscription_type = None - account.subscription_at = None +@router.post("/accounts/batch-check-subscription/async") +def start_batch_check_subscription_async(request: BatchCheckSubscriptionRequest): + """异步批量检测账号订阅状态(并发执行)。""" + 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 + ) - db.commit() - results["success_count"] += 1 - results["details"].append( - { - "id": account_id, - "email": account.email, - "success": True, - "subscription_type": status, - "confidence": confidence, - "source": detail.get("source"), - "token_refreshed": refreshed, - } - ) - except Exception as e: - results["failed_count"] += 1 - results["details"].append( - {"id": account_id, "email": account.email, "success": False, "error": str(e)} - ) + op_task_id = _create_payment_op_task( + "batch_check_subscription", + progress={"total": len(ids), "completed": 0, "success": 0, "failed": 0}, + ) + task_manager.update_domain_task( + "payment", + op_task_id, + payload={ + "ids": [int(item) for item in ids], + "proxy": explicit_proxy, + "select_all": bool(request.select_all), + "status_filter": request.status_filter, + "email_service_filter": request.email_service_filter, + "search_filter": request.search_filter, + }, + ) - return results + if not ids: + _update_payment_op_task( + op_task_id, + status="completed", + started_at=_payment_now_iso(), + finished_at=_payment_now_iso(), + message="没有可检测的账号", + result={"success_count": 0, "failed_count": 0, "total": 0, "cancelled": False, "details": []}, + ) + else: + _PAYMENT_OP_TASK_EXECUTOR.submit( + _run_payment_op_task_guard, + op_task_id, + "batch_check_subscription", + _run_batch_check_subscription_async_task, + ids, + explicit_proxy, + ) + return _build_payment_op_task_snapshot(_get_payment_op_task_or_404(op_task_id)) @router.post("/accounts/{account_id}/mark-subscription") -def mark_subscription(account_id: int, request: MarkSubscriptionRequest): +def mark_subscription(account_id: int, request: MarkSubscriptionRequest, http_request: Request): """手动标记账号订阅类型""" allowed = ("free", "plus", "team") if request.subscription_type not in allowed: @@ -3306,9 +6862,37 @@ def mark_subscription(account_id: int, request: MarkSubscriptionRequest): account = db.query(Account).filter(Account.id == account_id).first() if not account: raise HTTPException(status_code=404, detail="账号不存在") + actor = _resolve_actor(http_request) + before = { + "subscription_type": account.subscription_type, + "subscription_at": account.subscription_at.isoformat() if account.subscription_at else None, + } - account.subscription_type = None if request.subscription_type == "free" else request.subscription_type - account.subscription_at = datetime.utcnow() if request.subscription_type != "free" else None + now = datetime.utcnow() + _apply_subscription_result( + account, + status=request.subscription_type, + checked_at=now, + confidence="high", + promote_reason="manual_mark_subscription", + ) db.commit() + crud.create_operation_audit_log( + db, + actor=actor, + action="account.subscription_manual_mark", + target_type="account", + target_id=account.id, + target_email=account.email, + payload={ + "before": before, + "after": { + "subscription_type": account.subscription_type, + "subscription_at": account.subscription_at.isoformat() if account.subscription_at else None, + }, + }, + ) return {"success": True, "subscription_type": request.subscription_type} + + diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index cb89f056..c6966562 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -11,7 +11,13 @@ from fastapi import APIRouter, HTTPException, Query, BackgroundTasks from pydantic import BaseModel, Field +from sqlalchemy import func +from ...config.constants import ( + RoleTag, + normalize_role_tag, + role_tag_to_account_label, +) from ...database import crud from ...database.session import get_db from ...database.models import RegistrationTask, Proxy @@ -77,6 +83,7 @@ class RegistrationTaskCreate(BaseModel): sub2api_service_ids: List[int] = [] # 指定 Sub2API 服务 ID 列表 auto_upload_tm: bool = False tm_service_ids: List[int] = [] # 指定 TM 服务 ID 列表 + registration_type: str = RoleTag.CHILD.value # none / parent / child class BatchRegistrationRequest(BaseModel): @@ -96,6 +103,7 @@ class BatchRegistrationRequest(BaseModel): sub2api_service_ids: List[int] = [] auto_upload_tm: bool = False tm_service_ids: List[int] = [] + registration_type: str = RoleTag.CHILD.value # none / parent / child class RegistrationTaskResponse(BaseModel): @@ -164,6 +172,7 @@ class OutlookBatchRegistrationRequest(BaseModel): sub2api_service_ids: List[int] = [] auto_upload_tm: bool = False tm_service_ids: List[int] = [] + registration_type: str = RoleTag.CHILD.value # none / parent / child class OutlookBatchRegistrationResponse(BaseModel): @@ -208,9 +217,6 @@ def _normalize_email_service_config( if service_type == EmailServiceType.MOE_MAIL: if 'domain' in normalized and 'default_domain' not in normalized: normalized['default_domain'] = normalized.pop('domain') - elif service_type == EmailServiceType.YYDS_MAIL: - if 'domain' in normalized and 'default_domain' not in normalized: - normalized['default_domain'] = normalized.pop('domain') elif service_type in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): if 'default_domain' in normalized and 'domain' not in normalized: normalized['domain'] = normalized.pop('default_domain') @@ -224,7 +230,7 @@ def _normalize_email_service_config( return normalized -def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None): +def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, registration_type: str = RoleTag.CHILD.value): """ 在线程池中执行的同步注册任务 @@ -288,26 +294,12 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: else: # 使用默认配置或传入的配置 if service_type == EmailServiceType.TEMPMAIL: - if not settings.tempmail_enabled: - raise ValueError("Tempmail.lol 渠道已禁用,请先在邮箱服务页面启用") config = { "base_url": settings.tempmail_base_url, "timeout": settings.tempmail_timeout, "max_retries": settings.tempmail_max_retries, "proxy_url": actual_proxy_url, } - elif service_type == EmailServiceType.YYDS_MAIL: - api_key = settings.yyds_mail_api_key.get_secret_value() if settings.yyds_mail_api_key else "" - if not settings.yyds_mail_enabled or not api_key: - raise ValueError("YYDS Mail 渠道未启用或未配置 API Key,请先在邮箱服务页面配置") - config = { - "base_url": settings.yyds_mail_base_url, - "api_key": api_key, - "default_domain": settings.yyds_mail_default_domain, - "timeout": settings.yyds_mail_timeout, - "max_retries": settings.yyds_mail_max_retries, - "proxy_url": actual_proxy_url, - } elif service_type == EmailServiceType.MOE_MAIL: # 检查数据库中是否有可用的自定义域名服务 from ...database.models import EmailService as EmailServiceModel @@ -346,8 +338,11 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: email = svc.config.get("email") if svc.config else None if not email: continue + normalized_email = str(email).strip().lower() # 检查是否已在 accounts 表中注册 - existing = db.query(Account).filter(Account.email == email).first() + existing = db.query(Account).filter( + func.lower(Account.email) == normalized_email + ).first() if not existing: selected_service = svc logger.info(f"选择未注册的 Outlook 账户: {email}") @@ -419,14 +414,22 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: ) # 执行注册 + role_tag = normalize_role_tag(registration_type) + account_label = role_tag_to_account_label(role_tag) result = engine.run() if result.success: # 更新代理使用时间 update_proxy_usage(db, proxy_id) + metadata = result.metadata if isinstance(result.metadata, dict) else {} + metadata["account_label"] = account_label + metadata["role_tag"] = role_tag + metadata["registration_type"] = role_tag + result.metadata = metadata + # 保存到数据库 - engine.save_to_database(result) + engine.save_to_database(result, account_label=account_label, role_tag=role_tag) # 自动上传到 CPA(可多服务) if auto_upload_cpa: @@ -555,7 +558,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: pass -async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None): +async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, registration_type: str = RoleTag.CHILD.value): """ 异步执行注册任务 @@ -588,6 +591,7 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: sub2api_service_ids or [], auto_upload_tm, tm_service_ids or [], + registration_type, ) except Exception as e: logger.error(f"线程池执行异常: {task_uuid}, 错误: {e}") @@ -640,6 +644,7 @@ async def run_batch_parallel( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + registration_type: str = RoleTag.CHILD.value, ): """ 并行模式:所有任务同时提交,Semaphore 控制最大并发数 @@ -659,6 +664,7 @@ async def _run_one(idx: int, uuid: str): auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids or [], auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [], auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids or [], + registration_type=registration_type, ) with get_db() as db: t = crud.get_registration_task(db, uuid) @@ -706,6 +712,7 @@ async def run_batch_pipeline( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + registration_type: str = RoleTag.CHILD.value, ): """ 流水线模式:每隔 interval 秒启动一个新任务,Semaphore 限制最大并发数 @@ -725,6 +732,7 @@ async def _run_and_release(idx: int, uuid: str, pfx: str): auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids or [], auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [], auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids or [], + registration_type=registration_type, ) with get_db() as db: t = crud.get_registration_task(db, uuid) @@ -796,6 +804,7 @@ async def run_batch_registration( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + registration_type: str = RoleTag.CHILD.value, ): """根据 mode 分发到并行或流水线执行""" if mode == "parallel": @@ -805,6 +814,7 @@ async def run_batch_registration( auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids, auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids, auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids, + registration_type=registration_type, ) else: await run_batch_pipeline( @@ -814,6 +824,7 @@ async def run_batch_registration( auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids, auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids, auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids, + registration_type=registration_type, ) @@ -866,6 +877,7 @@ async def start_registration( request.sub2api_service_ids, request.auto_upload_tm, request.tm_service_ids, + request.registration_type, ) return task_to_response(task) @@ -943,6 +955,7 @@ async def start_batch_registration( request.sub2api_service_ids, request.auto_upload_tm, request.tm_service_ids, + request.registration_type, ) return BatchRegistrationResponse( @@ -1094,14 +1107,11 @@ async def get_registration_stats(): func.date(RegistrationTask.created_at) == today ).group_by(RegistrationTask.status).all() - today_count = db.query(func.count(RegistrationTask.id)).filter( - func.date(RegistrationTask.created_at) == today - ).scalar() - today_by_status = {status: count for status, count in today_status_stats} today_success = int(today_by_status.get("completed", 0)) today_failed = int(today_by_status.get("failed", 0)) - today_total = int(today_count or 0) + # 今日“注册”口径:仅统计成功 + 失败(不包含 cancelled/running 等) + today_total = today_success + today_failed today_success_rate = round((today_success / today_total) * 100, 1) if today_total > 0 else 0.0 return { @@ -1122,7 +1132,6 @@ async def get_available_email_services(): 返回所有已启用的邮箱服务,包括: - tempmail: 临时邮箱(无需配置) - - yyds_mail: YYDS Mail 临时邮箱(需 API Key) - outlook: 已导入的 Outlook 账户 - moe_mail: 已配置的自定义域名服务 """ @@ -1132,19 +1141,14 @@ async def get_available_email_services(): settings = get_settings() result = { "tempmail": { - "available": bool(settings.tempmail_enabled), - "count": 1 if settings.tempmail_enabled else 0, - "services": ([{ + "available": True, + "count": 1, + "services": [{ "id": None, "name": "Tempmail.lol", "type": "tempmail", "description": "临时邮箱,自动创建" - }] if settings.tempmail_enabled else []) - }, - "yyds_mail": { - "available": False, - "count": 0, - "services": [] + }] }, "outlook": { "available": False, @@ -1178,37 +1182,7 @@ async def get_available_email_services(): } } - yyds_api_key = settings.yyds_mail_api_key.get_secret_value() if settings.yyds_mail_api_key else "" - if settings.yyds_mail_enabled and yyds_api_key: - result["yyds_mail"]["available"] = True - result["yyds_mail"]["count"] = 1 - result["yyds_mail"]["services"].append({ - "id": None, - "name": "YYDS Mail", - "type": "yyds_mail", - "default_domain": settings.yyds_mail_default_domain or None, - "description": "YYDS Mail API 临时邮箱", - }) - with get_db() as db: - yyds_mail_services = db.query(EmailServiceModel).filter( - EmailServiceModel.service_type == "yyds_mail", - EmailServiceModel.enabled == True - ).order_by(EmailServiceModel.priority.asc()).all() - - for service in yyds_mail_services: - config = service.config or {} - result["yyds_mail"]["services"].append({ - "id": service.id, - "name": service.name, - "type": "yyds_mail", - "default_domain": config.get("default_domain"), - "priority": service.priority - }) - - if yyds_mail_services: - result["yyds_mail"]["count"] = len(result["yyds_mail"]["services"]) - result["yyds_mail"]["available"] = True # 获取 Outlook 账户 outlook_services = db.query(EmailServiceModel).filter( EmailServiceModel.service_type == "outlook", @@ -1362,10 +1336,11 @@ async def get_outlook_accounts_for_registration(): for service in outlook_services: config = service.config or {} email = config.get("email") or service.name + normalized_email = str(email or "").strip().lower() # 检查是否已注册(查询 accounts 表) existing_account = db.query(Account).filter( - Account.email == email + func.lower(Account.email) == normalized_email ).first() is_registered = existing_account is not None @@ -1406,6 +1381,7 @@ async def run_outlook_batch_registration( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + registration_type: str = RoleTag.CHILD.value, ): """ 异步执行 Outlook 批量注册任务,复用通用并发逻辑 @@ -1449,6 +1425,7 @@ async def run_outlook_batch_registration( sub2api_service_ids=sub2api_service_ids, auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids, + registration_type=registration_type, ) @@ -1499,10 +1476,11 @@ async def start_outlook_batch_registration( config = service.config or {} email = config.get("email") or service.name + normalized_email = str(email or "").strip().lower() # 检查是否已注册 existing_account = db.query(Account).filter( - Account.email == email + func.lower(Account.email) == normalized_email ).first() if existing_account: @@ -1553,6 +1531,7 @@ async def start_outlook_batch_registration( request.sub2api_service_ids, request.auto_upload_tm, request.tm_service_ids, + request.registration_type, ) return OutlookBatchRegistrationResponse( @@ -1601,3 +1580,4 @@ async def cancel_outlook_batch(batch_id: str): task_manager.cancel_batch(batch_id) return {"success": True, "message": "批量任务取消请求已提交,正在让它们有序收工"} + diff --git a/src/web/routes/selfcheck.py b/src/web/routes/selfcheck.py new file mode 100644 index 00000000..24216cd7 --- /dev/null +++ b/src/web/routes/selfcheck.py @@ -0,0 +1,440 @@ +""" +系统自检 API 路由 +""" + +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import Any, Dict, Optional, List + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from ...config.settings import get_settings, update_settings +from ...core.system_selfcheck import ( + REPAIR_CATALOG, + create_selfcheck_run, + execute_repair_plan, + execute_selfcheck_run, + get_selfcheck_run, + has_running_selfcheck_run, + list_repair_rollbacks, + list_selfcheck_runs, + preview_repair_actions, + rollback_repair_plan, + run_repair_action, +) +from ..task_manager import task_manager +from ..selfcheck_scheduler import selfcheck_scheduler + +logger = logging.getLogger(__name__) +router = APIRouter() + +_selfcheck_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="selfcheck") + + +class StartSelfCheckRequest(BaseModel): + mode: str = Field(default="quick", description="quick/full") + source: str = Field(default="manual", description="manual/api/scheduler") + run_async: bool = Field(default=True, description="是否异步执行") + + +class SelfCheckScheduleRequest(BaseModel): + enabled: bool = False + interval_minutes: int = Field(default=15, ge=5, le=24 * 60) + mode: str = Field(default="quick", description="quick/full") + run_now: bool = False + + +class RepairCenterPreviewRequest(BaseModel): + run_id: int + repair_keys: Optional[List[str]] = None + + +class RepairCenterExecuteRequest(BaseModel): + run_id: int + repair_keys: List[str] + + +def _normalize_mode(value: Optional[str]) -> str: + return "full" if str(value or "").strip().lower() == "full" else "quick" + + +def _resolve_actor_header(request: Optional[Request]) -> str: + if request is None: + return "system" + for key in ("x-operator", "x-user", "x-username"): + value = str(request.headers.get(key) or "").strip() + if value: + return value[:120] + return "api" + + +def _selfcheck_task_id(run_id: int) -> str: + return f"selfcheck-{int(run_id)}" + + +def _parse_selfcheck_run_id(task_id: str) -> Optional[int]: + text = str(task_id or "").strip() + if not text: + return None + if text.startswith("selfcheck-"): + suffix = text.split("selfcheck-", 1)[1] + if suffix.isdigit(): + return int(suffix) + if text.isdigit(): + return int(text) + return None + + +def _build_running_run_payload() -> Optional[Dict[str, Any]]: + runs = list_selfcheck_runs(limit=20) + for item in runs: + if str(item.get("status")) in {"pending", "running"}: + return item + return None + + +def _run_selfcheck_async(run_id: int, mode: str, source: str, task_id: str) -> None: + acquired, running, quota = task_manager.try_acquire_domain_slot("selfcheck", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + task_manager.update_domain_task( + "selfcheck", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=reason, + error=reason, + ) + run = get_selfcheck_run(run_id) + if run and str(run.get("status") or "").lower() in {"pending", "running"}: + try: + # 触发一次受控执行,立即命中取消并写入终态,避免 pending 脏状态。 + task_manager.request_domain_task_cancel("selfcheck", task_id) + execute_selfcheck_run( + run_id, + mode=mode, + source=source, + cancel_checker=lambda: True, + ) + except Exception: + logger.debug("自检任务并发拒绝后写入终态失败: run_id=%s", run_id, exc_info=True) + return + try: + task_manager.update_domain_task( + "selfcheck", + task_id, + status="running", + started_at=datetime.utcnow().isoformat(), + message="系统自检执行中", + ) + result = execute_selfcheck_run( + run_id, + mode=mode, + source=source, + cancel_checker=lambda: task_manager.is_domain_task_cancel_requested("selfcheck", task_id), + ) + status_text = str(result.get("status") or "").strip().lower() + mapped_status = status_text if status_text in {"completed", "failed", "cancelled"} else "completed" + task_manager.update_domain_task( + "selfcheck", + task_id, + status=mapped_status, + finished_at=datetime.utcnow().isoformat(), + message=str(result.get("summary") or "系统自检执行完成"), + error=result.get("error_message"), + result=result, + progress={"completed": int(result.get("total_checks") or 0), "total": int(result.get("total_checks") or 0)}, + ) + except Exception as exc: + logger.exception("系统自检后台执行失败: run_id=%s error=%s", run_id, exc) + task_manager.update_domain_task( + "selfcheck", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=f"任务异常: {exc}", + error=str(exc), + ) + finally: + task_manager.release_domain_slot("selfcheck", task_id) + + +def _start_selfcheck_background(mode: str, source: str) -> Dict[str, Any]: + run = create_selfcheck_run(mode=mode, source=source) + run_id = int(run["id"]) + task_id = _selfcheck_task_id(run_id) + task_manager.register_domain_task( + domain="selfcheck", + task_id=task_id, + task_type="selfcheck_run", + payload={"run_id": run_id, "mode": mode, "source": source}, + progress={"completed": 0, "total": 0}, + max_retries=3, + ) + _selfcheck_executor.submit(_run_selfcheck_async, run_id, mode, source, task_id) + return get_selfcheck_run(run_id) or run + + +@router.get("/runs") +def api_list_selfcheck_runs(limit: int = 20): + return {"runs": list_selfcheck_runs(limit=limit)} + + +@router.get("/runs/{run_id}") +def api_get_selfcheck_run(run_id: int): + run = get_selfcheck_run(run_id) + if not run: + raise HTTPException(status_code=404, detail="自检任务不存在") + return run + + +@router.post("/runs/{run_id}/cancel") +def api_cancel_selfcheck_run(run_id: int): + run = get_selfcheck_run(run_id) + if not run: + raise HTTPException(status_code=404, detail="自检任务不存在") + task_id = _selfcheck_task_id(run_id) + task_manager.request_domain_task_cancel("selfcheck", task_id) + return { + "success": True, + "task_id": task_id, + "run_id": run_id, + "status": "cancelling", + } + + +def cancel_selfcheck_domain_task(task_id: str) -> Dict[str, Any]: + run_id = _parse_selfcheck_run_id(task_id) + if not run_id: + raise HTTPException(status_code=404, detail="自检任务不存在") + task_manager.request_domain_task_cancel("selfcheck", _selfcheck_task_id(run_id)) + return {"success": True, "task_id": _selfcheck_task_id(run_id), "run_id": run_id, "status": "cancelling"} + + +def retry_selfcheck_domain_task(task_id: str) -> Dict[str, Any]: + snapshot = task_manager.get_domain_task("selfcheck", task_id) + payload = dict((snapshot or {}).get("payload") or {}) + run_id = int(payload.get("run_id") or (_parse_selfcheck_run_id(task_id) or 0)) + mode = _normalize_mode(payload.get("mode") or "quick") + source = str(payload.get("source") or "manual").strip().lower() or "manual" + if run_id <= 0 and not snapshot: + raise HTTPException(status_code=404, detail="自检任务不存在") + latest = _start_selfcheck_background(mode, source) + return { + "success": True, + "message": "已创建新的自检重试任务", + "run": latest, + "retry_from": task_id, + } + + +@router.post("/runs") +def api_start_selfcheck_run(request: StartSelfCheckRequest): + mode = _normalize_mode(request.mode) + source = str(request.source or "manual").strip().lower() or "manual" + + if has_running_selfcheck_run(): + running = _build_running_run_payload() + raise HTTPException( + status_code=409, + detail={ + "message": "已有运行中的自检任务,请稍后再试", + "running_run": running, + }, + ) + + if request.run_async: + latest = _start_selfcheck_background(mode, source) + return { + "success": True, + "message": "自检任务已创建并开始执行", + "run": latest, + } + + run = create_selfcheck_run(mode=mode, source=source) + run_id = int(run["id"]) + task_id = _selfcheck_task_id(run_id) + task_manager.register_domain_task( + domain="selfcheck", + task_id=task_id, + task_type="selfcheck_run", + payload={"run_id": run_id, "mode": mode, "source": source}, + progress={"completed": 0, "total": 0}, + max_retries=3, + ) + acquired, running, quota = task_manager.try_acquire_domain_slot("selfcheck", task_id) + if not acquired: + reason = f"并发配额已满(running={running}, quota={quota})" + task_manager.update_domain_task( + "selfcheck", + task_id, + status="failed", + finished_at=datetime.utcnow().isoformat(), + message=reason, + error=reason, + ) + raise HTTPException(status_code=429, detail=reason) + try: + result = execute_selfcheck_run( + run_id, + mode=mode, + source=source, + cancel_checker=lambda: task_manager.is_domain_task_cancel_requested("selfcheck", task_id), + ) + status_text = str(result.get("status") or "").strip().lower() + mapped_status = status_text if status_text in {"completed", "failed", "cancelled"} else "completed" + task_manager.update_domain_task( + "selfcheck", + task_id, + status=mapped_status, + finished_at=datetime.utcnow().isoformat(), + message=str(result.get("summary") or "系统自检执行完成"), + error=result.get("error_message"), + result=result, + progress={"completed": int(result.get("total_checks") or 0), "total": int(result.get("total_checks") or 0)}, + ) + finally: + task_manager.release_domain_slot("selfcheck", task_id) + return { + "success": True, + "message": "自检任务执行完成", + "run": result, + } + + +@router.post("/runs/{run_id}/repairs/{repair_key}") +def api_run_selfcheck_repair(run_id: int, repair_key: str): + run = get_selfcheck_run(run_id) + if not run: + raise HTTPException(status_code=404, detail="自检任务不存在") + try: + result = run_repair_action(run_id, repair_key) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("执行自检修复动作失败: run_id=%s repair_key=%s error=%s", run_id, repair_key, exc) + raise HTTPException(status_code=500, detail=str(exc)) from exc + return { + "success": True, + "repair": result, + "run": get_selfcheck_run(run_id), + } + + +@router.get("/repairs") +def api_list_selfcheck_repairs(): + return {"repairs": REPAIR_CATALOG} + + +@router.post("/repair-center/preview") +def api_repair_center_preview(request: RepairCenterPreviewRequest): + run = get_selfcheck_run(int(request.run_id)) + if not run: + raise HTTPException(status_code=404, detail="自检任务不存在") + preview = preview_repair_actions(int(request.run_id), request.repair_keys) + return {"success": True, "preview": preview} + + +@router.post("/repair-center/execute") +def api_repair_center_execute(request: RepairCenterExecuteRequest, http_request: Request): + run = get_selfcheck_run(int(request.run_id)) + if not run: + raise HTTPException(status_code=404, detail="自检任务不存在") + try: + result = execute_repair_plan( + int(request.run_id), + request.repair_keys, + actor=_resolve_actor_header(http_request), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"success": True, "result": result} + + +@router.get("/repair-center/rollbacks") +def api_repair_center_rollbacks(limit: int = 20): + return {"success": True, "items": list_repair_rollbacks(limit=limit)} + + +@router.post("/repair-center/rollbacks/{rollback_id}/rollback") +def api_repair_center_rollback(rollback_id: str): + try: + result = rollback_repair_plan(rollback_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"success": True, "result": result} + + +@router.get("/schedule") +def api_get_selfcheck_schedule(): + settings = get_settings() + return { + "enabled": bool(getattr(settings, "selfcheck_auto_enabled", False)), + "interval_minutes": int(getattr(settings, "selfcheck_interval_minutes", 15) or 15), + "mode": _normalize_mode(getattr(settings, "selfcheck_mode", "quick")), + "runtime": selfcheck_scheduler.snapshot(), + } + + +@router.post("/schedule") +def api_update_selfcheck_schedule(request: SelfCheckScheduleRequest): + mode = _normalize_mode(request.mode) + interval_minutes = max(5, min(24 * 60, int(request.interval_minutes or 15))) + + update_settings( + selfcheck_auto_enabled=bool(request.enabled), + selfcheck_interval_minutes=interval_minutes, + selfcheck_mode=mode, + ) + + selfcheck_scheduler.notify_schedule_updated() + if request.run_now: + if request.enabled: + selfcheck_scheduler.request_run_now("manual") + elif not has_running_selfcheck_run(): + _start_selfcheck_background(mode, "manual") + + return { + "success": True, + "message": "自检定时设置已更新", + "schedule": { + "enabled": bool(request.enabled), + "interval_minutes": interval_minutes, + "mode": mode, + }, + "runtime": selfcheck_scheduler.snapshot(), + } + + +@router.post("/schedule/run-now") +def api_selfcheck_run_now(): + if has_running_selfcheck_run(): + running = _build_running_run_payload() + raise HTTPException( + status_code=409, + detail={ + "message": "已有运行中的自检任务", + "running_run": running, + }, + ) + settings = get_settings() + mode = _normalize_mode(getattr(settings, "selfcheck_mode", "quick")) + if bool(getattr(settings, "selfcheck_auto_enabled", False)): + runtime = selfcheck_scheduler.request_run_now("manual") + else: + _start_selfcheck_background(mode, "manual") + runtime = selfcheck_scheduler.snapshot() + return { + "success": True, + "message": "已请求立即执行自检", + "runtime": runtime, + } + + +@router.get("/runtime") +def api_selfcheck_runtime(): + return selfcheck_scheduler.snapshot() diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index a41799c5..c2d6037d 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -4,13 +4,15 @@ import logging import os -from typing import Optional +from typing import Optional, Any, Dict, List, Tuple, Set +from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, UploadFile, File from pydantic import BaseModel from ...config.settings import get_settings, update_settings from ...database import crud +from ...database.models import Proxy from ...database.session import get_db logger = logging.getLogger(__name__) @@ -60,6 +62,21 @@ class WebUISettings(BaseModel): access_password: Optional[str] = None +class AutoQuickRefreshSettings(BaseModel): + """账号管理自动一键刷新设置""" + enabled: bool = False + interval_minutes: int = 30 + retry_limit: int = 2 + run_now: bool = False + + +class CircuitBreakerSettings(BaseModel): + enabled: bool = True + failure_threshold: int = 5 + cooldown_seconds: int = 180 + probe_interval_seconds: int = 30 + + class AllSettings(BaseModel): """所有设置""" proxy: ProxySettings @@ -67,6 +84,37 @@ class AllSettings(BaseModel): webui: WebUISettings +def _verify_auto_quick_refresh_settings_persisted( + *, + enabled: bool, + interval_minutes: int, + retry_limit: int, +) -> Tuple[bool, List[str]]: + """保存后回读数据库,确保设置真正落库。""" + expected = { + "webui.auto_quick_refresh.enabled": "true" if enabled else "false", + "webui.auto_quick_refresh.interval_minutes": str(interval_minutes), + "webui.auto_quick_refresh.retry_limit": str(retry_limit), + } + mismatches: List[str] = [] + + try: + with get_db() as db: + for key, expected_value in expected.items(): + row = crud.get_setting(db, key) + actual_value = "" + if row and row.value is not None: + actual_value = str(row.value).strip() + if actual_value != expected_value: + mismatches.append( + f"{key}: expected={expected_value}, actual={actual_value or ''}" + ) + except Exception as e: + mismatches.append(f"db_error: {e}") + + return len(mismatches) == 0, mismatches + + # ============== API Endpoints ============== @router.get("") @@ -105,22 +153,22 @@ async def get_all_settings(): "debug": settings.debug, "has_access_password": bool(settings.webui_access_password and settings.webui_access_password.get_secret_value()), }, + "auto_quick_refresh": { + "enabled": bool(getattr(settings, "auto_quick_refresh_enabled", False)), + "interval_minutes": int(getattr(settings, "auto_quick_refresh_interval_minutes", 30) or 30), + "retry_limit": int(getattr(settings, "auto_quick_refresh_retry_limit", 2) or 2), + }, + "circuit_breaker": { + "enabled": bool(getattr(settings, "circuit_breaker_enabled", True)), + "failure_threshold": int(getattr(settings, "circuit_breaker_failure_threshold", 5) or 5), + "cooldown_seconds": int(getattr(settings, "circuit_breaker_cooldown_seconds", 180) or 180), + "probe_interval_seconds": int(getattr(settings, "circuit_breaker_probe_interval_seconds", 30) or 30), + }, "tempmail": { - "enabled": settings.tempmail_enabled, - "api_url": settings.tempmail_base_url, "base_url": settings.tempmail_base_url, "timeout": settings.tempmail_timeout, "max_retries": settings.tempmail_max_retries, }, - "yyds_mail": { - "enabled": settings.yyds_mail_enabled, - "api_url": settings.yyds_mail_base_url, - "base_url": settings.yyds_mail_base_url, - "default_domain": settings.yyds_mail_default_domain, - "timeout": settings.yyds_mail_timeout, - "max_retries": settings.yyds_mail_max_retries, - "has_api_key": bool(settings.yyds_mail_api_key and settings.yyds_mail_api_key.get_secret_value()), - }, "email_code": { "timeout": settings.email_code_timeout, "poll_interval": settings.email_code_poll_interval, @@ -128,6 +176,83 @@ async def get_all_settings(): } +@router.get("/auto-quick-refresh") +async def get_auto_quick_refresh_settings(): + """获取账号管理自动一键刷新设置与运行状态""" + from ..auto_quick_refresh_scheduler import auto_quick_refresh_scheduler + + settings = get_settings() + return { + "enabled": bool(getattr(settings, "auto_quick_refresh_enabled", False)), + "interval_minutes": int(getattr(settings, "auto_quick_refresh_interval_minutes", 30) or 30), + "retry_limit": int(getattr(settings, "auto_quick_refresh_retry_limit", 2) or 2), + "runtime": auto_quick_refresh_scheduler.snapshot(), + } + + +@router.post("/auto-quick-refresh") +async def update_auto_quick_refresh_settings(request: AutoQuickRefreshSettings): + """更新账号管理自动一键刷新设置""" + from ..auto_quick_refresh_scheduler import auto_quick_refresh_scheduler + + interval_minutes = max(5, min(24 * 60, int(request.interval_minutes or 30))) + retry_limit = max(0, min(5, int(request.retry_limit or 0))) + + update_settings( + auto_quick_refresh_enabled=bool(request.enabled), + auto_quick_refresh_interval_minutes=interval_minutes, + auto_quick_refresh_retry_limit=retry_limit, + ) + persisted_ok, mismatches = _verify_auto_quick_refresh_settings_persisted( + enabled=bool(request.enabled), + interval_minutes=interval_minutes, + retry_limit=retry_limit, + ) + if not persisted_ok: + logger.error("自动一键刷新设置落库校验失败: %s", "; ".join(mismatches)) + raise HTTPException( + status_code=500, + detail="自动一键刷新设置保存失败(数据库写入未生效),请重试", + ) + + auto_quick_refresh_scheduler.notify_schedule_updated() + if request.run_now and request.enabled: + auto_quick_refresh_scheduler.request_run_now("manual") + + return { + "success": True, + "message": "自动一键刷新设置已更新", + "schedule": { + "enabled": bool(request.enabled), + "interval_minutes": interval_minutes, + "retry_limit": retry_limit, + }, + "runtime": auto_quick_refresh_scheduler.snapshot(), + } + + +@router.get("/runtime/circuit-breaker") +async def get_circuit_breaker_settings(): + settings = get_settings() + return { + "enabled": bool(getattr(settings, "circuit_breaker_enabled", True)), + "failure_threshold": int(getattr(settings, "circuit_breaker_failure_threshold", 5) or 5), + "cooldown_seconds": int(getattr(settings, "circuit_breaker_cooldown_seconds", 180) or 180), + "probe_interval_seconds": int(getattr(settings, "circuit_breaker_probe_interval_seconds", 30) or 30), + } + + +@router.post("/runtime/circuit-breaker") +async def update_circuit_breaker_settings(request: CircuitBreakerSettings): + update_settings( + circuit_breaker_enabled=bool(request.enabled), + circuit_breaker_failure_threshold=max(1, min(20, int(request.failure_threshold or 1))), + circuit_breaker_cooldown_seconds=max(10, min(3600, int(request.cooldown_seconds or 10))), + circuit_breaker_probe_interval_seconds=max(3, min(600, int(request.probe_interval_seconds or 3))), + ) + return {"success": True, "message": "熔断器设置已更新"} + + @router.get("/proxy/dynamic") async def get_dynamic_proxy_settings(): """获取动态代理设置""" @@ -497,11 +622,7 @@ async def get_recent_logs( class TempmailSettings(BaseModel): """临时邮箱设置""" api_url: Optional[str] = None - enabled: Optional[bool] = None - yyds_api_url: Optional[str] = None - yyds_api_key: Optional[str] = None - yyds_default_domain: Optional[str] = None - yyds_enabled: Optional[bool] = None + enabled: bool = True class EmailCodeSettings(BaseModel): @@ -516,20 +637,10 @@ async def get_tempmail_settings(): settings = get_settings() return { - "tempmail": { - "api_url": settings.tempmail_base_url, - "timeout": settings.tempmail_timeout, - "max_retries": settings.tempmail_max_retries, - "enabled": settings.tempmail_enabled, - }, - "yyds_mail": { - "api_url": settings.yyds_mail_base_url, - "default_domain": settings.yyds_mail_default_domain, - "timeout": settings.yyds_mail_timeout, - "max_retries": settings.yyds_mail_max_retries, - "enabled": settings.yyds_mail_enabled, - "has_api_key": bool(settings.yyds_mail_api_key and settings.yyds_mail_api_key.get_secret_value()), - }, + "api_url": settings.tempmail_base_url, + "timeout": settings.tempmail_timeout, + "max_retries": settings.tempmail_max_retries, + "enabled": True # 临时邮箱默认可用 } @@ -540,16 +651,6 @@ async def update_tempmail_settings(request: TempmailSettings): if request.api_url: update_dict["tempmail_base_url"] = request.api_url - if request.enabled is not None: - update_dict["tempmail_enabled"] = request.enabled - if request.yyds_api_url is not None: - update_dict["yyds_mail_base_url"] = request.yyds_api_url - if request.yyds_api_key is not None: - update_dict["yyds_mail_api_key"] = request.yyds_api_key - if request.yyds_default_domain is not None: - update_dict["yyds_mail_default_domain"] = request.yyds_default_domain - if request.yyds_enabled is not None: - update_dict["yyds_mail_enabled"] = request.yyds_enabled update_settings(**update_dict) @@ -611,6 +712,106 @@ class ProxyUpdateRequest(BaseModel): priority: Optional[int] = None +class ProxyBatchImportRequest(BaseModel): + """批量导入代理请求""" + content: str + default_type: str = "http" + enabled: bool = True + overwrite_existing: bool = False + + +def _normalize_proxy_type(proxy_type: Optional[str]) -> str: + value = str(proxy_type or "http").strip().lower() + if value in {"http", "https"}: + return "http" + if value in {"socks", "socks5", "socks5h"}: + return "socks5" + return "http" + + +def _build_proxy_key(proxy_type: str, host: str, port: int, username: Optional[str]) -> Tuple[str, str, int, str]: + return ( + _normalize_proxy_type(proxy_type), + str(host or "").strip().lower(), + int(port or 0), + str(username or "").strip(), + ) + + +def _parse_proxy_import_line(line: str, default_type: str) -> Optional[Dict[str, Any]]: + raw = str(line or "").strip() + if not raw: + return None + if raw.startswith("#") or raw.startswith("//"): + return None + + # 支持 CSV: name,type,host,port[,username[,password]] + parts = [item.strip() for item in raw.split(",")] + if len(parts) >= 4 and _normalize_proxy_type(parts[1]) in {"http", "socks5"}: + name = parts[0] or "" + proxy_type = _normalize_proxy_type(parts[1]) + host = parts[2] + try: + port = int(parts[3]) + except Exception as exc: + raise ValueError("CSV 端口无效") from exc + if port <= 0 or port > 65535: + raise ValueError("端口必须在 1-65535 之间") + username = parts[4] if len(parts) >= 5 and parts[4] else None + password = parts[5] if len(parts) >= 6 and parts[5] else None + if not host: + raise ValueError("主机不能为空") + return { + "name": (name or f"{host}:{port}")[:100], + "type": proxy_type, + "host": host[:255], + "port": int(port), + "username": (username[:100] if username else None), + "password": (password[:255] if password else None), + "auth_provided": bool(username or password), + } + + # 支持 name,proxy_line 形式 + custom_name = "" + payload = raw + if "," in raw: + first, rest = raw.split(",", 1) + if rest.strip(): + custom_name = first.strip() + payload = rest.strip() + + source = payload if "://" in payload else f"{default_type}://{payload}" + try: + parsed = urlparse(source) + except Exception as exc: + raise ValueError("代理格式解析失败") from exc + + host = str(parsed.hostname or "").strip() + if not host: + raise ValueError("主机不能为空") + try: + port = int(parsed.port) if parsed.port else 0 + except Exception as exc: + raise ValueError("端口无效") from exc + if port <= 0 or port > 65535: + raise ValueError("端口必须在 1-65535 之间") + + proxy_type = _normalize_proxy_type(parsed.scheme or default_type) + username = str(parsed.username or "").strip() or None + password = str(parsed.password or "").strip() or None + name = custom_name or f"{host}:{port}" + + return { + "name": name[:100], + "type": proxy_type, + "host": host[:255], + "port": int(port), + "username": (username[:100] if username else None), + "password": (password[:255] if password else None), + "auth_provided": bool(parsed.username is not None or parsed.password is not None), + } + + @router.get("/proxies") async def get_proxies_list(enabled: Optional[bool] = None): """获取代理列表""" @@ -622,6 +823,100 @@ async def get_proxies_list(enabled: Optional[bool] = None): } +@router.post("/proxies/batch-import") +async def batch_import_proxies(request: ProxyBatchImportRequest): + """批量导入代理""" + default_type = _normalize_proxy_type(request.default_type) + lines = str(request.content or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") + if len(lines) > 5000: + raise HTTPException(status_code=400, detail="导入行数过多,最多支持 5000 行") + + parsed_rows: List[Tuple[int, Dict[str, Any]]] = [] + errors: List[Dict[str, Any]] = [] + for idx, line in enumerate(lines, start=1): + try: + parsed = _parse_proxy_import_line(line, default_type) + if parsed: + parsed_rows.append((idx, parsed)) + except Exception as exc: + errors.append({ + "line": idx, + "raw": str(line or "")[:200], + "error": str(exc), + }) + + if not parsed_rows and not errors: + raise HTTPException(status_code=400, detail="没有可导入的代理,请检查输入格式") + + created = 0 + updated = 0 + skipped = 0 + + with get_db() as db: + existing = crud.get_proxies(db, limit=100000) + existing_map: Dict[Tuple[str, str, int, str], Proxy] = {} + for p in existing: + key = _build_proxy_key(p.type, p.host, p.port, p.username) + existing_map[key] = p + + seen_keys: Set[Tuple[str, str, int, str]] = set() + for line_no, item in parsed_rows: + key = _build_proxy_key(item["type"], item["host"], item["port"], item.get("username")) + if key in seen_keys: + skipped += 1 + continue + seen_keys.add(key) + + proxy = existing_map.get(key) + try: + if proxy: + if request.overwrite_existing: + proxy.name = item["name"] + proxy.enabled = bool(request.enabled) + if item.get("auth_provided"): + proxy.username = item.get("username") + proxy.password = item.get("password") + updated += 1 + else: + skipped += 1 + continue + + db.add( + Proxy( + name=item["name"], + type=item["type"], + host=item["host"], + port=item["port"], + username=item.get("username"), + password=item.get("password"), + enabled=bool(request.enabled), + priority=0, + ) + ) + created += 1 + except Exception as exc: + errors.append({ + "line": line_no, + "raw": str(lines[line_no - 1] if line_no - 1 < len(lines) else "")[:200], + "error": str(exc), + }) + + if created or updated: + db.commit() + crud._ensure_single_default_proxy(db) + + return { + "success": True, + "total_lines": len(lines), + "valid_rows": len(parsed_rows), + "created": created, + "updated": updated, + "skipped": skipped, + "failed": len(errors), + "errors": errors[:100], + } + + @router.post("/proxies") async def create_proxy_item(request: ProxyCreateRequest): """创建代理""" diff --git a/src/web/routes/tasks.py b/src/web/routes/tasks.py new file mode 100644 index 00000000..cf0e773d --- /dev/null +++ b/src/web/routes/tasks.py @@ -0,0 +1,193 @@ +""" +统一任务中心路由(accounts/payment/selfcheck/auto_team)。 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +from ..task_manager import task_manager +from . import accounts as accounts_routes +from . import payment as payment_routes +from . import selfcheck as selfcheck_routes + +router = APIRouter() + +SUPPORTED_DOMAINS = ("accounts", "payment", "selfcheck", "auto_team") + + +class DomainQuotaRequest(BaseModel): + quota: int = Field(..., ge=1, le=64) + + +def _normalize_domain(domain: str) -> str: + text = str(domain or "").strip().lower() + if text not in SUPPORTED_DOMAINS: + raise HTTPException(status_code=400, detail=f"domain 仅支持 {', '.join(SUPPORTED_DOMAINS)}") + return text + + +def _normalize_status(value: Any) -> str: + text = str(value or "").strip().lower() + return text or "unknown" + + +def _take_recent(items: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]: + safe_limit = max(1, min(200, int(limit or 50))) + sorted_items = sorted( + items, + key=lambda row: str(row.get("created_at") or ""), + reverse=True, + ) + return sorted_items[:safe_limit] + + +def _count_status(rows: List[Dict[str, Any]]) -> Dict[str, int]: + result: Dict[str, int] = {} + for row in rows: + status = _normalize_status(row.get("status")) + result[status] = int(result.get(status, 0)) + 1 + return result + + +@router.get("/summary") +def get_tasks_summary(limit: int = Query(50, ge=1, le=200)): + now_iso = datetime.utcnow().isoformat() + domains: Dict[str, Dict[str, Any]] = {} + + for domain in SUPPORTED_DOMAINS: + rows = task_manager.list_domain_tasks(domain=domain, limit=500) + recent = _take_recent(rows, limit) + domains[domain] = { + "total": len(rows), + "by_status": _count_status(rows), + "recent": recent, + } + + return { + "success": True, + "generated_at": now_iso, + "quotas": task_manager.domain_quota_snapshot(), + "accounts": domains.get("accounts", {}), + "payment": domains.get("payment", {}), + "selfcheck": domains.get("selfcheck", {}), + "auto_team": domains.get("auto_team", {}), + "domains": domains, + } + + +@router.get("/quotas") +def get_task_domain_quotas(): + return { + "success": True, + "quotas": task_manager.domain_quota_snapshot(), + } + + +@router.post("/quotas/{domain}") +def update_task_domain_quota(domain: str, request: DomainQuotaRequest): + domain_key = _normalize_domain(domain) + quota = task_manager.set_domain_quota(domain_key, request.quota) + return { + "success": True, + "domain": domain_key, + "quota": quota, + "snapshot": task_manager.domain_quota_snapshot(), + } + + +@router.get("/{domain}/{task_id}") +def get_unified_task(domain: str, task_id: str): + domain_key = _normalize_domain(domain) + task = task_manager.get_domain_task(domain_key, task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + return {"success": True, "domain": domain_key, "task": task} + + +@router.post("/{domain}/{task_id}/cancel") +def cancel_unified_task(domain: str, task_id: str): + domain_key = _normalize_domain(domain) + if domain_key == "accounts": + return accounts_routes.cancel_account_async_task(task_id) + if domain_key == "payment": + return payment_routes.cancel_payment_op_task(task_id) + if domain_key == "selfcheck": + return selfcheck_routes.cancel_selfcheck_domain_task(task_id) + + # auto_team 目前仅支持全局取消标记(协作保留扩展点) + snapshot = task_manager.request_domain_task_cancel(domain_key, task_id) + return { + "success": True, + "domain": domain_key, + "task_id": task_id, + "status": "cancelling", + "task": snapshot, + } + + +@router.post("/{domain}/{task_id}/pause") +def pause_unified_task(domain: str, task_id: str): + domain_key = _normalize_domain(domain) + if domain_key == "accounts": + return accounts_routes.pause_account_async_task(task_id) + if domain_key == "payment": + return payment_routes.pause_payment_op_task(task_id) + + snapshot = task_manager.request_domain_task_pause(domain_key, task_id) + if not snapshot: + raise HTTPException(status_code=404, detail="任务不存在") + return { + "success": True, + "domain": domain_key, + "task_id": task_id, + "status": "paused", + "task": snapshot, + } + + +@router.post("/{domain}/{task_id}/resume") +def resume_unified_task(domain: str, task_id: str): + domain_key = _normalize_domain(domain) + if domain_key == "accounts": + return accounts_routes.resume_account_async_task(task_id) + if domain_key == "payment": + return payment_routes.resume_payment_op_task(task_id) + + snapshot = task_manager.request_domain_task_resume(domain_key, task_id) + if not snapshot: + raise HTTPException(status_code=404, detail="任务不存在") + return { + "success": True, + "domain": domain_key, + "task_id": task_id, + "status": "running", + "task": snapshot, + } + + +@router.post("/{domain}/{task_id}/retry") +def retry_unified_task(domain: str, task_id: str): + domain_key = _normalize_domain(domain) + if domain_key == "accounts": + return {"success": True, "domain": domain_key, "task": accounts_routes.retry_account_async_task(task_id)} + if domain_key == "payment": + return {"success": True, "domain": domain_key, "task": payment_routes.retry_payment_op_task(task_id)} + if domain_key == "selfcheck": + return selfcheck_routes.retry_selfcheck_domain_task(task_id) + + # auto_team 重试:仅记录请求,业务层按后续异步化接入 + snapshot = task_manager.request_domain_task_retry(domain_key, task_id) + if not snapshot: + raise HTTPException(status_code=404, detail="任务不存在") + return { + "success": True, + "domain": domain_key, + "task_id": task_id, + "message": "已记录重试请求(等待 auto_team 异步任务接入)", + "task": snapshot, + } diff --git a/src/web/routes/websocket.py b/src/web/routes/websocket.py index d864f837..a5d76890 100644 --- a/src/web/routes/websocket.py +++ b/src/web/routes/websocket.py @@ -7,6 +7,7 @@ import logging from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from ..auth import is_websocket_authenticated, websocket_auth_failure from ..task_manager import task_manager logger = logging.getLogger(__name__) @@ -24,6 +25,10 @@ async def task_websocket(websocket: WebSocket, task_uuid: str): - 客户端发送: {"type": "ping"} - 心跳 - 客户端发送: {"type": "cancel"} - 取消任务 """ + if not is_websocket_authenticated(websocket): + code, reason = websocket_auth_failure() + await websocket.close(code=code, reason=reason) + return await websocket.accept() # 注册连接(会记录当前日志数量,避免重复发送历史日志) @@ -105,6 +110,10 @@ async def batch_websocket(websocket: WebSocket, batch_id: str): - 客户端发送: {"type": "ping"} - 心跳 - 客户端发送: {"type": "cancel"} - 取消批量任务 """ + if not is_websocket_authenticated(websocket): + code, reason = websocket_auth_failure() + await websocket.close(code=code, reason=reason) + return await websocket.accept() # 注册连接(会记录当前日志数量,避免重复发送历史日志) diff --git a/src/web/selfcheck_scheduler.py b/src/web/selfcheck_scheduler.py new file mode 100644 index 00000000..a9b141d2 --- /dev/null +++ b/src/web/selfcheck_scheduler.py @@ -0,0 +1,247 @@ +""" +系统自检定时调度器 +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from ..config.settings import get_settings +from ..core.system_selfcheck import create_selfcheck_run, execute_selfcheck_run, has_running_selfcheck_run + +logger = logging.getLogger(__name__) + +SELFCHECK_MIN_INTERVAL_MINUTES = 5 +SELFCHECK_MAX_INTERVAL_MINUTES = 24 * 60 +SELFCHECK_POLL_SECONDS = 5 +SELFCHECK_BUSY_RETRY_SECONDS = 90 +SELFCHECK_FAILURE_BACKOFF_BASE_SECONDS = 30 +SELFCHECK_FAILURE_BACKOFF_MAX_SECONDS = 300 +SELFCHECK_LOG_MAX_ENTRIES = 120 +SELFCHECK_LOG_SNAPSHOT_LIMIT = 40 + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _to_iso(dt: Optional[datetime]) -> Optional[str]: + return dt.isoformat() if dt else None + + +def _clamp_int(value: Any, min_value: int, max_value: int, default: int) -> int: + try: + parsed = int(value) + except Exception: + parsed = int(default) + return max(min_value, min(max_value, parsed)) + + +def _normalize_mode(value: Any) -> str: + return "full" if str(value or "").strip().lower() == "full" else "quick" + + +class SelfCheckScheduler: + def __init__(self) -> None: + self._lock = threading.Lock() + self._running: bool = False + self._run_now_requested: bool = False + self._next_run_at: Optional[datetime] = None + self._last_started_at: Optional[datetime] = None + self._last_finished_at: Optional[datetime] = None + self._last_status: str = "idle" # idle / running / success / failed / skipped_busy + self._last_reason: str = "" + self._last_error: str = "" + self._last_run: Optional[Dict[str, Any]] = None + self._consecutive_failures: int = 0 + self._logs: List[Dict[str, str]] = [] + + def _read_schedule(self) -> Dict[str, Any]: + settings = get_settings() + enabled = bool(getattr(settings, "selfcheck_auto_enabled", False)) + interval_minutes = _clamp_int( + getattr(settings, "selfcheck_interval_minutes", 15), + SELFCHECK_MIN_INTERVAL_MINUTES, + SELFCHECK_MAX_INTERVAL_MINUTES, + 15, + ) + mode = _normalize_mode(getattr(settings, "selfcheck_mode", "quick")) + return { + "enabled": enabled, + "interval_minutes": interval_minutes, + "mode": mode, + } + + def _append_log_locked(self, level: str, message: str, when: Optional[datetime] = None) -> None: + self._logs.append( + { + "time": _to_iso(when or _utc_now()) or "", + "level": str(level or "info").lower(), + "message": str(message or "").strip(), + } + ) + if len(self._logs) > SELFCHECK_LOG_MAX_ENTRIES: + del self._logs[0 : len(self._logs) - SELFCHECK_LOG_MAX_ENTRIES] + + def _append_log(self, level: str, message: str, when: Optional[datetime] = None) -> None: + with self._lock: + self._append_log_locked(level, message, when) + + def _snapshot_locked(self) -> Dict[str, Any]: + schedule = self._read_schedule() + return { + "enabled": bool(schedule["enabled"]), + "interval_minutes": int(schedule["interval_minutes"]), + "mode": str(schedule["mode"]), + "running": bool(self._running), + "run_now_requested": bool(self._run_now_requested), + "next_run_at": _to_iso(self._next_run_at), + "last_started_at": _to_iso(self._last_started_at), + "last_finished_at": _to_iso(self._last_finished_at), + "last_status": self._last_status, + "last_reason": self._last_reason, + "last_error": self._last_error, + "last_run": self._last_run or None, + "consecutive_failures": int(self._consecutive_failures), + "logs": list(self._logs[-SELFCHECK_LOG_SNAPSHOT_LIMIT:]), + } + + def snapshot(self) -> Dict[str, Any]: + with self._lock: + return self._snapshot_locked() + + def notify_schedule_updated(self) -> Dict[str, Any]: + now = _utc_now() + schedule = self._read_schedule() + with self._lock: + if not schedule["enabled"] and not self._running: + self._next_run_at = None + self._run_now_requested = False + self._append_log_locked("info", "系统自检定时任务已禁用", now) + elif schedule["enabled"] and not self._running: + self._next_run_at = now + timedelta(minutes=int(schedule["interval_minutes"])) + self._append_log_locked( + "info", + f"系统自检定时任务已启用,每 {int(schedule['interval_minutes'])} 分钟执行", + now, + ) + return self._snapshot_locked() + + def request_run_now(self, reason: str = "manual") -> Dict[str, Any]: + now = _utc_now() + with self._lock: + self._run_now_requested = True + if not self._running: + self._next_run_at = now + self._last_reason = str(reason or "manual") + self._append_log_locked("info", "已请求立即执行一次系统自检", now) + return self._snapshot_locked() + + async def run_loop(self) -> None: + logger.info("系统自检调度器启动") + self._append_log("info", "调度器已启动") + while True: + try: + await self._tick_once() + await asyncio.sleep(SELFCHECK_POLL_SECONDS) + except asyncio.CancelledError: + logger.info("系统自检调度器已停止") + self._append_log("info", "调度器已停止") + break + except Exception as exc: + logger.warning("系统自检调度器异常: %s", exc) + await asyncio.sleep(SELFCHECK_POLL_SECONDS) + + async def _tick_once(self) -> None: + schedule = self._read_schedule() + now = _utc_now() + + should_start = False + reason = "scheduled" + with self._lock: + if not schedule["enabled"]: + if not self._running: + self._next_run_at = None + self._run_now_requested = False + return + + if self._running: + return + + if self._next_run_at is None: + self._next_run_at = now + timedelta(minutes=int(schedule["interval_minutes"])) + return + + if self._run_now_requested or now >= self._next_run_at: + should_start = True + reason = "manual" if self._run_now_requested else "scheduled" + self._running = True + self._run_now_requested = False + self._last_status = "running" + self._last_reason = reason + self._last_error = "" + self._last_started_at = now + self._last_finished_at = None + self._append_log_locked("info", f"开始执行系统自检({reason})", now) + + if should_start: + asyncio.create_task(self._run_once(schedule, reason)) + + async def _run_once(self, schedule: Dict[str, Any], reason: str) -> None: + mode = _normalize_mode(schedule.get("mode")) + status = "failed" + error = "" + run_payload: Optional[Dict[str, Any]] = None + + try: + run_payload, status, error = await asyncio.to_thread(self._execute_once, mode, reason) + except Exception as exc: + status = "failed" + error = str(exc) + + now = _utc_now() + interval_minutes = int(schedule.get("interval_minutes") or 15) + with self._lock: + self._running = False + self._last_finished_at = now + self._last_status = status + self._last_error = error + self._last_run = run_payload + + if status == "success": + self._consecutive_failures = 0 + self._next_run_at = now + timedelta(minutes=interval_minutes) + summary = str((run_payload or {}).get("summary") or "执行完成") + self._append_log_locked("success", f"系统自检完成:{summary}", now) + elif status == "skipped_busy": + self._consecutive_failures = 0 + self._next_run_at = now + timedelta(seconds=SELFCHECK_BUSY_RETRY_SECONDS) + self._append_log_locked("warning", "已有运行中的自检任务,本轮跳过", now) + else: + self._consecutive_failures += 1 + backoff_seconds = min( + SELFCHECK_FAILURE_BACKOFF_MAX_SECONDS, + SELFCHECK_FAILURE_BACKOFF_BASE_SECONDS * (2 ** max(0, self._consecutive_failures - 1)), + ) + self._next_run_at = now + timedelta(seconds=backoff_seconds) + self._append_log_locked("error", f"系统自检失败:{error or 'unknown_error'}", now) + + @staticmethod + def _execute_once(mode: str, reason: str) -> tuple[Optional[Dict[str, Any]], str, str]: + if has_running_selfcheck_run(): + return None, "skipped_busy", "" + + source = "scheduler" if reason == "scheduled" else "manual" + run = create_selfcheck_run(mode=mode, source=source) + run_id = int(run["id"]) + result = execute_selfcheck_run(run_id, mode=mode, source=source) + if str(result.get("status")) in {"completed"}: + return result, "success", "" + return result, "failed", str(result.get("error_message") or "存在失败检查项") + + +selfcheck_scheduler = SelfCheckScheduler() diff --git a/src/web/services/__init__.py b/src/web/services/__init__.py new file mode 100644 index 00000000..6e1430d3 --- /dev/null +++ b/src/web/services/__init__.py @@ -0,0 +1,4 @@ +""" +Web 服务层(Service)。 +""" + diff --git a/src/web/services/accounts_service.py b/src/web/services/accounts_service.py new file mode 100644 index 00000000..74f9f402 --- /dev/null +++ b/src/web/services/accounts_service.py @@ -0,0 +1,17 @@ +""" +账号业务服务层:对路由层暴露稳定的查询/聚合接口。 +""" + +from __future__ import annotations + +from typing import Dict, Iterator + +from ..repositories.account_repository import iter_query_in_batches, query_role_tag_counts + + +def stream_accounts(query, *, batch_size: int = 200) -> Iterator: + return iter_query_in_batches(query, batch_size=batch_size) + + +def get_role_tag_counts(db) -> Dict[str, int]: + return query_role_tag_counts(db) diff --git a/src/web/task_manager.py b/src/web/task_manager.py index fde9adf7..7acbfb0a 100644 --- a/src/web/task_manager.py +++ b/src/web/task_manager.py @@ -7,7 +7,7 @@ import logging import threading from concurrent.futures import ThreadPoolExecutor -from typing import Dict, Optional, List, Callable, Any +from typing import Dict, Optional, List, Callable, Any, Set, Tuple from collections import defaultdict from datetime import datetime @@ -41,6 +41,18 @@ _batch_logs: Dict[str, List[str]] = defaultdict(list) _batch_locks: Dict[str, threading.Lock] = {} +# 统一任务中心(跨模块任务状态) +_DOMAIN_DEFAULT_QUOTAS: Dict[str, int] = { + "accounts": 6, + "payment": 4, + "auto_team": 3, + "selfcheck": 2, +} +_domain_tasks: Dict[str, Dict[str, Dict[str, Any]]] = defaultdict(dict) +_domain_running: Dict[str, Set[str]] = defaultdict(set) +_domain_quotas: Dict[str, int] = dict(_DOMAIN_DEFAULT_QUOTAS) +_domain_lock = threading.Lock() + def _get_log_lock(task_uuid: str) -> threading.Lock: """线程安全地获取或创建任务日志锁""" @@ -391,6 +403,260 @@ def callback() -> bool: return self.is_cancelled(task_uuid) return callback + # ============== 统一任务中心(accounts/payment/auto_team/selfcheck) ============== + + def _ensure_domain_task_locked( + self, + *, + domain: str, + task_id: str, + task_type: Optional[str] = None, + payload: Optional[Dict[str, Any]] = None, + progress: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + domain_key = str(domain or "").strip().lower() + if not domain_key: + raise ValueError("domain 不能为空") + task_key = str(task_id or "").strip() + if not task_key: + raise ValueError("task_id 不能为空") + + tasks = _domain_tasks.setdefault(domain_key, {}) + task = tasks.get(task_key) + if task is None: + task = { + "id": task_key, + "domain": domain_key, + "task_type": str(task_type or "unknown"), + "status": "pending", + "message": "任务已创建,等待执行", + "created_at": datetime.utcnow().isoformat(), + "started_at": None, + "finished_at": None, + "cancel_requested": False, + "pause_requested": False, + "paused": False, + "retry_count": 0, + "max_retries": 0, + "payload": dict(payload or {}), + "progress": dict(progress or {}), + "result": None, + "error": None, + "details": [], + "_created_ts": datetime.utcnow().timestamp(), + } + tasks[task_key] = task + else: + if payload: + task.setdefault("payload", {}).update(dict(payload)) + if progress: + task.setdefault("progress", {}).update(dict(progress)) + if task_type is not None and str(task_type).strip(): + task["task_type"] = str(task_type) + return task + + @staticmethod + def _domain_task_snapshot(task: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": task.get("id"), + "domain": task.get("domain"), + "task_type": task.get("task_type"), + "status": task.get("status"), + "message": task.get("message"), + "created_at": task.get("created_at"), + "started_at": task.get("started_at"), + "finished_at": task.get("finished_at"), + "cancel_requested": bool(task.get("cancel_requested")), + "pause_requested": bool(task.get("pause_requested")), + "paused": bool(task.get("paused")), + "retry_count": int(task.get("retry_count") or 0), + "max_retries": int(task.get("max_retries") or 0), + "payload": dict(task.get("payload") or {}), + "progress": dict(task.get("progress") or {}), + "result": task.get("result"), + "error": task.get("error"), + "details": list(task.get("details") or []), + } + + def set_domain_quota(self, domain: str, quota: int) -> int: + domain_key = str(domain or "").strip().lower() + safe_quota = max(1, int(quota or 1)) + with _domain_lock: + _domain_quotas[domain_key] = safe_quota + return safe_quota + + def get_domain_quota(self, domain: str) -> int: + domain_key = str(domain or "").strip().lower() + with _domain_lock: + return int(_domain_quotas.get(domain_key, _DOMAIN_DEFAULT_QUOTAS.get(domain_key, 2))) + + def get_domain_running_count(self, domain: str) -> int: + domain_key = str(domain or "").strip().lower() + with _domain_lock: + return len(_domain_running.get(domain_key, set())) + + def register_domain_task( + self, + *, + domain: str, + task_id: str, + task_type: str, + payload: Optional[Dict[str, Any]] = None, + progress: Optional[Dict[str, Any]] = None, + max_retries: int = 0, + ) -> Dict[str, Any]: + with _domain_lock: + task = self._ensure_domain_task_locked( + domain=domain, + task_id=task_id, + task_type=task_type, + payload=payload, + progress=progress, + ) + task["max_retries"] = max(0, int(max_retries or 0)) + return self._domain_task_snapshot(task) + + def update_domain_task(self, domain: str, task_id: str, **fields) -> Optional[Dict[str, Any]]: + with _domain_lock: + task_type = fields.pop("task_type", None) + task = self._ensure_domain_task_locked( + domain=domain, + task_id=task_id, + task_type=str(task_type) if task_type is not None else None, + ) + progress = fields.pop("progress", None) + details = fields.pop("details", None) + if progress is not None: + task.setdefault("progress", {}).update(dict(progress or {})) + if details is not None: + task["details"] = list(details or []) + task.update(fields) + if task.get("status") in {"completed", "failed", "cancelled"}: + _domain_running.get(str(domain).strip().lower(), set()).discard(str(task_id)) + return self._domain_task_snapshot(task) + + def append_domain_task_detail(self, domain: str, task_id: str, detail: Dict[str, Any], max_items: int = 500) -> None: + with _domain_lock: + task = self._ensure_domain_task_locked(domain=domain, task_id=task_id) + details = task.setdefault("details", []) + details.append(dict(detail or {})) + if len(details) > max_items: + task["details"] = details[-max_items:] + + def set_domain_task_progress(self, domain: str, task_id: str, **progress_fields) -> None: + with _domain_lock: + task = self._ensure_domain_task_locked(domain=domain, task_id=task_id) + task.setdefault("progress", {}).update(dict(progress_fields or {})) + + def get_domain_task(self, domain: str, task_id: str) -> Optional[Dict[str, Any]]: + domain_key = str(domain or "").strip().lower() + task_key = str(task_id or "").strip() + if not domain_key or not task_key: + return None + with _domain_lock: + task = _domain_tasks.get(domain_key, {}).get(task_key) + return self._domain_task_snapshot(task) if task else None + + def list_domain_tasks(self, domain: Optional[str] = None, limit: int = 100) -> List[Dict[str, Any]]: + safe_limit = max(1, min(500, int(limit or 100))) + with _domain_lock: + if domain: + domain_key = str(domain).strip().lower() + tasks = list(_domain_tasks.get(domain_key, {}).values()) + else: + tasks = [] + for by_domain in _domain_tasks.values(): + tasks.extend(by_domain.values()) + tasks.sort(key=lambda item: float(item.get("_created_ts", 0.0)), reverse=True) + return [self._domain_task_snapshot(item) for item in tasks[:safe_limit]] + + def request_domain_task_cancel(self, domain: str, task_id: str) -> Optional[Dict[str, Any]]: + with _domain_lock: + task = self._ensure_domain_task_locked(domain=domain, task_id=task_id) + task["cancel_requested"] = True + if str(task.get("status") or "").lower() in {"pending", "running"}: + task["message"] = "已提交取消请求,等待任务结束" + return self._domain_task_snapshot(task) + + def is_domain_task_cancel_requested(self, domain: str, task_id: str) -> bool: + with _domain_lock: + task = _domain_tasks.get(str(domain or "").strip().lower(), {}).get(str(task_id or "").strip()) + return bool(task and task.get("cancel_requested")) + + def request_domain_task_pause(self, domain: str, task_id: str) -> Optional[Dict[str, Any]]: + with _domain_lock: + task = self._ensure_domain_task_locked(domain=domain, task_id=task_id) + status = str(task.get("status") or "").strip().lower() + if status in {"completed", "failed", "cancelled"}: + return self._domain_task_snapshot(task) + task["pause_requested"] = True + task["paused"] = True + if status in {"pending", "running", "paused"}: + task["status"] = "paused" + task["message"] = "任务已暂停,等待继续" + return self._domain_task_snapshot(task) + + def request_domain_task_resume(self, domain: str, task_id: str) -> Optional[Dict[str, Any]]: + with _domain_lock: + task = self._ensure_domain_task_locked(domain=domain, task_id=task_id) + status = str(task.get("status") or "").strip().lower() + if status in {"completed", "failed", "cancelled"}: + return self._domain_task_snapshot(task) + task["pause_requested"] = False + task["paused"] = False + if status == "paused": + task["status"] = "running" + task["message"] = "任务已继续执行" + return self._domain_task_snapshot(task) + + def is_domain_task_pause_requested(self, domain: str, task_id: str) -> bool: + with _domain_lock: + task = _domain_tasks.get(str(domain or "").strip().lower(), {}).get(str(task_id or "").strip()) + return bool(task and task.get("pause_requested")) + + def request_domain_task_retry(self, domain: str, task_id: str) -> Optional[Dict[str, Any]]: + with _domain_lock: + task = _domain_tasks.get(str(domain or "").strip().lower(), {}).get(str(task_id or "").strip()) + if not task: + return None + task["retry_requested"] = True + return self._domain_task_snapshot(task) + + def try_acquire_domain_slot(self, domain: str, task_id: str) -> Tuple[bool, int, int]: + domain_key = str(domain or "").strip().lower() + task_key = str(task_id or "").strip() + with _domain_lock: + quota = int(_domain_quotas.get(domain_key, _DOMAIN_DEFAULT_QUOTAS.get(domain_key, 2))) + running_set = _domain_running.setdefault(domain_key, set()) + if task_key in running_set: + return True, len(running_set), quota + if len(running_set) >= quota: + return False, len(running_set), quota + running_set.add(task_key) + task = self._ensure_domain_task_locked(domain=domain_key, task_id=task_key) + task["status"] = "running" + task["started_at"] = task.get("started_at") or datetime.utcnow().isoformat() + task["message"] = task.get("message") or "任务执行中" + return True, len(running_set), quota + + def release_domain_slot(self, domain: str, task_id: str) -> None: + with _domain_lock: + _domain_running.get(str(domain or "").strip().lower(), set()).discard(str(task_id or "").strip()) + + def domain_quota_snapshot(self) -> Dict[str, Dict[str, int]]: + with _domain_lock: + domains = set(_domain_quotas.keys()) | set(_domain_running.keys()) | set(_DOMAIN_DEFAULT_QUOTAS.keys()) + snapshot: Dict[str, Dict[str, int]] = {} + for domain in sorted(domains): + quota = int(_domain_quotas.get(domain, _DOMAIN_DEFAULT_QUOTAS.get(domain, 2))) + running = len(_domain_running.get(domain, set())) + snapshot[domain] = { + "quota": quota, + "running": running, + "available": max(0, quota - running), + } + return snapshot + # 全局实例 task_manager = TaskManager() diff --git a/static/css/style.css b/static/css/style.css index f1cff73d..4cf71aae 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -6,10 +6,12 @@ /* CSS 变量 - 亮色主题 */ :root { /* 主色调 */ - --primary-color: #10a37f; - --primary-hover: #0d8a6a; - --primary-light: rgba(16, 163, 127, 0.1); - --primary-dark: #0a7d5e; + --primary-color: #6366F1; + --primary-hover: #5558e6; + --primary-light: rgba(99, 102, 241, 0.16); + --primary-dark: #6366F1; + --secondary-accent: #6366F1; + --brand-gradient: linear-gradient(135deg, #6366F1 0%, #6366F1 100%); /* 语义色 */ --danger-color: #ef4444; @@ -17,8 +19,8 @@ --danger-light: rgba(239, 68, 68, 0.1); --warning-color: #f59e0b; --warning-light: rgba(245, 158, 11, 0.1); - --success-color: #22c55e; - --success-light: rgba(34, 197, 94, 0.1); + --success-color: #6366F1; + --success-light: rgba(99, 102, 241, 0.12); --info-color: #3b82f6; --info-light: rgba(59, 130, 246, 0.1); @@ -63,22 +65,26 @@ --spacing-md: 16px; --spacing-lg: 24px; --spacing-xl: 32px; + --control-height: 40px; + --control-height-sm: 32px; } /* 暗色主题 */ [data-theme="dark"] { - --primary-color: #34d399; - --primary-hover: #6ee7b7; - --primary-light: rgba(52, 211, 153, 0.15); - --primary-dark: #10b981; + --primary-color: #6366F1; + --primary-hover: #5558e6; + --primary-light: rgba(99, 102, 241, 0.2); + --primary-dark: #6366F1; + --secondary-accent: #6366F1; + --brand-gradient: linear-gradient(135deg, #6366F1 0%, #6366F1 100%); --danger-color: #f87171; --danger-hover: #fca5a5; --danger-light: rgba(248, 113, 113, 0.15); --warning-color: #fbbf24; --warning-light: rgba(251, 191, 36, 0.15); - --success-color: #4ade80; - --success-light: rgba(74, 222, 128, 0.15); + --success-color: #6366F1; + --success-light: rgba(99, 102, 241, 0.2); --info-color: #60a5fa; --info-light: rgba(96, 165, 250, 0.15); @@ -121,67 +127,6 @@ body { -moz-osx-font-smoothing: grayscale; } -.site-notice-wrapper { - padding: var(--spacing-md) 0 0; -} - -.site-notice { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - padding: 14px 18px; - border: 1px solid rgba(245, 158, 11, 0.28); - border-radius: var(--radius-lg); - background: linear-gradient(135deg, rgba(245, 158, 11, 0.14), rgba(16, 163, 127, 0.08)); - box-shadow: var(--shadow-sm); -} - -.site-notice-heading { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; - color: var(--text-primary); - font-size: 0.95rem; -} - -.site-notice-heading strong { - color: #b45309; -} - -.site-notice-text { - color: var(--text-secondary); - font-size: 0.875rem; - margin: 0; -} - -.site-notice-links { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.site-notice-links a { - display: inline-flex; - align-items: center; - min-height: 36px; - padding: 0 14px; - border-radius: var(--radius-full); - background: var(--surface); - border: 1px solid var(--border); - color: var(--primary-color); - text-decoration: none; - font-size: 0.875rem; - font-weight: 600; - transition: all var(--transition); -} - -.site-notice-links a:hover { - color: var(--primary-hover); - border-color: var(--primary-color); - transform: translateY(-1px); -} - /* ============================================ 布局 ============================================ */ @@ -264,7 +209,17 @@ body { } /* 主题切换按钮 */ +.nav-actions { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); +} + +.selfcheck-toggle, .theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); @@ -272,14 +227,31 @@ body { cursor: pointer; color: var(--text-secondary); transition: all var(--transition); - margin-left: var(--spacing-md); + text-decoration: none; + font-size: 1rem; + line-height: 1; } +.selfcheck-toggle { + margin-left: 0; +} + +.theme-toggle { + margin-left: 0; +} + +.selfcheck-toggle:hover, .theme-toggle:hover { color: var(--text-primary); background: var(--surface-hover); } +.selfcheck-toggle.active { + color: var(--primary-color); + border-color: var(--primary-color); + background: var(--primary-light); +} + /* ============================================ 主内容区 ============================================ */ @@ -380,6 +352,11 @@ body { transition: all var(--transition); } +.form-group input, +.form-group select { + height: var(--control-height); +} + .form-group input:hover, .form-group select:hover, .form-group textarea:hover { @@ -435,6 +412,7 @@ body { justify-content: center; gap: var(--spacing-sm); padding: 10px 20px; + min-height: var(--control-height); font-size: 0.875rem; font-weight: 500; font-family: inherit; @@ -451,12 +429,12 @@ body { } .btn-primary { - background: var(--primary-color); + background: var(--brand-gradient); color: white; } .btn-primary:hover:not(:disabled) { - background: var(--primary-hover); + filter: brightness(1.03); transform: translateY(-1px); box-shadow: var(--shadow-md); } @@ -487,12 +465,12 @@ body { } .btn-success { - background: #10a37f; + background: var(--brand-gradient); color: #fff; } .btn-success:hover:not(:disabled) { - background: #0f8f70; + filter: brightness(1.03); transform: translateY(-1px); box-shadow: var(--shadow-md); } @@ -542,6 +520,7 @@ body { .btn-sm { padding: 6px 12px; + min-height: var(--control-height-sm); font-size: 0.75rem; } @@ -1122,7 +1101,7 @@ body { } .progress-bar { - background: linear-gradient(90deg, var(--primary-color), var(--primary-dark)); + background: var(--brand-gradient); height: 100%; border-radius: var(--radius-full); transition: width 0.3s ease; @@ -1132,7 +1111,7 @@ body { .progress-bar.indeterminate { background: linear-gradient(90deg, transparent 0%, - var(--primary-color) 50%, + var(--secondary-accent) 50%, transparent 100%); animation: indeterminate 1.5s infinite linear; } @@ -1311,19 +1290,6 @@ body { } @media (max-width: 768px) { - .site-notice { - padding: 12px 14px; - } - - .site-notice-heading { - font-size: 0.9rem; - } - - .site-notice-links a { - width: 100%; - justify-content: center; - } - .container { padding: 0 var(--spacing-md); } diff --git a/static/js/accounts.js b/static/js/accounts.js index bcce0243..e681e213 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -9,8 +9,218 @@ let pageSize = 20; let totalAccounts = 0; let selectedAccounts = new Set(); let isLoading = false; +let isBatchRefreshing = false; +let isBatchValidating = false; +let isBatchCheckingSubscription = false; +let isOverviewRefreshing = false; +let isQuickWorkflowRunning = false; +let quickWorkflowStepLabel = ''; let selectAllPages = false; // 是否选中了全部页 -let currentFilters = { status: '', email_service: '', search: '' }; // 当前筛选条件 +let currentFilters = { status: '', email_service: '', role_tag: '', search: '' }; // 当前筛选条件 +let autoQuickRefreshSettings = null; +let autoQuickRefreshFormDirty = false; +let isTaskPausing = false; +let isTaskResuming = false; +let pendingAccountListRefresh = null; +let pendingAccountStatsRefresh = null; +const TASK_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']); +const activeBatchTasks = { + refresh: null, + validate: null, + subscription: null, + overview: null, +}; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeTaskState(taskRef = {}) { + const status = String(taskRef?.status || '').trim().toLowerCase(); + const paused = Boolean(taskRef?.paused) || status === 'paused'; + return { ...taskRef, status, paused }; +} + +function trackBatchTask(key, taskRef = null) { + if (!Object.prototype.hasOwnProperty.call(activeBatchTasks, key)) return; + activeBatchTasks[key] = taskRef ? normalizeTaskState(taskRef) : null; + updateBatchButtons(); +} + +function patchBatchTask(key, patch = {}) { + if (!Object.prototype.hasOwnProperty.call(activeBatchTasks, key)) return; + const current = activeBatchTasks[key]; + if (!current) return; + activeBatchTasks[key] = normalizeTaskState({ ...current, ...(patch || {}) }); + updateBatchButtons(); +} + +function getRunningBatchTasks() { + return Object.entries(activeBatchTasks) + .map(([key, task]) => ({ key, ...(task || {}) })) + .filter((task) => task.id && !TASK_TERMINAL_STATUSES.has(String(task.status || '').toLowerCase())); +} + +function getPausableBatchTasks() { + return getRunningBatchTasks().filter((task) => !Boolean(task.paused)); +} + +function getResumableBatchTasks() { + return getRunningBatchTasks().filter((task) => Boolean(task.paused)); +} + +async function watchDomainTask(fetchTask, onUpdate, maxWaitMs = 20 * 60 * 1000, options = {}) { + const startedAt = Date.now(); + const poller = createAdaptivePoller({ + baseIntervalMs: Number(options.baseIntervalMs || 1200), + maxIntervalMs: Number(options.maxIntervalMs || 12000), + }); + let lastError = null; + + while (Date.now() - startedAt < maxWaitMs) { + try { + const task = await fetchTask(); + poller.recordSuccess(); + + if (typeof onUpdate === 'function') { + onUpdate(task); + } + + const status = String(task?.status || '').toLowerCase(); + if (TASK_TERMINAL_STATUSES.has(status)) { + return task; + } + } catch (error) { + lastError = error; + const statusCode = Number(error?.response?.status || 0); + if (statusCode === 404) { + throw error; + } + poller.recordError(); + } + + await sleep(poller.nextDelay({ forceSlow: !api.networkOnline })); + } + + if (lastError && lastError.message) { + throw new Error(`任务等待超时: ${lastError.message}`); + } + throw new Error('任务等待超时,请稍后刷新查看结果'); +} + +async function watchAccountTask(taskId, onUpdate, maxWaitMs = 20 * 60 * 1000) { + return watchDomainTask( + () => api.get(`/accounts/tasks/${taskId}`, { + requestKey: `accounts:task:${taskId}`, + cancelPrevious: true, + retry: 0, + timeoutMs: 30000, + silentNetworkError: true, + silentTimeoutError: true, + priority: 'low', + }), + onUpdate, + maxWaitMs, + { baseIntervalMs: 1200, maxIntervalMs: 12000 }, + ); +} + +async function watchPaymentTask(taskId, onUpdate, maxWaitMs = 20 * 60 * 1000) { + return watchDomainTask( + () => api.get(`/payment/ops/tasks/${taskId}`, { + requestKey: `payment:task:${taskId}`, + cancelPrevious: true, + retry: 0, + timeoutMs: 30000, + silentNetworkError: true, + silentTimeoutError: true, + priority: 'low', + }), + onUpdate, + maxWaitMs, + { baseIntervalMs: 1200, maxIntervalMs: 12000 }, + ); +} + +function replaceAccountRowStatus(accountId, nextStatus) { + const normalizedId = Number(accountId || 0); + const normalizedStatus = String(nextStatus || '').trim().toLowerCase(); + if (normalizedId <= 0 || !normalizedStatus) return false; + + const row = elements.table?.querySelector(`tr[data-id="${normalizedId}"]`); + if (!row) return false; + + const statusCell = row.children?.[5]; + if (!statusCell) return false; + + statusCell.innerHTML = renderAccountStatusDot(normalizedStatus, normalizedId); + return true; +} + +function collectValidatedStatusMap(taskOrResult) { + const detailRows = Array.isArray(taskOrResult?.details) + ? taskOrResult.details + : (Array.isArray(taskOrResult?.result?.details) ? taskOrResult.result.details : []); + const statusMap = new Map(); + + detailRows.forEach((detail) => { + const accountId = Number(detail?.id || 0); + const status = String(detail?.status || '').trim().toLowerCase(); + if (accountId > 0 && status) { + statusMap.set(accountId, status); + } + }); + + return statusMap; +} + +function applyValidatedStatuses(taskOrResult) { + const statusMap = collectValidatedStatusMap(taskOrResult); + let updatedCount = 0; + statusMap.forEach((status, accountId) => { + if (replaceAccountRowStatus(accountId, status)) { + updatedCount += 1; + } + }); + return updatedCount; +} + +async function refreshAccountsView(options = {}) { + const refreshStats = options.refreshStats !== false; + const refreshList = options.refreshList !== false; + const settleDelayMs = Math.max(0, Number(options.settleDelayMs || 0)); + const tasks = []; + + if (settleDelayMs > 0) { + await delay(settleDelayMs); + } + + if (refreshStats) { + if (!pendingAccountStatsRefresh) { + pendingAccountStatsRefresh = loadStats().finally(() => { + pendingAccountStatsRefresh = null; + }); + } + tasks.push(pendingAccountStatsRefresh); + } + + if (refreshList) { + if (!pendingAccountListRefresh) { + pendingAccountListRefresh = loadAccounts().finally(() => { + pendingAccountListRefresh = null; + }); + } + tasks.push(pendingAccountListRefresh); + } + + if (tasks.length > 0) { + await Promise.all(tasks); + } +} // DOM 元素 const elements = { @@ -19,14 +229,20 @@ const elements = { activeAccounts: document.getElementById('active-accounts'), expiredAccounts: document.getElementById('expired-accounts'), failedAccounts: document.getElementById('failed-accounts'), + motherAccounts: document.getElementById('mother-accounts'), + childAccounts: document.getElementById('child-accounts'), filterStatus: document.getElementById('filter-status'), filterService: document.getElementById('filter-service'), + filterRoleTag: document.getElementById('filter-role-tag'), searchInput: document.getElementById('search-input'), - refreshBtn: document.getElementById('refresh-btn'), + quickRefreshBtn: document.getElementById('quick-refresh-btn'), + autoQuickRefreshSettingsBtn: document.getElementById('auto-quick-refresh-settings-btn'), batchRefreshBtn: document.getElementById('batch-refresh-btn'), batchValidateBtn: document.getElementById('batch-validate-btn'), batchUploadBtn: document.getElementById('batch-upload-btn'), batchCheckSubBtn: document.getElementById('batch-check-sub-btn'), + batchPauseBtn: document.getElementById('batch-pause-btn'), + batchResumeBtn: document.getElementById('batch-resume-btn'), batchDeleteBtn: document.getElementById('batch-delete-btn'), exportBtn: document.getElementById('export-btn'), exportMenu: document.getElementById('export-menu'), @@ -36,13 +252,26 @@ const elements = { pageInfo: document.getElementById('page-info'), detailModal: document.getElementById('detail-modal'), modalBody: document.getElementById('modal-body'), - closeModal: document.getElementById('close-modal') + closeModal: document.getElementById('close-modal'), + autoQuickRefreshModal: document.getElementById('auto-quick-refresh-modal'), + autoQuickRefreshEnabled: document.getElementById('auto-quick-refresh-enabled'), + autoQuickRefreshInterval: document.getElementById('auto-quick-refresh-interval'), + autoQuickRefreshRetry: document.getElementById('auto-quick-refresh-retry'), + autoQuickRefreshRunNow: document.getElementById('auto-quick-refresh-run-now'), + autoQuickRefreshRuntime: document.getElementById('auto-quick-refresh-runtime'), + closeAutoQuickRefreshModalBtn: document.getElementById('close-auto-quick-refresh-modal'), + cancelAutoQuickRefreshBtn: document.getElementById('cancel-auto-quick-refresh-btn'), + saveAutoQuickRefreshBtn: document.getElementById('save-auto-quick-refresh-btn'), }; // 初始化 document.addEventListener('DOMContentLoaded', () => { loadStats(); loadAccounts(); + loadAutoQuickRefreshSettings({ silent: true }); + setInterval(() => { + loadAutoQuickRefreshSettings({ silent: true }); + }, 30000); initEventListeners(); updateBatchButtons(); // 初始化按钮状态 renderSelectAllBanner(); @@ -63,6 +292,12 @@ function initEventListeners() { loadAccounts(); }); + elements.filterRoleTag?.addEventListener('change', () => { + currentPage = 1; + resetSelectAllPages(); + loadAccounts(); + }); + // 搜索(防抖) elements.searchInput.addEventListener('input', debounce(() => { currentPage = 1; @@ -80,21 +315,17 @@ function initEventListeners() { } }); - // 刷新 - elements.refreshBtn.addEventListener('click', () => { - loadStats(); - loadAccounts(); - toast.info('已刷新'); - }); - // 批量刷新Token elements.batchRefreshBtn.addEventListener('click', handleBatchRefresh); + elements.autoQuickRefreshSettingsBtn?.addEventListener('click', openAutoQuickRefreshModal); // 批量验证Token elements.batchValidateBtn.addEventListener('click', handleBatchValidate); // 批量检测订阅 elements.batchCheckSubBtn.addEventListener('click', handleBatchCheckSubscription); + elements.batchPauseBtn?.addEventListener('click', pauseActiveBatchTasks); + elements.batchResumeBtn?.addEventListener('click', resumeActiveBatchTasks); // 上传下拉菜单 const uploadMenu = document.getElementById('upload-menu'); @@ -168,6 +399,28 @@ function initEventListeners() { } }); + elements.closeAutoQuickRefreshModalBtn?.addEventListener('click', closeAutoQuickRefreshModal); + elements.cancelAutoQuickRefreshBtn?.addEventListener('click', closeAutoQuickRefreshModal); + elements.saveAutoQuickRefreshBtn?.addEventListener('click', saveAutoQuickRefreshSettings); + [ + elements.autoQuickRefreshEnabled, + elements.autoQuickRefreshInterval, + elements.autoQuickRefreshRetry, + elements.autoQuickRefreshRunNow, + ].forEach((input) => { + input?.addEventListener('change', () => { + autoQuickRefreshFormDirty = true; + }); + input?.addEventListener('input', () => { + autoQuickRefreshFormDirty = true; + }); + }); + elements.autoQuickRefreshModal?.addEventListener('click', (e) => { + if (e.target === elements.autoQuickRefreshModal) { + closeAutoQuickRefreshModal(); + } + }); + // 点击其他地方关闭下拉菜单 document.addEventListener('click', () => { elements.exportMenu.classList.remove('active'); @@ -176,15 +429,186 @@ function initEventListeners() { }); } +function formatSchedulerTime(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleString('zh-CN'); +} + +function renderAutoQuickRefreshRuntime(runtime) { + const info = runtime || {}; + const logs = Array.isArray(info.logs) ? info.logs : []; + if (logs.length === 0) { + return ` +
执行日志
+
暂无执行日志
+ `; + } + const rows = logs + .slice(-20) + .reverse() + .map((item) => { + const levelRaw = String(item?.level || 'info').trim().toLowerCase(); + const level = ['success', 'warning', 'error', 'info'].includes(levelRaw) ? levelRaw : 'info'; + const timeText = formatSchedulerTime(item?.time); + const message = String(item?.message || '').trim() || '-'; + return ` +
+ ${escapeHtml(timeText)} + ${escapeHtml(level)} + ${escapeHtml(message)} +
+ `; + }) + .join(''); + return ` +
执行日志
+
${rows}
+ `; +} + +function updateAutoQuickRefreshButton() { + const btn = elements.autoQuickRefreshSettingsBtn; + if (!btn) return; + const enabled = Boolean(autoQuickRefreshSettings?.enabled); + const interval = Number(autoQuickRefreshSettings?.interval_minutes || 0); + const runtime = autoQuickRefreshSettings?.runtime || {}; + if (runtime.running) { + btn.textContent = '⚙️ 运行中'; + btn.title = '定时自动一键刷新正在执行'; + return; + } + if (enabled && interval > 0) { + btn.textContent = `⚙️ 定时(${interval}m)`; + btn.title = `定时自动一键刷新已启用,每 ${interval} 分钟执行`; + return; + } + btn.textContent = '⚙️ 定时'; + btn.title = '定时自动一键刷新设置'; +} + +function fillAutoQuickRefreshForm(options = {}) { + if (!autoQuickRefreshSettings) return; + let syncSettings = options.syncSettings !== false; + const syncRuntime = options.syncRuntime !== false; + const force = options.force === true; + const modalActive = elements.autoQuickRefreshModal?.classList.contains('active'); + if (!force && modalActive && autoQuickRefreshFormDirty) { + syncSettings = false; + } + + if (syncSettings && elements.autoQuickRefreshEnabled) { + elements.autoQuickRefreshEnabled.checked = Boolean(autoQuickRefreshSettings.enabled); + } + if (syncSettings && elements.autoQuickRefreshInterval) { + elements.autoQuickRefreshInterval.value = String(autoQuickRefreshSettings.interval_minutes || 30); + } + if (syncSettings && elements.autoQuickRefreshRetry) { + elements.autoQuickRefreshRetry.value = String(autoQuickRefreshSettings.retry_limit || 2); + } + if (syncSettings && elements.autoQuickRefreshRunNow) { + elements.autoQuickRefreshRunNow.checked = false; + } + if (syncRuntime && elements.autoQuickRefreshRuntime) { + elements.autoQuickRefreshRuntime.innerHTML = renderAutoQuickRefreshRuntime(autoQuickRefreshSettings.runtime || {}); + } +} + +async function loadAutoQuickRefreshSettings(options = {}) { + const silent = options.silent === true; + try { + const data = await api.get('/settings/auto-quick-refresh', { + requestKey: 'settings:auto-quick-refresh', + cancelPrevious: true, + retry: 1, + timeoutMs: 15000, + }); + autoQuickRefreshSettings = data || {}; + updateAutoQuickRefreshButton(); + if (elements.autoQuickRefreshModal?.classList.contains('active')) { + fillAutoQuickRefreshForm({ + syncSettings: !autoQuickRefreshFormDirty, + syncRuntime: true, + }); + } + } catch (error) { + if (!silent) { + toast.error('加载定时设置失败: ' + error.message); + } + } +} + +async function openAutoQuickRefreshModal() { + if (!elements.autoQuickRefreshModal) return; + await loadAutoQuickRefreshSettings({ silent: false }); + autoQuickRefreshFormDirty = false; + fillAutoQuickRefreshForm({ force: true, syncSettings: true, syncRuntime: true }); + elements.autoQuickRefreshModal.classList.add('active'); +} + +function closeAutoQuickRefreshModal() { + autoQuickRefreshFormDirty = false; + elements.autoQuickRefreshModal?.classList.remove('active'); +} + +async function saveAutoQuickRefreshSettings() { + if (!elements.saveAutoQuickRefreshBtn) return; + const enabled = Boolean(elements.autoQuickRefreshEnabled?.checked); + const interval = Math.max(5, Math.min(1440, Number(elements.autoQuickRefreshInterval?.value || 30))); + const retryLimit = Math.max(0, Math.min(5, Number(elements.autoQuickRefreshRetry?.value || 2))); + const runNow = enabled && Boolean(elements.autoQuickRefreshRunNow?.checked); + + const btn = elements.saveAutoQuickRefreshBtn; + const originalText = btn.textContent; + btn.disabled = true; + btn.textContent = '保存中...'; + + try { + await api.post('/settings/auto-quick-refresh', { + enabled, + interval_minutes: interval, + retry_limit: retryLimit, + run_now: runNow, + }, { + requestKey: 'settings:auto-quick-refresh:update', + cancelPrevious: true, + timeoutMs: 20000, + retry: 0, + }); + toast.success(runNow ? '设置已保存,已触发一次立即执行' : '定时自动一键刷新设置已保存'); + autoQuickRefreshFormDirty = false; + closeAutoQuickRefreshModal(); + await loadAutoQuickRefreshSettings({ silent: true }); + } catch (error) { + toast.error('保存定时设置失败: ' + error.message); + } finally { + btn.disabled = false; + btn.textContent = originalText; + } +} + // 加载统计信息 async function loadStats() { try { - const data = await api.get('/accounts/stats/summary'); + const data = await api.get('/accounts/stats/summary', { + requestKey: 'accounts:stats', + cancelPrevious: true, + retry: 1, + }); elements.totalAccounts.textContent = format.number(data.total || 0); elements.activeAccounts.textContent = format.number(data.by_status?.active || 0); elements.expiredAccounts.textContent = format.number(data.by_status?.expired || 0); elements.failedAccounts.textContent = format.number(data.by_status?.failed || 0); + const parentCount = Number(data.tagged_role_counts?.parent ?? data.by_role_tag?.parent ?? 0); + const childCount = Number(data.tagged_role_counts?.child ?? data.by_role_tag?.child ?? 0); + if (elements.motherAccounts) { + elements.motherAccounts.textContent = format.number(parentCount); + } + if (elements.childAccounts) { + elements.childAccounts.textContent = format.number(childCount); + } // 添加动画效果 animateValue(elements.totalAccounts, data.total || 0); @@ -221,29 +645,29 @@ async function loadAccounts() { `; // 记录当前筛选条件 - currentFilters.status = elements.filterStatus.value; - currentFilters.email_service = elements.filterService.value; - currentFilters.search = elements.searchInput.value.trim(); + currentFilters = filterProtocol.normalize({ + status: elements.filterStatus.value, + email_service: elements.filterService.value, + role_tag: elements.filterRoleTag?.value || '', + search: elements.searchInput.value.trim(), + }); - const params = new URLSearchParams({ + const params = filterProtocol.toQuery({ page: currentPage, page_size: pageSize, + status: currentFilters.status, + email_service: currentFilters.email_service, + role_tag: currentFilters.role_tag, + search: currentFilters.search, }); - - if (currentFilters.status) { - params.append('status', currentFilters.status); - } - - if (currentFilters.email_service) { - params.append('email_service', currentFilters.email_service); - } - - if (currentFilters.search) { - params.append('search', currentFilters.search); - } + const queryText = params.toString(); try { - const data = await api.get(`/accounts?${params}`); + const data = await api.get(`/accounts${queryText ? `?${queryText}` : ''}`, { + requestKey: 'accounts:list', + cancelPrevious: true, + retry: 1, + }); totalAccounts = data.total; renderAccounts(data.accounts); updatePagination(); @@ -262,6 +686,7 @@ async function loadAccounts() { `; } finally { isLoading = false; + updateBatchButtons(); } } @@ -292,6 +717,7 @@ function renderAccounts(accounts) { + ${renderAccountLabelBadge(account.account_label)} @@ -304,12 +730,12 @@ function renderAccounts(accounts) { : '-'} ${getServiceTypeText(account.email_service)} - ${renderAccountStatusDot(account.status)} + ${renderAccountStatusDot(account.status, account.id)}
${account.cpa_uploaded - ? `` - : `-`} + ? `` + : ``}
@@ -325,6 +751,7 @@ function renderAccounts(accounts) { @@ -392,7 +819,7 @@ function hasActiveSubscription(subscriptionType) { return normalized === 'plus' || normalized === 'team'; } -function renderAccountStatusDot(status) { +function renderAccountStatusDot(status, accountId) { const normalized = String(status || '').trim().toLowerCase(); const dotClass = ['active', 'expired', 'banned', 'failed'].includes(normalized) ? normalized @@ -407,15 +834,14 @@ function renderAccountStatusDot(status) { function renderSubscriptionStatus(subscriptionType) { const normalized = normalizeSubscriptionType(subscriptionType); - const subscribed = hasActiveSubscription(normalized); - const dotClass = subscribed ? 'subscribed' : 'unsubscribed'; - const label = subscribed ? normalized.toUpperCase() : 'FREE'; - const title = subscribed - ? `已订阅: ${normalized}` - : '未检测到 Plus/Team 订阅'; + const variant = (normalized === 'plus' || normalized === 'team') ? normalized : 'free'; + const label = variant.toUpperCase(); + const title = variant === 'free' + ? '未检测到 Plus/Team 订阅' + : `已订阅: ${variant}`; return ` -
- +
+ ${escapeHtml(label)}
`; @@ -454,13 +880,16 @@ function resetSelectAllPages() { // 构建批量请求体(含 select_all 和筛选参数) function buildBatchPayload(extraFields = {}) { + const filterPayload = filterProtocol.toPayload({ + status_filter: currentFilters.status, + email_service_filter: currentFilters.email_service, + search_filter: currentFilters.search, + }); if (selectAllPages) { return { ids: [], select_all: true, - status_filter: currentFilters.status || null, - email_service_filter: currentFilters.email_service || null, - search_filter: currentFilters.search || null, + ...filterPayload, ...extraFields }; } @@ -508,28 +937,135 @@ function selectAllPagesAction() { renderSelectAllBanner(); } +async function pauseActiveBatchTasks() { + const tasks = getPausableBatchTasks(); + if (!tasks.length || isTaskPausing) return; + + isTaskPausing = true; + updateBatchButtons(); + try { + const results = await Promise.allSettled( + tasks.map((task) => api.post(`/tasks/${task.domain}/${task.id}/pause`, {}, { + timeoutMs: 15000, + retry: 0, + priority: 'high', + })), + ); + let successCount = 0; + let failedCount = 0; + results.forEach((item, index) => { + if (item.status === 'fulfilled') { + successCount += 1; + patchBatchTask(tasks[index].key, { + status: 'paused', + paused: true, + pause_requested: true, + }); + return; + } + failedCount += 1; + }); + if (successCount > 0) { + toast.success(`已暂停 ${successCount} 个任务`); + } + if (failedCount > 0) { + toast.warning(`${failedCount} 个任务暂停失败`); + } + } catch (error) { + toast.error(`暂停任务失败: ${error.message}`); + } finally { + isTaskPausing = false; + updateBatchButtons(); + } +} + +async function resumeActiveBatchTasks() { + const tasks = getResumableBatchTasks(); + if (!tasks.length || isTaskResuming) return; + + isTaskResuming = true; + updateBatchButtons(); + try { + const results = await Promise.allSettled( + tasks.map((task) => api.post(`/tasks/${task.domain}/${task.id}/resume`, {}, { + timeoutMs: 15000, + retry: 0, + priority: 'high', + })), + ); + let successCount = 0; + let failedCount = 0; + results.forEach((item, index) => { + if (item.status === 'fulfilled') { + successCount += 1; + patchBatchTask(tasks[index].key, { + status: 'running', + paused: false, + pause_requested: false, + }); + return; + } + failedCount += 1; + }); + if (successCount > 0) { + toast.success(`已继续 ${successCount} 个任务`); + } + if (failedCount > 0) { + toast.warning(`${failedCount} 个任务继续失败`); + } + } catch (error) { + toast.error(`继续任务失败: ${error.message}`); + } finally { + isTaskResuming = false; + updateBatchButtons(); + } +} + // 更新批量操作按钮 function updateBatchButtons() { const count = getEffectiveCount(); - elements.batchDeleteBtn.disabled = count === 0; - elements.batchRefreshBtn.disabled = count === 0; - elements.batchValidateBtn.disabled = count === 0; - elements.batchUploadBtn.disabled = count === 0; - elements.batchCheckSubBtn.disabled = count === 0; + const baseDisabled = count === 0 || isQuickWorkflowRunning || isTaskPausing || isTaskResuming; + 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.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.batchUploadBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传'; elements.batchCheckSubBtn.textContent = count > 0 ? `🔍 检测 (${count})` : '🔍 检测订阅'; + + const pausableCount = getPausableBatchTasks().length; + const resumableCount = getResumableBatchTasks().length; + if (elements.batchPauseBtn) { + elements.batchPauseBtn.disabled = pausableCount === 0 || isTaskPausing; + elements.batchPauseBtn.textContent = isTaskPausing + ? '⏸️ 暂停中...' + : (pausableCount > 0 ? `⏸️ 暂停(${pausableCount})` : '⏸️ 暂停'); + } + if (elements.batchResumeBtn) { + elements.batchResumeBtn.disabled = resumableCount === 0 || isTaskResuming; + elements.batchResumeBtn.textContent = isTaskResuming + ? '▶️ 继续中...' + : (resumableCount > 0 ? `▶️ 继续(${resumableCount})` : '▶️ 继续'); + } } // 刷新单个账号Token async function refreshToken(id) { try { toast.info('正在刷新Token...'); - const result = await api.post(`/accounts/${id}/refresh`); + const result = await api.post(`/accounts/${id}/refresh`, {}, { + timeoutMs: 60000, + retry: 0, + }); if (result.success) { toast.success('Token刷新成功'); @@ -542,46 +1078,445 @@ async function refreshToken(id) { } } -// 批量刷新Token -async function handleBatchRefresh() { - const count = getEffectiveCount(); - if (count === 0) return; +async function validateToken(id) { + try { + toast.info('正在验证Token...'); + const result = await api.post(`/accounts/${id}/validate`, {}, { + timeoutMs: 30000, + retry: 0, + }); - const confirmed = await confirm(`确定要刷新选中的 ${count} 个账号的Token吗?`); - if (!confirmed) return; + const nextStatus = String(result?.status || '').trim().toLowerCase(); + if (nextStatus) { + replaceAccountRowStatus(id, nextStatus); + } + + if (result.valid) { + toast.success('Token 验证通过'); + } else { + toast.warning(`Token 无效: ${result.error || '未知错误'}`, 5000); + } + + await refreshAccountsView({ settleDelayMs: 80 }); + } catch (error) { + toast.error('验证失败: ' + error.message); + } +} - elements.batchRefreshBtn.disabled = true; - elements.batchRefreshBtn.textContent = '刷新中...'; +async function runBatchRefreshTask(payload, count, sourceLabel, options = {}) { + const showToast = options.showToast !== false; + const reloadAfter = options.reloadAfter !== false; + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; + isBatchRefreshing = true; + updateBatchButtons(); try { - const result = await api.post('/accounts/batch-refresh', buildBatchPayload()); - toast.success(`成功刷新 ${result.success_count} 个,失败 ${result.failed_count} 个`); - loadAccounts(); + const task = await api.post('/accounts/batch-refresh/async', payload, { + timeoutMs: 20000, + retry: 0, + requestKey: 'accounts:batch-refresh', + cancelPrevious: true, + }); + const taskId = task?.id; + if (!taskId) { + throw new Error('任务创建失败:未返回任务 ID'); + } + trackBatchTask('refresh', { + id: taskId, + domain: 'accounts', + status: task?.status || 'pending', + paused: Boolean(task?.paused), + }); + + if (showToast) { + toast.info(`${sourceLabel}任务已启动(${taskId.slice(0, 8)})`); + } + const finalTask = await watchAccountTask(taskId, (progressTask) => { + patchBatchTask('refresh', { + status: progressTask?.status || 'running', + paused: Boolean(progressTask?.paused), + pause_requested: Boolean(progressTask?.pause_requested), + }); + const progress = progressTask?.progress || {}; + const completed = Number(progress.completed || 0); + const total = Number(progress.total || count); + const paused = Boolean(progressTask?.paused) || String(progressTask?.status || '').toLowerCase() === 'paused'; + elements.batchRefreshBtn.textContent = paused ? `已暂停 ${completed}/${total}` : `刷新中 ${completed}/${total}`; + if (elements.quickRefreshBtn && !isQuickWorkflowRunning) { + elements.quickRefreshBtn.textContent = paused ? `⚡ 已暂停 ${completed}/${total}` : `⚡ 刷新中 ${completed}/${total}`; + } + if (onProgress) { + onProgress({ completed, total, task: progressTask }); + } + }); + patchBatchTask('refresh', { + status: finalTask?.status || 'completed', + paused: false, + pause_requested: false, + }); + const result = finalTask?.result || {}; + const status = String(finalTask?.status || '').toLowerCase(); + if (status === 'completed') { + if (showToast) { + toast.success(`成功刷新 ${result.success_count || 0} 个,失败 ${result.failed_count || 0} 个`); + } + } else if (status === 'cancelled') { + if (showToast) { + toast.warning(`任务已取消(成功 ${result.success_count || 0},失败 ${result.failed_count || 0})`, 5000); + } + } else { + if (showToast) { + toast.error(`任务执行失败: ${finalTask?.error || finalTask?.message || '未知错误'}`); + } + } + if (reloadAfter) { + await refreshAccountsView({ settleDelayMs: 80 }); + } + return { + ok: status === 'completed', + status, + result, + task: finalTask, + error: status === 'failed' ? (finalTask?.error || finalTask?.message || '未知错误') : null, + }; } catch (error) { - toast.error('批量刷新失败: ' + error.message); + if (showToast) { + toast.error(`${sourceLabel}失败: ${error.message}`); + } + return { + ok: false, + status: 'failed', + result: null, + task: null, + error: error.message, + }; } finally { + trackBatchTask('refresh', null); + isBatchRefreshing = false; updateBatchButtons(); } } -// 批量验证Token -async function handleBatchValidate() { - if (getEffectiveCount() === 0) return; +function buildQuickRefreshPayload() { + return { + ids: [], + select_all: true, + ...filterProtocol.toPayload({ + status_filter: currentFilters.status, + email_service_filter: currentFilters.email_service, + search_filter: currentFilters.search, + }), + }; +} - elements.batchValidateBtn.disabled = true; +async function runBatchValidateTask(payload, count, sourceLabel, options = {}) { + const showToast = options.showToast !== false; + const reloadAfter = options.reloadAfter !== false; + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; + isBatchValidating = true; + updateBatchButtons(); elements.batchValidateBtn.textContent = '验证中...'; try { - const result = await api.post('/accounts/batch-validate', buildBatchPayload()); - toast.info(`有效: ${result.valid_count},无效: ${result.invalid_count}`); - loadAccounts(); + const task = await api.post('/accounts/batch-validate/async', payload, { + timeoutMs: 20000, + retry: 0, + requestKey: 'accounts:batch-validate:async', + cancelPrevious: true, + }); + const taskId = task?.id; + if (!taskId) { + throw new Error('任务创建失败:未返回任务 ID'); + } + trackBatchTask('validate', { + id: taskId, + domain: 'accounts', + status: task?.status || 'pending', + paused: Boolean(task?.paused), + }); + + if (showToast) { + toast.info(`${sourceLabel}任务已启动(${taskId.slice(0, 8)})`); + } + + const finalTask = await watchAccountTask(taskId, (progressTask) => { + patchBatchTask('validate', { + status: progressTask?.status || 'running', + paused: Boolean(progressTask?.paused), + pause_requested: Boolean(progressTask?.pause_requested), + }); + const progress = progressTask?.progress || {}; + const completed = Number(progress.completed || 0); + const total = Number(progress.total || count); + const paused = Boolean(progressTask?.paused) || String(progressTask?.status || '').toLowerCase() === 'paused'; + elements.batchValidateBtn.textContent = paused ? `已暂停 ${completed}/${total}` : `验证中 ${completed}/${total}`; + applyValidatedStatuses(progressTask); + if (onProgress) { + onProgress({ completed, total, task: progressTask }); + } + }); + patchBatchTask('validate', { + status: finalTask?.status || 'completed', + paused: false, + pause_requested: false, + }); + + const result = finalTask?.result || {}; + const status = String(finalTask?.status || '').toLowerCase(); + if (status === 'completed') { + if (showToast) { + const workers = Number(result.worker_count || 0); + const retries = Number(result.retry_count || 0); + const durationMs = Number(result.duration_ms || 0); + let message = `有效: ${result.valid_count || 0},无效: ${result.invalid_count || 0}`; + if (workers > 0) message += `,并发: ${workers}`; + if (retries > 0) message += `,重试: ${retries}`; + if (durationMs > 0) message += `,耗时: ${durationMs}ms`; + toast.success(message); + } + } else if (status === 'cancelled') { + if (showToast) { + toast.warning(`任务已取消(有效 ${result.valid_count || 0},无效 ${result.invalid_count || 0})`, 5000); + } + } else if (showToast) { + toast.error(`任务执行失败: ${finalTask?.error || finalTask?.message || '未知错误'}`); + } + + if (reloadAfter) { + applyValidatedStatuses(finalTask); + await refreshAccountsView({ settleDelayMs: 80 }); + } + return { + ok: status === 'completed', + status, + result, + task: finalTask, + error: status === 'failed' ? (finalTask?.error || finalTask?.message || '未知错误') : null, + }; } catch (error) { - toast.error('批量验证失败: ' + error.message); + if (showToast) { + toast.error(`${sourceLabel}失败: ${error.message}`); + } + return { + ok: false, + status: 'failed', + result: null, + task: null, + error: error.message, + }; } finally { + trackBatchTask('validate', null); + isBatchValidating = false; updateBatchButtons(); } } +async function runBatchCheckSubscriptionTask(payload, count, sourceLabel, options = {}) { + const showToast = options.showToast !== false; + const reloadAfter = options.reloadAfter !== false; + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; + isBatchCheckingSubscription = true; + updateBatchButtons(); + elements.batchCheckSubBtn.textContent = '检测中...'; + + try { + const task = await api.post('/payment/accounts/batch-check-subscription/async', payload, { + timeoutMs: 20000, + retry: 0, + requestKey: 'payment:batch-check-subscription', + cancelPrevious: true, + }); + const taskId = task?.id; + if (!taskId) { + throw new Error('任务创建失败:未返回任务 ID'); + } + trackBatchTask('subscription', { + id: taskId, + domain: 'payment', + status: task?.status || 'pending', + paused: Boolean(task?.paused), + }); + + if (showToast) { + toast.info(`${sourceLabel}任务已启动(${taskId.slice(0, 8)})`); + } + const finalTask = await watchPaymentTask(taskId, (progressTask) => { + patchBatchTask('subscription', { + status: progressTask?.status || 'running', + paused: Boolean(progressTask?.paused), + pause_requested: Boolean(progressTask?.pause_requested), + }); + const progress = progressTask?.progress || {}; + const completed = Number(progress.completed || 0); + const total = Number(progress.total || count); + const paused = Boolean(progressTask?.paused) || String(progressTask?.status || '').toLowerCase() === 'paused'; + elements.batchCheckSubBtn.textContent = paused ? `已暂停 ${completed}/${total}` : `检测中 ${completed}/${total}`; + if (onProgress) { + onProgress({ completed, total, task: progressTask }); + } + }); + patchBatchTask('subscription', { + status: finalTask?.status || 'completed', + paused: false, + pause_requested: false, + }); + + const result = finalTask?.result || {}; + const status = String(finalTask?.status || '').toLowerCase(); + if (status === 'completed') { + if (showToast) { + let message = `成功: ${result.success_count || 0}`; + if ((result.failed_count || 0) > 0) message += `, 失败: ${result.failed_count || 0}`; + toast.success(message); + } + } else if (status === 'cancelled') { + if (showToast) { + toast.warning(`任务已取消(成功 ${result.success_count || 0},失败 ${result.failed_count || 0})`, 5000); + } + } else if (showToast) { + toast.error(`任务执行失败: ${finalTask?.error || finalTask?.message || '未知错误'}`); + } + + if (reloadAfter) { + await refreshAccountsView({ refreshStats: false, settleDelayMs: 80 }); + } + return { + ok: status === 'completed', + status, + result, + task: finalTask, + error: status === 'failed' ? (finalTask?.error || finalTask?.message || '未知错误') : null, + }; + } catch (error) { + if (showToast) { + toast.error(`${sourceLabel}失败: ${error.message}`); + } + return { + ok: false, + status: 'failed', + result: null, + task: null, + error: error.message, + }; + } finally { + trackBatchTask('subscription', null); + isBatchCheckingSubscription = false; + updateBatchButtons(); + } +} + +async function runOverviewRefreshTask(payload, count, sourceLabel, options = {}) { + const showToast = options.showToast !== false; + const reloadAfter = options.reloadAfter !== false; + const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; + isOverviewRefreshing = true; + updateBatchButtons(); + + try { + const task = await api.post('/accounts/overview/refresh/async', payload, { + timeoutMs: 20000, + retry: 0, + requestKey: 'accounts:overview-refresh', + cancelPrevious: true, + }); + const taskId = task?.id; + if (!taskId) { + throw new Error('任务创建失败:未返回任务 ID'); + } + trackBatchTask('overview', { + id: taskId, + domain: 'accounts', + status: task?.status || 'pending', + paused: Boolean(task?.paused), + }); + if (showToast) { + toast.info(`${sourceLabel}任务已启动(${taskId.slice(0, 8)})`); + } + + const finalTask = await watchAccountTask(taskId, (progressTask) => { + patchBatchTask('overview', { + status: progressTask?.status || 'running', + paused: Boolean(progressTask?.paused), + pause_requested: Boolean(progressTask?.pause_requested), + }); + const progress = progressTask?.progress || {}; + const completed = Number(progress.completed || 0); + const total = Number(progress.total || count); + if (onProgress) { + onProgress({ completed, total, task: progressTask }); + } + }); + patchBatchTask('overview', { + status: finalTask?.status || 'completed', + paused: false, + pause_requested: false, + }); + const result = finalTask?.result || {}; + const status = String(finalTask?.status || '').toLowerCase(); + + if (status === 'completed') { + if (showToast) { + toast.success(`总览刷新完成:成功 ${result.success_count || 0},失败 ${result.failed_count || 0}`); + } + } else if (status === 'cancelled') { + if (showToast) { + toast.warning(`任务已取消(成功 ${result.success_count || 0},失败 ${result.failed_count || 0})`, 5000); + } + } else if (showToast) { + toast.error(`任务执行失败: ${finalTask?.error || finalTask?.message || '未知错误'}`); + } + + if (reloadAfter) { + await refreshAccountsView({ refreshStats: false, settleDelayMs: 80 }); + } + return { + ok: status === 'completed', + status, + result, + task: finalTask, + error: status === 'failed' ? (finalTask?.error || finalTask?.message || '未知错误') : null, + }; + } catch (error) { + if (showToast) { + toast.error(`${sourceLabel}失败: ${error.message}`); + } + return { + ok: false, + status: 'failed', + result: null, + task: null, + error: error.message, + }; + } finally { + trackBatchTask('overview', null); + isOverviewRefreshing = false; + updateBatchButtons(); + } +} + +async function handleQuickRefreshAll() { + toast.warning('一键刷新功能已屏蔽,请使用批量验证与批量检测订阅', 4000); + return; +} + +// 批量刷新Token +async function handleBatchRefresh() { + const count = getEffectiveCount(); + if (count === 0 || isBatchRefreshing) return; + + const confirmed = await confirm(`确定要刷新选中的 ${count} 个账号的Token吗?`); + if (!confirmed) return; + + await runBatchRefreshTask(buildBatchPayload(), count, '批量刷新'); +} + +// 批量验证Token +async function handleBatchValidate() { + const count = getEffectiveCount(); + if (count === 0 || isBatchValidating) return; + await runBatchValidateTask(buildBatchPayload(), count, '批量验证'); +} + // 查看账号详情 async function viewAccount(id) { try { @@ -612,6 +1547,10 @@ async function viewAccount(id) { 邮箱服务 ${getServiceTypeText(account.email_service)}
+
+ 账号标签 + ${getAccountLabelText(account.account_label)} +
状态 @@ -907,6 +1846,27 @@ function selectCpaService() { }); } +function normalizeAccountLabel(value) { + const text = String(value || '').trim().toLowerCase(); + if (text === 'mother' || text === 'parent' || text === 'manager') return 'mother'; + if (text === 'child' || text === 'member') return 'child'; + if (text === '普通' || text === 'normal') return 'none'; + return 'none'; +} + +function getAccountLabelText(value) { + const normalized = normalizeAccountLabel(value); + if (normalized === 'mother') return '母号'; + if (normalized === 'child') return '子号'; + return '普通'; +} + +function renderAccountLabelBadge(value) { + const normalized = normalizeAccountLabel(value); + if (normalized === 'none') return ''; + return ``; +} + // 统一上传入口:弹出目标选择 async function uploadAccount(id) { const targets = [ @@ -1000,6 +1960,36 @@ async function handleBatchUploadCpa() { // ============== 订阅状态 ============== +function accountLabelToRoleTag(value) { + const normalized = normalizeAccountLabel(value); + if (normalized === 'mother') return 'parent'; + if (normalized === 'child') return 'child'; + return 'none'; +} + +// 手动标记账号标签 +async function markAccountLabel(id, currentLabel = 'none') { + const current = normalizeAccountLabel(currentLabel); + const defaultValue = current === 'mother' ? 'mother' : (current === 'child' ? 'child' : 'none'); + const input = prompt('请输入账号标号(mother=母号 / child=子号 / none=普通):', defaultValue); + if (input === null) return; + + const normalized = normalizeAccountLabel(input); + const roleTag = accountLabelToRoleTag(normalized); + const nextText = getAccountLabelText(normalized); + + const confirmed = await confirm(`确认将账号标号修改为「${nextText}」吗?`); + if (!confirmed) return; + + try { + await api.patch(`/accounts/${id}`, { role_tag: roleTag }); + toast.success(`账号标号已更新为 ${nextText}`); + loadAccounts(); + } catch (e) { + toast.error('更新标号失败: ' + e.message); + } +} + // 手动标记订阅类型 async function markSubscription(id) { const type = prompt('请输入订阅类型 (plus / team / free):', 'plus'); @@ -1022,24 +2012,10 @@ async function markSubscription(id) { // 批量检测订阅状态 async function handleBatchCheckSubscription() { const count = getEffectiveCount(); - if (count === 0) return; + if (count === 0 || isBatchCheckingSubscription) return; const confirmed = await confirm(`确定要检测选中的 ${count} 个账号的订阅状态吗?`); if (!confirmed) return; - - elements.batchCheckSubBtn.disabled = true; - elements.batchCheckSubBtn.textContent = '检测中...'; - - try { - const result = await api.post('/payment/accounts/batch-check-subscription', buildBatchPayload()); - let message = `成功: ${result.success_count}`; - if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`; - toast.success(message); - loadAccounts(); - } catch (e) { - toast.error('批量检测失败: ' + e.message); - } finally { - updateBatchButtons(); - } + await runBatchCheckSubscriptionTask(buildBatchPayload(), count, '批量检测订阅'); } // ============== Sub2API 上传 ============== diff --git a/static/js/accounts_overview.js b/static/js/accounts_overview.js index 1d360305..5d9832bd 100644 --- a/static/js/accounts_overview.js +++ b/static/js/accounts_overview.js @@ -5,6 +5,60 @@ */ const VIEW_MODE_STORAGE_KEY = 'accounts_overview_view_mode'; +const AUTO_REFRESH_SCOPE_STORAGE_KEY = 'accounts_overview_auto_refresh_scope_v1'; + +const overviewApi = { + loadStats() { + return api.get('/accounts/stats/overview', { + requestKey: 'overview:stats', + cancelPrevious: true, + retry: 1, + }); + }, + loadCards() { + return api.get('/accounts/overview/cards', { + requestKey: 'overview:cards', + cancelPrevious: true, + retry: 1, + timeoutMs: 15000, + }); + }, + startRefreshTask(payload) { + return api.post('/accounts/overview/refresh/async', payload, { + timeoutMs: 20000, + retry: 0, + requestKey: 'overview:refresh-task', + cancelPrevious: true, + }); + }, + refreshSingle(payload) { + return api.post('/accounts/overview/refresh', payload, { + timeoutMs: 60000, + retry: 0, + requestKey: `overview:refresh-single:${Number(payload?.ids?.[0] || 0)}`, + cancelPrevious: true, + }); + }, + loadSelectable() { + return api.get('/accounts/overview/cards/selectable', { + requestKey: 'overview:addable', + cancelPrevious: true, + retry: 1, + }); + }, + removeCards(payload) { + return api.post('/accounts/overview/cards/remove', payload, { + timeoutMs: 20000, + retry: 0, + }); + }, + attachCard(id) { + return api.post(`/accounts/overview/cards/${id}/attach`, {}, { + timeoutMs: 15000, + retry: 0, + }); + }, +}; const overviewState = { summary: null, @@ -19,8 +73,37 @@ const overviewState = { cardRefreshTimer: null, cardCountdownTimer: null, cardNextRefreshAt: null, + isBulkRefreshing: false, + cardAutoRefreshScope: storage.get(AUTO_REFRESH_SCOPE_STORAGE_KEY, 'stale_failed') || 'stale_failed', }; +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function watchOverviewTask(taskId, onUpdate, maxWaitMs = 20 * 60 * 1000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < maxWaitMs) { + const task = await api.get(`/accounts/tasks/${taskId}`, { + requestKey: `overview:task:${taskId}`, + cancelPrevious: true, + retry: 0, + timeoutMs: 30000, + }); + + if (typeof onUpdate === 'function') { + onUpdate(task); + } + + const status = String(task?.status || '').toLowerCase(); + if (['completed', 'failed', 'cancelled'].includes(status)) { + return task; + } + await sleep(1200); + } + throw new Error('任务等待超时,请稍后刷新查看结果'); +} + function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text ?? ''; @@ -38,13 +121,12 @@ function toSortedEntries(data) { const SERVICE_DIST_PALETTE = [ '#3b82f6', // blue - '#10b981', // emerald - '#f59e0b', // amber + '#2563eb', // deep blue + '#4f46e5', // indigo '#8b5cf6', // violet - '#ef4444', // red '#06b6d4', // cyan - '#84cc16', // lime - '#f97316', // orange + '#2f80ff', // azure + '#7c3aed', // purple ]; function hashText(text) { @@ -62,7 +144,7 @@ function getDistributionBarColor(containerId, key, index) { if (containerId === 'dist-subscription') { if (value.includes('team')) return '#8b5cf6'; // team: purple - if (value.includes('plus')) return '#10b981'; + if (value.includes('plus')) return '#2f80ff'; if (value.includes('pro')) return '#0ea5e9'; if (value.includes('free')) return '#94a3b8'; } @@ -81,7 +163,7 @@ function getDistributionBarColor(containerId, key, index) { if (containerId === 'dist-source') { if (value === 'register') return '#3b82f6'; - if (value === 'login') return '#10b981'; + if (value === 'login') return '#8b5cf6'; } return '#3b82f6'; @@ -255,9 +337,17 @@ function renderRecentAccounts(accounts) { `).join(''); } +function setToolbarRefreshLoading(loading) { + const button = document.getElementById('card-refresh-btn'); + if (!button) return; + button.disabled = Boolean(loading); + button.classList.toggle('is-loading', Boolean(loading)); + button.setAttribute('aria-busy', loading ? 'true' : 'false'); +} + async function loadLegacyOverview() { try { - const data = await api.get('/accounts/stats/overview'); + const data = await overviewApi.loadStats(); overviewState.summary = data; setText('ov-total', data.total); @@ -446,8 +536,9 @@ function renderPlanCards() { } async function loadPlanCards(forceRefresh = false) { + void forceRefresh; try { - const data = await api.get('/accounts/overview/cards'); + const data = await overviewApi.loadCards(); overviewState.cards = Array.isArray(data?.accounts) ? data.accounts : []; syncSelectedCardIds(); updatePlanFilterOptions(); @@ -463,28 +554,88 @@ async function loadPlanCards(forceRefresh = false) { } } -async function refreshSelectedOrAll(force = true, silent = false) { +function getCardsForRefresh(mode = 'visible') { + const source = Array.isArray(overviewState.filteredCards) && overviewState.filteredCards.length + ? overviewState.filteredCards + : overviewState.cards; + if (mode !== 'stale_failed') return source; + return source.filter((item) => { + const stale = Boolean(item?.overview_stale); + const hasError = Boolean(item?.overview_error); + const hourlyUnknown = String(item?.hourly_quota?.status || '').toLowerCase() === 'unknown'; + const weeklyUnknown = String(item?.weekly_quota?.status || '').toLowerCase() === 'unknown'; + return stale || hasError || (hourlyUnknown && weeklyUnknown); + }); +} + +function pickRefreshTargetIds(options = {}) { + const mode = String(options?.targetMode || 'visible'); + const preferSelected = options?.preferSelected !== false; const selectedIds = Array.from(overviewState.selectedCardIds); - const visibleIds = overviewState.filteredCards + if (preferSelected && selectedIds.length) { + return selectedIds.filter((id) => Number.isFinite(Number(id))); + } + return getCardsForRefresh(mode) .map((item) => Number(item.id)) .filter((id) => Number.isFinite(id)); - const targetIds = selectedIds.length ? selectedIds : visibleIds; +} + +async function refreshSelectedOrAll(force = true, silent = false, options = {}) { + if (overviewState.isBulkRefreshing) { + if (!silent) toast.info('刷新任务仍在执行,请稍候'); + return; + } + + const targetIds = pickRefreshTargetIds(options); + + overviewState.isBulkRefreshing = true; + setToolbarRefreshLoading(true); try { if (!targetIds.length) { await loadPlanCards(false); if (!silent) toast.info('当前没有可刷新的卡片'); return; } - - await api.post('/accounts/overview/refresh', { + const task = await overviewApi.startRefreshTask({ ids: targetIds, force, select_all: false, }); + const taskId = task?.id; + if (!taskId) { + throw new Error('任务创建失败:未返回任务 ID'); + } + + if (!silent) toast.info(`总览刷新任务已启动(${taskId.slice(0, 8)})`); + const finalTask = await watchOverviewTask(taskId, (progressTask) => { + const progress = progressTask?.progress || {}; + const completed = Number(progress.completed || 0); + const total = Number(progress.total || targetIds.length); + if (!silent && total > 0) { + const text = `刷新中 ${completed}/${total}`; + const el = document.getElementById('card-selection-info'); + if (el) el.textContent = text; + } + }); + + const status = String(finalTask?.status || '').toLowerCase(); + const result = finalTask?.result || {}; await loadPlanCards(false); - if (!silent) toast.success(`已刷新 ${targetIds.length} 个账号配额`); + updateSelectionInfo(); + if (!silent) { + if (status === 'completed') { + toast.success(`刷新完成:成功 ${result.success_count || 0},失败 ${result.failed_count || 0}`); + } else if (status === 'cancelled') { + toast.warning(`任务已取消(成功 ${result.success_count || 0},失败 ${result.failed_count || 0})`, 5000); + } else { + toast.error(`刷新失败: ${finalTask?.error || finalTask?.message || '未知错误'}`); + } + } } catch (error) { if (!silent) toast.error(`刷新失败: ${error.message || '未知错误'}`); + } finally { + overviewState.isBulkRefreshing = false; + setToolbarRefreshLoading(false); } } @@ -499,10 +650,14 @@ function setCardRefreshLoading(button, loading) { async function refreshSingleCard(accountId, button = null) { if (!Number.isFinite(Number(accountId))) return; if (button?.classList.contains('is-loading')) return; + if (overviewState.isBulkRefreshing) { + toast.info('批量刷新进行中,请稍后再刷新单卡'); + return; + } setCardRefreshLoading(button, true); toast.info(`正在刷新账号 #${accountId} 配额...`, 1500); try { - const result = await api.post('/accounts/overview/refresh', { + const result = await overviewApi.refreshSingle({ ids: [accountId], force: true, select_all: false, @@ -531,7 +686,7 @@ async function removeSingleCard(accountId) { if (!Number.isFinite(id)) return; const ok = await confirm('确认从卡片列表删除该账号吗?(不会删除账号管理数据)', '删除卡片'); if (!ok) return; - await api.post('/accounts/overview/cards/remove', { + await overviewApi.removeCards({ ids: [id], select_all: false, }); @@ -543,7 +698,7 @@ async function removeSingleCard(accountId) { async function loadAddableAccounts() { try { - const data = await api.get('/accounts/overview/cards/selectable'); + const data = await overviewApi.loadSelectable(); overviewState.addableCards = Array.isArray(data?.accounts) ? data.accounts : []; } catch (error) { overviewState.addableCards = []; @@ -591,7 +746,7 @@ async function restoreSelectedAddableAccount() { toast.warning('请先选择一个账号'); return; } - await api.post(`/accounts/overview/cards/${id}/attach`, {}); + await overviewApi.attachCard(id); await loadAddableAccounts(); await loadPlanCards(false); await loadLegacyOverview(); @@ -672,7 +827,7 @@ function setFieldValue(id, value) { async function submitAddAccount() { const selectedExisting = getSelectedExistingAccount(); if (selectedExisting) { - await api.post(`/accounts/overview/cards/${Number(selectedExisting.id)}/attach`, {}); + await overviewApi.attachCard(Number(selectedExisting.id)); closeModalById('overview-add-modal'); resetAddModalFields(); await loadAddableAccounts(); @@ -767,7 +922,7 @@ async function removeSelectedAccounts() { const ok = await confirm(`确认从卡片列表删除 ${ids.length} 个账号吗?(不会删除账号管理数据)`, '批量删除卡片'); if (!ok) return; - const result = await api.post('/accounts/overview/cards/remove', { ids, select_all: false }); + const result = await overviewApi.removeCards({ ids, select_all: false }); const removedCount = Number(result?.removed_count || 0); toast.success(`删除完成:${removedCount} 个卡片`); overviewState.selectedCardIds.clear(); @@ -822,7 +977,11 @@ function restartCardAutoRefresh() { updateCardNextRefreshText(); overviewState.cardRefreshTimer = setInterval(async () => { - await refreshSelectedOrAll(true, true); + if (document.hidden) return; + await refreshSelectedOrAll(true, true, { + targetMode: overviewState.cardAutoRefreshScope, + preferSelected: false, + }); overviewState.cardNextRefreshAt = Date.now() + intervalMs; updateCardNextRefreshText(); }, intervalMs); @@ -838,14 +997,6 @@ function restartCardAutoRefresh() { } function bindEvents() { - const legacyRefreshBtn = document.getElementById('legacy-refresh-btn'); - if (legacyRefreshBtn) { - legacyRefreshBtn.addEventListener('click', async () => { - await loadLegacyOverview(); - await loadPlanCards(true); - }); - } - const cardSearchInput = document.getElementById('card-search-input'); if (cardSearchInput) { cardSearchInput.addEventListener('input', debounce(applyCardFilters, 240)); @@ -891,6 +1042,22 @@ function bindEvents() { restartCardAutoRefresh(); }); } + const cardRefreshScope = document.getElementById('card-refresh-scope'); + if (cardRefreshScope) { + const nextScope = ['stale_failed', 'visible'].includes(overviewState.cardAutoRefreshScope) + ? overviewState.cardAutoRefreshScope + : 'stale_failed'; + overviewState.cardAutoRefreshScope = nextScope; + cardRefreshScope.value = nextScope; + cardRefreshScope.addEventListener('change', () => { + const value = ['stale_failed', 'visible'].includes(cardRefreshScope.value) + ? cardRefreshScope.value + : 'stale_failed'; + overviewState.cardAutoRefreshScope = value; + storage.set(AUTO_REFRESH_SCOPE_STORAGE_KEY, value); + restartCardAutoRefresh(); + }); + } const addBtn = document.getElementById('card-add-btn'); if (addBtn) { @@ -905,7 +1072,10 @@ function bindEvents() { if (toolbarRefreshBtn) { toolbarRefreshBtn.addEventListener('click', async () => { try { - await refreshSelectedOrAll(true, true); + await refreshSelectedOrAll(true, true, { + targetMode: 'visible', + preferSelected: true, + }); await Promise.all([ loadLegacyOverview(), loadAddableAccounts(), @@ -916,6 +1086,23 @@ function bindEvents() { } }); } + const toolbarRefreshFailedBtn = document.getElementById('card-refresh-failed-btn'); + if (toolbarRefreshFailedBtn) { + toolbarRefreshFailedBtn.addEventListener('click', async () => { + try { + await refreshSelectedOrAll(true, false, { + targetMode: 'stale_failed', + preferSelected: false, + }); + await Promise.all([ + loadLegacyOverview(), + loadAddableAccounts(), + ]); + } catch (error) { + toast.error(`刷新失败: ${error?.message || '未知错误'}`); + } + }); + } const importBtn = document.getElementById('card-import-btn'); if (importBtn) { @@ -1042,7 +1229,7 @@ document.addEventListener('DOMContentLoaded', async () => { ]); initResults.forEach((item, index) => { if (item.status === 'rejected') { - const target = index === 0 ? '总览统计' : '卡片列表'; + const target = index === 0 ? '总览统计' : (index === 1 ? '卡片列表' : '可选账号'); toast.warning(`${target}初始化失败,已降级显示`); } }); diff --git a/static/js/app.js b/static/js/app.js index 8fd65b6d..4cb3f204 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -11,6 +11,11 @@ let batchPollingInterval = null; let accountsPollingInterval = null; let todayStatsPollingInterval = null; let todayStatsResetInterval = null; +let logPollingInFlight = false; +let batchPollingInFlight = false; +let outlookBatchPollingInFlight = false; +let isRecentAccountsLoading = false; +let isTodayStatsLoading = false; let isBatchMode = false; let isOutlookBatchMode = false; let outlookAccounts = []; @@ -22,7 +27,6 @@ let displayedLogs = new Set(); // 用于日志去重 let toastShown = false; // 标记是否已显示过 toast let availableServices = { tempmail: { available: true, services: [] }, - yyds_mail: { available: false, services: [] }, outlook: { available: false, services: [] }, moe_mail: { available: false, services: [] }, temp_mail: { available: false, services: [] }, @@ -38,21 +42,13 @@ let wsHeartbeatInterval = null; // 心跳定时器 let batchWsHeartbeatInterval = null; // 批量任务心跳定时器 let activeTaskUuid = null; // 当前活跃的单任务 UUID(用于页面重新可见时重连) let activeBatchId = null; // 当前活跃的批量任务 ID(用于页面重新可见时重连) -let wsReconnectTimer = null; -let batchWsReconnectTimer = null; -let wsReconnectAttempts = 0; -let batchWsReconnectAttempts = 0; -let wsManualClose = false; -let batchWsManualClose = false; - -const WS_RECONNECT_BASE_DELAY = 1000; -const WS_RECONNECT_MAX_DELAY = 10000; // DOM 元素 const elements = { form: document.getElementById('registration-form'), emailService: document.getElementById('email-service'), regMode: document.getElementById('reg-mode'), + registrationType: document.getElementById('registration-type'), regModeGroup: document.getElementById('reg-mode-group'), batchCountGroup: document.getElementById('batch-count-group'), batchCount: document.getElementById('batch-count'), @@ -241,6 +237,11 @@ function initEventListeners() { }); } +function isIgnorableBackgroundError(error) { + const name = String(error?.name || ''); + return name === 'NetworkOfflineError' || name === 'AbortError'; +} + // 加载可用的邮箱服务 async function loadAvailableServices() { try { @@ -262,31 +263,18 @@ function updateEmailServiceOptions() { const select = elements.emailService; select.innerHTML = ''; - // 官方临时邮箱渠道 - if ((availableServices.tempmail && availableServices.tempmail.available) || - (availableServices.yyds_mail && availableServices.yyds_mail.available)) { + // Tempmail + if (availableServices.tempmail.available) { const optgroup = document.createElement('optgroup'); optgroup.label = '🌐 临时邮箱'; - if (availableServices.tempmail && availableServices.tempmail.available) { - availableServices.tempmail.services.forEach(service => { - const option = document.createElement('option'); - option.value = `tempmail:${service.id || 'default'}`; - option.textContent = service.name; - option.dataset.type = 'tempmail'; - optgroup.appendChild(option); - }); - } - - if (availableServices.yyds_mail && availableServices.yyds_mail.available) { - availableServices.yyds_mail.services.forEach(service => { - const option = document.createElement('option'); - option.value = `yyds_mail:${service.id || 'default'}`; - option.textContent = service.name + (service.default_domain ? ` (@${service.default_domain})` : ''); - option.dataset.type = 'yyds_mail'; - optgroup.appendChild(option); - }); - } + availableServices.tempmail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `tempmail:${service.id || 'default'}`; + option.textContent = service.name; + option.dataset.type = 'tempmail'; + optgroup.appendChild(option); + }); select.appendChild(optgroup); } @@ -436,11 +424,6 @@ function handleServiceChange(e) { if (service) { addLog('info', `[系统] 已选择 Outlook 账户: ${service.name}`); } - } else if (type === 'yyds_mail') { - const service = availableServices.yyds_mail.services.find(s => (s.id || 'default') == id); - if (service) { - addLog('info', `[系统] 已选择 YYDS Mail 渠道: ${service.name}`); - } } else if (type === 'moe_mail') { const service = availableServices.moe_mail.services.find(s => s.id == id); if (service) { @@ -513,6 +496,7 @@ async function handleStartRegistration(e) { // 构建请求数据(代理从设置中自动获取) const requestData = { email_service_type: emailServiceType, + registration_type: elements.registrationType ? elements.registrationType.value : 'none', auto_upload_cpa: elements.autoUploadCpa ? elements.autoUploadCpa.checked : false, cpa_service_ids: elements.autoUploadCpa && elements.autoUploadCpa.checked ? getSelectedServiceIds(elements.cpaServiceSelect) : [], auto_upload_sub2api: elements.autoUploadSub2api ? elements.autoUploadSub2api.checked : false, @@ -567,105 +551,24 @@ async function handleSingleRegistration(requestData) { // ============== WebSocket 功能 ============== -function getReconnectDelay(attempt) { - return Math.min(WS_RECONNECT_BASE_DELAY * (2 ** Math.max(0, attempt - 1)), WS_RECONNECT_MAX_DELAY); -} - -function clearWebSocketReconnect() { - if (wsReconnectTimer) { - clearTimeout(wsReconnectTimer); - wsReconnectTimer = null; - } - wsReconnectAttempts = 0; -} - -function clearBatchWebSocketReconnect() { - if (batchWsReconnectTimer) { - clearTimeout(batchWsReconnectTimer); - batchWsReconnectTimer = null; - } - batchWsReconnectAttempts = 0; -} - -function scheduleWebSocketReconnect(taskUuid) { - if (!taskUuid || wsReconnectTimer || wsManualClose || taskCompleted || taskFinalStatus !== null || activeTaskUuid !== taskUuid) { - return; - } - - wsReconnectAttempts += 1; - const delay = getReconnectDelay(wsReconnectAttempts); - addLog('warning', `[系统] WebSocket 已断开,${delay / 1000} 秒后尝试重连任务监控...`); - - wsReconnectTimer = setTimeout(() => { - wsReconnectTimer = null; - connectWebSocket(taskUuid); - }, delay); -} - -function scheduleBatchWebSocketReconnect(batchId) { - if (!batchId || batchWsReconnectTimer || batchWsManualClose || batchCompleted || batchFinalStatus !== null || activeBatchId !== batchId) { - return; - } - - batchWsReconnectAttempts += 1; - const delay = getReconnectDelay(batchWsReconnectAttempts); - addLog('warning', `[系统] 批量任务 WebSocket 已断开,${delay / 1000} 秒后尝试重连监控...`); - - batchWsReconnectTimer = setTimeout(() => { - batchWsReconnectTimer = null; - connectBatchWebSocket(batchId); - }, delay); -} - -function startCurrentBatchPolling(batchId) { - if (!batchId) return; - - const pollingMode = currentBatch && currentBatch.batch_id === batchId - ? currentBatch.pollingMode - : (isOutlookBatchMode ? 'outlook_batch' : 'batch'); - - if (pollingMode === 'outlook_batch') { - startOutlookBatchPolling(batchId); - return; - } - - startBatchPolling(batchId); -} - // 连接 WebSocket function connectWebSocket(taskUuid) { - activeTaskUuid = taskUuid; - - if (webSocket && [WebSocket.OPEN, WebSocket.CONNECTING].includes(webSocket.readyState)) { - return; - } - - if (wsReconnectTimer) { - clearTimeout(wsReconnectTimer); - wsReconnectTimer = null; - } - wsManualClose = false; - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/ws/task/${taskUuid}`; try { - const socket = new WebSocket(wsUrl); - webSocket = socket; + webSocket = new WebSocket(wsUrl); - socket.onopen = () => { - if (webSocket !== socket) return; + webSocket.onopen = () => { console.log('WebSocket 连接成功'); useWebSocket = true; - clearWebSocketReconnect(); // 停止轮询(如果有) stopLogPolling(); // 开始心跳 startWebSocketHeartbeat(); }; - socket.onmessage = (event) => { - if (webSocket !== socket) return; + webSocket.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'log') { @@ -713,52 +616,43 @@ function connectWebSocket(taskUuid) { } }; - socket.onclose = (event) => { - const isCurrentSocket = webSocket === socket; - if (isCurrentSocket) { - webSocket = null; - stopWebSocketHeartbeat(); - } - + webSocket.onclose = (event) => { console.log('WebSocket 连接关闭:', event.code); + stopWebSocketHeartbeat(); - const shouldReconnect = isCurrentSocket && - !wsManualClose && - !taskCompleted && - taskFinalStatus === null && - activeTaskUuid === taskUuid; + // 只有在任务未完成且最终状态不是完成状态时才切换到轮询 + // 使用 taskFinalStatus 而不是 currentTask.status,因为 currentTask 可能已被重置 + const shouldPoll = !taskCompleted && + taskFinalStatus === null; // 如果 taskFinalStatus 有值,说明任务已完成 - if (shouldReconnect) { - console.log('WebSocket 断开,准备自动重连'); + if (shouldPoll && currentTask) { + console.log('切换到轮询模式'); useWebSocket = false; - startLogPolling(taskUuid); - scheduleWebSocketReconnect(taskUuid); + startLogPolling(currentTask.task_uuid); } }; - socket.onerror = (error) => { - if (webSocket !== socket) return; + webSocket.onerror = (error) => { console.error('WebSocket 错误:', error); + // 切换到轮询 useWebSocket = false; + stopWebSocketHeartbeat(); + startLogPolling(taskUuid); }; } catch (error) { console.error('WebSocket 连接失败:', error); useWebSocket = false; startLogPolling(taskUuid); - scheduleWebSocketReconnect(taskUuid); } } // 断开 WebSocket function disconnectWebSocket() { - wsManualClose = true; - clearWebSocketReconnect(); stopWebSocketHeartbeat(); if (webSocket) { - const socket = webSocket; + webSocket.close(); webSocket = null; - socket.close(); } } @@ -812,7 +706,7 @@ async function handleBatchRegistration(requestData) { try { const data = await api.post('/registration/batch', requestData); - currentBatch = { ...data, pollingMode: 'batch' }; + currentBatch = data; activeBatchId = data.batch_id; // 保存用于重连 // 持久化到 sessionStorage,跨页面导航后可恢复 sessionStorage.setItem('activeTask', JSON.stringify({ batch_id: data.batch_id, mode: 'batch', total: data.count })); @@ -821,7 +715,7 @@ async function handleBatchRegistration(requestData) { showBatchStatus(data); // 优先使用 WebSocket - connectBatchWebSocket(data.batch_id); + connectBatchWebSocket(data.batch_id, 'batch'); } catch (error) { addLog('error', `[错误] 启动失败: ${error.message}`); @@ -889,15 +783,23 @@ async function handleCancelTask() { // 开始轮询日志 function startLogPolling(taskUuid) { - if (logPollingInterval) { - return; - } - + stopLogPolling(); let lastLogIndex = 0; + logPollingInFlight = false; logPollingInterval = setInterval(async () => { + if (logPollingInFlight) return; + logPollingInFlight = true; try { - const data = await api.get(`/registration/tasks/${taskUuid}/logs`); + const data = await api.get(`/registration/tasks/${taskUuid}/logs`, { + timeoutMs: 15000, + retry: 0, + requestKey: `app:task-logs:${taskUuid}`, + cancelPrevious: true, + silentNetworkError: true, + silentTimeoutError: true, + priority: 'low', + }); // 更新任务状态 updateTaskStatus(data.status); @@ -941,7 +843,11 @@ function startLogPolling(taskUuid) { } } } catch (error) { - console.error('轮询日志失败:', error); + if (!isIgnorableBackgroundError(error)) { + console.error('轮询日志失败:', error); + } + } finally { + logPollingInFlight = false; } }, 1000); } @@ -952,17 +858,26 @@ function stopLogPolling() { clearInterval(logPollingInterval); logPollingInterval = null; } + logPollingInFlight = false; } // 开始轮询批量状态 function startBatchPolling(batchId) { - if (batchPollingInterval) { - return; - } - + stopBatchPolling(); + batchPollingInFlight = false; batchPollingInterval = setInterval(async () => { + if (batchPollingInFlight) return; + batchPollingInFlight = true; try { - const data = await api.get(`/registration/batch/${batchId}`); + const data = await api.get(`/registration/batch/${batchId}`, { + timeoutMs: 15000, + retry: 0, + requestKey: `app:batch-status:${batchId}`, + cancelPrevious: true, + silentNetworkError: true, + silentTimeoutError: true, + priority: 'low', + }); updateBatchProgress(data); // 检查是否完成 @@ -984,7 +899,11 @@ function startBatchPolling(batchId) { } } } catch (error) { - console.error('轮询批量状态失败:', error); + if (!isIgnorableBackgroundError(error)) { + console.error('轮询批量状态失败:', error); + } + } finally { + batchPollingInFlight = false; } }, 2000); } @@ -995,6 +914,8 @@ function stopBatchPolling() { clearInterval(batchPollingInterval); batchPollingInterval = null; } + batchPollingInFlight = false; + outlookBatchPollingInFlight = false; } // 显示任务状态 @@ -1067,10 +988,74 @@ function updateBatchProgress(data) { } } +function normalizeRecentRoleTag(roleTag, accountLabel = '') { + const role = String(roleTag || '').trim().toLowerCase(); + if (role === 'parent') return 'parent'; + if (role === 'child') return 'child'; + const label = String(accountLabel || '').trim().toLowerCase(); + if (label === 'mother' || label === 'parent') return 'parent'; + if (label === 'child') return 'child'; + return 'none'; +} + +function getRecentRoleTagText(roleTag) { + if (roleTag === 'parent') return '母号'; + if (roleTag === 'child') return '子号'; + return '普通'; +} + +function getRecentRoleBadgeClass(roleTag) { + if (roleTag === 'parent') return 'parent'; + if (roleTag === 'child') return 'child'; + return 'none'; +} + +function buildRecentRoleSelectOptions(currentRoleTag) { + const role = normalizeRecentRoleTag(currentRoleTag); + const options = [ + { value: 'none', text: '普通' }, + { value: 'parent', text: '母号' }, + { value: 'child', text: '子号' }, + ]; + return options.map(item => ``).join(''); +} + +async function handleRecentAccountRoleTagChange(selectEl) { + const accountId = Number(selectEl?.dataset?.accountRoleId || 0); + if (!accountId) return; + + const previousRole = normalizeRecentRoleTag(selectEl.dataset.prevRole || 'none'); + const nextRole = normalizeRecentRoleTag(selectEl.value || 'none'); + if (nextRole === previousRole) return; + + selectEl.disabled = true; + try { + await api.patch(`/accounts/${accountId}`, { role_tag: nextRole }); + selectEl.dataset.prevRole = nextRole; + toast.success(`账号 #${accountId} 标签已更新为${getRecentRoleTagText(nextRole)}`); + await loadRecentAccounts(true); + } catch (error) { + selectEl.value = previousRole; + toast.error(`更新标签失败: ${error.message}`); + } finally { + selectEl.disabled = false; + } +} + // 加载最近注册的账号 -async function loadRecentAccounts() { +async function loadRecentAccounts(silent = false) { + if (isRecentAccountsLoading) return; + isRecentAccountsLoading = true; try { - const data = await api.get('/accounts?page=1&page_size=10'); + const data = await api.get('/accounts?page=1&page_size=10', { + timeoutMs: 30000, + retry: silent ? 0 : 1, + requestKey: 'app:recent-accounts', + cancelPrevious: true, + silentNetworkError: silent, + silentTimeoutError: silent, + priority: silent ? 'low' : 'normal', + }); if (data.accounts.length === 0) { elements.recentAccountsTable.innerHTML = ` @@ -1103,6 +1088,13 @@ async function loadRecentAccounts() { ` : '-'} + + + + ${getRecentRoleTagText(normalizeRecentRoleTag(account.role_tag, account.account_label))} + + + ${getStatusIcon(account.status)} @@ -1116,17 +1108,26 @@ async function loadRecentAccounts() { elements.recentAccountsTable.querySelectorAll('.copy-pwd-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); copyToClipboard(btn.dataset.pwd); }); }); - } catch (error) { - console.error('加载账号列表失败:', error); + if (!silent || !isIgnorableBackgroundError(error)) { + console.error('加载账号列表失败:', error); + } + if (!silent) { + toast.warning('加载账号列表失败,请稍后重试'); + } + } finally { + isRecentAccountsLoading = false; } } // 开始账号列表轮询 function startAccountsPolling() { + if (accountsPollingInterval) { + clearInterval(accountsPollingInterval); + } // 每30秒刷新一次账号列表 accountsPollingInterval = setInterval(() => { - loadRecentAccounts(); + loadRecentAccounts(true); }, 30000); } @@ -1162,20 +1163,35 @@ function renderTodayStats(total, success, failed, rate) { } async function loadTodayStats(silent = true) { + if (isTodayStatsLoading) return; + isTodayStatsLoading = true; try { - const data = await api.get('/registration/stats'); + const data = await api.get('/registration/stats', { + timeoutMs: 25000, + retry: silent ? 0 : 1, + requestKey: 'app:today-stats', + cancelPrevious: true, + silentNetworkError: silent, + silentTimeoutError: silent, + priority: silent ? 'low' : 'normal', + }); const byStatus = data?.by_status || {}; const total = Number(data?.today_total ?? data?.today_count ?? 0); const success = Number(data?.today_success ?? byStatus.completed ?? 0); const failed = Number(data?.today_failed ?? byStatus.failed ?? 0); - const fallbackRate = total > 0 ? (success / total) * 100 : 0; + const finished = success + failed; + const fallbackRate = finished > 0 ? (success / finished) * 100 : 0; const rate = Number(data?.today_success_rate ?? fallbackRate); renderTodayStats(total, success, failed, Number.isFinite(rate) ? rate : 0); } catch (error) { - console.error('加载今日统计失败:', error); + if (!silent || !isIgnorableBackgroundError(error)) { + console.error('加载今日统计失败:', error); + } if (!silent) { toast.error('加载今日统计失败'); } + } finally { + isTodayStatsLoading = false; } } @@ -1267,10 +1283,6 @@ function getLogType(log) { function resetButtons() { elements.startBtn.disabled = false; elements.cancelBtn.disabled = true; - stopLogPolling(); - stopBatchPolling(); - clearWebSocketReconnect(); - clearBatchWebSocketReconnect(); currentTask = null; currentBatch = null; isBatchMode = false; @@ -1335,7 +1347,7 @@ function renderOutlookAccountsList() {
${escapeHtml(account.email)}
${account.is_registered - ? `✓ 已注册` + ? `✓ 已注册${account.registered_account_id ? ` #${account.registered_account_id}` : ''}` : '未注册' } ${account.has_oauth ? ' | OAuth' : ''} @@ -1404,6 +1416,7 @@ async function handleOutlookBatchRegistration() { const requestData = { service_ids: selectedIds, skip_registered: skipRegistered, + registration_type: elements.registrationType ? elements.registrationType.value : 'none', interval_min: intervalMin, interval_max: intervalMax, concurrency: Math.min(50, Math.max(1, concurrency)), @@ -1428,7 +1441,7 @@ async function handleOutlookBatchRegistration() { return; } - currentBatch = { batch_id: data.batch_id, ...data, pollingMode: 'outlook_batch' }; + currentBatch = { batch_id: data.batch_id, ...data }; activeBatchId = data.batch_id; // 保存用于重连 // 持久化到 sessionStorage,跨页面导航后可恢复 sessionStorage.setItem('activeTask', JSON.stringify({ batch_id: data.batch_id, mode: isOutlookBatchMode ? 'outlook_batch' : 'batch', total: data.to_register })); @@ -1439,7 +1452,7 @@ async function handleOutlookBatchRegistration() { showBatchStatus({ count: data.to_register }); // 优先使用 WebSocket - connectBatchWebSocket(data.batch_id); + connectBatchWebSocket(data.batch_id, 'outlook_batch'); } catch (error) { addLog('error', `[错误] 启动失败: ${error.message}`); @@ -1450,39 +1463,41 @@ async function handleOutlookBatchRegistration() { // ============== 批量任务 WebSocket 功能 ============== -// 连接批量任务 WebSocket -function connectBatchWebSocket(batchId) { - activeBatchId = batchId; +function normalizeBatchMode(mode) { + const text = String(mode || '').trim().toLowerCase(); + if (text === 'outlook_batch') return 'outlook_batch'; + if (text === 'batch') return 'batch'; + return isOutlookBatchMode ? 'outlook_batch' : 'batch'; +} - if (batchWebSocket && [WebSocket.OPEN, WebSocket.CONNECTING].includes(batchWebSocket.readyState)) { +function startCurrentBatchPolling(batchId, mode = null) { + const normalizedMode = normalizeBatchMode(mode); + if (normalizedMode === 'outlook_batch') { + startOutlookBatchPolling(batchId); return; } + startBatchPolling(batchId); +} - if (batchWsReconnectTimer) { - clearTimeout(batchWsReconnectTimer); - batchWsReconnectTimer = null; - } - batchWsManualClose = false; - +// 连接批量任务 WebSocket +function connectBatchWebSocket(batchId, mode = null) { + const batchMode = normalizeBatchMode(mode); + const batchLabel = batchMode === 'outlook_batch' ? 'Outlook 批量任务' : '批量任务'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/ws/batch/${batchId}`; try { - const socket = new WebSocket(wsUrl); - batchWebSocket = socket; + batchWebSocket = new WebSocket(wsUrl); - socket.onopen = () => { - if (batchWebSocket !== socket) return; + batchWebSocket.onopen = () => { console.log('批量任务 WebSocket 连接成功'); - clearBatchWebSocketReconnect(); // 停止轮询(如果有) stopBatchPolling(); // 开始心跳 startBatchWebSocketHeartbeat(); }; - socket.onmessage = (event) => { - if (batchWebSocket !== socket) return; + batchWebSocket.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'log') { @@ -1515,12 +1530,12 @@ function connectBatchWebSocket(batchId) { if (!toastShown) { toastShown = true; if (data.status === 'completed') { - addLog('success', `[完成] Outlook 批量任务完成!成功: ${data.success}, 失败: ${data.failed}, 跳过: ${data.skipped || 0}`); + addLog('success', `[完成] ${batchLabel}完成!成功: ${data.success}, 失败: ${data.failed}, 跳过: ${data.skipped || 0}`); if (data.success > 0) { - toast.success(`Outlook 批量注册完成,成功 ${data.success} 个`); + toast.success(`${batchLabel}完成,成功 ${data.success} 个`); loadRecentAccounts(); } else { - toast.warning('Outlook 批量注册完成,但没有成功注册任何账号'); + toast.warning(`${batchLabel}完成,但没有成功注册任何账号`); } } else if (data.status === 'failed') { addLog('error', '[错误] 批量任务执行失败'); @@ -1535,49 +1550,40 @@ function connectBatchWebSocket(batchId) { } }; - socket.onclose = (event) => { - const isCurrentSocket = batchWebSocket === socket; - if (isCurrentSocket) { - batchWebSocket = null; - stopBatchWebSocketHeartbeat(); - } - + batchWebSocket.onclose = (event) => { console.log('批量任务 WebSocket 连接关闭:', event.code); + stopBatchWebSocketHeartbeat(); - const shouldReconnect = isCurrentSocket && - !batchWsManualClose && - !batchCompleted && - batchFinalStatus === null && - activeBatchId === batchId; + // 只有在任务未完成且最终状态不是完成状态时才切换到轮询 + // 使用 batchFinalStatus 而不是 currentBatch.status,因为 currentBatch 可能已被重置 + const shouldPoll = !batchCompleted && + batchFinalStatus === null; // 如果 batchFinalStatus 有值,说明任务已完成 - if (shouldReconnect) { - console.log('批量任务 WebSocket 断开,准备自动重连'); - startCurrentBatchPolling(batchId); - scheduleBatchWebSocketReconnect(batchId); + if (shouldPoll && currentBatch) { + console.log('切换到轮询模式'); + startCurrentBatchPolling(currentBatch.batch_id, batchMode); } }; - socket.onerror = (error) => { - if (batchWebSocket !== socket) return; + batchWebSocket.onerror = (error) => { console.error('批量任务 WebSocket 错误:', error); + stopBatchWebSocketHeartbeat(); + // 切换到轮询 + startCurrentBatchPolling(batchId, batchMode); }; } catch (error) { console.error('批量任务 WebSocket 连接失败:', error); - startCurrentBatchPolling(batchId); - scheduleBatchWebSocketReconnect(batchId); + startCurrentBatchPolling(batchId, batchMode); } } // 断开批量任务 WebSocket function disconnectBatchWebSocket() { - batchWsManualClose = true; - clearBatchWebSocketReconnect(); stopBatchWebSocketHeartbeat(); if (batchWebSocket) { - const socket = batchWebSocket; + batchWebSocket.close(); batchWebSocket = null; - socket.close(); } } @@ -1608,13 +1614,22 @@ function cancelBatchViaWebSocket() { // 开始轮询 Outlook 批量状态(降级方案) function startOutlookBatchPolling(batchId) { - if (batchPollingInterval) { - return; - } - + stopBatchPolling(); + let lastLogIndex = 0; + outlookBatchPollingInFlight = false; batchPollingInterval = setInterval(async () => { + if (outlookBatchPollingInFlight) return; + outlookBatchPollingInFlight = true; try { - const data = await api.get(`/registration/outlook-batch/${batchId}`); + const data = await api.get(`/registration/outlook-batch/${batchId}`, { + timeoutMs: 15000, + retry: 0, + requestKey: `app:outlook-batch-status:${batchId}`, + cancelPrevious: true, + silentNetworkError: true, + silentTimeoutError: true, + priority: 'low', + }); // 更新进度 updateBatchProgress({ @@ -1626,13 +1641,12 @@ function startOutlookBatchPolling(batchId) { // 输出日志 if (data.logs && data.logs.length > 0) { - const lastLogIndex = batchPollingInterval.lastLogIndex || 0; for (let i = lastLogIndex; i < data.logs.length; i++) { const log = data.logs[i]; const logType = getLogType(log); addLog(logType, log); } - batchPollingInterval.lastLogIndex = data.logs.length; + lastLogIndex = data.logs.length; } // 检查是否完成 @@ -1653,11 +1667,13 @@ function startOutlookBatchPolling(batchId) { } } } catch (error) { - console.error('轮询 Outlook 批量状态失败:', error); + if (!isIgnorableBackgroundError(error)) { + console.error('轮询 Outlook 批量状态失败:', error); + } + } finally { + outlookBatchPollingInFlight = false; } }, 2000); - - batchPollingInterval.lastLogIndex = 0; } // ============== 页面可见性重连机制 ============== @@ -1681,7 +1697,7 @@ function initVisibilityReconnect() { if (activeBatchId && !batchCompleted && batchWsDisconnected) { console.log('[重连] 页面重新可见,重连批量任务 WebSocket:', activeBatchId); addLog('info', '[系统] 页面重新激活,正在重连批量任务监控...'); - connectBatchWebSocket(activeBatchId); + connectBatchWebSocket(activeBatchId, isOutlookBatchMode ? 'outlook_batch' : 'batch'); } }); } @@ -1737,7 +1753,7 @@ async function restoreActiveTask() { return; } // 批量任务仍在运行,恢复状态 - currentBatch = { batch_id, ...data, pollingMode: mode }; + currentBatch = { batch_id, ...data }; activeBatchId = batch_id; isOutlookBatchMode = (mode === 'outlook_batch'); batchCompleted = false; @@ -1749,24 +1765,10 @@ async function restoreActiveTask() { showBatchStatus({ count: total || data.total }); updateBatchProgress(data); addLog('info', `[系统] 检测到进行中的批量任务,正在重连监控... (${batch_id.substring(0, 8)})`); - connectBatchWebSocket(batch_id); + connectBatchWebSocket(batch_id, mode); } catch { sessionStorage.removeItem('activeTask'); } } } - -async function refreshOutlookRegistrationStatus() { - try { - const ids = outlookAccounts.map(item => item.id).filter(Boolean); - const data = await api.post('/registration/outlook/check-accounts', { service_ids: ids }); - outlookAccounts = data.accounts || []; - renderOutlookAccounts(outlookAccounts); - addLog('info', `[??] Outlook ??????? (???: ${data.registered_count}, ???: ${data.unregistered_count})`); - toast.success('Outlook ???????'); - } catch (error) { - console.error('?? Outlook ??????:', error); - toast.error('?? Outlook ??????: ' + error.message); - } -} diff --git a/static/js/auto_team.js b/static/js/auto_team.js new file mode 100644 index 00000000..d100457a --- /dev/null +++ b/static/js/auto_team.js @@ -0,0 +1,990 @@ +(function () { + const fp = window.filterProtocol || { + normalizeValue(value) { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text ? text : null; + }, + toQuery(filters = {}) { + const params = new URLSearchParams(); + Object.entries(filters || {}).forEach(([key, raw]) => { + if (!key) return; + if (raw === null || raw === undefined || raw === "") return; + params.set(String(key), String(raw)); + }); + return params; + }, + }; + + function nowTime() { + return new Date().toLocaleTimeString("zh-CN", { hour12: false }); + } + + function escapeHtml(text) { + return String(text || "") + .replace(/&/g, "&") + .replace(//g, ">"); + } + + function safeError(error) { + if (!error) return "未知错误"; + if (typeof error === "string") return error; + if (error.data && error.data.detail) { + if (typeof error.data.detail === "string") return error.data.detail; + return JSON.stringify(error.data.detail); + } + if (error.message) return error.message; + try { + return JSON.stringify(error); + } catch (_e) { + return "未知错误"; + } + } + + function normalizePlanType(rawPlan) { + const value = String(rawPlan || "").trim().toLowerCase(); + if (value.includes("plus") || value.includes("pro")) return "plus"; + if (value.includes("team") || value.includes("enterprise")) return "team"; + return "free"; + } + + function getPlanBadgeText(rawPlan) { + const plan = normalizePlanType(rawPlan); + if (plan === "plus") return "PLUS"; + if (plan === "team") return "TEAM"; + return "FREE"; + } + + const BLOCKED_INVITER_STATUSES = new Set([ + "failed", + "banned", + "deleted", + "disabled", + "invalid", + "inactive", + "frozen", + "expired", + "error", + "locked", + "suspended", + ]); + + function isHardRemoveAuthSource(rawSource) { + const source = String(rawSource || "").trim().toLowerCase(); + if (!source) return false; + return ( + source.includes("hard_remove_auth") + || source.includes("http_401") + || source.includes("http_403") + || source.includes("token has been invalidated") + || source.includes("authentication token has been invalidated") + || source.includes("please try signing in again") + ); + } + + const INVITER_CACHE_KEY = "auto_team_inviter_accounts_cache_v1"; + const TEAM_GROUP_CACHE_KEY = "auto_team_groups_cache_v1"; + + class AutoTeamPage { + constructor() { + this.els = { + targetEmail: document.getElementById("targetEmail"), + inviterAccount: document.getElementById("inviterAccount"), + inviterList: document.getElementById("inviterList"), + managerList: document.getElementById("managerList"), + memberList: document.getElementById("memberList"), + tabInvite: document.getElementById("tabInvite"), + tabManage: document.getElementById("tabManage"), + panelInvite: document.getElementById("panelInvite"), + panelManage: document.getElementById("panelManage"), + btnPickTargetEmail: document.getElementById("btnPickTargetEmail"), + btnReloadInviterList: document.getElementById("btnReloadInviterList"), + btnManualPullInviter: document.getElementById("btnManualPullInviter"), + targetModal: document.getElementById("targetModal"), + targetModalList: document.getElementById("targetModalList"), + targetModalSearch: document.getElementById("targetModalSearch"), + targetModalSelectedInfo: document.getElementById("targetModalSelectedInfo"), + btnCloseTargetModal: document.getElementById("btnCloseTargetModal"), + btnTargetSelectAll: document.getElementById("btnTargetSelectAll"), + btnTargetClearAll: document.getElementById("btnTargetClearAll"), + btnAddSelectedTargets: document.getElementById("btnAddSelectedTargets"), + manualInviterModal: document.getElementById("manualInviterModal"), + manualInviterList: document.getElementById("manualInviterList"), + manualInviterSearch: document.getElementById("manualInviterSearch"), + manualInviterSelectedInfo: document.getElementById("manualInviterSelectedInfo"), + btnCloseManualInviterModal: document.getElementById("btnCloseManualInviterModal"), + btnManualInviterSelectAll: document.getElementById("btnManualInviterSelectAll"), + btnManualInviterClearAll: document.getElementById("btnManualInviterClearAll"), + btnSubmitManualInviter: document.getElementById("btnSubmitManualInviter"), + btnInvite: document.getElementById("btnInvite"), + btnReloadAccounts: document.getElementById("btnReloadAccounts"), + btnReloadTeamGroups: document.getElementById("btnReloadTeamGroups"), + btnClearLog: document.getElementById("btnClearLog"), + resultBox: document.getElementById("resultBox"), + logBox: document.getElementById("autoTeamLog"), + }; + this.targetAccounts = []; + this.selectedTargetIds = new Set(); + this.manualInviterCandidates = []; + this.selectedManualInviterIds = new Set(); + this.inviterAccounts = []; + this.teamManagers = []; + this.teamMembers = []; + this.teamGroupsLoaded = false; + this.teamGroupsLoading = false; + this.inviterLoaded = false; + this.manualLoadMode = true; + this.inviterBackgroundSeq = 0; + this.bindEvents(); + this.bootstrap(); + } + + bindEvents() { + this.els.tabInvite?.addEventListener("click", () => this.switchTab("invite")); + this.els.tabManage?.addEventListener("click", () => this.switchTab("manage")); + this.els.btnPickTargetEmail?.addEventListener("click", () => this.openTargetModal()); + this.els.btnReloadInviterList?.addEventListener("click", () => this.loadInviterAccounts(true, this.els.btnReloadInviterList, true)); + this.els.btnManualPullInviter?.addEventListener("click", () => this.openManualInviterModal()); + this.els.btnCloseTargetModal?.addEventListener("click", () => this.closeTargetModal()); + this.els.btnTargetSelectAll?.addEventListener("click", () => this.selectVisibleTargets()); + this.els.btnTargetClearAll?.addEventListener("click", () => this.clearSelectedTargets()); + this.els.btnAddSelectedTargets?.addEventListener("click", () => this.addSelectedTargetsToInput()); + this.els.targetModalSearch?.addEventListener("input", () => this.renderTargetModalList()); + this.els.targetModal?.addEventListener("click", (e) => { + if (e.target === this.els.targetModal) { + this.closeTargetModal(); + } + }); + this.els.targetModalList?.addEventListener("change", (e) => { + const el = e.target; + if (!el || !el.matches('input[type="checkbox"][data-target-id]')) return; + const id = String(el.dataset.targetId || ""); + if (!id) return; + if (el.checked) this.selectedTargetIds.add(id); + else this.selectedTargetIds.delete(id); + this.updateTargetSelectedInfo(); + }); + this.els.manualInviterModal?.addEventListener("click", (e) => { + if (e.target === this.els.manualInviterModal) { + this.closeManualInviterModal(); + } + }); + this.els.btnCloseManualInviterModal?.addEventListener("click", () => this.closeManualInviterModal()); + this.els.btnManualInviterSelectAll?.addEventListener("click", () => this.selectVisibleManualInviters()); + this.els.btnManualInviterClearAll?.addEventListener("click", () => this.clearSelectedManualInviters()); + this.els.btnSubmitManualInviter?.addEventListener("click", () => this.submitManualInviterSelection()); + this.els.manualInviterSearch?.addEventListener("input", () => this.renderManualInviterList()); + this.els.manualInviterList?.addEventListener("change", (e) => { + const el = e.target; + if (!el || !el.matches('input[type="checkbox"][data-inviter-id]')) return; + const id = String(el.dataset.inviterId || ""); + if (!id) return; + if (el.checked) this.selectedManualInviterIds.add(id); + else this.selectedManualInviterIds.delete(id); + this.updateManualInviterSelectedInfo(); + }); + this.els.btnInvite?.addEventListener("click", () => this.handleInvite()); + this.els.btnReloadAccounts?.addEventListener("click", () => this.loadInviterAccounts(true, this.els.btnReloadAccounts, true)); + this.els.btnReloadTeamGroups?.addEventListener("click", () => this.loadTeamGroups(true, true)); + this.els.btnClearLog?.addEventListener("click", () => this.clearLogs()); + } + + switchTab(tab) { + const inviteActive = tab === "invite"; + this.els.tabInvite?.classList.toggle("active", inviteActive); + this.els.tabManage?.classList.toggle("active", !inviteActive); + this.els.panelInvite?.classList.toggle("active", inviteActive); + this.els.panelManage?.classList.toggle("active", !inviteActive); + } + + async bootstrap() { + this.log("team页面已加载,已关闭首次自动刷新。请手动点击“刷新”加载 Team 管理账号列表。"); + const cachedInvitersRaw = this.readCache(INVITER_CACHE_KEY, []); + const cachedInviters = this.pruneInviterAccounts( + Array.isArray(cachedInvitersRaw) ? cachedInvitersRaw : [], + "本地缓存", + ); + this.inviterAccounts = cachedInviters; + this.inviterLoaded = true; + this.fillSelect(this.inviterAccounts); + this.renderInviters(this.inviterAccounts); + if (this.inviterAccounts.length > 0) { + this.log(`已载入本地缓存管理账号: ${this.inviterAccounts.length} 个`); + } else { + this.log("当前无本地缓存管理账号,请手动刷新加载。"); + } + + const cachedGroups = this.readCache(TEAM_GROUP_CACHE_KEY, {}); + const cachedManagers = Array.isArray(cachedGroups?.managers) ? cachedGroups.managers : []; + const cachedMembers = Array.isArray(cachedGroups?.members) ? cachedGroups.members : []; + this.teamManagers = cachedManagers; + this.teamMembers = cachedMembers; + if (cachedManagers.length > 0 || cachedMembers.length > 0) { + this.teamGroupsLoaded = true; + this.renderTeamGroupList(this.els.managerList, this.teamManagers, true); + this.renderTeamGroupList(this.els.memberList, this.teamMembers, false); + this.log(`已载入本地缓存分类: 母号=${this.teamManagers.length} 子号=${this.teamMembers.length}`); + } else { + this.renderTeamGroupList(this.els.managerList, [], true); + this.renderTeamGroupList(this.els.memberList, [], false); + } + } + + readCache(key, fallback) { + try { + const raw = localStorage.getItem(key); + if (!raw) return fallback; + return JSON.parse(raw); + } catch (_e) { + return fallback; + } + } + + writeCache(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (_e) { + // ignore + } + } + + pruneInviterAccounts(list, stage = "") { + const source = Array.isArray(list) ? list : []; + const filtered = source.filter((item) => { + const status = String(item?.status || "").trim().toLowerCase(); + const verifySource = String(item?.manager_verify_source || item?.verify_source || "").trim(); + if (BLOCKED_INVITER_STATUSES.has(status)) return false; + if (isHardRemoveAuthSource(verifySource)) return false; + return true; + }); + const sorted = [...filtered].sort((a, b) => { + const aRole = String(a?.role_tag || "").trim().toLowerCase(); + const bRole = String(b?.role_tag || "").trim().toLowerCase(); + const aParent = aRole === "parent" || aRole === "mother" ? 0 : 1; + const bParent = bRole === "parent" || bRole === "mother" ? 0 : 1; + if (aParent !== bParent) return aParent - bParent; + const aPriority = Number.isFinite(Number(a?.priority)) ? Number(a.priority) : 50; + const bPriority = Number.isFinite(Number(b?.priority)) ? Number(b.priority) : 50; + if (aPriority !== bPriority) return aPriority - bPriority; + const aId = Number.isFinite(Number(a?.id)) ? Number(a.id) : 0; + const bId = Number.isFinite(Number(b?.id)) ? Number(b.id) : 0; + return bId - aId; + }); + const removed = source.length - filtered.length; + if (removed > 0 && stage) { + this.log(`${stage}剔除失效管理账号: ${removed} 个`); + } + return sorted; + } + + emitInviterSync() {} + + async refreshInviterAccountsInBackground(force = false) { + const seq = ++this.inviterBackgroundSeq; + try { + const queryParams = new URLSearchParams(); + if (force) queryParams.set("force", "1"); + queryParams.set("local_only", "0"); + const query = `?${queryParams.toString()}`; + const data = await api.get(`/auto-team/inviter-accounts${query}`, { + timeoutMs: 15000, + retry: 0, + cancelPrevious: true, + requestKey: "auto-team:inviter-accounts:bg", + silentNetworkError: true, + silentTimeoutError: true, + }); + if (seq !== this.inviterBackgroundSeq) return; + const accountsRaw = Array.isArray(data.accounts) ? data.accounts : []; + const verified = this.pruneInviterAccounts(accountsRaw, "后台校验"); + if (!verified.length && this.inviterAccounts.length > 0) { + this.log("后台校验返回空,保留当前本地管理账号列表。"); + return; + } + this.inviterAccounts = verified; + this.writeCache(INVITER_CACHE_KEY, this.inviterAccounts); + this.fillSelect(this.inviterAccounts); + this.renderInviters(this.inviterAccounts); + if (verified.length > 0) { + this.log(`后台校验完成:可用 Team 管理账号 ${verified.length} 个`); + } else { + this.log("后台校验完成:当前无可用 Team 管理账号"); + } + } catch (error) { + const msg = safeError(error); + this.log(`后台校验失败(已保留当前列表): ${msg}`); + } + } + + clearLogs() { + this.els.logBox.innerHTML = ""; + this.log("日志已清空。"); + } + + log(message) { + const line = document.createElement("div"); + line.className = "line"; + line.textContent = `[${nowTime()}] ${message}`; + this.els.logBox.appendChild(line); + this.els.logBox.scrollTop = this.els.logBox.scrollHeight; + } + + setReloadButtonLoading(button, isLoading) { + const btn = button || this.els.btnReloadInviterList || this.els.btnReloadAccounts; + if (!btn) return; + if (isLoading) { + if (!btn.dataset.originalLabel) { + btn.dataset.originalLabel = String(btn.textContent || "").trim() || "刷新"; + } + btn.disabled = true; + btn.classList.add("btn-refresh-loading"); + btn.innerHTML = ''; + return; + } + btn.disabled = false; + btn.classList.remove("btn-refresh-loading"); + const label = btn.dataset.originalLabel || "刷新"; + btn.textContent = label; + delete btn.dataset.originalLabel; + } + + setResult(type, title, content, extra) { + const box = this.els.resultBox; + box.style.display = "block"; + let color = "var(--text-secondary)"; + if (type === "success") color = "var(--success-color)"; + if (type === "error") color = "var(--danger-color)"; + if (type === "warning") color = "var(--warning-color)"; + + let html = `
${title}
`; + if (content) { + html += `
${String(content).replace(//g, ">")}
`; + } + if (extra) { + html += `
${JSON.stringify(extra, null, 2)}
`; + } + box.innerHTML = html; + } + + getInviterId() { + const rawId = this.els.inviterAccount.value; + return rawId ? Number(rawId) : null; + } + + parseTargetEmails() { + const raw = String(this.els.targetEmail?.value || ""); + const tokens = raw + .split(/[\n,;,;\s]+/) + .map((x) => x.trim().toLowerCase()) + .filter(Boolean); + + const unique = [...new Set(tokens)]; + const emailRe = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + const valid = []; + const invalid = []; + unique.forEach((email) => { + if (emailRe.test(email)) valid.push(email); + else invalid.push(email); + }); + return { valid, invalid }; + } + + renderInviters(list) { + if (!this.els.inviterList) return; + if (!Array.isArray(list) || list.length === 0) { + if (this.manualLoadMode && !this.inviterLoaded) { + this.els.inviterList.innerHTML = '
首次不自动加载,请点击“刷新”读取 Team 管理账号。
'; + return; + } + this.els.inviterList.innerHTML = '
暂无可用 Team 管理账号(需 team + token + workspace)。
'; + return; + } + + this.els.inviterList.innerHTML = list.map((item) => { + const email = escapeHtml(item.email || ""); + const workspace = escapeHtml(item.workspace_id || "-"); + const status = escapeHtml(item.status || "-"); + const roleTag = escapeHtml(item.role_tag || "-"); + const poolState = escapeHtml(item.pool_state || "-"); + return ` +
+
${email}
+
+ TEAM + ID: ${item.id} + 状态: ${status} + 标签: ${roleTag} + 池: ${poolState} +
+
+ workspace: ${workspace} +
+
+ `; + }).join(""); + } + + fillSelect(list) { + const current = this.els.inviterAccount.value; + this.els.inviterAccount.innerHTML = ''; + list.forEach((item) => { + const option = document.createElement("option"); + option.value = String(item.id); + option.textContent = `${item.email} (ID=${item.id})`; + this.els.inviterAccount.appendChild(option); + }); + if (current && list.some((x) => String(x.id) === current)) { + this.els.inviterAccount.value = current; + } + } + + renderTeamGroupList(container, list, isManager) { + if (!container) return; + if (!Array.isArray(list) || list.length === 0) { + if (this.manualLoadMode && !this.teamGroupsLoaded) { + container.innerHTML = `
首次不自动加载,请点击“刷新”读取${isManager ? "母号" : "子号"}账号
`; + return; + } + container.innerHTML = `
暂无${isManager ? "母号" : "子号"}账号
`; + return; + } + container.innerHTML = list.map((item) => { + const email = escapeHtml(item.email || ""); + const workspace = escapeHtml(item.workspace_id || "-"); + const status = escapeHtml(item.status || "-"); + return ` +
+
${email}
+
+ ${isManager ? "母号" : "子号"} + ID: ${item.id} + 状态: ${status} +
+
+ workspace: ${workspace} +
+
+ `; + }).join(""); + } + + async loadTeamGroups(withToast, force = false) { + try { + this.teamGroupsLoading = true; + if (this.els.btnReloadTeamGroups) { + loading.show(this.els.btnReloadTeamGroups, "刷新中..."); + } + const queryParams = fp.toQuery({ force: force ? 1 : null }); + const queryText = queryParams.toString(); + const query = queryText ? `?${queryText}` : ""; + const data = await api.get(`/auto-team/team-accounts${query}`, { + timeoutMs: 12000, + retry: 0, + silentNetworkError: true, + silentTimeoutError: true, + }); + this.teamGroupsLoaded = true; + const managers = Array.isArray(data.managers) ? data.managers : []; + const members = Array.isArray(data.members) ? data.members : []; + const hasExistingGroups = (Array.isArray(this.teamManagers) && this.teamManagers.length > 0) + || (Array.isArray(this.teamMembers) && this.teamMembers.length > 0); + if (managers.length === 0 && members.length === 0 && hasExistingGroups) { + this.renderTeamGroupList(this.els.managerList, this.teamManagers, true); + this.renderTeamGroupList(this.els.memberList, this.teamMembers, false); + this.log("team分类刷新返回空,已保留当前母号/子号列表(避免误清空)"); + if (withToast) { + toast.warning("返回空结果,已保留当前 Team 分类列表"); + } + return; + } + this.teamManagers = managers; + this.teamMembers = members; + this.writeCache(TEAM_GROUP_CACHE_KEY, { + managers: this.teamManagers, + members: this.teamMembers, + }); + this.renderTeamGroupList(this.els.managerList, this.teamManagers, true); + this.renderTeamGroupList(this.els.memberList, this.teamMembers, false); + this.log(`team分类加载完成: 母号=${managers.length} 子号=${members.length}`); + if (withToast) { + toast.success(`已刷新:母号 ${managers.length},子号 ${members.length}`); + } + } catch (error) { + const msg = safeError(error); + this.teamGroupsLoaded = true; + if (this.teamManagers.length > 0 || this.teamMembers.length > 0) { + this.renderTeamGroupList(this.els.managerList, this.teamManagers, true); + this.renderTeamGroupList(this.els.memberList, this.teamMembers, false); + this.log(`team分类加载失败,保留当前入池显示: ${msg}`); + } else { + this.renderTeamGroupList(this.els.managerList, [], true); + this.renderTeamGroupList(this.els.memberList, [], false); + this.log(`team分类加载失败: ${msg}`); + } + if (withToast) { + toast.error(`加载失败: ${msg}`); + } + } finally { + this.teamGroupsLoading = false; + if (this.els.btnReloadTeamGroups) { + loading.hide(this.els.btnReloadTeamGroups); + } + } + } + + async loadTargetAccounts(withToast) { + try { + loading.show(this.els.btnPickTargetEmail, "..."); + const data = await api.get("/auto-team/target-accounts"); + const accounts = data.accounts || []; + const lockedTotal = Number(data.locked_total || 0); + this.targetAccounts = accounts; + this.renderTargetModalList(); + this.log(`目标邮箱候选账号已加载: ${accounts.length} 个(邀请锁定 ${lockedTotal})`); + if (withToast) { + toast.success(`可选子号 ${accounts.length} 个`); + } + } catch (error) { + const msg = safeError(error); + this.log(`读取目标账号失败: ${msg}`); + if (withToast) { + toast.error(msg); + } + } finally { + loading.hide(this.els.btnPickTargetEmail); + } + } + + getFilteredTargetAccounts() { + const q = String(fp.normalizeValue(this.els.targetModalSearch?.value) || "").toLowerCase(); + if (!q) return this.targetAccounts; + return this.targetAccounts.filter((item) => { + const email = String(item.email || "").toLowerCase(); + const idText = String(item.id || ""); + return email.includes(q) || idText.includes(q); + }); + } + + updateTargetSelectedInfo() { + if (!this.els.targetModalSelectedInfo) return; + this.els.targetModalSelectedInfo.textContent = `已选 ${this.selectedTargetIds.size} 个`; + } + + renderTargetModalList() { + const container = this.els.targetModalList; + if (!container) return; + const list = this.getFilteredTargetAccounts(); + if (!list.length) { + container.innerHTML = '
暂无可选账号(仅 free 且非红色状态)
'; + this.updateTargetSelectedInfo(); + return; + } + + container.innerHTML = list.map((item) => { + const id = String(item.id); + const checked = this.selectedTargetIds.has(id) ? "checked" : ""; + const planClass = normalizePlanType(item.plan || "free"); + const planText = getPlanBadgeText(item.plan || "free"); + return ` + + `; + }).join(""); + this.updateTargetSelectedInfo(); + } + + async openTargetModal() { + await this.loadTargetAccounts(false); + this.els.targetModal?.classList.add("show"); + } + + closeTargetModal() { + this.els.targetModal?.classList.remove("show"); + } + + selectVisibleTargets() { + const list = this.getFilteredTargetAccounts(); + list.forEach((item) => this.selectedTargetIds.add(String(item.id))); + this.renderTargetModalList(); + } + + clearSelectedTargets() { + this.selectedTargetIds.clear(); + this.renderTargetModalList(); + } + + addSelectedTargetsToInput() { + const selectedItems = this.targetAccounts.filter((x) => this.selectedTargetIds.has(String(x.id))); + if (!selectedItems.length) { + toast.warning("请先勾选账号"); + return; + } + + const existing = this.parseTargetEmails().valid; + const merged = [...new Set([ + ...existing, + ...selectedItems.map((x) => String(x.email || "").trim().toLowerCase()).filter(Boolean), + ])]; + this.els.targetEmail.value = merged.join("\n"); + this.log(`已批量添加目标邮箱: ${selectedItems.length} 个`); + toast.success(`已添加 ${selectedItems.length} 个邮箱`); + this.closeTargetModal(); + } + + async openManualInviterModal() { + this.els.manualInviterModal?.classList.add("show"); + if (Array.isArray(this.manualInviterCandidates) && this.manualInviterCandidates.length) { + this.renderManualInviterList(); + } else if (this.els.manualInviterList) { + this.els.manualInviterList.innerHTML = '
加载候选账号中...
'; + this.updateManualInviterSelectedInfo(); + } + // 弹窗先打开,候选列表异步加载,避免按钮点击后“卡一下”。 + void this.loadManualInviterCandidates(false); + } + + closeManualInviterModal() { + this.els.manualInviterModal?.classList.remove("show"); + } + + async loadManualInviterCandidates(withToast = false) { + try { + loading.show(this.els.btnManualPullInviter, "..."); + const data = await api.get("/auto-team/inviter-candidates?force=1", { + timeoutMs: 12000, + retry: 0, + silentNetworkError: true, + silentTimeoutError: true, + }); + this.manualInviterCandidates = Array.isArray(data.accounts) ? data.accounts : []; + this.renderManualInviterList(); + this.log(`手动拉入候选已加载: ${this.manualInviterCandidates.length} 个`); + if (withToast) { + toast.success(`候选账号 ${this.manualInviterCandidates.length} 个`); + } + } catch (error) { + const msg = safeError(error); + this.log(`加载手动拉入候选失败: ${msg}`); + if (withToast) { + toast.error(msg); + } + } finally { + loading.hide(this.els.btnManualPullInviter); + } + } + + getFilteredManualInviterCandidates() { + const q = String(fp.normalizeValue(this.els.manualInviterSearch?.value) || "").toLowerCase(); + if (!q) return this.manualInviterCandidates; + return this.manualInviterCandidates.filter((item) => { + const email = String(item.email || "").toLowerCase(); + const idText = String(item.id || ""); + const roleTag = String(item.role_tag || ""); + const poolState = String(item.pool_state || ""); + return email.includes(q) || idText.includes(q) || roleTag.includes(q) || poolState.includes(q); + }); + } + + updateManualInviterSelectedInfo() { + if (!this.els.manualInviterSelectedInfo) return; + this.els.manualInviterSelectedInfo.textContent = `已选 ${this.selectedManualInviterIds.size} 个`; + } + + renderManualInviterList() { + const container = this.els.manualInviterList; + if (!container) return; + const list = this.getFilteredManualInviterCandidates(); + if (!list.length) { + container.innerHTML = '
暂无可拉入账号(仅母号/普通)
'; + this.updateManualInviterSelectedInfo(); + return; + } + container.innerHTML = list.map((item) => { + const id = String(item.id || ""); + const checked = this.selectedManualInviterIds.has(id) ? "checked" : ""; + const roleText = String(item.role_tag || "none"); + const poolText = String(item.pool_state || "candidate_pool"); + return ` + + `; + }).join(""); + this.updateManualInviterSelectedInfo(); + } + + selectVisibleManualInviters() { + const list = this.getFilteredManualInviterCandidates(); + list.forEach((item) => this.selectedManualInviterIds.add(String(item.id))); + this.renderManualInviterList(); + } + + clearSelectedManualInviters() { + this.selectedManualInviterIds.clear(); + this.renderManualInviterList(); + } + + async submitManualInviterSelection() { + const ids = [...this.selectedManualInviterIds].map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0); + if (!ids.length) { + toast.warning("请先选择要拉入的账号"); + return; + } + try { + loading.show(this.els.btnSubmitManualInviter, "处理中..."); + const data = await api.post("/auto-team/inviter-pool/add", { + account_ids: ids, + }, { + timeoutMs: 15000, + retry: 0, + priority: "high", + }); + const added = Array.isArray(data.added) ? data.added.length : 0; + const skipped = Array.isArray(data.skipped) ? data.skipped.length : 0; + const invalid = Array.isArray(data.invalid) ? data.invalid.length : 0; + this.log(`手动拉入完成: added=${added} skipped=${skipped} invalid=${invalid}`); + toast.success(`拉入完成:新增 ${added},跳过 ${skipped},无效 ${invalid}`); + this.closeManualInviterModal(); + this.selectedManualInviterIds.clear(); + await this.loadInviterAccounts(false, this.els.btnReloadInviterList, true); + await this.loadTeamGroups(false, true); + } catch (error) { + const msg = safeError(error); + this.log(`手动拉入失败: ${msg}`); + toast.error(msg); + } finally { + loading.hide(this.els.btnSubmitManualInviter); + } + } + + async loadInviterAccounts(withToast, loadingBtn = null, force = false) { + const queryParams = fp.toQuery({ + force: force ? 1 : null, + local_only: 1, + }); + const queryText = queryParams.toString(); + const query = queryText ? `?${queryText}` : ""; + const btn = loadingBtn || this.els.btnReloadAccounts || this.els.btnReloadInviterList; + try { + this.setReloadButtonLoading(btn, true); + const data = await api.get(`/auto-team/inviter-accounts${query}`, { + timeoutMs: 12000, + retry: 0, + silentNetworkError: true, + silentTimeoutError: true, + }); + this.inviterLoaded = true; + const accountsRaw = Array.isArray(data.accounts) ? data.accounts : []; + const accounts = this.pruneInviterAccounts(accountsRaw, "刷新结果"); + this.inviterAccounts = accounts; + this.writeCache(INVITER_CACHE_KEY, this.inviterAccounts); + this.fillSelect(this.inviterAccounts); + this.renderInviters(this.inviterAccounts); + if (accounts.length > 0) { + this.log(`读取可用 Team 邀请账号完成: ${accounts.length} 个(自动按管理号入池)`); + } else { + this.log("读取可用 Team 邀请账号完成: 0 个(已按最新规则清空不符合账号)"); + } + if (withToast) { + if (accounts.length > 0) { + toast.success(`已刷新,可用账号 ${accounts.length} 个`); + } else { + toast.warning("已刷新:当前无符合条件的 Team 管理账号"); + } + } + void this.refreshInviterAccountsInBackground(force); + } catch (error) { + let msg = safeError(error); + this.inviterLoaded = true; + const abortLike = String(msg || "").toLowerCase().includes("abort") || String(error?.name || "").toLowerCase() === "aborterror"; + if (abortLike) { + try { + const retryData = await api.get(`/auto-team/inviter-accounts${query}`, { + timeoutMs: 12000, + retry: 0, + cancelPrevious: false, + requestKey: `auto-team:inviter-accounts:retry:${Date.now()}`, + silentNetworkError: true, + silentTimeoutError: true, + }); + const retryRaw = Array.isArray(retryData.accounts) ? retryData.accounts : []; + const retryAccounts = this.pruneInviterAccounts(retryRaw, "中断重试"); + this.inviterAccounts = retryAccounts; + this.writeCache(INVITER_CACHE_KEY, this.inviterAccounts); + this.fillSelect(this.inviterAccounts); + this.renderInviters(this.inviterAccounts); + this.log(`读取邀请账号中断后重试成功: ${retryAccounts.length} 个`); + if (withToast) { + toast.success(`重试成功,可用账号 ${retryAccounts.length} 个`); + } + return; + } catch (retryError) { + const retryMsg = safeError(retryError); + msg = `${msg} | retry=${retryMsg}`; + } + } + if (this.inviterAccounts.length > 0) { + const kept = this.pruneInviterAccounts(this.inviterAccounts, "失败保留"); + this.inviterAccounts = kept; + this.writeCache(INVITER_CACHE_KEY, this.inviterAccounts); + this.fillSelect(this.inviterAccounts); + this.renderInviters(this.inviterAccounts); + if (this.inviterAccounts.length > 0) { + this.log(`读取邀请账号失败,保留当前入池显示: ${msg}`); + } else { + this.log(`读取邀请账号失败,且本地保留后无可用账号: ${msg}`); + } + } else { + this.renderInviters([]); + this.log(`读取邀请账号失败: ${msg}`); + } + if (withToast) { + toast.error(`读取失败: ${msg}`); + } + } finally { + this.setReloadButtonLoading(btn, false); + } + } + + async runSilentPrecheck(sampleEmail, inviterAccountId, totalEmails, invalidCount) { + let lastError = null; + const maxAttempts = 2; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const data = await api.post("/auto-team/preview", { + target_email: sampleEmail, + inviter_account_id: inviterAccountId, + }); + const tip = totalEmails > 1 + ? `自动预检通过。当前 ${totalEmails} 个目标邮箱(示例: ${sampleEmail})。` + : "自动预检通过,可以执行 Team 自动邀请。"; + if (invalidCount > 0) { + this.log(`自动预检提示: 发现无效邮箱 ${invalidCount} 个,执行时将自动跳过。`); + } + this.log(`自动预检成功: inviter=${data.inviter?.email || "-"} | ${tip}`); + return data; + } catch (error) { + lastError = error; + const msg = String(safeError(error) || ""); + const lowerMsg = msg.toLowerCase(); + const retryable = [ + "timeout", + "timed out", + "connection", + "network", + "502", + "503", + "504", + ].some((k) => lowerMsg.includes(k)); + if (retryable && attempt < maxAttempts) { + this.log(`自动预检网络波动,第 ${attempt}/${maxAttempts} 次重试中...`); + await new Promise((resolve) => setTimeout(resolve, 800 * attempt)); + continue; + } + if (retryable) { + this.log(`自动预检网络异常,已跳过预检直接执行邀请: ${msg}`); + return null; + } + throw error; + } + } + throw lastError || new Error("自动预检失败"); + } + + async handleInvite() { + const { valid, invalid } = this.parseTargetEmails(); + const inviter_account_id = this.getInviterId(); + if (!valid.length) { + toast.error("请先填写有效目标邮箱"); + return; + } + + try { + loading.show(this.els.btnInvite, "邀请中..."); + this.log(`开始执行team邀请流程(共 ${valid.length} 个目标邮箱)。`); + if (invalid.length) { + this.log(`检测到无效邮箱 ${invalid.length} 个,已自动跳过。`); + } + + await this.runSilentPrecheck(valid[0], inviter_account_id, valid.length, invalid.length); + + let successCount = 0; + let failedCount = 0; + const successItems = []; + const failedItems = []; + const successfulEmails = new Set(); + + for (let i = 0; i < valid.length; i++) { + const email = valid[i]; + this.log(`执行邀请 ${i + 1}/${valid.length}: ${email}`); + try { + const data = await api.post("/auto-team/invite", { + target_email: email, + inviter_account_id, + }); + successCount += 1; + successItems.push({ + email, + inviter: data?.inviter?.email || "-", + message: data?.message || "邀请已提交", + }); + successfulEmails.add(String(email || "").trim().toLowerCase()); + this.log(`邀请成功: ${email} <- ${data?.inviter?.email || "-"}`); + } catch (error) { + failedCount += 1; + const msg = safeError(error); + failedItems.push({ email, error: msg }); + this.log(`邀请失败: ${email} | ${msg}`); + } + } + + const summary = `执行完成:成功 ${successCount},失败 ${failedCount},跳过无效 ${invalid.length}`; + const resultType = failedCount > 0 ? (successCount > 0 ? "warning" : "error") : "success"; + this.setResult( + resultType, + "自动邀请结果", + summary, + { success: successItems, failed: failedItems, invalid }, + ); + if (failedCount > 0) { + toast.warning(summary); + } else { + toast.success(summary); + } + if (successfulEmails.size) { + this.targetAccounts = this.targetAccounts.filter((item) => { + const email = String(item?.email || "").trim().toLowerCase(); + return !successfulEmails.has(email); + }); + this.selectedTargetIds = new Set( + [...this.selectedTargetIds].filter((id) => { + const item = this.targetAccounts.find((x) => String(x.id) === String(id)); + return !!item; + }) + ); + this.renderTargetModalList(); + } + await this.loadTeamGroups(false, true); + } catch (error) { + const msg = safeError(error); + this.log(`邀请失败: ${msg}`); + this.setResult("error", "邀请失败", msg); + toast.error(msg); + } finally { + loading.hide(this.els.btnInvite); + } + } + } + + document.addEventListener("DOMContentLoaded", () => { + window.autoTeamPage = new AutoTeamPage(); + }); +})(); + diff --git a/static/js/auto_team_manage.js b/static/js/auto_team_manage.js new file mode 100644 index 00000000..4a32f0f2 --- /dev/null +++ b/static/js/auto_team_manage.js @@ -0,0 +1,1172 @@ + +(function () { + const STATUS_LABEL = { + active: '可用', + full: '已满', + expired: '已过期', + blocked: '已阻断', + unknown: '待校验', + error: '异常', + banned: '已封禁', + failed: '失败', + }; + + const COLUMN_KEYS = ['members', 'plan', 'expires']; + const HIDDEN_COLS_KEY = 'auto_team_manage_hidden_cols_v1'; + const TEAM_INVITER_CACHE_KEY = 'auto_team_inviter_accounts_cache_v1'; + const TEAM_ROWS_CACHE_KEY = 'auto_team_manage_rows_cache_v1'; + const TEAM_MEMBERS_CACHE_KEY = 'auto_team_manage_members_cache_v1'; + const DEFAULT_TEAM_MAX_MEMBERS = 5; + + function esc(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function fmtDate(raw) { + if (!raw) return '-'; + const dt = new Date(raw); + if (Number.isNaN(dt.getTime())) return String(raw); + const y = dt.getFullYear(); + const m = String(dt.getMonth() + 1).padStart(2, '0'); + const d = String(dt.getDate()).padStart(2, '0'); + const h = String(dt.getHours()).padStart(2, '0'); + const mm = String(dt.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${d} ${h}:${mm}`; + } + + function statusText(status) { + const key = String(status || '').toLowerCase(); + return STATUS_LABEL[key] || key || '未知'; + } + + const BLOCKED_INVITER_STATUSES = new Set([ + 'failed', + 'banned', + 'deleted', + 'disabled', + 'invalid', + 'inactive', + 'frozen', + 'expired', + 'error', + 'locked', + 'suspended', + ]); + + function isHardRemoveAuthSource(rawSource) { + const source = String(rawSource || '').trim().toLowerCase(); + if (!source) return false; + return ( + source.includes('hard_remove_auth') + || source.includes('http_401') + || source.includes('http_403') + || source.includes('token has been invalidated') + || source.includes('authentication token has been invalidated') + || source.includes('please try signing in again') + ); + } + + function isBlockedInviter(item) { + const status = String(item?.status || '').trim().toLowerCase(); + const source = String(item?.manager_verify_source || item?.verify_source || '').trim(); + if (BLOCKED_INVITER_STATUSES.has(status)) return true; + if (isHardRemoveAuthSource(source)) return true; + return false; + } + + function normalizePlan(planRaw) { + const value = String(planRaw || '').trim().toLowerCase(); + if (value.includes('plus') || value.includes('pro')) return 'plus'; + if (value.includes('team') || value.includes('enterprise')) return 'team'; + return 'free'; + } + + function planText(planRaw) { + const plan = normalizePlan(planRaw); + if (plan === 'plus') return 'PLUS'; + if (plan === 'team') return 'TEAM'; + return 'FREE'; + } + + function safeError(error) { + if (!error) return '未知错误'; + if (typeof error === 'string') return error; + if (error.data && error.data.detail) { + if (typeof error.data.detail === 'string') return error.data.detail; + try { return JSON.stringify(error.data.detail); } catch (_e) { return String(error.data.detail); } + } + if (error.message) return error.message; + try { return JSON.stringify(error); } catch (_e) { return '未知错误'; } + } + + function decodeJwtPayload(token) { + const raw = String(token || '').trim(); + if (!raw) return {}; + const parts = raw.split('.'); + if (parts.length < 2) return {}; + try { + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const pad = '='.repeat((4 - (base64.length % 4)) % 4); + const decoded = atob(base64 + pad); + const text = decodeURIComponent( + Array.from(decoded).map((ch) => `%${ch.charCodeAt(0).toString(16).padStart(2, '0')}`).join('') + ); + return JSON.parse(text); + } catch (_e) { + return {}; + } + } + + function extractTokenFields(accessToken) { + const payload = decodeJwtPayload(accessToken); + const auth = payload['https://api.openai.com/auth'] || {}; + const profile = payload['https://api.openai.com/profile'] || {}; + const email = String(profile.email || payload.email || '').trim().toLowerCase(); + const accountId = String(auth.chatgpt_account_id || '').trim(); + const clientId = String(payload.client_id || '').trim(); + const planRaw = String(auth.chatgpt_plan_type || '').trim().toLowerCase(); + let plan = planRaw; + if (plan.includes('team') || plan.includes('enterprise')) plan = 'team'; + else if (plan.includes('plus')) plan = 'plus'; + else if (plan.includes('basic') || plan.includes('free')) plan = 'free'; + return { email, accountId, clientId, plan }; + } + + class TeamManageConsole { + constructor() { + this.els = { + tabManage: document.getElementById('tabManage'), + panelManage: document.getElementById('panelManage'), + teamStatTotal: document.getElementById('teamStatTotal'), + teamStatAvailable: document.getElementById('teamStatAvailable'), + teamStatusFilter: document.getElementById('teamStatusFilter'), + teamSearchInput: document.getElementById('teamSearchInput'), + btnToggleColumnMenu: document.getElementById('btnToggleColumnMenu'), + teamColumnMenu: document.getElementById('teamColumnMenu'), + btnImportTeam: document.getElementById('btnImportTeam'), + btnReloadTeamConsole: document.getElementById('btnReloadTeamConsole'), + teamSelectAll: document.getElementById('teamSelectAll'), + teamTableBody: document.getElementById('teamTableBody'), + teamImportModal: document.getElementById('teamImportModal'), + btnCloseTeamImportModal: document.getElementById('btnCloseTeamImportModal'), + btnTeamImportSingleTab: document.getElementById('btnTeamImportSingleTab'), + btnTeamImportBatchTab: document.getElementById('btnTeamImportBatchTab'), + teamImportSinglePanel: document.getElementById('teamImportSinglePanel'), + teamImportBatchPanel: document.getElementById('teamImportBatchPanel'), + teamImportModalHint: document.getElementById('teamImportModalHint'), + teamImportAccessToken: document.getElementById('teamImportAccessToken'), + teamImportRefreshToken: document.getElementById('teamImportRefreshToken'), + teamImportSessionToken: document.getElementById('teamImportSessionToken'), + teamImportClientId: document.getElementById('teamImportClientId'), + teamImportEmail: document.getElementById('teamImportEmail'), + teamImportAccountId: document.getElementById('teamImportAccountId'), + teamImportBatchText: document.getElementById('teamImportBatchText'), + btnSubmitTeamImport: document.getElementById('btnSubmitTeamImport'), + + teamMemberModal: document.getElementById('teamMemberModal'), + teamMemberModalSub: document.getElementById('teamMemberModalSub'), + teamMemberModalHint: document.getElementById('teamMemberModalHint'), + btnCloseTeamMemberModal: document.getElementById('btnCloseTeamMemberModal'), + teamMemberInviteEmail: document.getElementById('teamMemberInviteEmail'), + btnInviteMember: document.getElementById('btnInviteMember'), + btnReloadTeamMembers: document.getElementById('btnReloadTeamMembers'), + teamJoinedMembersBody: document.getElementById('teamJoinedMembersBody'), + teamInvitedMembersBody: document.getElementById('teamInvitedMembersBody'), + }; + + this.rows = []; + this.filteredRows = []; + this.loaded = false; + this.teamConsoleUnavailable = false; + this.hiddenCols = new Set(this.readHiddenCols()); + this.memberAccountId = null; + this.memberAccountEmail = ''; + this.teamImportMode = 'single'; + this.consoleLoadSeq = 0; + this.membersCache = this.readJsonCache(TEAM_MEMBERS_CACHE_KEY, {}); + this.bindEvents(); + this.applyColumnVisibility(); + this.restoreRowsFromCache(); + this.renderRows(); + } + + bindEvents() { + this.els.tabManage?.addEventListener('click', () => { + if (!this.loaded) this.renderRows(); + }); + + this.els.teamStatusFilter?.addEventListener('change', () => this.applyFilters()); + this.els.teamSearchInput?.addEventListener('input', () => this.applyFilters()); + this.els.btnReloadTeamConsole?.addEventListener('click', () => this.loadConsole(true, true)); + this.els.btnImportTeam?.addEventListener('click', () => this.openImportModal()); + this.els.btnCloseTeamImportModal?.addEventListener('click', () => this.closeImportModal()); + this.els.teamImportModal?.addEventListener('click', (e) => { + if (e.target === this.els.teamImportModal) this.closeImportModal(); + }); + this.els.btnTeamImportSingleTab?.addEventListener('click', () => this.setImportMode('single')); + this.els.btnTeamImportBatchTab?.addEventListener('click', () => this.setImportMode('batch')); + this.els.btnSubmitTeamImport?.addEventListener('click', () => this.submitTeamImport()); + + this.els.btnToggleColumnMenu?.addEventListener('click', (e) => { + e.stopPropagation(); + this.els.teamColumnMenu?.classList.toggle('show'); + }); + this.els.teamColumnMenu?.addEventListener('change', (e) => this.onColumnToggle(e)); + + this.els.teamSelectAll?.addEventListener('change', () => { + const checked = !!this.els.teamSelectAll.checked; + this.els.teamTableBody?.querySelectorAll('input[data-row-select]').forEach((el) => { + el.checked = checked; + }); + }); + + this.els.teamTableBody?.addEventListener('click', (e) => this.handleTableAction(e)); + + this.els.btnCloseTeamMemberModal?.addEventListener('click', () => this.closeMemberModal()); + this.els.teamMemberModal?.addEventListener('click', (e) => { + if (e.target === this.els.teamMemberModal) this.closeMemberModal(); + }); + this.els.btnInviteMember?.addEventListener('click', () => this.inviteMember()); + this.els.btnReloadTeamMembers?.addEventListener('click', () => this.loadMembers(true)); + this.els.teamJoinedMembersBody?.addEventListener('click', (e) => this.handleMemberTableAction(e, 'joined')); + this.els.teamInvitedMembersBody?.addEventListener('click', (e) => this.handleMemberTableAction(e, 'invited')); + + document.addEventListener('click', (e) => { + if (!this.els.teamColumnMenu || !this.els.btnToggleColumnMenu) return; + if (!this.els.teamColumnMenu.contains(e.target) && !this.els.btnToggleColumnMenu.contains(e.target)) { + this.els.teamColumnMenu.classList.remove('show'); + } + }); + } + readHiddenCols() { + try { + const raw = localStorage.getItem(HIDDEN_COLS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((x) => COLUMN_KEYS.includes(x)) : []; + } catch (_e) { + return []; + } + } + + saveHiddenCols() { + try { + localStorage.setItem(HIDDEN_COLS_KEY, JSON.stringify([...this.hiddenCols])); + } catch (_e) { + // ignore + } + } + + readJsonCache(key, fallback) { + try { + const raw = localStorage.getItem(key); + if (!raw) return fallback; + return JSON.parse(raw); + } catch (_e) { + return fallback; + } + } + + writeJsonCache(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (_e) { + // ignore + } + } + + persistRowsCache() { + this.writeJsonCache(TEAM_ROWS_CACHE_KEY, this.rows || []); + } + + restoreRowsFromCache() { + const cachedRows = this.readJsonCache(TEAM_ROWS_CACHE_KEY, []); + if (Array.isArray(cachedRows) && cachedRows.length > 0) { + this.rows = cachedRows.map((x) => this.normalizeRow(x)); + this.loaded = true; + this.recomputeStats(); + this.applyFilters(); + } + } + + syncRowsFromInviters(inviters, options = {}) { + const sourceRaw = Array.isArray(inviters) ? inviters : []; + const source = sourceRaw.filter((item) => !isBlockedInviter(item)); + if (!source.length) return; + const pruneMissing = !!options.pruneMissing; + const applyStats = options.applyStats !== false; + const existingMap = new Map((this.rows || []).map((row) => [Number(row.id || 0), this.normalizeRow(row)])); + const orderedIds = []; + const nextMap = new Map(); + + source.forEach((item) => { + const incoming = this.fromInviterToRow(item); + const id = Number(incoming.id || 0); + if (!id) return; + orderedIds.push(id); + const existing = existingMap.get(id); + if (existing) { + const maxMembers = Number(existing.max_members || incoming.max_members || DEFAULT_TEAM_MAX_MEMBERS); + const currentMembers = Number(existing.current_members || 0); + nextMap.set(id, this.normalizeRow({ + ...existing, + ...incoming, + current_members: Number.isFinite(currentMembers) ? currentMembers : 0, + max_members: Number.isFinite(maxMembers) && maxMembers > 0 ? maxMembers : DEFAULT_TEAM_MAX_MEMBERS, + member_ratio: `${Number.isFinite(currentMembers) ? currentMembers : 0}/${Number.isFinite(maxMembers) && maxMembers > 0 ? maxMembers : DEFAULT_TEAM_MAX_MEMBERS}`, + })); + } else { + nextMap.set(id, this.normalizeRow(incoming)); + } + }); + + if (!pruneMissing) { + existingMap.forEach((row, id) => { + if (!nextMap.has(id)) nextMap.set(id, this.normalizeRow(row)); + }); + } + + const nextRows = []; + orderedIds.forEach((id) => { + if (nextMap.has(id)) nextRows.push(nextMap.get(id)); + }); + nextMap.forEach((row, id) => { + if (!orderedIds.includes(id)) nextRows.push(row); + }); + + this.rows = nextRows; + this.loaded = true; + this.persistRowsCache(); + if (applyStats) { + this.recomputeStats(); + this.applyFilters(); + } + } + + syncRowsFromConsole(consoleRows, options = {}) { + const source = Array.isArray(consoleRows) ? consoleRows : []; + if (!source.length) return false; + const applyStats = options.applyStats !== false; + const existingMap = new Map((this.rows || []).map((row) => [Number(row.id || 0), this.normalizeRow(row)])); + const orderedIds = []; + const nextMap = new Map(); + + source.forEach((item) => { + const incoming = this.normalizeRow(item); + const id = Number(incoming.id || 0); + if (!id) return; + orderedIds.push(id); + const existing = existingMap.get(id); + if (existing) { + nextMap.set(id, this.normalizeRow({ + ...existing, + ...incoming, + current_members: Number.isFinite(Number(incoming.current_members)) + ? Number(incoming.current_members) + : Number(existing.current_members || 0), + max_members: Number.isFinite(Number(incoming.max_members)) && Number(incoming.max_members) > 0 + ? Number(incoming.max_members) + : Number(existing.max_members || DEFAULT_TEAM_MAX_MEMBERS), + member_ratio: incoming.member_ratio || existing.member_ratio || '0/5', + })); + } else { + nextMap.set(id, incoming); + } + }); + + existingMap.forEach((row, id) => { + if (!nextMap.has(id)) nextMap.set(id, this.normalizeRow(row)); + }); + + const nextRows = []; + orderedIds.forEach((id) => { + if (nextMap.has(id)) nextRows.push(nextMap.get(id)); + }); + nextMap.forEach((row, id) => { + if (!orderedIds.includes(id)) nextRows.push(row); + }); + + this.rows = nextRows; + this.loaded = true; + this.persistRowsCache(); + if (applyStats) { + this.recomputeStats(); + this.applyFilters(); + } + return true; + } + + updateRowMemberStats(accountId, joinedCount) { + const id = Number(accountId || 0); + if (!id) return; + const idx = this.rows.findIndex((x) => Number(x.id || 0) === id); + if (idx < 0) return; + const row = this.rows[idx] || {}; + const maxMembersRaw = Number(row.max_members || DEFAULT_TEAM_MAX_MEMBERS); + const maxMembers = Number.isFinite(maxMembersRaw) && maxMembersRaw > 0 ? maxMembersRaw : DEFAULT_TEAM_MAX_MEMBERS; + const current = Math.max(0, Number(joinedCount || 0)); + this.rows.splice(idx, 1, this.normalizeRow({ + ...row, + current_members: current, + max_members: maxMembers, + member_ratio: `${current}/${maxMembers}`, + })); + this.persistRowsCache(); + this.recomputeStats(); + this.applyFilters(); + } + + onColumnToggle(event) { + const target = event.target; + if (!target || !target.matches('input[type="checkbox"][data-col-toggle]')) return; + const key = String(target.dataset.colToggle || ''); + if (!COLUMN_KEYS.includes(key)) return; + if (target.checked) this.hiddenCols.delete(key); + else this.hiddenCols.add(key); + this.saveHiddenCols(); + this.applyColumnVisibility(); + } + + applyColumnVisibility() { + COLUMN_KEYS.forEach((key) => { + const hidden = this.hiddenCols.has(key); + document.querySelectorAll(`.col-${key}`).forEach((el) => { + el.style.display = hidden ? 'none' : ''; + }); + }); + this.els.teamColumnMenu?.querySelectorAll('input[type="checkbox"][data-col-toggle]').forEach((el) => { + const key = String(el.dataset.colToggle || ''); + el.checked = !this.hiddenCols.has(key); + }); + } + + normalizeRow(row) { + const currentRaw = Number(row.current_members || row.currentMembers || 0); + const maxRaw = Number(row.max_members || row.maxMembers || DEFAULT_TEAM_MAX_MEMBERS); + const max = Number.isFinite(maxRaw) && maxRaw > 0 + ? Math.min(maxRaw, DEFAULT_TEAM_MAX_MEMBERS) + : DEFAULT_TEAM_MAX_MEMBERS; + const current = Number.isFinite(currentRaw) ? Math.max(0, Math.min(currentRaw, max)) : 0; + return { + id: Number(row.id || 0), + email: String(row.email || ''), + account_id: String(row.account_id || row.workspace_id || ''), + team_name: String(row.team_name || 'MyTeam'), + current_members: current, + max_members: max, + member_ratio: `${current}/${max}`, + subscription_plan: String(row.subscription_plan || 'chatgptteamplan'), + expires_at: row.expires_at || null, + status: String(row.status || 'active').toLowerCase(), + role_tag: String(row.role_tag || ''), + pool_state: String(row.pool_state || ''), + priority: Number(row.priority || 50), + last_used_at: row.last_used_at || null, + workspace_id: String(row.workspace_id || ''), + }; + } + + markRowsSoftUnavailable(rows, status = 'expired') { + const sourceRows = Array.isArray(rows) ? rows : []; + const nextStatus = String(status || 'expired').trim().toLowerCase() || 'expired'; + return sourceRows.map((row) => this.normalizeRow({ + ...row, + status: nextStatus, + })); + } + + fromInviterToRow(item) { + const id = Number(item.id || 0); + return { + id: Number.isFinite(id) ? id : 0, + email: String(item.email || ''), + account_id: String(item.workspace_id || ''), + team_name: 'MyTeam', + current_members: 0, + max_members: DEFAULT_TEAM_MAX_MEMBERS, + member_ratio: `0/${DEFAULT_TEAM_MAX_MEMBERS}`, + subscription_plan: 'chatgptteamplan', + expires_at: null, + status: String(item.status || 'active').toLowerCase(), + role_tag: String(item.role_tag || ''), + pool_state: String(item.pool_state || ''), + priority: Number(item.priority || 50), + last_used_at: item.last_used_at || null, + workspace_id: String(item.workspace_id || ''), + }; + } + + async loadRowsFromInviterPool(force = false, seq = null) { + try { + const queryParams = new URLSearchParams(); + if (force) queryParams.set('force', '1'); + queryParams.set('local_only', '1'); + const query = `?${queryParams.toString()}`; + const inviterData = await api.get(`/auto-team/inviter-accounts${query}`, { + timeoutMs: 10000, + retry: 0, + priority: 'high', + requestKey: 'auto-team:inviter-accounts', + cancelPrevious: true, + silentNetworkError: true, + silentTimeoutError: true, + }); + if (seq != null && seq !== this.consoleLoadSeq) { + return false; + } + const inviters = (Array.isArray(inviterData.accounts) ? inviterData.accounts : []) + .filter((item) => !isBlockedInviter(item)); + if (!inviters.length) { + return false; + } + this.syncRowsFromInviters(inviters, { pruneMissing: false, applyStats: false }); + return true; + } catch (error) { + if (error?.name === 'AbortError' && error?.cancelReason === 'request_replaced') { + return false; + } + return false; + } + } + + async refreshConsoleRemote(seq, rowsBeforeLoad, options = {}) { + const force = !!options.force; + const withToast = !!options.withToast; + try { + const query = force ? '?force=1' : ''; + const data = await api.get(`/auto-team/team-console${query}`, { + timeoutMs: 15000, + retry: 0, + priority: 'high', + requestKey: 'auto-team:team-console', + cancelPrevious: true, + silentNetworkError: true, + silentTimeoutError: true, + }); + if (seq !== this.consoleLoadSeq) return; + + this.teamConsoleUnavailable = false; + const consoleRows = Array.isArray(data.rows) ? data.rows : []; + const remoteSynced = this.syncRowsFromConsole(consoleRows, { applyStats: false }); + + let usedInviterFallback = false; + if (!consoleRows.length) { + usedInviterFallback = await this.loadRowsFromInviterPool(false, seq); + } + if (seq !== this.consoleLoadSeq) return; + + if (!this.rows.length && !usedInviterFallback && rowsBeforeLoad.length) { + this.rows = this.markRowsSoftUnavailable(rowsBeforeLoad, 'expired'); + this.loaded = true; + this.recomputeStats(); + this.persistRowsCache(); + this.applyFilters(); + if (withToast) toast.warning('当前无可用 Team 管理账号,已保留历史 Team 列表并标记为过期'); + return; + } + + this.loaded = true; + this.recomputeStats(); + this.persistRowsCache(); + this.applyFilters(); + if (withToast) { + if (remoteSynced || usedInviterFallback) { + toast.success('Team 控制台已刷新'); + } else { + toast.warning('刷新完成,但当前无可用 Team 管理账号'); + } + } + } catch (error) { + if (error?.name === 'AbortError' && error?.cancelReason === 'request_replaced') { + return; + } + const msg = safeError(error); + const statusCode = Number(error?.response?.status || 0); + const fallbackOk = await this.loadRowsFromInviterPool(false, seq); + if (seq !== this.consoleLoadSeq) return; + + this.loaded = true; + if (fallbackOk) { + this.recomputeStats(); + this.applyFilters(); + this.persistRowsCache(); + if (statusCode === 404 || /not found/i.test(msg)) { + this.teamConsoleUnavailable = true; + if (withToast) toast.success('已切换邀请池模式(team-console 不可用)'); + return; + } + if (withToast) toast.warning(`team-console 读取失败,已回退邀请池:${msg}`); + return; + } + if (rowsBeforeLoad.length) { + this.rows = this.markRowsSoftUnavailable(rowsBeforeLoad, 'unknown'); + this.loaded = true; + this.recomputeStats(); + this.applyFilters(); + this.persistRowsCache(); + if (withToast) toast.warning(`team-console 读取失败,已保留当前列表(待校验):${msg}`); + return; + } + + this.filteredRows = []; + this.setStats(0, 0); + this.renderRows(); + if (withToast) toast.error(msg); + } + } + + async loadConsole(withToast, force = false) { + const seq = ++this.consoleLoadSeq; + const rowsBeforeLoad = Array.isArray(this.rows) ? this.rows.map((x) => this.normalizeRow(x)) : []; + const applyManualPoolSync = async () => { + const synced = await this.loadRowsFromInviterPool(true, seq); + if (seq !== this.consoleLoadSeq) return false; + if (synced) { + this.loaded = true; + this.recomputeStats(); + this.applyFilters(); + this.persistRowsCache(); + } + return synced; + }; + + if (this.teamConsoleUnavailable && !force) { + try { + loading.show(this.els.btnReloadTeamConsole, '刷新中...'); + const fallbackOk = await this.loadRowsFromInviterPool(false, seq); + if (seq !== this.consoleLoadSeq) return; + this.loaded = true; + if (fallbackOk) { + this.recomputeStats(); + this.applyFilters(); + this.persistRowsCache(); + if (withToast) toast.success('已按邀请池刷新 Team 列表'); + return; + } + if (rowsBeforeLoad.length) { + this.rows = this.markRowsSoftUnavailable(rowsBeforeLoad, 'expired'); + this.loaded = true; + this.recomputeStats(); + this.applyFilters(); + this.persistRowsCache(); + if (withToast) toast.warning('当前无可用 Team 管理账号,已保留历史 Team 列表并标记为过期'); + return; + } + this.filteredRows = []; + this.setStats(0, 0); + this.renderRows(); + if (withToast) toast.warning('当前无可用 Team 管理账号'); + return; + } finally { + if (seq === this.consoleLoadSeq) { + loading.hide(this.els.btnReloadTeamConsole); + } + } + } + + if (force) { + try { + loading.show(this.els.btnReloadTeamConsole, '刷新中...'); + const manualSynced = await applyManualPoolSync(); + if (seq !== this.consoleLoadSeq) return; + + if (!manualSynced && rowsBeforeLoad.length) { + this.rows = this.markRowsSoftUnavailable(rowsBeforeLoad, 'expired'); + this.loaded = true; + this.recomputeStats(); + this.applyFilters(); + this.persistRowsCache(); + if (withToast) toast.warning('本地未匹配到可用管理账号,已保留历史 Team 列表并标记为过期'); + } else if (!manualSynced && !this.rows.length) { + this.filteredRows = []; + this.setStats(0, 0); + this.renderRows(); + if (withToast) toast.warning('当前无可用 Team 管理账号'); + } else if (withToast) { + toast.success('已完成本地同步,正在后台校验 Team 状态...'); + } + } finally { + if (seq === this.consoleLoadSeq) { + loading.hide(this.els.btnReloadTeamConsole); + } + } + void this.refreshConsoleRemote(seq, rowsBeforeLoad, { force: true, withToast: false }); + return; + } + + try { + loading.show(this.els.btnReloadTeamConsole, '刷新中...'); + await this.refreshConsoleRemote(seq, rowsBeforeLoad, { force: false, withToast }); + } finally { + if (seq === this.consoleLoadSeq) { + loading.hide(this.els.btnReloadTeamConsole); + } + } + } + + setStats(total, available) { + if (this.els.teamStatTotal) this.els.teamStatTotal.textContent = String(total || 0); + if (this.els.teamStatAvailable) this.els.teamStatAvailable.textContent = String(available || 0); + } + + recomputeStats() { + const total = this.rows.length; + const available = this.rows.filter((row) => { + if (row.status !== 'active') return false; + if (!row.max_members) return true; + return row.current_members < row.max_members; + }).length; + this.setStats(total, available); + } + + applyFilters() { + const status = String(this.els.teamStatusFilter?.value || '').trim().toLowerCase(); + const keyword = String(this.els.teamSearchInput?.value || '').trim().toLowerCase(); + this.filteredRows = this.rows.filter((row) => { + if (status && row.status !== status) return false; + if (!keyword) return true; + return [ + row.email, + row.team_name, + row.subscription_plan, + row.member_ratio, + row.status, + ].some((x) => String(x || '').toLowerCase().includes(keyword)); + }); + this.renderRows(); + this.applyColumnVisibility(); + } + + renderRows() { + if (!this.els.teamTableBody) return; + if (!this.loaded) { + this.els.teamTableBody.innerHTML = '首次不自动加载,请点击“刷新”读取 Team 列表'; + return; + } + if (!this.filteredRows.length) { + this.els.teamTableBody.innerHTML = '暂无 Team 管理账号'; + return; + } + this.els.teamTableBody.innerHTML = this.filteredRows.map((row) => ` + + + ${row.id} + ${esc(row.email)} + ${esc(row.team_name || '-')} + ${esc(row.member_ratio || '-')} + + ${esc(planText(row.subscription_plan))} + + ${esc(fmtDate(row.expires_at))} + ${esc(statusText(row.status))} + +
+ + + +
+ + + `).join(''); + } + + setTinyBusy(button, busy) { + if (!button) return; + if (busy) { + if (!button.dataset.originHtml) button.dataset.originHtml = button.innerHTML; + button.innerHTML = '…'; + button.disabled = true; + return; + } + if (button.dataset.originHtml) { + button.innerHTML = button.dataset.originHtml; + delete button.dataset.originHtml; + } + button.disabled = false; + } + + setImportHint(text, isError = false) { + if (!this.els.teamImportModalHint) return; + this.els.teamImportModalHint.textContent = text || '-'; + this.els.teamImportModalHint.style.color = isError ? 'var(--danger-color)' : ''; + } + + setImportMode(mode) { + const next = mode === 'batch' ? 'batch' : 'single'; + this.teamImportMode = next; + this.els.btnTeamImportSingleTab?.classList.toggle('active', next === 'single'); + this.els.btnTeamImportBatchTab?.classList.toggle('active', next === 'batch'); + this.els.teamImportSinglePanel?.classList.toggle('active', next === 'single'); + this.els.teamImportBatchPanel?.classList.toggle('active', next === 'batch'); + } + + openImportModal() { + this.setImportMode('single'); + this.setImportHint('填写 Team Token 后点击导入。'); + this.els.teamImportModal?.classList.add('show'); + } + + closeImportModal() { + this.els.teamImportModal?.classList.remove('show'); + } + + buildImportItemFromRaw(rawItem) { + const accessToken = String(rawItem.access_token || rawItem.accessToken || '').trim(); + const refreshToken = String(rawItem.refresh_token || rawItem.refreshToken || '').trim(); + const sessionToken = String(rawItem.session_token || rawItem.sessionToken || '').trim(); + const clientIdInput = String(rawItem.client_id || rawItem.clientId || '').trim(); + const emailInput = String(rawItem.email || '').trim().toLowerCase(); + const accountIdInput = String(rawItem.account_id || rawItem.accountId || '').trim(); + const tokenFields = extractTokenFields(accessToken); + const email = emailInput || tokenFields.email; + const accountId = accountIdInput || tokenFields.accountId; + const clientId = clientIdInput || tokenFields.clientId; + const plan = String(tokenFields.plan || '').toLowerCase(); + + if (!accessToken) { + throw new Error('缺少 access_token'); + } + if (!email) { + throw new Error('缺少 email,且无法从 AT 中提取'); + } + + return { + email, + password: String(rawItem.password || '').trim() || null, + email_service: 'manual', + status: 'active', + client_id: clientId || null, + account_id: accountId || null, + workspace_id: accountId || null, + access_token: accessToken, + refresh_token: refreshToken || null, + session_token: sessionToken || null, + source: 'team_import', + role_tag: 'parent', + account_label: 'mother', + subscription_type: (plan === 'plus' || plan === 'team') ? plan : 'team', + metadata: { + imported_from: 'team_manage_modal', + imported_at: new Date().toISOString(), + }, + }; + } + + parseBatchImportText(raw) { + const text = String(raw || '').trim(); + if (!text) throw new Error('请先填写批量导入文本'); + if (text.startsWith('[')) { + const arr = JSON.parse(text); + if (!Array.isArray(arr)) throw new Error('JSON 数组格式不正确'); + return arr; + } + + const rows = text.split(/\r?\n/).map((x) => x.trim()).filter(Boolean); + const out = []; + rows.forEach((line, idx) => { + try { + out.push(JSON.parse(line)); + } catch (_e) { + throw new Error(`第 ${idx + 1} 行 JSON 解析失败`); + } + }); + return out; + } + + async submitTeamImport() { + try { + this.setImportHint('导入中...'); + loading.show(this.els.btnSubmitTeamImport, '导入中...'); + let rawItems = []; + + if (this.teamImportMode === 'single') { + rawItems = [{ + access_token: this.els.teamImportAccessToken?.value, + refresh_token: this.els.teamImportRefreshToken?.value, + session_token: this.els.teamImportSessionToken?.value, + client_id: this.els.teamImportClientId?.value, + email: this.els.teamImportEmail?.value, + account_id: this.els.teamImportAccountId?.value, + }]; + } else { + rawItems = this.parseBatchImportText(this.els.teamImportBatchText?.value || ''); + } + + const accounts = rawItems.map((item, idx) => { + try { + return this.buildImportItemFromRaw(item || {}); + } catch (e) { + throw new Error(`第 ${idx + 1} 条: ${e.message || e}`); + } + }); + + const data = await api.post('/accounts/import', { + accounts, + overwrite: true, + }); + const msg = `完成:创建 ${data.created || 0},更新 ${data.updated || 0},跳过 ${data.skipped || 0},失败 ${data.failed || 0}`; + this.setImportHint(msg, false); + toast.success('导入完成'); + await this.loadConsole(false); + if (!Number(data.failed || 0)) { + this.closeImportModal(); + } + } catch (error) { + const msg = safeError(error); + this.setImportHint(msg, true); + toast.error(msg); + } finally { + loading.hide(this.els.btnSubmitTeamImport); + } + } + + async handleTableAction(event) { + const btn = event.target.closest('button[data-action]'); + if (!btn) return; + const action = String(btn.dataset.action || ''); + const accountId = Number(btn.dataset.id || 0); + const email = String(btn.dataset.email || ''); + if (!accountId) return; + + if (action === 'members') { + await this.openMemberModal(accountId, email); + return; + } + + if (action === 'refresh') { + try { + this.setTinyBusy(btn, true); + await this.refreshOne(accountId); + toast.success('刷新成功'); + } catch (error) { + if (error?.name === 'AbortError' && error?.cancelReason === 'request_replaced') return; + toast.error(safeError(error)); + } finally { + this.setTinyBusy(btn, false); + } + return; + } + + if (action === 'delete') { + if (!confirm(`确定删除 Team 账号 ${email || accountId} 吗?`)) return; + try { + this.setTinyBusy(btn, true); + await api.delete(`/accounts/${accountId}`); + this.rows = this.rows.filter((x) => x.id !== accountId); + delete this.membersCache[String(accountId)]; + this.writeJsonCache(TEAM_MEMBERS_CACHE_KEY, this.membersCache); + this.persistRowsCache(); + this.applyFilters(); + this.recomputeStats(); + toast.success('删除成功'); + } catch (error) { + toast.error(safeError(error)); + } finally { + this.setTinyBusy(btn, false); + } + } + } + + async refreshOne(accountId) { + const data = await api.post(`/auto-team/team-accounts/${accountId}/refresh`, {}, { + timeoutMs: 15000, + retry: 0, + priority: 'high', + requestKey: `auto-team:refresh-one:${accountId}`, + cancelPrevious: true, + silentNetworkError: true, + silentTimeoutError: true, + }); + const row = this.normalizeRow(data.row || {}); + const idx = this.rows.findIndex((x) => x.id === accountId); + if (idx >= 0) this.rows.splice(idx, 1, row); + else this.rows.unshift(row); + this.persistRowsCache(); + this.applyFilters(); + this.recomputeStats(); + } + async openMemberModal(accountId, email) { + this.memberAccountId = accountId; + this.memberAccountEmail = email || '-'; + if (this.els.teamMemberModalSub) this.els.teamMemberModalSub.textContent = this.memberAccountEmail; + if (this.els.teamMemberInviteEmail) this.els.teamMemberInviteEmail.value = ''; + const cacheKey = String(accountId); + const cached = this.membersCache && typeof this.membersCache === 'object' ? this.membersCache[cacheKey] : null; + const cachedJoined = Array.isArray(cached?.joined_members) + ? cached.joined_members + : (Array.isArray(cached?.joined) ? cached.joined : []); + const cachedInvited = Array.isArray(cached?.invited_members) + ? cached.invited_members + : (Array.isArray(cached?.invited) ? cached.invited : []); + if (cached) { + this.renderJoined(cachedJoined); + this.renderInvited(cachedInvited); + this.updateRowMemberStats(accountId, cachedJoined.length); + if (this.els.teamMemberModalHint) { + this.els.teamMemberModalHint.textContent = `workspace: ${cached.workspace_id || '-'} | 已加入 ${cachedJoined.length} | 邀请中 ${cachedInvited.length}(缓存)`; + } + } else { + if (this.els.teamJoinedMembersBody) this.els.teamJoinedMembersBody.innerHTML = '加载中...'; + if (this.els.teamInvitedMembersBody) this.els.teamInvitedMembersBody.innerHTML = '加载中...'; + } + this.els.teamMemberModal?.classList.add('show'); + await this.loadMembers(false, { preserveExisting: true }); + } + + closeMemberModal() { + this.els.teamMemberModal?.classList.remove('show'); + this.memberAccountId = null; + this.memberAccountEmail = ''; + } + + async loadMembers(withToast, options = {}) { + const accountId = Number(this.memberAccountId || 0); + if (!accountId) return; + const preserveExisting = options.preserveExisting !== false; + const cacheKey = String(accountId); + const cached = this.membersCache && typeof this.membersCache === 'object' ? this.membersCache[cacheKey] : null; + const hasCached = !!cached; + if (!preserveExisting || !hasCached) { + if (this.els.teamJoinedMembersBody) this.els.teamJoinedMembersBody.innerHTML = '加载中...'; + if (this.els.teamInvitedMembersBody) this.els.teamInvitedMembersBody.innerHTML = '加载中...'; + } + try { + loading.show(this.els.btnReloadTeamMembers, '刷新中...'); + const data = await api.get(`/auto-team/team-accounts/${accountId}/members`, { + timeoutMs: 15000, + retry: 0, + priority: 'high', + requestKey: `auto-team:members:${accountId}`, + cancelPrevious: true, + silentNetworkError: true, + silentTimeoutError: true, + }); + const joined = Array.isArray(data.joined_members) ? data.joined_members : []; + const invited = Array.isArray(data.invited_members) ? data.invited_members : []; + this.renderJoined(joined); + this.renderInvited(invited); + this.membersCache[cacheKey] = { + workspace_id: data.workspace_id || '', + joined_members: joined, + invited_members: invited, + updated_at: new Date().toISOString(), + }; + this.writeJsonCache(TEAM_MEMBERS_CACHE_KEY, this.membersCache); + this.updateRowMemberStats(accountId, joined.length); + if (this.els.teamMemberModalHint) { + this.els.teamMemberModalHint.textContent = `workspace: ${data.workspace_id || '-'} | 已加入 ${joined.length} | 邀请中 ${invited.length}`; + } + if (withToast) toast.success('成员已刷新'); + } catch (error) { + if (error?.name === 'AbortError' && error?.cancelReason === 'request_replaced') return; + const msg = safeError(error); + if (preserveExisting && hasCached) { + if (this.els.teamMemberModalHint) this.els.teamMemberModalHint.textContent = `读取失败,已显示缓存: ${msg}`; + if (withToast) toast.warning(`读取失败,已显示缓存:${msg}`); + return; + } + if (this.els.teamJoinedMembersBody) this.els.teamJoinedMembersBody.innerHTML = `${esc(msg)}`; + if (this.els.teamInvitedMembersBody) this.els.teamInvitedMembersBody.innerHTML = '-'; + if (this.els.teamMemberModalHint) this.els.teamMemberModalHint.textContent = `读取失败: ${msg}`; + if (withToast) toast.error(msg); + } finally { + loading.hide(this.els.btnReloadTeamMembers); + } + } + + renderJoined(rows) { + if (!this.els.teamJoinedMembersBody) return; + if (!rows.length) { + this.els.teamJoinedMembersBody.innerHTML = '暂无已加入成员'; + return; + } + this.els.teamJoinedMembersBody.innerHTML = rows.map((item) => ` + + ${esc(item.email || '-')} + ${esc(item.role || 'standard-user')} + ${esc(fmtDate(item.added_at))} + + + `).join(''); + } + + renderInvited(rows) { + if (!this.els.teamInvitedMembersBody) return; + if (!rows.length) { + this.els.teamInvitedMembersBody.innerHTML = '暂无邀请中成员'; + return; + } + this.els.teamInvitedMembersBody.innerHTML = rows.map((item) => ` + + ${esc(item.email || '-')} + ${esc(item.role || 'standard-user')} + ${esc(fmtDate(item.added_at))} + + + `).join(''); + } + + async inviteMember() { + const accountId = Number(this.memberAccountId || 0); + if (!accountId) return; + const email = String(this.els.teamMemberInviteEmail?.value || '').trim().toLowerCase(); + const emailRe = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + if (!emailRe.test(email)) { + toast.warning('请输入正确邮箱'); + return; + } + try { + loading.show(this.els.btnInviteMember, '添加中...'); + const data = await api.post(`/auto-team/team-accounts/${accountId}/members/invite`, { email }); + toast.success(data?.message || '邀请已提交'); + if (this.els.teamMemberInviteEmail) this.els.teamMemberInviteEmail.value = ''; + await this.loadMembers(false); + await this.refreshOne(accountId); + } catch (error) { + toast.error(safeError(error)); + } finally { + loading.hide(this.els.btnInviteMember); + } + } + + async handleMemberTableAction(event, type) { + const btn = event.target.closest('button[data-member-action]'); + if (!btn) return; + const action = String(btn.dataset.memberAction || ''); + const accountId = Number(this.memberAccountId || 0); + if (!accountId) return; + + try { + this.setTinyBusy(btn, true); + if (type === 'invited' && action === 'revoke') { + const email = String(btn.dataset.email || '').trim().toLowerCase(); + if (!email) return; + await api.post(`/auto-team/team-accounts/${accountId}/members/revoke`, { email }); + toast.success('邀请已撤回'); + } else if (type === 'joined' && action === 'remove') { + const userId = String(btn.dataset.userId || '').trim(); + if (!userId) return; + await api.post(`/auto-team/team-accounts/${accountId}/members/remove`, { user_id: userId }); + toast.success('成员已移除'); + } else { + return; + } + await this.loadMembers(false); + await this.refreshOne(accountId); + } catch (error) { + toast.error(safeError(error)); + } finally { + this.setTinyBusy(btn, false); + } + } + } + + document.addEventListener('DOMContentLoaded', () => { + window.teamManageConsole = new TeamManageConsole(); + }); +})(); diff --git a/static/js/card_pool.js b/static/js/card_pool.js new file mode 100644 index 00000000..4f79a935 --- /dev/null +++ b/static/js/card_pool.js @@ -0,0 +1,831 @@ +(function () { + "use strict"; + + const STORAGE_KEY = "card_pool.redeem_codes.v1"; + const REDEEM_CODE_REGEX = /^UK(?:-[A-Z0-9]{5}){5}$/; + const VALID_STATUSES = new Set(["unused", "used", "expired"]); + const BUILTIN_SUPPLIERS = ["EFun"]; + const fp = window.filterProtocol || { + normalizeValue(value) { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text ? text : null; + }, + pickSort(value, allowed = [], fallback = "") { + const candidate = String(value || "").trim(); + return allowed.includes(candidate) ? candidate : fallback; + }, + }; + + const state = { + activeTab: "redeem", + statusFilter: "", + supplierFilter: "", + sortOrder: "created_desc", + search: "", + page: 1, + pageSize: 50, + codes: [], + selectedCodes: new Set(), + pageCodes: [], + editingCode: "", + }; + + function getNormalizedFilters() { + const normalized = { + status: fp.normalizeValue(state.statusFilter) || "", + supplier: fp.normalizeValue(state.supplierFilter) || "", + sort: fp.pickSort(state.sortOrder, ["created_desc", "created_asc"], "created_desc"), + search: String(fp.normalizeValue(state.search) || ""), + }; + return normalized; + } + + function safeJsonParse(raw, fallback) { + try { + const parsed = JSON.parse(raw); + return parsed ?? fallback; + } catch (_) { + return fallback; + } + } + + function normalizeCode(input) { + const compact = String(input || "") + .toUpperCase() + .replace(/[^A-Z0-9]/g, ""); + if (!compact.startsWith("UK")) { + return ""; + } + const body = compact.slice(2); + if (body.length !== 25) { + return ""; + } + return `UK-${body.slice(0, 5)}-${body.slice(5, 10)}-${body.slice(10, 15)}-${body.slice(15, 20)}-${body.slice(20, 25)}`; + } + + function normalizeSupplierKey(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ""); + } + + function normalizeSupplier(value) { + const text = String(value || "").trim(); + if (!text) { + return ""; + } + const key = normalizeSupplierKey(text); + if (key === "efun" || key === "efuncard") { + return "EFun"; + } + return text; + } + + function getResolvedStatus(item) { + if (!item || item.status === "used") { + return "used"; + } + if (item.expires_at) { + const expiresAt = Date.parse(item.expires_at); + if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) { + return "expired"; + } + } + if (item.status === "expired") { + return "expired"; + } + return "unused"; + } + + function sanitizeRecord(record) { + const code = normalizeCode(record?.code || ""); + if (!REDEEM_CODE_REGEX.test(code)) { + return null; + } + const status = VALID_STATUSES.has(record?.status) ? String(record.status) : "unused"; + const createdAt = record?.created_at && Number.isFinite(Date.parse(record.created_at)) + ? record.created_at + : new Date().toISOString(); + const expiresAt = record?.expires_at && Number.isFinite(Date.parse(record.expires_at)) + ? record.expires_at + : null; + const usedAt = record?.used_at && Number.isFinite(Date.parse(record.used_at)) + ? record.used_at + : null; + return { + code, + status, + supplier: normalizeSupplier(record?.supplier), + created_at: createdAt, + expires_at: expiresAt, + used_by_email: String(record?.used_by_email || "").trim(), + used_at: usedAt, + }; + } + + function loadCodes() { + const parsed = safeJsonParse(localStorage.getItem(STORAGE_KEY) || "[]", []); + if (!Array.isArray(parsed)) { + return []; + } + const dedup = new Map(); + parsed.forEach((item) => { + const sanitized = sanitizeRecord(item); + if (sanitized) { + dedup.set(sanitized.code, sanitized); + } + }); + return Array.from(dedup.values()).sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); + } + + function persistCodes() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.codes)); + } + + function escapeHtml(value) { + const div = document.createElement("div"); + div.textContent = String(value ?? ""); + return div.innerHTML; + } + + function formatDateTime(iso) { + if (!iso) { + return "-"; + } + const date = new Date(iso); + if (!Number.isFinite(date.getTime())) { + return "-"; + } + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const hh = String(date.getHours()).padStart(2, "0"); + const mm = String(date.getMinutes()).padStart(2, "0"); + return `${y}-${m}-${d} ${hh}:${mm}`; + } + + function toLocalDateTimeInputValue(iso) { + if (!iso) { + return ""; + } + const date = new Date(iso); + if (!Number.isFinite(date.getTime())) { + return ""; + } + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const hh = String(date.getHours()).padStart(2, "0"); + const mm = String(date.getMinutes()).padStart(2, "0"); + return `${y}-${m}-${d}T${hh}:${mm}`; + } + + function parseLocalDateTimeToIso(raw) { + const value = String(raw || "").trim(); + if (!value) { + return null; + } + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + return null; + } + return date.toISOString(); + } + + function formatStatusText(status) { + if (status === "unused") return "未使用"; + if (status === "used") return "已使用"; + return "已过期"; + } + + function getSupplierList() { + const values = Array.from( + new Set( + [...BUILTIN_SUPPLIERS, ...state.codes + .map((item) => normalizeSupplier(item.supplier)) + .filter(Boolean)] + ) + ); + values.sort((a, b) => { + if (a === "EFun" && b !== "EFun") return -1; + if (b === "EFun" && a !== "EFun") return 1; + return a.localeCompare(b, "zh-Hans-CN"); + }); + return values; + } + + function renderSupplierOptions() { + const supplierSelect = document.getElementById("supplier-filter"); + const supplierDatalist = document.getElementById("supplier-options"); + const importSupplierSelect = document.getElementById("import-supplier-input"); + if (!supplierSelect || !supplierDatalist) { + return; + } + const current = state.supplierFilter; + const list = getSupplierList(); + const hasEmptySupplier = state.codes.some((item) => !normalizeSupplier(item.supplier)); + + const selectOptions = ['']; + list.forEach((name) => { + selectOptions.push(``); + }); + if (hasEmptySupplier) { + selectOptions.push(''); + } + supplierSelect.innerHTML = selectOptions.join(""); + if (current && Array.from(supplierSelect.options).some((option) => option.value === current)) { + supplierSelect.value = current; + } else { + state.supplierFilter = ""; + supplierSelect.value = ""; + } + + supplierDatalist.innerHTML = list + .map((name) => ``) + .join(""); + + if (importSupplierSelect && importSupplierSelect.tagName.toLowerCase() === "select") { + const currentImport = String(importSupplierSelect.value || "").trim(); + const importOptions = [''] + .concat(list.map((name) => ``)); + importSupplierSelect.innerHTML = importOptions.join(""); + if (currentImport && Array.from(importSupplierSelect.options).some((option) => option.value === currentImport)) { + importSupplierSelect.value = currentImport; + } else { + importSupplierSelect.value = ""; + } + } + } + + function getRowsByFilter() { + const filters = getNormalizedFilters(); + const keyword = String(filters.search || "").toUpperCase(); + const rows = state.codes + .map((item) => ({ + ...item, + supplier: normalizeSupplier(item.supplier), + resolvedStatus: getResolvedStatus(item), + })) + .filter((item) => { + if (filters.status && item.resolvedStatus !== filters.status) { + return false; + } + if (filters.supplier) { + if (filters.supplier === "__EMPTY__") { + if (item.supplier) { + return false; + } + } else if (item.supplier !== filters.supplier) { + return false; + } + } + if (keyword) { + const haystack = [ + item.code, + item.supplier || "", + item.used_by_email || "", + formatStatusText(item.resolvedStatus), + ] + .join(" ") + .toUpperCase(); + if (!haystack.includes(keyword)) { + return false; + } + } + return true; + }); + + rows.sort((a, b) => { + const ta = Date.parse(a.created_at) || 0; + const tb = Date.parse(b.created_at) || 0; + if (filters.sort === "created_asc") { + if (ta !== tb) return ta - tb; + return a.code.localeCompare(b.code); + } + if (tb !== ta) return tb - ta; + return b.code.localeCompare(a.code); + }); + + return rows; + } + + function updateStats() { + let total = 0; + let unused = 0; + let used = 0; + let expired = 0; + state.codes.forEach((item) => { + total += 1; + const status = getResolvedStatus(item); + if (status === "unused") unused += 1; + if (status === "used") used += 1; + if (status === "expired") expired += 1; + }); + document.getElementById("stat-total").textContent = String(total); + document.getElementById("stat-unused").textContent = String(unused); + document.getElementById("stat-used").textContent = String(used); + document.getElementById("stat-expired").textContent = String(expired); + } + + function renderTable() { + const tbody = document.getElementById("redeem-codes-body"); + const rows = getRowsByFilter(); + const totalPages = Math.max(1, Math.ceil(rows.length / state.pageSize)); + if (state.page > totalPages) { + state.page = totalPages; + } + const start = (state.page - 1) * state.pageSize; + const pageRows = rows.slice(start, start + state.pageSize); + state.pageCodes = pageRows.map((item) => item.code); + + if (!pageRows.length) { + tbody.innerHTML = '暂无匹配数据。'; + } else { + tbody.innerHTML = pageRows + .map((item) => { + const status = item.resolvedStatus; + return ` + + + + + 🎫 + ${escapeHtml(item.code)} + + + ${item.supplier ? escapeHtml(item.supplier) : "-"} + ${formatStatusText(status)} + ${escapeHtml(formatDateTime(item.created_at))} + ${item.expires_at ? escapeHtml(formatDateTime(item.expires_at)) : "永久有效"} + ${item.used_by_email ? escapeHtml(item.used_by_email) : "-"} + ${item.used_at ? escapeHtml(formatDateTime(item.used_at)) : "-"} + +
+ + + +
+ + + `; + }) + .join(""); + } + + const pageInfo = document.getElementById("page-info"); + const prevBtn = document.getElementById("page-prev"); + const nextBtn = document.getElementById("page-next"); + pageInfo.textContent = `第 ${state.page} 页 / 共 ${totalPages} 页`; + prevBtn.disabled = state.page <= 1; + nextBtn.disabled = state.page >= totalPages; + + const allChecked = state.pageCodes.length > 0 && state.pageCodes.every((code) => state.selectedCodes.has(code)); + document.getElementById("select-all-codes").checked = allChecked; + document.getElementById("btn-delete-selected").disabled = state.selectedCodes.size === 0; + } + + function render() { + renderSupplierOptions(); + const filters = getNormalizedFilters(); + const sortSelect = document.getElementById("sort-order"); + if (sortSelect && sortSelect.value !== filters.sort) { + sortSelect.value = filters.sort; + } + updateStats(); + renderTable(); + } + + function setActiveTab(tab) { + state.activeTab = tab === "credit" ? "credit" : "redeem"; + document.getElementById("pool-tab-redeem").classList.toggle("active", state.activeTab === "redeem"); + document.getElementById("pool-tab-credit").classList.toggle("active", state.activeTab === "credit"); + document.getElementById("panel-redeem").classList.toggle("active", state.activeTab === "redeem"); + document.getElementById("panel-credit").classList.toggle("active", state.activeTab === "credit"); + } + + function setStatusFilter(status) { + state.statusFilter = String(fp.normalizeValue(status) || ""); + state.page = 1; + document.querySelectorAll(".status-chip").forEach((btn) => { + btn.classList.toggle("active", (btn.dataset.status || "") === state.statusFilter); + }); + render(); + } + + function parseImportInput(rawText) { + const tokens = String(rawText || "") + .split(/[\s,,;;]+/) + .map((item) => item.trim()) + .filter(Boolean); + const valid = []; + const invalid = []; + tokens.forEach((item) => { + const code = normalizeCode(item); + if (REDEEM_CODE_REGEX.test(code)) { + valid.push(code); + } else { + invalid.push(item); + } + }); + return { + valid: Array.from(new Set(valid)), + invalidCount: invalid.length, + }; + } + + function applySearch() { + const input = document.getElementById("redeem-search"); + state.search = String(fp.normalizeValue(input?.value) || ""); + state.page = 1; + render(); + } + + function clearImportInputs() { + document.getElementById("import-codes-input").value = ""; + document.getElementById("import-expire-days").value = ""; + document.getElementById("import-supplier-input").value = ""; + } + + function closeImportModal() { + document.getElementById("import-modal").classList.remove("active"); + } + + function openImportModal() { + renderSupplierOptions(); + document.getElementById("import-modal").classList.add("active"); + const input = document.getElementById("import-codes-input"); + if (input) { + input.focus(); + } + } + + function closeEditModal() { + document.getElementById("edit-modal").classList.remove("active"); + state.editingCode = ""; + } + + function openEditModal(code) { + const target = state.codes.find((item) => item.code === code); + if (!target) { + toast.warning("未找到对应兑换码"); + return; + } + renderSupplierOptions(); + state.editingCode = code; + document.getElementById("edit-code-display").value = target.code; + document.getElementById("edit-supplier-input").value = normalizeSupplier(target.supplier); + document.getElementById("edit-status-select").value = VALID_STATUSES.has(target.status) ? target.status : "unused"; + document.getElementById("edit-used-email").value = String(target.used_by_email || "").trim(); + document.getElementById("edit-expires-at").value = toLocalDateTimeInputValue(target.expires_at); + document.getElementById("edit-modal").classList.add("active"); + document.getElementById("edit-status-select").focus(); + } + + function handleEditConfirm() { + const code = String(state.editingCode || "").trim(); + if (!code) { + closeEditModal(); + return; + } + const target = state.codes.find((item) => item.code === code); + if (!target) { + closeEditModal(); + toast.warning("未找到对应兑换码"); + return; + } + + const nextStatus = String(document.getElementById("edit-status-select").value || "unused"); + if (!VALID_STATUSES.has(nextStatus)) { + toast.warning("状态无效"); + return; + } + const nextSupplier = normalizeSupplier(document.getElementById("edit-supplier-input").value); + const nextEmail = String(document.getElementById("edit-used-email").value || "").trim(); + const nextExpiresIso = parseLocalDateTimeToIso(document.getElementById("edit-expires-at").value); + const rawExpires = String(document.getElementById("edit-expires-at").value || "").trim(); + if (rawExpires && !nextExpiresIso) { + toast.warning("过期时间格式无效"); + return; + } + + target.status = nextStatus; + target.supplier = nextSupplier; + target.used_by_email = nextEmail; + target.expires_at = nextExpiresIso; + if (nextStatus === "used") { + if (!target.used_at) { + target.used_at = new Date().toISOString(); + } + } else { + target.used_at = null; + } + + persistCodes(); + closeEditModal(); + render(); + toast.success("修改成功"); + } + + function parseExpireDays() { + const raw = String(document.getElementById("import-expire-days").value || "").trim(); + if (!raw) { + return null; + } + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + return NaN; + } + return value; + } + + function buildExpiresAt(days) { + if (!Number.isInteger(days) || days <= 0) { + return null; + } + const target = new Date(); + target.setDate(target.getDate() + days); + target.setHours(23, 59, 59, 999); + return target.toISOString(); + } + + function handleImportConfirm() { + const text = document.getElementById("import-codes-input").value; + const supplier = normalizeSupplier(document.getElementById("import-supplier-input").value); + const parsed = parseImportInput(text); + if (!parsed.valid.length) { + toast.warning("没有可导入的兑换码"); + return; + } + + const expireDays = parseExpireDays(); + if (Number.isNaN(expireDays)) { + toast.warning("有效期必须是大于 0 的整数"); + return; + } + + const existingCodes = new Set(state.codes.map((item) => item.code)); + const nowIso = new Date().toISOString(); + const expiresAt = buildExpiresAt(expireDays); + let added = 0; + let duplicate = 0; + + parsed.valid.forEach((code) => { + if (existingCodes.has(code)) { + duplicate += 1; + return; + } + state.codes.push({ + code, + status: "unused", + supplier, + created_at: nowIso, + expires_at: expiresAt, + used_by_email: "", + used_at: null, + }); + existingCodes.add(code); + added += 1; + }); + + state.codes.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); + persistCodes(); + state.page = 1; + closeImportModal(); + clearImportInputs(); + render(); + + const fragments = [`成功导入 ${added} 条`]; + if (duplicate > 0) fragments.push(`重复跳过 ${duplicate} 条`); + if (parsed.invalidCount > 0) fragments.push(`格式错误 ${parsed.invalidCount} 条`); + toast.success(fragments.join(",")); + } + + function csvEscape(value) { + const raw = String(value ?? ""); + if (/[",\n]/.test(raw)) { + return `"${raw.replace(/"/g, "\"\"")}"`; + } + return raw; + } + + function exportCurrentRows() { + const rows = getRowsByFilter(); + if (!rows.length) { + toast.warning("暂无可导出的兑换码"); + return; + } + const header = ["兑换码", "供应商", "状态", "创建时间", "过期时间", "使用者邮箱", "使用时间"]; + const csvRows = [header]; + rows.forEach((item) => { + csvRows.push([ + item.code, + item.supplier || "-", + formatStatusText(item.resolvedStatus), + formatDateTime(item.created_at), + item.expires_at ? formatDateTime(item.expires_at) : "永久有效", + item.used_by_email || "-", + item.used_at ? formatDateTime(item.used_at) : "-", + ]); + }); + const content = csvRows.map((line) => line.map(csvEscape).join(",")).join("\n"); + const blob = new Blob([content], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `redeem_codes_${Date.now()}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + } + + async function copyText(text) { + const value = String(text || ""); + if (!value) return; + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(value); + return; + } + const textArea = document.createElement("textarea"); + textArea.value = value; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + } + + function removeCode(code) { + const before = state.codes.length; + state.codes = state.codes.filter((item) => item.code !== code); + state.selectedCodes.delete(code); + if (state.codes.length !== before) { + persistCodes(); + render(); + } + } + + function handleTableAction(event) { + const button = event.target.closest("button[data-action]"); + if (!button) return; + const row = event.target.closest("tr[data-code]"); + if (!row) return; + const code = row.dataset.code || ""; + const action = button.dataset.action || ""; + + if (action === "copy") { + copyText(code) + .then(() => toast.success("兑换码已复制")) + .catch(() => toast.error("复制失败")); + return; + } + if (action === "delete") { + if (!confirm(`确认删除兑换码 ${code} 吗?`)) return; + removeCode(code); + toast.success("已删除兑换码"); + return; + } + if (action === "edit") { + openEditModal(code); + return; + } + } + + function syncSelectAllState() { + const allChecked = state.pageCodes.length > 0 && state.pageCodes.every((code) => state.selectedCodes.has(code)); + document.getElementById("select-all-codes").checked = allChecked; + document.getElementById("btn-delete-selected").disabled = state.selectedCodes.size === 0; + } + + function deleteSelectedCodes() { + const selected = Array.from(state.selectedCodes); + if (!selected.length) { + return; + } + if (!confirm(`确认删除已选中的 ${selected.length} 条兑换码吗?`)) { + return; + } + const selectedSet = new Set(selected); + state.codes = state.codes.filter((item) => !selectedSet.has(item.code)); + state.selectedCodes.clear(); + persistCodes(); + render(); + toast.success(`已删除 ${selected.length} 条兑换码`); + } + + function bindEvents() { + document.getElementById("pool-tab-redeem").addEventListener("click", () => setActiveTab("redeem")); + document.getElementById("pool-tab-credit").addEventListener("click", () => setActiveTab("credit")); + + document.querySelectorAll(".status-chip").forEach((button) => { + button.addEventListener("click", () => setStatusFilter(button.dataset.status || "")); + }); + + document.getElementById("supplier-filter").addEventListener("change", (event) => { + state.supplierFilter = String(fp.normalizeValue(event.target.value) || ""); + state.page = 1; + render(); + }); + + document.getElementById("sort-order").addEventListener("change", (event) => { + const value = String(event.target.value || "created_desc"); + state.sortOrder = fp.pickSort(value, ["created_asc", "created_desc"], "created_desc"); + state.page = 1; + render(); + }); + + document.getElementById("redeem-search-btn").addEventListener("click", applySearch); + document.getElementById("redeem-search").addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + applySearch(); + } + }); + + document.getElementById("page-size").addEventListener("change", (event) => { + const value = Number(event.target.value); + state.pageSize = Number.isInteger(value) && value > 0 ? value : 50; + state.page = 1; + render(); + }); + + document.getElementById("page-prev").addEventListener("click", () => { + if (state.page <= 1) return; + state.page -= 1; + render(); + }); + + document.getElementById("page-next").addEventListener("click", () => { + state.page += 1; + render(); + }); + + document.getElementById("redeem-codes-body").addEventListener("change", (event) => { + const checkbox = event.target.closest(".code-select"); + if (!checkbox) return; + const code = checkbox.dataset.code || ""; + if (!code) return; + if (checkbox.checked) { + state.selectedCodes.add(code); + } else { + state.selectedCodes.delete(code); + } + syncSelectAllState(); + }); + + document.getElementById("select-all-codes").addEventListener("change", (event) => { + const checked = Boolean(event.target.checked); + state.pageCodes.forEach((code) => { + if (checked) { + state.selectedCodes.add(code); + } else { + state.selectedCodes.delete(code); + } + }); + render(); + }); + + document.getElementById("redeem-codes-body").addEventListener("click", handleTableAction); + + document.getElementById("btn-import-codes").addEventListener("click", openImportModal); + document.getElementById("btn-export-codes").addEventListener("click", exportCurrentRows); + document.getElementById("btn-delete-selected").addEventListener("click", deleteSelectedCodes); + + document.getElementById("import-modal-close").addEventListener("click", closeImportModal); + document.getElementById("import-modal-cancel").addEventListener("click", closeImportModal); + document.getElementById("import-modal-confirm").addEventListener("click", handleImportConfirm); + + document.getElementById("import-modal").addEventListener("click", (event) => { + if (event.target.id === "import-modal") { + closeImportModal(); + } + }); + + document.getElementById("edit-modal-close").addEventListener("click", closeEditModal); + document.getElementById("edit-modal-cancel").addEventListener("click", closeEditModal); + document.getElementById("edit-modal-confirm").addEventListener("click", handleEditConfirm); + document.getElementById("edit-modal").addEventListener("click", (event) => { + if (event.target.id === "edit-modal") { + closeEditModal(); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeImportModal(); + closeEditModal(); + } + }); + } + + function init() { + state.codes = loadCodes(); + bindEvents(); + render(); + } + + document.addEventListener("DOMContentLoaded", init); +})(); diff --git a/static/js/email_services.js b/static/js/email_services.js index 919f4012..1f9ea7d9 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -4,7 +4,7 @@ // 状态 let outlookServices = []; -let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + freemail + imap_mail +let customServices = []; // 合并 moe_mail + temp_mail + cloudmail + duck_mail + freemail + imap_mail let selectedOutlook = new Set(); let selectedCustom = new Set(); @@ -41,12 +41,6 @@ const elements = { tempmailApi: document.getElementById('tempmail-api'), tempmailEnabled: document.getElementById('tempmail-enabled'), testTempmailBtn: document.getElementById('test-tempmail-btn'), - yydsMailForm: document.getElementById('yyds-mail-form'), - yydsMailApi: document.getElementById('yyds-mail-api'), - yydsMailApiKey: document.getElementById('yyds-mail-api-key'), - yydsMailDomain: document.getElementById('yyds-mail-domain'), - yydsMailEnabled: document.getElementById('yyds-mail-enabled'), - testYydsMailBtn: document.getElementById('test-yyds-mail-btn'), // 添加自定义域名模态框 addCustomModal: document.getElementById('add-custom-modal'), @@ -55,7 +49,6 @@ const elements = { cancelAddCustom: document.getElementById('cancel-add-custom'), customSubType: document.getElementById('custom-sub-type'), addMoemailFields: document.getElementById('add-moemail-fields'), - addYydsMailFields: document.getElementById('add-yydsmail-fields'), addTempmailFields: document.getElementById('add-tempmail-fields'), addDuckmailFields: document.getElementById('add-duckmail-fields'), addFreemailFields: document.getElementById('add-freemail-fields'), @@ -67,7 +60,6 @@ const elements = { closeEditCustomModal: document.getElementById('close-edit-custom-modal'), cancelEditCustom: document.getElementById('cancel-edit-custom'), editMoemailFields: document.getElementById('edit-moemail-fields'), - editYydsMailFields: document.getElementById('edit-yydsmail-fields'), editTempmailFields: document.getElementById('edit-tempmail-fields'), editDuckmailFields: document.getElementById('edit-duckmail-fields'), editFreemailFields: document.getElementById('edit-freemail-fields'), @@ -83,9 +75,9 @@ const elements = { }; const CUSTOM_SUBTYPE_LABELS = { - yydsmail: 'YYDS Mail (YYDS Mail API)', moemail: '🔗 MoeMail(自定义域名 API)', tempmail: '📮 TempMail(自部署 Cloudflare Worker)', + cloudmail: '☁️ CloudMail(自部署 Cloudflare Worker)', duckmail: '🦆 DuckMail(DuckMail API)', freemail: 'Freemail(自部署 Cloudflare Worker)', imap: '📧 IMAP 邮箱(Gmail/QQ/163等)' @@ -168,8 +160,6 @@ function initEventListeners() { // 临时邮箱配置 elements.tempmailForm.addEventListener('submit', handleSaveTempmail); elements.testTempmailBtn.addEventListener('click', handleTestTempmail); - elements.yydsMailForm.addEventListener('submit', handleSaveYydsMail); - elements.testYydsMailBtn.addEventListener('click', handleTestYydsMail); // 点击其他地方关闭更多菜单 document.addEventListener('click', () => { @@ -193,8 +183,7 @@ function closeEmailMoreMenu(el) { function switchAddSubType(subType) { elements.customSubType.value = subType; elements.addMoemailFields.style.display = subType === 'moemail' ? '' : 'none'; - elements.addYydsMailFields.style.display = subType === 'yydsmail' ? '' : 'none'; - elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; + elements.addTempmailFields.style.display = (subType === 'tempmail' || subType === 'cloudmail') ? '' : 'none'; elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; elements.addImapFields.style.display = subType === 'imap' ? '' : 'none'; @@ -204,8 +193,7 @@ function switchAddSubType(subType) { function switchEditSubType(subType) { elements.editCustomSubTypeHidden.value = subType; elements.editMoemailFields.style.display = subType === 'moemail' ? '' : 'none'; - elements.editYydsMailFields.style.display = subType === 'yydsmail' ? '' : 'none'; - elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; + elements.editTempmailFields.style.display = (subType === 'tempmail' || subType === 'cloudmail') ? '' : 'none'; elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; elements.editImapFields.style.display = subType === 'imap' ? '' : 'none'; @@ -217,7 +205,7 @@ async function loadStats() { try { const data = await api.get('/email-services/stats'); elements.outlookCount.textContent = data.outlook_count || 0; - elements.customCount.textContent = (data.custom_count || 0) + (data.yyds_mail_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0); + elements.customCount.textContent = (data.custom_count || 0) + (data.temp_mail_count || 0) + (data.cloudmail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0); elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用'; elements.totalEnabled.textContent = data.enabled_count || 0; } catch (error) { @@ -234,7 +222,7 @@ async function loadOutlookServices() { if (outlookServices.length === 0) { elements.outlookTable.innerHTML = ` - +
📭
暂无 Outlook 账户
@@ -253,9 +241,7 @@ async function loadOutlookServices() { ${getOutlookAuthBadge(service)} - - ${getOutlookRegistrationBadge(service)} - + ${getOutlookRegistrationBadge(service)} ${service.enabled ? '✅' : '⭕'} ${service.priority} ${format.date(service.last_used)} @@ -274,6 +260,7 @@ async function loadOutlookServices() { `).join(''); + elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => { cb.addEventListener('change', (e) => { const id = parseInt(e.target.dataset.id); @@ -285,7 +272,7 @@ async function loadOutlookServices() { } catch (error) { console.error('加载 Outlook 服务失败:', error); - elements.outlookTable.innerHTML = `
加载失败
`; + elements.outlookTable.innerHTML = `
加载失败
`; } } @@ -311,12 +298,12 @@ function getCustomServiceTypeBadge(subType) { if (subType === 'moemail') { return 'MoeMail'; } - if (subType === 'yydsmail') { - return 'YYDS Mail'; - } if (subType === 'tempmail') { return 'TempMail'; } + if (subType === 'cloudmail') { + return 'CloudMail'; + } if (subType === 'duckmail') { return 'DuckMail'; } @@ -340,21 +327,21 @@ function getCustomServiceAddress(service) { return `${escapeHtml(baseUrl)}
默认域名:@${escapeHtml(domain)}
`; } -// 加载自定义邮箱服务(moe_mail + temp_mail + duck_mail + freemail 合并) +// 加载自定义邮箱服务(moe_mail + temp_mail + cloudmail + duck_mail + freemail + imap_mail 合并) async function loadCustomServices() { try { const [r1, r2, r3, r4, r5, r6] = await Promise.all([ api.get('/email-services?service_type=moe_mail'), - api.get('/email-services?service_type=yyds_mail'), api.get('/email-services?service_type=temp_mail'), + api.get('/email-services?service_type=cloudmail'), api.get('/email-services?service_type=duck_mail'), api.get('/email-services?service_type=freemail'), api.get('/email-services?service_type=imap_mail') ]); customServices = [ ...(r1.services || []).map(s => ({ ...s, _subType: 'moemail' })), - ...(r2.services || []).map(s => ({ ...s, _subType: 'yydsmail' })), - ...(r3.services || []).map(s => ({ ...s, _subType: 'tempmail' })), + ...(r2.services || []).map(s => ({ ...s, _subType: 'tempmail' })), + ...(r3.services || []).map(s => ({ ...s, _subType: 'cloudmail' })), ...(r4.services || []).map(s => ({ ...s, _subType: 'duckmail' })), ...(r5.services || []).map(s => ({ ...s, _subType: 'freemail' })), ...(r6.services || []).map(s => ({ ...s, _subType: 'imap' })) @@ -382,6 +369,7 @@ async function loadCustomServices() { ${escapeHtml(service.name)} ${getCustomServiceTypeBadge(service._subType)} ${getCustomServiceAddress(service)} + -- ${service.enabled ? '✅' : '⭕'} ${service.priority} ${format.date(service.last_used)} @@ -419,17 +407,9 @@ async function loadTempmailConfig() { try { const settings = await api.get('/settings'); if (settings.tempmail) { - elements.tempmailApi.value = settings.tempmail.api_url || settings.tempmail.base_url || ''; + elements.tempmailApi.value = settings.tempmail.api_url || ''; elements.tempmailEnabled.checked = settings.tempmail.enabled !== false; } - if (settings.yyds_mail) { - elements.yydsMailApi.value = settings.yyds_mail.api_url || settings.yyds_mail.base_url || ''; - elements.yydsMailDomain.value = settings.yyds_mail.default_domain || ''; - elements.yydsMailEnabled.checked = settings.yyds_mail.enabled === true; - elements.yydsMailApiKey.value = ''; - elements.yydsMailApiKey.dataset.hasKey = settings.yyds_mail.has_api_key ? 'true' : 'false'; - elements.yydsMailApiKey.placeholder = settings.yyds_mail.has_api_key ? '已设置,留空保持不变' : 'AC-your_api_key'; - } } catch (error) { // 忽略错误 } @@ -487,15 +467,8 @@ async function handleAddCustom(e) { api_key: formData.get('api_key'), default_domain: formData.get('domain') }; - } else if (subType === 'yydsmail') { - serviceType = 'yyds_mail'; - config = { - base_url: formData.get('yyds_base_url'), - api_key: formData.get('yyds_api_key'), - default_domain: formData.get('yyds_domain') - }; - } else if (subType === 'tempmail') { - serviceType = 'temp_mail'; + } else if (subType === 'tempmail' || subType === 'cloudmail') { + serviceType = subType === 'cloudmail' ? 'cloudmail' : 'temp_mail'; config = { base_url: formData.get('tm_base_url'), admin_password: formData.get('tm_admin_password'), @@ -528,11 +501,6 @@ async function handleAddCustom(e) { }; } - if (subType === 'yydsmail' && (!config.base_url || !config.api_key)) { - toast.error('YYDS Mail 需要填写 API URL 和 API Key'); - return; - } - const data = { service_type: serviceType, name: formData.get('name'), @@ -622,7 +590,6 @@ async function handleSaveTempmail(e) { enabled: elements.tempmailEnabled.checked }); toast.success('配置已保存'); - loadStats(); } catch (error) { toast.error('保存失败: ' + error.message); } @@ -634,7 +601,6 @@ async function handleTestTempmail() { elements.testTempmailBtn.textContent = '测试中...'; try { const result = await api.post('/email-services/test-tempmail', { - provider: 'tempmail', api_url: elements.tempmailApi.value }); if (result.success) toast.success('临时邮箱连接正常'); @@ -647,65 +613,6 @@ async function handleTestTempmail() { } } -// 保存 YYDS Mail 配置 -async function handleSaveYydsMail(e) { - e.preventDefault(); - const apiKey = elements.yydsMailApiKey.value.trim(); - const hasSavedKey = elements.yydsMailApiKey.dataset.hasKey === 'true'; - - if (elements.yydsMailEnabled.checked && !apiKey && !hasSavedKey) { - toast.error('启用 YYDS Mail 前请先填写 API Key'); - return; - } - - const payload = { - yyds_api_url: elements.yydsMailApi.value, - yyds_default_domain: elements.yydsMailDomain.value, - yyds_enabled: elements.yydsMailEnabled.checked - }; - if (apiKey || !hasSavedKey) { - payload.yyds_api_key = apiKey; - } - - try { - await api.post('/settings/tempmail', payload); - if (apiKey) { - elements.yydsMailApiKey.value = ''; - elements.yydsMailApiKey.dataset.hasKey = 'true'; - elements.yydsMailApiKey.placeholder = '已设置,留空保持不变'; - } else if (!hasSavedKey && !apiKey) { - elements.yydsMailApiKey.dataset.hasKey = 'false'; - } - toast.success('YYDS Mail 配置已保存'); - loadStats(); - } catch (error) { - toast.error('保存失败: ' + error.message); - } -} - -// 测试 YYDS Mail -async function handleTestYydsMail() { - elements.testYydsMailBtn.disabled = true; - elements.testYydsMailBtn.textContent = '测试中...'; - try { - const payload = { - provider: 'yyds_mail', - api_url: elements.yydsMailApi.value - }; - const apiKey = elements.yydsMailApiKey.value.trim(); - if (apiKey) payload.api_key = apiKey; - - const result = await api.post('/email-services/test-tempmail', payload); - if (result.success) toast.success('YYDS Mail 连接正常'); - else toast.error('连接失败: ' + (result.error || '未知错误')); - } catch (error) { - toast.error('测试失败: ' + error.message); - } finally { - elements.testYydsMailBtn.disabled = false; - elements.testYydsMailBtn.textContent = '🔌 测试连接'; - } -} - // 更新批量按钮 function updateBatchButtons() { const count = selectedOutlook.size; @@ -730,8 +637,8 @@ async function editCustomService(id, subType) { const resolvedSubType = subType || ( service.service_type === 'temp_mail' ? 'tempmail' - : service.service_type === 'yyds_mail' - ? 'yydsmail' + : service.service_type === 'cloudmail' + ? 'cloudmail' : service.service_type === 'duck_mail' ? 'duckmail' : service.service_type === 'freemail' @@ -753,12 +660,7 @@ async function editCustomService(id, subType) { document.getElementById('edit-custom-api-key').value = ''; document.getElementById('edit-custom-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : 'API Key'; document.getElementById('edit-custom-domain').value = service.config?.default_domain || service.config?.domain || ''; - } else if (resolvedSubType === 'yydsmail') { - document.getElementById('edit-yyds-base-url').value = service.config?.base_url || ''; - document.getElementById('edit-yyds-api-key').value = ''; - document.getElementById('edit-yyds-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : 'Enter API Key'; - document.getElementById('edit-yyds-domain').value = service.config?.default_domain || service.config?.domain || ''; - } else if (resolvedSubType === 'tempmail') { + } else if (resolvedSubType === 'tempmail' || resolvedSubType === 'cloudmail') { document.getElementById('edit-tm-base-url').value = service.config?.base_url || ''; document.getElementById('edit-tm-admin-password').value = ''; document.getElementById('edit-tm-admin-password').placeholder = service.config?.admin_password ? '已设置,留空保持不变' : '请输入 Admin 密码'; @@ -804,14 +706,7 @@ async function handleEditCustom(e) { }; const apiKey = formData.get('api_key'); if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); - } else if (subType === 'yydsmail') { - config = { - base_url: formData.get('yyds_base_url'), - default_domain: formData.get('yyds_domain') - }; - const apiKey = formData.get('yyds_api_key'); - if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); - } else if (subType === 'tempmail') { + } else if (subType === 'tempmail' || subType === 'cloudmail') { config = { base_url: formData.get('tm_base_url'), domain: formData.get('tm_domain'), diff --git a/static/js/payment.js b/static/js/payment.js index 3ff0ca4c..8818079b 100644 --- a/static/js/payment.js +++ b/static/js/payment.js @@ -1,6 +1,6 @@ /** * 支付页面 JavaScript - * 支付页面:半自动 + 第三方自动绑卡 + 全自动绑卡任务管理 + 用户完成后自动验订阅 + * 支付页面:半自动 + 全自动绑卡任务管理 + 用户完成后自动验订阅 */ const COUNTRY_CURRENCY_MAP = { @@ -26,7 +26,20 @@ const BILLING_STORAGE_KEY = "payment.billing_profile_non_sensitive"; const BILLING_TEMPLATE_STORAGE_KEY = "payment.billing_templates_v1"; const THIRD_PARTY_BIND_URL_STORAGE_KEY = "payment.third_party_bind_api_url"; const BIND_MODE_STORAGE_KEY = "payment.bind_mode"; +const VENDOR_REDEEM_STORAGE_KEY = "payment.vendor_redeem_code"; +const VENDOR_CHECKOUT_STORAGE_KEY = "payment.vendor_checkout_url"; +const EFUN_BASE_URL_STORAGE_KEY = "payment.efun_base_url"; +const EFUN_API_KEY_STORAGE_KEY = "payment.efun_api_key"; +const EFUN_CODE_STORAGE_KEY = "payment.efun_code"; +const EFUN_MINUTES_STORAGE_KEY = "payment.efun_minutes"; +const CARD_POOL_REDEEM_STORAGE_KEY = "card_pool.redeem_codes.v1"; +const CARD_POOL_EFUN_SUPPLIER_KEY = "efun"; +const CARD_POOL_EFUN_SUPPLIER_LABEL = "EFun"; +const ALLOWED_BIND_MODES = new Set(["semi_auto", "local_auto", "vendor_efun"]); const THIRD_PARTY_BIND_DEFAULT_URL = "https://twilight-river-f148.482091502.workers.dev/"; +const EFUN_BASE_URL_DEFAULT = "https://card.aimizy.com/api/v1/bindcard"; +const VENDOR_REDEEM_CODE_REGEX = /^[A-Z0-9]{4}(?:-[A-Z0-9]{4}){3}$/; +const EFUN_CODE_REGEX = /^UK(?:-[A-Z0-9]{5}){5}$/i; const BILLING_TEMPLATE_MAX = 200; const BILLING_COUNTRY_CURRENCY_MAP = { US: "USD", @@ -85,6 +98,15 @@ let selectedPlan = "plus"; let generatedLink = ""; let isGeneratingCheckoutLink = false; let paymentAccounts = []; +let isLoadingBindTaskList = false; +const vendorProgressState = { + taskId: 0, + timer: null, + cursor: 0, + elapsedSeconds: 0, + forceStopAfterSeconds: 120, + lastConfig: null, +}; const bindTaskState = { page: 1, @@ -93,6 +115,8 @@ const bindTaskState = { search: "", }; let bindTaskAutoRefreshTimer = null; +const bindTaskActionRunning = new Set(); +let bindTaskLoadPromise = null; let billingBatchProfiles = []; @@ -112,6 +136,35 @@ function formatErrorMessage(error) { } } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function watchPaymentOpTask(taskId, onUpdate, maxWaitMs = 20 * 60 * 1000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < maxWaitMs) { + const task = await api.get(`/payment/ops/tasks/${taskId}`, { + timeoutMs: 30000, + retry: 0, + requestKey: `payment:op-task:${taskId}`, + cancelPrevious: true, + }); + + if (typeof onUpdate === "function") { + onUpdate(task); + } + + const status = String(task?.status || "").toLowerCase(); + if (["completed", "failed", "cancelled"].includes(status)) { + return task; + } + + await sleep(1200); + } + + throw new Error("支付任务等待超时,请稍后重试"); +} + function escapeHtml(value) { const div = document.createElement("div"); div.textContent = String(value ?? ""); @@ -1119,6 +1172,7 @@ function getTaskStatusText(status) { function startBindTaskAutoRefresh() { stopBindTaskAutoRefresh(); bindTaskAutoRefreshTimer = setInterval(() => { + if (document.hidden) return; const bindTaskTab = document.getElementById("tab-content-bind-task"); if (!bindTaskTab?.classList.contains("active")) return; loadBindCardTasks(true); @@ -1156,7 +1210,15 @@ function resetGenerateLinkButtonState() { } function getBindMode() { - return (document.getElementById("bind-mode-select")?.value || "semi_auto").trim() || "semi_auto"; + const modeSelect = document.getElementById("bind-mode-select"); + const mode = String(modeSelect?.value || "semi_auto").trim().toLowerCase() || "semi_auto"; + if (ALLOWED_BIND_MODES.has(mode)) { + return mode; + } + if (modeSelect) { + modeSelect.value = "semi_auto"; + } + return "semi_auto"; } function updateSemiAutoActionsVisibility(mode) { @@ -1232,21 +1294,722 @@ async function fetchSelectedAccountInbox() { } } -function onBindModeChange() { - const mode = getBindMode(); +function stopVendorProgressPolling() { + if (!vendorProgressState.timer) return; + clearTimeout(vendorProgressState.timer); + vendorProgressState.timer = null; +} + +function setVendorStopButtonState(disabled = false, show = true) { + const btn = document.getElementById("vendor-stop-btn"); + if (!btn) return; + btn.style.display = show ? "" : "none"; + btn.disabled = Boolean(disabled); +} + +function setVendorRetryButtonState(disabled = false, show = true) { + const btn = document.getElementById("vendor-retry-btn"); + if (!btn) return; + btn.style.display = show ? "" : "none"; + btn.disabled = Boolean(disabled); +} + +function showVendorProgressPanel(show = true) { + const panel = document.getElementById("vendor-progress-panel"); + if (!panel) return; + panel.classList.toggle("show", Boolean(show)); +} + +function resetVendorProgressPanel() { + vendorProgressState.cursor = 0; + vendorProgressState.elapsedSeconds = 0; + vendorProgressState.forceStopAfterSeconds = 120; + const bar = document.getElementById("vendor-progress-bar"); + const percent = document.getElementById("vendor-progress-percent"); + const log = document.getElementById("vendor-progress-log"); + const title = document.getElementById("vendor-progress-title"); + const runtime = document.getElementById("vendor-progress-runtime"); + if (bar) bar.style.width = "0%"; + if (percent) percent.textContent = "0%"; + if (title) title.textContent = "卡商订阅进度"; + if (log) log.textContent = "等待执行..."; + if (runtime) runtime.textContent = "已运行 0 秒 / 可随时停止"; + setVendorRetryButtonState(false, false); +} + +function appendVendorLogs(logs) { + if (!Array.isArray(logs) || !logs.length) return; + const logBox = document.getElementById("vendor-progress-log"); + if (!logBox) return; + const lines = logs.map((item) => `[${item?.time || "--:--:--"}] ${item?.message || ""}`); + const existing = String(logBox.textContent || "").trim(); + logBox.textContent = existing ? `${existing}\n${lines.join("\n")}` : lines.join("\n"); + logBox.scrollTop = logBox.scrollHeight; +} + +function updateVendorRuntimeHint(progressPayload = {}, status = "running") { + const runtime = document.getElementById("vendor-progress-runtime"); + if (!runtime) return; + const elapsed = Math.max( + 0, + Number(progressPayload?.elapsed_seconds ?? vendorProgressState.elapsedSeconds ?? 0), + ); + const forceStopAfter = Math.max( + 1, + Number(progressPayload?.force_stop_after_seconds ?? vendorProgressState.forceStopAfterSeconds ?? 120), + ); + const canForceStop = Boolean(progressPayload?.can_force_stop) || elapsed >= forceStopAfter; + vendorProgressState.elapsedSeconds = elapsed; + vendorProgressState.forceStopAfterSeconds = forceStopAfter; + + const currentStatus = String(status || progressPayload?.status || "running").toLowerCase(); + if (currentStatus === "completed") { + runtime.textContent = `任务已完成(总耗时 ${elapsed} 秒)`; + return; + } + if (currentStatus === "failed") { + runtime.textContent = `任务已失败(运行 ${elapsed} 秒)`; + return; + } + if (currentStatus === "cancelled") { + runtime.textContent = `任务已停止(运行 ${elapsed} 秒)`; + return; + } + if (currentStatus === "pending") { + runtime.textContent = `已运行 ${elapsed} 秒 / 接口执行已完成,等待订阅同步`; + return; + } + + if (canForceStop) { + runtime.textContent = `已运行 ${elapsed} 秒 / 已可停止`; + return; + } + + const left = Math.max(0, forceStopAfter - elapsed); + runtime.textContent = `已运行 ${elapsed} 秒 / 可随时停止(建议 ${forceStopAfter} 秒后强制,剩余 ${left} 秒)`; +} + +function formatBeijingDateTime(dateStr) { + if (!dateStr) return "-"; + const raw = String(dateStr).trim(); + if (!raw) return "-"; + // 后端多数时间字段是 UTC naive(无时区),这里按 UTC 解析后固定转北京时间显示 + const normalized = /[zZ]$|[+\-]\d{2}:\d{2}$/.test(raw) ? raw : `${raw}Z`; + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) { + return format.date(dateStr); + } + return date.toLocaleString("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +function updateVendorProgressUi(progressPayload, taskId) { + const progress = Math.max(0, Math.min(100, Number(progressPayload?.progress || 0))); + const status = String(progressPayload?.status || "running").toLowerCase(); + const bar = document.getElementById("vendor-progress-bar"); + const percent = document.getElementById("vendor-progress-percent"); + const title = document.getElementById("vendor-progress-title"); + if (bar) bar.style.width = `${progress}%`; + if (percent) percent.textContent = `${progress}%`; + if (title) { + if (status === "completed") { + title.textContent = `任务 #${taskId} 订阅完成`; + } else if (status === "failed") { + title.textContent = `任务 #${taskId} 订阅失败`; + } else if (status === "cancelled") { + title.textContent = `任务 #${taskId} 已停止`; + } else if (status === "pending") { + title.textContent = `任务 #${taskId} 已提交,等待同步`; + } else { + title.textContent = `任务 #${taskId} 执行中`; + } + } + updateVendorRuntimeHint(progressPayload, status); + if (status === "running") { + setVendorStopButtonState(false, true); + setVendorRetryButtonState(false, false); + return; + } + if (status === "failed") { + setVendorStopButtonState(true, true); + setVendorRetryButtonState(false, true); + return; + } + if (status === "pending") { + setVendorStopButtonState(false, true); + setVendorRetryButtonState(false, true); + return; + } + setVendorStopButtonState(true, true); + setVendorRetryButtonState(false, false); +} + +async function pollVendorProgress(taskId) { + if (!taskId) return; + try { + const payload = await api.get(`/payment/bind-card/tasks/${taskId}/vendor-progress?cursor=${vendorProgressState.cursor}`); + const progressPayload = payload?.progress || {}; + const logs = progressPayload?.logs || []; + appendVendorLogs(logs); + vendorProgressState.cursor = Number(progressPayload?.next_cursor || vendorProgressState.cursor || 0); + updateVendorProgressUi(progressPayload, taskId); + const state = String(progressPayload?.status || "").toLowerCase(); + if (state === "completed") { + toast.success(`任务 #${taskId} 订阅完成`); + stopVendorProgressPolling(); + setVendorStopButtonState(true, true); + setVendorRetryButtonState(false, false); + await loadBindCardTasks(true); + return; + } + if (state === "failed") { + toast.error(`任务 #${taskId} 订阅失败`); + stopVendorProgressPolling(); + setVendorStopButtonState(true, true); + setVendorRetryButtonState(false, true); + await loadBindCardTasks(true); + return; + } + if (state === "cancelled") { + toast.warning(`任务 #${taskId} 已停止`, 4000); + stopVendorProgressPolling(); + setVendorStopButtonState(true, true); + setVendorRetryButtonState(false, true); + await loadBindCardTasks(true); + return; + } + if (state === "pending") { + toast.warning(`任务 #${taskId} 接口执行已完成,等待订阅同步`, 6000); + stopVendorProgressPolling(); + // pending 阶段仍允许手动停止(仅停止订阅任务,不影响其他任务) + setVendorStopButtonState(false, true); + setVendorRetryButtonState(false, true); + await loadBindCardTasks(true); + return; + } + vendorProgressState.timer = setTimeout(() => { + pollVendorProgress(taskId); + }, 2000); + } catch (error) { + const status = Number(error?.response?.status || 0); + const detail = String(error?.data?.detail || "").trim(); + if (status === 404 && detail.includes("绑卡任务不存在")) { + appendVendorLogs([{ time: "--:--:--", message: "进度服务未命中任务,已停止轮询(可刷新任务列表后重试)" }]); + stopVendorProgressPolling(); + setVendorStopButtonState(false, true); + setVendorRetryButtonState(false, true); + return; + } + appendVendorLogs([{ time: "--:--:--", message: `进度轮询失败: ${formatErrorMessage(error)}` }]); + vendorProgressState.timer = setTimeout(() => { + pollVendorProgress(taskId); + }, 3000); + } +} + +async function startVendorAutoBind(task, vendorConfig) { + const taskId = Number(task?.id || 0); + if (!taskId) { + throw new Error("任务 ID 无效"); + } + const redeemCode = String(vendorConfig?.redeem_code || "").trim(); + if (!redeemCode) { + throw new Error("兑换码不能为空"); + } + showVendorProgressPanel(true); + resetVendorProgressPanel(); + vendorProgressState.taskId = taskId; + vendorProgressState.lastConfig = { + redeem_code: redeemCode, + checkout_url: String(vendorConfig?.checkout_url || "").trim(), + api_url: String(vendorConfig?.api_url || "").trim(), + api_key: String(vendorConfig?.api_key || "").trim(), + }; + stopVendorProgressPolling(); + setVendorStopButtonState(true, true); + setVendorRetryButtonState(true, false); + appendVendorLogs([{ time: "--:--:--", message: `任务 #${taskId} 已创建,开始执行卡商接口订阅...` }]); + const data = await api.post(`/payment/bind-card/tasks/${taskId}/auto-bind-vendor`, { + redeem_code: redeemCode, + checkout_url: String(vendorConfig?.checkout_url || "").trim() || undefined, + api_url: String(vendorConfig?.api_url || "").trim() || undefined, + api_key: String(vendorConfig?.api_key || "").trim() || undefined, + timeout_seconds: 240, + }); + updateVendorProgressUi(data?.progress || { progress: 5, status: "running" }, taskId); + appendVendorLogs(data?.progress?.logs || []); + vendorProgressState.cursor = Number(data?.progress?.next_cursor || 0); + vendorProgressState.timer = setTimeout(() => { + pollVendorProgress(taskId); + }, 1200); +} + +async function retryVendorAutoTask() { + const taskId = Number(vendorProgressState.taskId || 0); + if (!taskId) { + toast.warning("当前没有可重测任务"); + return; + } + const fallbackConfig = collectEfunConfig(); + const config = { + ...(vendorProgressState.lastConfig || {}), + redeem_code: String((vendorProgressState.lastConfig || {}).redeem_code || fallbackConfig.code || "").trim(), + api_url: String((vendorProgressState.lastConfig || {}).api_url || fallbackConfig.api_url || "").trim(), + api_key: String((vendorProgressState.lastConfig || {}).api_key || fallbackConfig.api_key || "").trim(), + checkout_url: String((vendorProgressState.lastConfig || {}).checkout_url || "").trim(), + }; + if (!config.redeem_code) { + toast.warning("请先填写兑换码"); + return; + } + try { + await startVendorAutoBind({ id: taskId }, config); + toast.success(`任务 #${taskId} 已重新开始测试`); + } catch (error) { + toast.error(`重新测试失败: ${formatErrorMessage(error)}`); + } +} + +async function stopVendorAutoTask() { + let taskId = Number(vendorProgressState.taskId || 0); + setVendorStopButtonState(true, true); + try { + if (!taskId) { + const activeStop = await api.post("/payment/bind-card/vendor-stop-active", {}); + const resolvedId = Number(activeStop?.task?.id || 0); + if (!resolvedId) { + throw new Error("当前没有可停止的卡商任务"); + } + taskId = resolvedId; + vendorProgressState.taskId = resolvedId; + appendVendorLogs(activeStop?.progress?.logs || []); + updateVendorProgressUi(activeStop?.progress || { status: "cancelled", progress: 100 }, taskId); + vendorProgressState.cursor = Number(activeStop?.progress?.next_cursor || vendorProgressState.cursor || 0); + stopVendorProgressPolling(); + toast.success(`任务 #${taskId} 已发送停止指令`); + await loadBindCardTasks(true); + return; + } + const stopPaths = [ + `/payment/bind-card/tasks/${taskId}/vendor-stop`, + `/payment/bind-card/tasks/${taskId}/stop-vendor`, + `/payment/bind-card/tasks/${taskId}/stop`, + `/payment/bind-card/tasks/${taskId}/cancel`, + ]; + let data = null; + let lastNotFoundError = null; + for (const path of stopPaths) { + try { + data = await api.post(path, {}); + break; + } catch (error) { + const status = Number(error?.response?.status || 0); + const detail = String(error?.data?.detail || error?.message || "").trim(); + const isRouteNotFound = status === 404 && (!detail || detail.toLowerCase() === "not found"); + if (isRouteNotFound) { + lastNotFoundError = error; + continue; + } + throw error; + } + } + if (!data) { + // 兜底:按“当前活跃任务”停止,避免 taskId 漂移导致无法停止。 + const activeStop = await api.post("/payment/bind-card/vendor-stop-active", {}); + const resolvedId = Number(activeStop?.task?.id || taskId || 0); + if (!resolvedId) { + throw lastNotFoundError || new Error("停止接口不可用"); + } + taskId = resolvedId; + vendorProgressState.taskId = resolvedId; + data = activeStop; + } + appendVendorLogs(data?.progress?.logs || []); + updateVendorProgressUi(data?.progress || { status: "cancelled", progress: 100 }, taskId); + vendorProgressState.cursor = Number(data?.progress?.next_cursor || vendorProgressState.cursor || 0); + stopVendorProgressPolling(); + toast.success(`任务 #${taskId} 已发送停止指令`); + await loadBindCardTasks(true); + } catch (error) { + setVendorStopButtonState(false, true); + toast.error(`停止失败: ${formatErrorMessage(error)}`); + } +} + +function collectVendorConfig() { + const checkoutUrl = getInputValue("vendor-checkout-input"); + const redeemCode = normalizeVendorRedeemCode(getInputValue("vendor-redeem-input")); + setInputValue("vendor-redeem-input", redeemCode); + return { + checkout_url: checkoutUrl, + redeem_code: redeemCode, + }; +} + +function normalizeVendorRedeemCode(raw) { + const compact = String(raw || "").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 16); + if (!compact) return ""; + const groups = compact.match(/.{1,4}/g) || []; + return groups.join("-"); +} + +function normalizeEfunCode(raw) { + const compact = String(raw || "") + .toUpperCase() + .replace(/[^A-Z0-9]/g, ""); + if (!compact.startsWith("UK")) { + return String(raw || "").trim().toUpperCase().replace(/\s+/g, ""); + } + const body = compact.slice(2); + if (body.length !== 25) { + return String(raw || "").trim().toUpperCase().replace(/\s+/g, ""); + } + return `UK-${body.slice(0, 5)}-${body.slice(5, 10)}-${body.slice(10, 15)}-${body.slice(15, 20)}-${body.slice(20, 25)}`; +} + +function safeJsonParse(raw, fallback) { + try { + const parsed = JSON.parse(raw); + return parsed ?? fallback; + } catch (_) { + return fallback; + } +} + +function normalizeCardPoolSupplier(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ""); +} + +function isEfunSupplier(value) { + const normalized = normalizeCardPoolSupplier(value); + return normalized === CARD_POOL_EFUN_SUPPLIER_KEY || normalized === "efuncard"; +} + +function getCardPoolResolvedStatus(item) { + if (!item) return "used"; + const rawStatus = String(item.status || "").trim().toLowerCase(); + if (rawStatus === "used") return "used"; + const expiresRaw = String(item.expires_at || "").trim(); + if (expiresRaw) { + const expiresAt = Date.parse(expiresRaw); + if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) { + return "expired"; + } + } + if (rawStatus === "expired") return "expired"; + return "unused"; +} + +function loadCardPoolRedeemCodes() { + const parsed = safeJsonParse(localStorage.getItem(CARD_POOL_REDEEM_STORAGE_KEY) || "[]", []); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .map((item) => ({ + code: normalizeEfunCode(item?.code || ""), + supplier: normalizeCardPoolSupplier(item?.supplier || ""), + status: getCardPoolResolvedStatus(item), + raw: item || {}, + })) + .filter((item) => item.code); +} + +function getAvailableEfunPoolCodes() { + return loadCardPoolRedeemCodes().filter( + (item) => isEfunSupplier(item.supplier) && item.status === "unused" + ); +} + +function renderEfunPoolHint() { + const hintEl = document.getElementById("efun-pool-hint"); + if (!hintEl) return; + const manualCode = normalizeEfunCode(getInputValue("efun-code-input")); + const available = getAvailableEfunPoolCodes(); + if (manualCode) { + hintEl.textContent = `已手动输入兑换码;卡池可用(供应商=${CARD_POOL_EFUN_SUPPLIER_LABEL}):${available.length}`; + return; + } + if (available.length > 0) { + hintEl.textContent = `卡池可用(供应商=${CARD_POOL_EFUN_SUPPLIER_LABEL}):${available.length},留空将自动使用第一张`; + return; + } + hintEl.textContent = `卡池可用(供应商=${CARD_POOL_EFUN_SUPPLIER_LABEL}):0,请手动输入兑换码`; +} + +function resolveEfunCodeFromInputOrPool() { + const manualCode = normalizeEfunCode(getInputValue("efun-code-input")); + if (manualCode) { + return { code: manualCode, source: "manual" }; + } + const available = getAvailableEfunPoolCodes(); + if (!available.length) { + return { code: "", source: "none" }; + } + const picked = available[0]; + const code = normalizeEfunCode(picked.code); + if (code) { + setInputValue("efun-code-input", code); + storage.set(EFUN_CODE_STORAGE_KEY, code); + } + return { code, source: "pool" }; +} + +function markCardPoolCodeUsed(code, email = "") { + const normalizedCode = normalizeEfunCode(code); + if (!normalizedCode) return false; + const parsed = safeJsonParse(localStorage.getItem(CARD_POOL_REDEEM_STORAGE_KEY) || "[]", []); + if (!Array.isArray(parsed)) return false; + let changed = false; + const nowIso = new Date().toISOString(); + const next = parsed.map((item) => { + const itemCode = normalizeEfunCode(item?.code || ""); + if (!itemCode || itemCode !== normalizedCode) { + return item; + } + changed = true; + return { + ...item, + status: "used", + used_by_email: String(email || item?.used_by_email || "").trim(), + used_at: nowIso, + }; + }); + if (changed) { + localStorage.setItem(CARD_POOL_REDEEM_STORAGE_KEY, JSON.stringify(next)); + } + return changed; +} + +function setEfunResult(content, isError = false) { + const box = document.getElementById("efun-result-box"); + if (!box) return; + const text = typeof content === "string" ? content : JSON.stringify(content || {}, null, 2); + box.textContent = text; + box.classList.add("show"); + box.style.color = isError ? "#fecaca" : "#c7d2fe"; +} + +function collectEfunConfig() { + const apiUrl = String(getInputValue("efun-base-url-input") || "").trim(); + const apiKey = String(getInputValue("efun-api-key-input") || "").trim(); + const code = normalizeEfunCode(getInputValue("efun-code-input")); + setInputValue("efun-code-input", code); + return { + api_url: apiUrl || undefined, + // 兼容旧逻辑:仍保留 base_url 别名,映射到同一地址。 + base_url: apiUrl || undefined, + api_key: apiKey || undefined, + code, + }; +} + +async function callEfunApi(path, payload) { + return api.post(`/payment/efun/${path}`, payload); +} + +async function redeemEfunAndFill(showToast = true, overrideConfig = null) { + const config = { + ...collectEfunConfig(), + ...(overrideConfig || {}), + }; + config.code = normalizeEfunCode(config.code || ""); + setInputValue("efun-code-input", config.code); + if (!config.code) { + throw new Error("请先填写 CDK 激活码"); + } + if (!EFUN_CODE_REGEX.test(config.code)) { + throw new Error("CDK 格式无效"); + } + const payload = { + code: config.code, + base_url: config.base_url, + api_key: config.api_key, + }; + const data = await callEfunApi("redeem", payload); + const card = data?.card || {}; + const number = String(card.card_number || "").replace(/\s+/g, ""); + const month = String(card.exp_month || "").trim(); + const year = String(card.exp_year || "").trim(); + const cvc = String(card.cvc || "").trim(); + + if (number) setInputValue("card-number-input", number); + if (month || year) setInputValue("card-expiry-input", formatExpiryInput(month, year)); + if (cvc) setInputValue("card-cvc-input", cvc); + if (!getInputValue("billing-country-input")) setInputValue("billing-country-input", "US"); + if (!getInputValue("billing-currency-input")) setInputValue("billing-currency-input", "USD"); + + storage.set(EFUN_CODE_STORAGE_KEY, config.code); + storage.set(EFUN_BASE_URL_STORAGE_KEY, String(config.base_url || EFUN_BASE_URL_DEFAULT)); + setEfunResult(data?.raw || data); + + if (showToast) { + toast.success(`EFun 开卡成功,已回填卡号 ${String(card.masked || "").trim() || ""}`); + } + return data; +} + +async function queryEfunCard() { + const config = collectEfunConfig(); + if (!config.code) { + toast.warning("请先填写 CDK 激活码"); + return; + } + try { + const data = await callEfunApi("query", { + code: config.code, + base_url: config.base_url, + api_key: config.api_key, + }); + setEfunResult(data?.data || data); + toast.success("查卡完成"); + } catch (error) { + setEfunResult(`查卡失败: ${formatErrorMessage(error)}`, true); + toast.error(`查卡失败: ${formatErrorMessage(error)}`); + } +} + +async function queryEfunBilling() { + const config = collectEfunConfig(); + if (!config.code) { + toast.warning("请先填写 CDK 激活码"); + return; + } + try { + const data = await callEfunApi("billing", { + code: config.code, + base_url: config.base_url, + api_key: config.api_key, + }); + setEfunResult(data?.data || data); + toast.success("账单查询完成"); + } catch (error) { + setEfunResult(`账单查询失败: ${formatErrorMessage(error)}`, true); + toast.error(`账单查询失败: ${formatErrorMessage(error)}`); + } +} + +async function queryEfun3ds() { + const config = collectEfunConfig(); + if (!config.code) { + toast.warning("请先填写 CDK 激活码"); + return; + } + try { + const data = await callEfunApi("3ds/verify", { + code: config.code, + base_url: config.base_url, + api_key: config.api_key, + minutes: config.minutes || 30, + }); + setEfunResult(data?.data || data); + toast.success("3DS 查询完成"); + } catch (error) { + setEfunResult(`3DS 查询失败: ${formatErrorMessage(error)}`, true); + toast.error(`3DS 查询失败: ${formatErrorMessage(error)}`); + } +} + +async function cancelEfunCard() { + const config = collectEfunConfig(); + if (!config.code) { + toast.warning("请先填写 CDK 激活码"); + return; + } + const ok = await confirm(`确认销卡 ${config.code} 吗?`, "EFun 销卡"); + if (!ok) return; + try { + const data = await callEfunApi("cancel", { + code: config.code, + base_url: config.base_url, + api_key: config.api_key, + }); + setEfunResult(data?.data || data); + toast.success("销卡完成"); + } catch (error) { + setEfunResult(`销卡失败: ${formatErrorMessage(error)}`, true); + toast.error(`销卡失败: ${formatErrorMessage(error)}`); + } +} + +function updateBindModeSpecificUi(mode) { + const bindMode = mode || getBindMode(); + const isVendor = bindMode === "vendor_auto"; + const isVendorEfun = bindMode === "vendor_efun"; + const isVendorFlow = isVendor || isVendorEfun; const thirdPartyPanel = document.getElementById("third-party-config"); - if (thirdPartyPanel) { - thirdPartyPanel.style.display = mode === "third_party" ? "" : "none"; + const vendorPanel = document.getElementById("vendor-auto-config"); + const vendorEfunPanel = document.getElementById("vendor-efun-config"); + const commonFields = document.getElementById("bind-common-fields"); + const pasteSection = document.getElementById("bind-paste-section"); + const cardAddressSection = document.getElementById("bind-card-address-section"); + const generateBtn = document.getElementById("generate-link-btn"); + const autoOpenWrap = document.getElementById("bind-auto-open-wrap"); + const linkBox = document.getElementById("link-box"); + const stopBtn = document.getElementById("vendor-stop-btn"); + + if (thirdPartyPanel) thirdPartyPanel.style.display = bindMode === "third_party" ? "" : "none"; + if (vendorPanel) vendorPanel.style.display = isVendor ? "" : "none"; + if (vendorEfunPanel) vendorEfunPanel.style.display = isVendorEfun ? "" : "none"; + if (commonFields) commonFields.style.display = isVendor ? "none" : ""; + if (pasteSection) pasteSection.style.display = isVendorEfun ? "none" : ""; + if (cardAddressSection) cardAddressSection.style.display = isVendorEfun ? "none" : ""; + if (generateBtn) generateBtn.style.display = isVendorFlow ? "none" : ""; + if (autoOpenWrap) autoOpenWrap.style.display = isVendorFlow ? "none" : "flex"; + if (stopBtn) stopBtn.style.display = isVendorFlow ? "" : "none"; + setVendorRetryButtonState(false, false); + if (linkBox) { + if (isVendorFlow) { + linkBox.classList.remove("show"); + linkBox.style.display = "none"; + generatedLink = ""; + const linkText = document.getElementById("link-text"); + const openStatus = document.getElementById("open-status"); + if (linkText) linkText.value = ""; + if (openStatus) openStatus.textContent = ""; + } else { + linkBox.style.display = ""; + } + } + if (!isVendorFlow) { + showVendorProgressPanel(false); + stopVendorProgressPolling(); + vendorProgressState.taskId = 0; + vendorProgressState.lastConfig = null; + } + if (isVendorEfun) { + renderEfunPoolHint(); } +} + +function onBindModeChange() { + const mode = getBindMode(); + updateBindModeSpecificUi(mode); const actionBtn = document.getElementById("create-bind-task-btn"); if (actionBtn) { - if (mode === "third_party") { - actionBtn.textContent = "创建并执行第三方自动绑卡"; - } else if (mode === "local_auto") { - actionBtn.textContent = "创建并执行全自动绑卡"; + actionBtn.classList.remove("btn-primary", "btn-secondary", "btn-success", "btn-purple", "btn-danger"); + actionBtn.classList.remove("efun-subscribe-wide"); + if (mode === "local_auto") { + actionBtn.textContent = "创建并执行全自动订阅"; + actionBtn.classList.add("btn-secondary"); + } else if (mode === "vendor_efun") { + actionBtn.textContent = "订阅"; + actionBtn.classList.add("btn-success"); + actionBtn.classList.add("efun-subscribe-wide"); } else { actionBtn.textContent = "生成并加入绑卡任务(半自动)"; + actionBtn.classList.add("btn-secondary"); } } updateSemiAutoActionsVisibility(mode); @@ -1264,7 +2027,7 @@ function restoreBindModeConfig() { const modeSelect = document.getElementById("bind-mode-select"); const savedMode = String(storage.get(BIND_MODE_STORAGE_KEY, "semi_auto") || "semi_auto"); if (modeSelect) { - modeSelect.value = ["semi_auto", "third_party", "local_auto"].includes(savedMode) ? savedMode : "semi_auto"; + modeSelect.value = ALLOWED_BIND_MODES.has(savedMode) ? savedMode : "semi_auto"; } const savedApiUrl = String(storage.get(THIRD_PARTY_BIND_URL_STORAGE_KEY, "") || "").trim(); @@ -1273,6 +2036,18 @@ function restoreBindModeConfig() { if (!savedApiUrl) { storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, initialApiUrl); } + + const savedVendorCheckout = String(storage.get(VENDOR_CHECKOUT_STORAGE_KEY, "") || "").trim(); + const savedRedeemCode = normalizeVendorRedeemCode(String(storage.get(VENDOR_REDEEM_STORAGE_KEY, "") || "").trim()); + setInputValue("vendor-checkout-input", savedVendorCheckout); + setInputValue("vendor-redeem-input", savedRedeemCode); + setInputValue( + "efun-base-url-input", + String(storage.get(EFUN_BASE_URL_STORAGE_KEY, EFUN_BASE_URL_DEFAULT) || EFUN_BASE_URL_DEFAULT).trim() || EFUN_BASE_URL_DEFAULT + ); + setInputValue("efun-api-key-input", String(storage.get(EFUN_API_KEY_STORAGE_KEY, "") || "").trim()); + setInputValue("efun-code-input", normalizeEfunCode(String(storage.get(EFUN_CODE_STORAGE_KEY, "") || "").trim())); + onBindModeChange(); } @@ -1370,7 +2145,48 @@ document.addEventListener("DOMContentLoaded", () => { storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, apiUrl); }, 200) ); + document.getElementById("vendor-checkout-input")?.addEventListener( + "input", + debounce(() => { + storage.set(VENDOR_CHECKOUT_STORAGE_KEY, getInputValue("vendor-checkout-input")); + }, 200) + ); + document.getElementById("vendor-redeem-input")?.addEventListener( + "input", + debounce(() => { + const normalized = normalizeVendorRedeemCode(getInputValue("vendor-redeem-input")); + setInputValue("vendor-redeem-input", normalized); + storage.set(VENDOR_REDEEM_STORAGE_KEY, normalized); + }, 200) + ); + document.getElementById("efun-base-url-input")?.addEventListener( + "input", + debounce(() => { + const value = String(getInputValue("efun-base-url-input") || "").trim(); + storage.set(EFUN_BASE_URL_STORAGE_KEY, value || EFUN_BASE_URL_DEFAULT); + }, 200) + ); + document.getElementById("efun-api-key-input")?.addEventListener( + "input", + debounce(() => { + storage.set(EFUN_API_KEY_STORAGE_KEY, String(getInputValue("efun-api-key-input") || "").trim()); + }, 200) + ); + document.getElementById("efun-code-input")?.addEventListener( + "input", + debounce(() => { + const normalized = normalizeEfunCode(getInputValue("efun-code-input")); + setInputValue("efun-code-input", normalized); + storage.set(EFUN_CODE_STORAGE_KEY, normalized); + renderEfunPoolHint(); + }, 200) + ); document.getElementById("bind-mode-select")?.addEventListener("change", onBindModeChange); + window.addEventListener("storage", (event) => { + if (!event || event.key === CARD_POOL_REDEEM_STORAGE_KEY) { + renderEfunPoolHint(); + } + }); loadAccounts(); onCountryChange(); @@ -1378,21 +2194,111 @@ document.addEventListener("DOMContentLoaded", () => { startBindTaskAutoRefresh(); switchPaymentTab("link"); - window.addEventListener("beforeunload", stopBindTaskAutoRefresh); + window.addEventListener("beforeunload", () => { + stopBindTaskAutoRefresh(); + stopVendorProgressPolling(); + }); }); // 加载账号列表 +async function fetchBindFailStats(accountIds = []) { + const ids = (Array.isArray(accountIds) ? accountIds : []) + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item) && item > 0); + if (!ids.length) { + return new Map(); + } + try { + const data = await api.post("/payment/bind-card/tasks/fail-stats", { + account_ids: ids, + }, { + timeoutMs: 12000, + retry: 0, + silentNetworkError: true, + silentTimeoutError: true, + requestKey: "payment:bind-fail-stats", + cancelPrevious: true, + priority: "high", + }); + const rows = Array.isArray(data?.stats) ? data.stats : []; + const map = new Map(); + rows.forEach((row) => { + const accountId = Number(row?.account_id || 0); + const failCount = Number(row?.fail_count || 0); + if (!Number.isFinite(accountId) || accountId <= 0) return; + map.set(accountId, Number.isFinite(failCount) && failCount > 0 ? failCount : 0); + }); + return map; + } catch (_e) { + return new Map(); + } +} + async function loadAccounts() { try { // 后端 page_size 最大为 100,超限会返回 422。 - // 这里读取账号管理列表,不按状态硬过滤,避免“有账号但选不到”。 + // 支付页账号下拉:仅显示「母号」且未订阅 plus/team 的账号。 const data = await api.get("/accounts?page=1&page_size=100"); const sel = document.getElementById("account-select"); if (!sel) return; sel.innerHTML = ''; - paymentAccounts = Array.isArray(data.accounts) ? data.accounts : []; - (data.accounts || []).forEach((acc) => { + const allAccounts = Array.isArray(data.accounts) ? data.accounts : []; + const bindFailCountMap = await fetchBindFailStats(allAccounts.map((acc) => Number(acc?.id || 0))); + const toRoleRank = (acc) => { + const role = String(acc?.role_tag || "").trim().toLowerCase(); + if (role === "parent") return 0; + if (role === "child") return 1; + return 2; + }; + const toPriority = (acc) => { + const num = Number(acc?.priority || 50); + return Number.isFinite(num) ? num : 50; + }; + const toLastUsedRank = (acc) => { + const ms = Date.parse(String(acc?.last_used_at || "")); + if (Number.isNaN(ms)) return -1; + return ms; + }; + const toFailRank = (acc) => { + const accountId = Number(acc?.id || 0); + if (Number.isFinite(accountId) && accountId > 0) { + const count = bindFailCountMap.get(accountId); + if (Number.isFinite(count)) { + return count; + } + } + return String(acc?.status || "").trim().toLowerCase() === "failed" ? 1 : 0; + }; + + paymentAccounts = allAccounts + .filter((acc) => { + const sub = String(acc?.subscription_type || "").trim().toLowerCase(); + if (sub === "plus" || sub === "team") { + return false; + } + const roleTag = String(acc?.role_tag || "").trim().toLowerCase(); + const accountLabel = String(acc?.account_label || "").trim().toLowerCase(); + const isParent = roleTag === "parent" || accountLabel === "mother" || accountLabel === "母号"; + return isParent; + }) + .sort((a, b) => { + const roleDiff = toRoleRank(a) - toRoleRank(b); + if (roleDiff !== 0) return roleDiff; + + const priorityDiff = toPriority(a) - toPriority(b); + if (priorityDiff !== 0) return priorityDiff; + + const lastUsedDiff = toLastUsedRank(a) - toLastUsedRank(b); + if (lastUsedDiff !== 0) return lastUsedDiff; + + const failDiff = toFailRank(a) - toFailRank(b); + if (failDiff !== 0) return failDiff; + + return Number(a?.id || 0) - Number(b?.id || 0); + }); + + paymentAccounts.forEach((acc) => { const opt = document.createElement("option"); opt.value = acc.id; const subText = acc.subscription_type ? ` (${String(acc.subscription_type).toUpperCase()})` : ""; @@ -1561,24 +2467,53 @@ async function createBindCardTask() { } const bindMode = getBindMode(); - const bindData = collectBillingFormData(); - const missing = []; - if (!bindData.card_number) missing.push("卡号"); - if (!bindData.exp_month || !bindData.exp_year) missing.push("有效期"); - if (!bindData.cvc) missing.push("CVC"); - if (!bindData.billing_name) missing.push("姓名"); - if (!bindData.address_line1) missing.push("地址"); - if (!bindData.postal_code) missing.push("邮编"); - if (missing.length && bindMode === "semi_auto") { - toast.warning(`绑卡资料未完整:${missing.join("、")}(本次仅创建半自动任务,不会阻断)`, 5000); - } - if (missing.length && (bindMode === "third_party" || bindMode === "local_auto")) { - const modeText = bindMode === "third_party" ? "第三方自动绑卡" : "全自动绑卡"; - toast.warning(`${modeText}需要完整资料:${missing.join("、")}`, 5000); - return; + const isVendorEfun = bindMode === "vendor_efun"; + const efunConfig = isVendorEfun ? collectEfunConfig() : {}; + const vendorConfigForRun = isVendorEfun + ? { + redeem_code: "", + checkout_url: String(payload?.custom_checkout_url || "").trim(), + api_url: String(efunConfig.api_url || "").trim(), + api_key: String(efunConfig.api_key || "").trim(), + } + : null; + if (isVendorEfun) { + const resolved = resolveEfunCodeFromInputOrPool(); + const resolvedCode = String(resolved?.code || "").trim(); + if (!resolvedCode) { + renderEfunPoolHint(); + toast.warning("卡商EFun模式需要兑换码:可先在卡池添加供应商=EFun的可用码,或手动填写"); + return; + } + efunConfig.code = resolvedCode; + vendorConfigForRun.redeem_code = resolvedCode; + setInputValue("efun-code-input", resolvedCode); + storage.set(EFUN_CODE_STORAGE_KEY, resolvedCode); + if (!EFUN_CODE_REGEX.test(String(efunConfig.code || ""))) { + toast.warning("CDK 格式无效"); + return; + } + renderEfunPoolHint(); + setEfunResult("已准备就绪:将执行接口流程(强制生成 Checkout -> EFun 开卡 -> bindcard 提交 -> 同步订阅)"); + } else { + let bindData = collectBillingFormData(); + const missing = []; + if (!bindData.card_number) missing.push("卡号"); + if (!bindData.exp_month || !bindData.exp_year) missing.push("有效期"); + if (!bindData.cvc) missing.push("CVC"); + if (!bindData.billing_name) missing.push("姓名"); + if (!bindData.address_line1) missing.push("地址"); + if (!bindData.postal_code) missing.push("邮编"); + if (missing.length && bindMode === "semi_auto") { + toast.warning(`绑卡资料未完整:${missing.join("、")}(本次仅创建半自动任务,不会阻断)`, 5000); + } + if (missing.length && bindMode === "local_auto") { + toast.warning(`全自动绑卡需要完整资料:${missing.join("、")}`, 5000); + return; + } } - payload.auto_open = Boolean(document.getElementById("bind-auto-open")?.checked); + payload.auto_open = isVendorEfun ? false : Boolean(document.getElementById("bind-auto-open")?.checked); payload.bind_mode = bindMode; setButtonLoading("create-bind-task-btn", "创建中...", true); @@ -1588,7 +2523,7 @@ async function createBindCardTask() { throw new Error(data?.detail || "创建绑卡任务失败"); } - if (data.link) { + if (data.link && !isVendorEfun) { showGeneratedLink({ link: data.link, source: data.source, @@ -1596,37 +2531,24 @@ async function createBindCardTask() { }); } - if (bindMode === "third_party") { - toast.info(`任务 #${data.task.id} 已创建,正在调用第三方自动绑卡...`, 3000); - try { - const autoResult = await submitThirdPartyAutoBind(data.task, bindData); - if (autoResult?.verified) { - toast.success(`任务 #${data.task.id} 自动绑卡完成: ${String(autoResult.subscription_type || "").toUpperCase()}`); - } else if (autoResult?.paid_confirmed) { - toast.success(`任务 #${data.task.id} 已确认支付,等待订阅同步(可点“同步订阅”)`, 7000); - } else if (autoResult?.pending || autoResult?.need_user_action) { - const tp = autoResult?.third_party || {}; - const assess = tp?.assessment || {}; - const snapshot = assess?.snapshot || {}; - const paymentStatus = String(snapshot?.payment_status || "").toUpperCase() || "UNKNOWN"; - toast.warning( - `任务 #${data.task.id} 第三方已受理(payment_status=${paymentStatus}),可能需要 challenge;请在支付页完成后点“我已完成支付”或“同步订阅”。`, - 9000 - ); - } else { - const sub = String(autoResult?.subscription_type || "free").toUpperCase(); - toast.warning(`任务 #${data.task.id} 第三方提交成功,但当前订阅为 ${sub},请稍后再同步`, 7000); - } - } catch (autoErr) { - toast.error(`任务 #${data.task.id} 第三方自动绑卡失败: ${formatErrorMessage(autoErr)}`); - } - } else if (bindMode === "local_auto") { + if (bindMode === "local_auto") { + const bindData = collectBillingFormData(); toast.info(`任务 #${data.task.id} 已创建,已在后台执行全自动绑卡,可继续修改参数并创建新任务`, 5000); runLocalAutoBindInBackground(data.task, { ...bindData }); + } else if (bindMode === "vendor_efun") { + toast.info(`任务 #${data.task.id} 已创建,开始执行接口订阅流程`, 4000); + showVendorProgressPanel(true); + await startVendorAutoBind(data.task, vendorConfigForRun || {}); + await loadBindCardTasks(true); } else { toast.success(`绑卡任务已创建 #${data.task.id}${data.auto_opened ? ",浏览器已打开" : ""}`); + switchPaymentTab("bind-task"); + await loadBindCardTasks(); + return; + } + if (bindMode !== "vendor_efun") { + switchPaymentTab("bind-task"); } - switchPaymentTab("bind-task"); await loadBindCardTasks(); } catch (e) { toast.error(`创建绑卡任务失败: ${formatErrorMessage(e)}`); @@ -1686,64 +2608,91 @@ async function openIncognito() { async function loadBindCardTasks(silent = false) { const tbody = document.getElementById("bind-card-task-table"); if (!tbody) return; - - if (!silent) { - setButtonLoading("refresh-bind-task-btn", "刷新中...", true); + if (bindTaskLoadPromise) { + if (!silent) { + toast.info("列表正在刷新,请稍候...", 1800); + } + return bindTaskLoadPromise; } - try { - const params = new URLSearchParams({ - page: String(bindTaskState.page), - page_size: String(bindTaskState.pageSize), - }); - if (bindTaskState.status) params.set("status", bindTaskState.status); - if (bindTaskState.search) params.set("search", bindTaskState.search); - const data = await api.get(`/payment/bind-card/tasks?${params.toString()}`); - const tasks = data?.tasks || []; + const runner = async () => { + isLoadingBindTaskList = true; + if (!silent) { + setButtonLoading("refresh-bind-task-btn", "刷新中...", true); + } + try { + const params = new URLSearchParams({ + page: String(bindTaskState.page), + page_size: String(bindTaskState.pageSize), + }); + if (bindTaskState.status) params.set("status", bindTaskState.status); + if (bindTaskState.search) params.set("search", bindTaskState.search); + + const data = await api.get(`/payment/bind-card/tasks?${params.toString()}`, { + timeoutMs: 45000, + retry: silent ? 0 : 1, + requestKey: "payment:bind-task-list", + cancelPrevious: true, + silentNetworkError: silent, + silentTimeoutError: silent, + priority: silent ? "low" : "normal", + }); + const tasks = data?.tasks || []; + + if (!tasks.length) { + tbody.innerHTML = ` + +
暂无绑卡任务
+ + `; + return; + } - if (!tasks.length) { - tbody.innerHTML = ` + tbody.innerHTML = tasks.map((task) => ` -
暂无绑卡任务
+ ${task.id} + ${escapeHtml(task.account_email || "-")} + ${String(task.plan_type || "-").toUpperCase()} + ${escapeHtml(getTaskStatusText(task.status))} + + + ${escapeHtml(task.checkout_url || "-")} + + + ${escapeHtml(task.checkout_source || "-")} + ${formatBeijingDateTime(task.created_at)} + +
+ + + + +
+ ${task.last_error ? `
${escapeHtml(task.last_error)}
` : ""} + - `; - return; + `).join(""); + } catch (e) { + if (!silent) { + tbody.innerHTML = ` + +
加载失败: ${escapeHtml(formatErrorMessage(e))}
+ + `; + } + } finally { + isLoadingBindTaskList = false; + if (!silent) { + setButtonLoading("refresh-bind-task-btn", "刷新中...", false); + } } + }; - tbody.innerHTML = tasks.map((task) => ` - - ${task.id} - ${escapeHtml(task.account_email || "-")} - ${String(task.plan_type || "-").toUpperCase()} - ${escapeHtml(getTaskStatusText(task.status))} - - - ${escapeHtml(task.checkout_url || "-")} - - - ${escapeHtml(task.checkout_source || "-")} - ${format.date(task.created_at)} - -
- - - - -
- ${task.last_error ? `
${escapeHtml(task.last_error)}
` : ""} - - - `).join(""); - } catch (e) { - tbody.innerHTML = ` - -
加载失败: ${escapeHtml(formatErrorMessage(e))}
- - `; + bindTaskLoadPromise = runner(); + try { + return await bindTaskLoadPromise; } finally { - if (!silent) { - setButtonLoading("refresh-bind-task-btn", "刷新中...", false); - } + bindTaskLoadPromise = null; } } @@ -1762,13 +2711,38 @@ async function openBindCardTask(taskId) { } async function markBindCardTaskUserAction(taskId) { + const lockKey = `mark:${taskId}`; + if (bindTaskActionRunning.has(lockKey)) { + toast.info(`任务 #${taskId} 正在验证中,请稍候...`, 2500); + return; + } + bindTaskActionRunning.add(lockKey); try { - toast.info(`任务 #${taskId} 正在验证订阅,最多等待 180 秒...`, 3000); - const data = await api.post(`/payment/bind-card/tasks/${taskId}/mark-user-action`, { + toast.info(`任务 #${taskId} 已进入后台验证,最多等待 180 秒...`, 3000); + const opTask = await api.post(`/payment/bind-card/tasks/${taskId}/mark-user-action/async`, { timeout_seconds: 180, interval_seconds: 10, + }, { + timeoutMs: 20000, + retry: 0, }); - if (data?.verified) { + + const opTaskId = String(opTask?.id || "").trim(); + if (!opTaskId) { + throw new Error("任务创建失败:未返回任务 ID"); + } + + const finalTask = await watchPaymentOpTask(opTaskId); + const finalStatus = String(finalTask?.status || "").toLowerCase(); + const data = finalTask?.result || {}; + + if (finalStatus === "failed") { + throw new Error(finalTask?.error || finalTask?.message || "验证任务失败"); + } + + if (finalStatus === "cancelled" || data?.cancelled) { + toast.warning(`任务 #${taskId} 验证已取消`, 5000); + } else if (data?.verified) { toast.success(`任务 #${taskId} 验证成功: ${String(data.subscription_type || "").toUpperCase()}`); } else { const sub = String(data?.subscription_type || "free").toUpperCase(); @@ -1783,32 +2757,65 @@ async function markBindCardTaskUserAction(taskId) { } await loadBindCardTasks(); } catch (e) { - // 兼容旧后端:如果 mark-user-action 尚未部署,自动降级到 sync-subscription。 + // 兼容旧后端:如果 mark-user-action/async 尚未部署,自动降级到旧同步接口。 const detail = String(e?.data?.detail || "").toLowerCase(); const isRouteNotFound = e?.response?.status === 404 && detail === "not found"; if (isRouteNotFound) { try { - const fallback = await api.post(`/payment/bind-card/tasks/${taskId}/sync-subscription`, {}); - const sub = String(fallback?.subscription_type || "free").toUpperCase(); - if (sub === "PLUS" || sub === "TEAM") { - toast.success(`任务 #${taskId} 已通过兼容模式同步成功: ${sub}`); + const fallbackMark = await api.post(`/payment/bind-card/tasks/${taskId}/mark-user-action`, { + timeout_seconds: 180, + interval_seconds: 10, + }, { + timeoutMs: 210000, + retry: 0, + }); + if (fallbackMark?.verified) { + toast.success(`任务 #${taskId} 验证成功: ${String(fallbackMark.subscription_type || "").toUpperCase()}`); } else { - toast.warning(`任务 #${taskId} 兼容同步完成,但当前仍是 ${sub}`, 5000); + const sub = String(fallbackMark?.subscription_type || "free").toUpperCase(); + toast.warning(`任务 #${taskId} 兼容验证完成,但当前仍是 ${sub}`, 5000); } await loadBindCardTasks(); return; } catch (fallbackErr) { - toast.error(`验证订阅失败(兼容模式也失败): ${formatErrorMessage(fallbackErr)}`); + // 兼容极老版本:mark-user-action 不存在时再降级 sync-subscription。 + const detail2 = String(fallbackErr?.data?.detail || "").toLowerCase(); + const isMarkRouteMissing = fallbackErr?.response?.status === 404 && detail2 === "not found"; + if (isMarkRouteMissing) { + try { + const fallback = await api.post(`/payment/bind-card/tasks/${taskId}/sync-subscription`, {}, { + timeoutMs: 60000, + retry: 0, + }); + const sub = String(fallback?.subscription_type || "free").toUpperCase(); + if (sub === "PLUS" || sub === "TEAM") { + toast.success(`任务 #${taskId} 已通过兼容模式同步成功: ${sub}`); + } else { + toast.warning(`任务 #${taskId} 兼容同步完成,但当前仍是 ${sub}`, 5000); + } + await loadBindCardTasks(); + return; + } catch (fallbackSyncErr) { + toast.error(`验证订阅失败(兼容模式也失败): ${formatErrorMessage(fallbackSyncErr)}`); + return; + } + } + toast.error(`验证订阅失败(兼容模式失败): ${formatErrorMessage(fallbackErr)}`); return; } } toast.error(`验证订阅失败: ${formatErrorMessage(e)}`); + } finally { + bindTaskActionRunning.delete(lockKey); } } async function syncBindCardTask(taskId) { try { - const data = await api.post(`/payment/bind-card/tasks/${taskId}/sync-subscription`, {}); + const data = await api.post(`/payment/bind-card/tasks/${taskId}/sync-subscription`, {}, { + timeoutMs: 60000, + retry: 0, + }); const sub = String(data?.subscription_type || "free").toUpperCase(); const source = String(data?.detail?.source || "unknown"); const confidence = String(data?.detail?.confidence || "unknown"); @@ -1854,3 +2861,9 @@ window.syncBindCardTask = syncBindCardTask; window.deleteBindCardTask = deleteBindCardTask; window.fillFromBatchProfile = fillFromBatchProfile; window.randomBillingByCountry = randomBillingByCountry; +window.redeemEfunAndFill = redeemEfunAndFill; +window.queryEfunCard = queryEfunCard; +window.queryEfunBilling = queryEfunBilling; +window.queryEfun3ds = queryEfun3ds; +window.cancelEfunCard = cancelEfunCard; +window.retryVendorAutoTask = retryVendorAutoTask; diff --git a/static/js/selfcheck.js b/static/js/selfcheck.js new file mode 100644 index 00000000..e7558719 --- /dev/null +++ b/static/js/selfcheck.js @@ -0,0 +1,559 @@ +/** + * 系统自检页面脚本 + */ + +const selfcheckState = { + runs: [], + selectedRunId: null, + selectedRun: null, + repairCatalog: {}, + repairPreview: null, + scheduleInitialized: false, + pollTimer: null, +}; + +function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = String(value ?? ''); + return div.innerHTML; +} + +function normalizeStatus(status) { + const text = String(status || '').toLowerCase(); + if (text === 'completed' || text === 'pass') return { cls: 'completed', text: '通过' }; + if (text === 'running') return { cls: 'running', text: '运行中' }; + if (text === 'pending') return { cls: 'pending', text: '等待中' }; + if (text === 'warn') return { cls: 'warning', text: '警告' }; + if (text === 'fail' || text === 'failed') return { cls: 'failed', text: '失败' }; + if (text === 'skip') return { cls: 'disabled', text: '跳过' }; + return { cls: 'disabled', text: text || '-' }; +} + +function statusBadge(status) { + const normalized = normalizeStatus(status); + return `${escapeHtml(normalized.text)}`; +} + +function parseErrorMessage(error) { + const detail = error?.data?.detail; + if (typeof detail === 'string') return detail; + if (detail && typeof detail === 'object') { + if (typeof detail.message === 'string') return detail.message; + return JSON.stringify(detail); + } + return error?.message || '请求失败'; +} + +function getRepairName(key) { + return selfcheckState.repairCatalog?.[key]?.name || key; +} + +function renderRuntime(runtime) { + const statusNode = document.getElementById('runtime-status'); + const nextRunNode = document.getElementById('runtime-next-run'); + const lastRunNode = document.getElementById('runtime-last-run'); + const summaryNode = document.getElementById('runtime-last-summary'); + const logsNode = document.getElementById('selfcheck-runtime-logs'); + if (!statusNode || !nextRunNode || !lastRunNode || !summaryNode || !logsNode) return; + + statusNode.textContent = normalizeStatus(runtime?.last_status).text || '-'; + nextRunNode.textContent = runtime?.next_run_at ? format.date(runtime.next_run_at) : '-'; + lastRunNode.textContent = runtime?.last_finished_at ? format.date(runtime.last_finished_at) : '-'; + summaryNode.textContent = runtime?.last_run?.summary || runtime?.last_error || '-'; + + const logs = Array.isArray(runtime?.logs) ? runtime.logs : []; + if (!logs.length) { + logsNode.innerHTML = '
--暂无调度日志
'; + return; + } + + logsNode.innerHTML = logs.map((item) => { + const level = String(item?.level || 'info').toLowerCase(); + return ` +
+ ${escapeHtml(format.date(item?.time))} + ${escapeHtml(level)} + ${escapeHtml(item?.message || '')} +
+ `; + }).join(''); +} + +function renderSchedule(data) { + const enabledNode = document.getElementById('selfcheck-auto-enabled'); + const intervalNode = document.getElementById('selfcheck-interval-select'); + const modeNode = document.getElementById('selfcheck-auto-mode-select'); + if (!enabledNode || !intervalNode || !modeNode) return; + + if (!selfcheckState.scheduleInitialized) { + enabledNode.checked = Boolean(data?.enabled); + intervalNode.value = String(data?.interval_minutes || 15); + modeNode.value = String(data?.mode || 'quick'); + selfcheckState.scheduleInitialized = true; + } + + renderRuntime(data?.runtime || {}); +} + +function renderRuns() { + const body = document.getElementById('selfcheck-runs-body'); + if (!body) return; + const rows = selfcheckState.runs || []; + if (!rows.length) { + body.innerHTML = '
暂无运行记录
'; + return; + } + + body.innerHTML = rows.map((run) => { + const isSelected = Number(run.id) === Number(selfcheckState.selectedRunId); + return ` + + #${run.id} + ${escapeHtml(run.mode || '-')} + ${escapeHtml(run.source || '-')} + ${statusBadge(run.status)} + ${Number(run.score || 0)} + ${Number(run.total_checks || 0)} + ${escapeHtml(run.started_at ? format.date(run.started_at) : '-')} + ${escapeHtml(run.finished_at ? format.date(run.finished_at) : '-')} + ${escapeHtml(run.summary || '-')} + + `; + }).join(''); +} + +function renderRepairResults(repairs) { + const container = document.getElementById('selfcheck-repair-results'); + if (!container) return; + const list = Array.isArray(repairs) ? repairs : []; + if (!list.length) { + container.innerHTML = ''; + return; + } + container.innerHTML = list.map((item) => { + return ` +
+ ${escapeHtml(item?.name || item?.key || '修复动作')} +
完成时间:${escapeHtml(format.date(item?.finished_at || ''))}
+
耗时:${escapeHtml(String(item?.duration_ms || 0))} ms
+
结果:${escapeHtml(JSON.stringify(item?.detail || {}))}
+
+ `; + }).join(''); +} + +function renderRepairPreview(preview) { + const container = document.getElementById('repair-center-preview-list'); + if (!container) return; + const items = Array.isArray(preview?.items) ? preview.items : []; + if (!items.length) { + container.innerHTML = '
暂无预览结果
'; + return; + } + container.innerHTML = items.map((item) => { + const impactCount = Number(item?.impact_count || 0); + const checked = impactCount > 0 ? 'checked' : ''; + return ` + + `; + }).join(''); +} + +function renderRepairRollbacks(items) { + const container = document.getElementById('repair-center-rollbacks'); + if (!container) return; + const list = Array.isArray(items) ? items : []; + if (!list.length) { + container.innerHTML = '
暂无回滚点
'; + return; + } + container.innerHTML = list.map((item) => { + return ` +
+
+
${escapeHtml(item?.rollback_id || '-')}
+
${escapeHtml(format.date(item?.created_at || ''))} | run #${escapeHtml(String(item?.run_id || '-'))}
+
修复项:${escapeHtml((item?.repair_keys || []).join(', ') || '-')}
+
+ +
+ `; + }).join(''); +} + +function renderRunDetail(run) { + const titleNode = document.getElementById('selfcheck-detail-title'); + const listNode = document.getElementById('selfcheck-check-list'); + if (!titleNode || !listNode) return; + + if (!run) { + selfcheckState.selectedRun = null; + titleNode.textContent = '请在上方点击一条运行记录'; + listNode.innerHTML = '
暂无检查数据
'; + renderRepairResults([]); + renderRepairPreview(null); + return; + } + selfcheckState.selectedRun = run; + + titleNode.textContent = `运行 #${run.id} | ${run.mode} | ${normalizeStatus(run.status).text} | 评分 ${run.score || 0}`; + const checks = Array.isArray(run?.result_data?.checks) ? run.result_data.checks : []; + if (!checks.length) { + listNode.innerHTML = '
当前运行暂无检查结果
'; + renderRepairResults(run?.result_data?.repairs || []); + return; + } + + listNode.innerHTML = checks.map((check) => { + const fixes = Array.isArray(check?.fixes) ? check.fixes : []; + const detailsText = check?.details ? JSON.stringify(check.details, null, 2) : ''; + return ` +
+
+
${escapeHtml(check?.name || check?.key || '-')}
+
${statusBadge(check?.status)} ${Number(check?.duration_ms || 0)}ms
+
+
${escapeHtml(check?.message || '-')}
+ ${detailsText ? `
查看明细
${escapeHtml(detailsText)}
` : ''} + ${fixes.length ? ` +
+ ${fixes.map((fixKey) => ` + + `).join('')} +
+ ` : ''} +
+ `; + }).join(''); + + renderRepairResults(run?.result_data?.repairs || []); +} + +async function loadRepairCatalog() { + try { + const data = await api.get('/selfcheck/repairs', { requestKey: 'selfcheck-repairs', cancelPrevious: true }); + selfcheckState.repairCatalog = data?.repairs || {}; + } catch (error) { + selfcheckState.repairCatalog = {}; + } +} + +async function loadScheduleAndRuntime() { + const data = await api.get('/selfcheck/schedule', { requestKey: 'selfcheck-schedule', cancelPrevious: true, silentNetworkError: true, priority: 'low' }); + renderSchedule(data); + return data; +} + +async function loadRuns() { + const data = await api.get('/selfcheck/runs?limit=60', { requestKey: 'selfcheck-runs', cancelPrevious: true }); + selfcheckState.runs = Array.isArray(data?.runs) ? data.runs : []; + if (!selfcheckState.selectedRunId && selfcheckState.runs.length) { + selfcheckState.selectedRunId = selfcheckState.runs[0].id; + } + renderRuns(); + if (selfcheckState.selectedRunId) { + await loadRunDetail(selfcheckState.selectedRunId); + } else { + renderRunDetail(null); + } +} + +async function loadRunDetail(runId) { + if (!runId) { + renderRunDetail(null); + return; + } + const run = await api.get(`/selfcheck/runs/${Number(runId)}`, { requestKey: `selfcheck-run-${runId}`, cancelPrevious: true }); + selfcheckState.selectedRunId = Number(run.id); + renderRuns(); + renderRunDetail(run); +} + +async function startRun() { + const modeNode = document.getElementById('selfcheck-mode-select'); + const runBtn = document.getElementById('selfcheck-run-btn'); + if (!modeNode || !runBtn) return; + + loading.show(runBtn, '执行中...'); + try { + const payload = { mode: modeNode.value, source: 'manual', run_async: true }; + const result = await api.post('/selfcheck/runs', payload); + const run = result?.run || null; + if (run?.id) { + selfcheckState.selectedRunId = Number(run.id); + } + toast.success(result?.message || '自检任务已启动'); + await loadRuns(); + } catch (error) { + const message = parseErrorMessage(error); + toast.error(`启动失败: ${message}`); + } finally { + loading.hide(runBtn); + } +} + +async function saveSchedule() { + const btn = document.getElementById('selfcheck-save-schedule-btn'); + const enabledNode = document.getElementById('selfcheck-auto-enabled'); + const intervalNode = document.getElementById('selfcheck-interval-select'); + const modeNode = document.getElementById('selfcheck-auto-mode-select'); + if (!btn || !enabledNode || !intervalNode || !modeNode) return; + + loading.show(btn, '保存中...'); + try { + const payload = { + enabled: Boolean(enabledNode.checked), + interval_minutes: Number(intervalNode.value || 15), + mode: String(modeNode.value || 'quick'), + run_now: false, + }; + const data = await api.post('/selfcheck/schedule', payload); + renderSchedule({ ...payload, runtime: data?.runtime || {} }); + toast.success(data?.message || '保存成功'); + } catch (error) { + toast.error(`保存失败: ${parseErrorMessage(error)}`); + } finally { + loading.hide(btn); + } +} + +async function runNow() { + const btn = document.getElementById('selfcheck-run-now-btn'); + if (!btn) return; + loading.show(btn, '请求中...'); + try { + const data = await api.post('/selfcheck/schedule/run-now', {}); + renderRuntime(data?.runtime || {}); + toast.success(data?.message || '已请求立即执行'); + await loadRuns(); + } catch (error) { + toast.error(parseErrorMessage(error)); + } finally { + loading.hide(btn); + } +} + +async function executeRepair(runId, repairKey, btn) { + if (!runId || !repairKey) return; + if (btn) loading.show(btn, '执行中...'); + try { + const data = await api.post(`/selfcheck/runs/${Number(runId)}/repairs/${encodeURIComponent(repairKey)}`, {}); + toast.success(`${getRepairName(repairKey)} 执行完成`); + const run = data?.run; + if (run?.id) { + renderRunDetail(run); + await loadRuns(); + } else { + await loadRunDetail(runId); + } + } catch (error) { + toast.error(`修复失败: ${parseErrorMessage(error)}`); + } finally { + if (btn) loading.hide(btn); + } +} + +function collectRepairKeysFromSelectedRun() { + const run = selfcheckState.selectedRun; + if (!run) return []; + const checks = Array.isArray(run?.result_data?.checks) ? run.result_data.checks : []; + const keys = []; + checks.forEach((check) => { + const fixes = Array.isArray(check?.fixes) ? check.fixes : []; + fixes.forEach((key) => { + const text = String(key || '').trim(); + if (text && !keys.includes(text)) { + keys.push(text); + } + }); + }); + if (keys.length) return keys; + return Object.keys(selfcheckState.repairCatalog || {}); +} + +function collectCheckedPreviewKeys() { + const nodes = Array.from(document.querySelectorAll('.repair-center-key:checked')); + return nodes.map((node) => String(node?.dataset?.repairKey || '').trim()).filter(Boolean); +} + +async function loadRepairRollbacks() { + try { + const data = await api.get('/selfcheck/repair-center/rollbacks?limit=20', { + requestKey: 'selfcheck-repair-rollbacks', + cancelPrevious: true, + silentNetworkError: true, + priority: 'low', + }); + renderRepairRollbacks(data?.items || []); + } catch (error) { + renderRepairRollbacks([]); + } +} + +async function previewRepairCenter() { + const btn = document.getElementById('repair-center-preview-btn'); + const run = selfcheckState.selectedRun; + if (!run?.id) { + toast.warning('请先选择一条自检运行记录'); + return; + } + const keys = collectRepairKeysFromSelectedRun(); + if (!keys.length) { + toast.warning('当前运行没有可预览的修复项'); + return; + } + if (btn) loading.show(btn, '预览中...'); + try { + const data = await api.post('/selfcheck/repair-center/preview', { + run_id: Number(run.id), + repair_keys: keys, + }); + selfcheckState.repairPreview = data?.preview || null; + renderRepairPreview(selfcheckState.repairPreview); + toast.success('预览完成'); + } catch (error) { + toast.error(`预览失败: ${parseErrorMessage(error)}`); + } finally { + if (btn) loading.hide(btn); + } +} + +async function executeRepairCenter() { + const btn = document.getElementById('repair-center-execute-btn'); + const run = selfcheckState.selectedRun; + if (!run?.id) { + toast.warning('请先选择一条自检运行记录'); + return; + } + const keys = collectCheckedPreviewKeys(); + if (!keys.length) { + toast.warning('请先勾选要执行的修复项'); + return; + } + if (btn) loading.show(btn, '执行中...'); + try { + const data = await api.post('/selfcheck/repair-center/execute', { + run_id: Number(run.id), + repair_keys: keys, + }); + const rollbackId = data?.result?.rollback_id; + toast.success(`修复完成${rollbackId ? `,回滚点: ${rollbackId}` : ''}`); + await loadRunDetail(Number(run.id)); + await loadRepairRollbacks(); + } catch (error) { + toast.error(`执行失败: ${parseErrorMessage(error)}`); + } finally { + if (btn) loading.hide(btn); + } +} + +async function rollbackRepairPoint(rollbackId, btn) { + const id = String(rollbackId || '').trim(); + if (!id) return; + if (btn) loading.show(btn, '回滚中...'); + try { + const data = await api.post(`/selfcheck/repair-center/rollbacks/${encodeURIComponent(id)}/rollback`, {}); + toast.success(`回滚完成:恢复账号 ${Number(data?.result?.restored_accounts || 0)} 条`); + await loadRuns(); + await loadRepairRollbacks(); + } catch (error) { + toast.error(`回滚失败: ${parseErrorMessage(error)}`); + } finally { + if (btn) loading.hide(btn); + } +} + +function bindEvents() { + const runBtn = document.getElementById('selfcheck-run-btn'); + const refreshBtn = document.getElementById('selfcheck-refresh-btn'); + const saveScheduleBtn = document.getElementById('selfcheck-save-schedule-btn'); + const runNowBtn = document.getElementById('selfcheck-run-now-btn'); + const runsBody = document.getElementById('selfcheck-runs-body'); + const checkList = document.getElementById('selfcheck-check-list'); + const repairPreviewBtn = document.getElementById('repair-center-preview-btn'); + const repairExecuteBtn = document.getElementById('repair-center-execute-btn'); + const repairRollbackRefreshBtn = document.getElementById('repair-center-refresh-rollbacks-btn'); + const rollbackBox = document.getElementById('repair-center-rollbacks'); + + runBtn?.addEventListener('click', startRun); + refreshBtn?.addEventListener('click', async () => { + try { + await loadRuns(); + await loadScheduleAndRuntime(); + toast.success('刷新完成'); + } catch (error) { + toast.error(`刷新失败: ${parseErrorMessage(error)}`); + } + }); + saveScheduleBtn?.addEventListener('click', saveSchedule); + runNowBtn?.addEventListener('click', runNow); + repairPreviewBtn?.addEventListener('click', previewRepairCenter); + repairExecuteBtn?.addEventListener('click', executeRepairCenter); + repairRollbackRefreshBtn?.addEventListener('click', loadRepairRollbacks); + + runsBody?.addEventListener('click', (event) => { + const target = event.target.closest('tr[data-run-id]'); + if (!target) return; + const runId = Number(target.dataset.runId || 0); + if (!runId) return; + loadRunDetail(runId).catch((error) => { + toast.error(`加载运行详情失败: ${parseErrorMessage(error)}`); + }); + }); + + checkList?.addEventListener('click', (event) => { + const button = event.target.closest('.selfcheck-repair-btn'); + if (!button) return; + const runId = Number(button.dataset.runId || 0); + const repairKey = String(button.dataset.repairKey || ''); + executeRepair(runId, repairKey, button); + }); + + rollbackBox?.addEventListener('click', (event) => { + const button = event.target.closest('.repair-center-rollback-btn'); + if (!button) return; + const rollbackId = String(button.dataset.rollbackId || ''); + rollbackRepairPoint(rollbackId, button); + }); +} + +function startPolling() { + if (selfcheckState.pollTimer) { + clearInterval(selfcheckState.pollTimer); + } + selfcheckState.pollTimer = setInterval(async () => { + try { + await loadScheduleAndRuntime(); + const running = selfcheckState.runs.some((run) => ['running', 'pending'].includes(String(run.status || '').toLowerCase())); + if (running) { + await loadRuns(); + } + } catch (error) { + // 轮询静默失败,不打断页面交互 + } + }, 5000); +} + +async function initSelfcheckPage() { + bindEvents(); + try { + await loadRepairCatalog(); + await loadScheduleAndRuntime(); + await loadRuns(); + await loadRepairRollbacks(); + } catch (error) { + toast.error(`初始化失败: ${parseErrorMessage(error)}`); + } + startPolling(); +} + +document.addEventListener('DOMContentLoaded', initSelfcheckPage); diff --git a/static/js/settings.js b/static/js/settings.js index 46aba297..de673a43 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -32,12 +32,19 @@ const elements = { // 代理列表 proxiesTable: document.getElementById('proxies-table'), addProxyBtn: document.getElementById('add-proxy-btn'), + proxyBatchImportBtn: document.getElementById('proxy-batch-import-btn'), testAllProxiesBtn: document.getElementById('test-all-proxies-btn'), addProxyModal: document.getElementById('add-proxy-modal'), proxyItemForm: document.getElementById('proxy-item-form'), closeProxyModal: document.getElementById('close-proxy-modal'), cancelProxyBtn: document.getElementById('cancel-proxy-btn'), proxyModalTitle: document.getElementById('proxy-modal-title'), + proxyBatchImportModal: document.getElementById('proxy-batch-import-modal'), + closeProxyBatchImportModal: document.getElementById('close-proxy-batch-import-modal'), + cancelProxyBatchImportBtn: document.getElementById('cancel-proxy-batch-import-btn'), + proxyBatchImportForm: document.getElementById('proxy-batch-import-form'), + proxyBatchImportText: document.getElementById('proxy-batch-import-text'), + proxyBatchImportResult: document.getElementById('proxy-batch-import-result'), // 动态代理设置 dynamicProxyForm: document.getElementById('dynamic-proxy-form'), testDynamicProxyBtn: document.getElementById('test-dynamic-proxy-btn'), @@ -72,8 +79,8 @@ const elements = { emailCodeForm: document.getElementById('email-code-form'), // Outlook 设置 outlookSettingsForm: document.getElementById('outlook-settings-form'), - // Web UI 访问控制 - webuiSettingsForm: document.getElementById('webui-settings-form') + // 系统设置(端口 + 访问控制) + systemSettingsForm: document.getElementById('system-settings-form') }; // 选中的服务 ID @@ -211,6 +218,9 @@ function initEventListeners() { if (elements.addProxyBtn) { elements.addProxyBtn.addEventListener('click', () => openProxyModal()); } + if (elements.proxyBatchImportBtn) { + elements.proxyBatchImportBtn.addEventListener('click', openProxyBatchImportModal); + } if (elements.testAllProxiesBtn) { elements.testAllProxiesBtn.addEventListener('click', handleTestAllProxies); @@ -235,6 +245,22 @@ function initEventListeners() { if (elements.proxyItemForm) { elements.proxyItemForm.addEventListener('submit', handleSaveProxyItem); } + if (elements.closeProxyBatchImportModal) { + elements.closeProxyBatchImportModal.addEventListener('click', closeProxyBatchImportModal); + } + if (elements.cancelProxyBatchImportBtn) { + elements.cancelProxyBatchImportBtn.addEventListener('click', closeProxyBatchImportModal); + } + if (elements.proxyBatchImportModal) { + elements.proxyBatchImportModal.addEventListener('click', (e) => { + if (e.target === elements.proxyBatchImportModal) { + closeProxyBatchImportModal(); + } + }); + } + if (elements.proxyBatchImportForm) { + elements.proxyBatchImportForm.addEventListener('submit', handleProxyBatchImport); + } // 动态代理设置 if (elements.dynamicProxyForm) { @@ -254,8 +280,8 @@ function initEventListeners() { elements.outlookSettingsForm.addEventListener('submit', handleSaveOutlookSettings); } - if (elements.webuiSettingsForm) { - elements.webuiSettingsForm.addEventListener('submit', handleSaveWebuiSettings); + if (elements.systemSettingsForm) { + elements.systemSettingsForm.addEventListener('submit', handleSaveSystemSettings); } // Team Manager 服务管理 if (elements.addTmServiceBtn) { @@ -354,12 +380,19 @@ async function loadSettings() { // 加载 Outlook 设置 loadOutlookSettings(); - // Web UI 访问密码提示 - if (data.webui?.has_access_password) { - const input = document.getElementById('webui-access-password'); - if (input) { - input.value = ''; - input.placeholder = '已配置,留空保持不变'; + // 系统设置(端口 + 访问密码) + const webuiPortInput = document.getElementById('webui-port'); + if (webuiPortInput) { + webuiPortInput.value = data.webui?.port || 1455; + } + const passwordInput = document.getElementById('webui-access-password'); + if (passwordInput) { + if (data.webui?.has_access_password) { + passwordInput.value = ''; + passwordInput.placeholder = '已配置,留空保持不变'; + } else { + passwordInput.value = ''; + passwordInput.placeholder = '未配置,留空表示不修改'; } } @@ -369,22 +402,29 @@ async function loadSettings() { } } -// 保存 Web UI 设置 -async function handleSaveWebuiSettings(e) { +// 保存系统设置(端口 + 访问控制) +async function handleSaveSystemSettings(e) { e.preventDefault(); + const portRaw = document.getElementById('webui-port')?.value; + const parsedPort = parseInt(portRaw, 10); const accessPassword = document.getElementById('webui-access-password').value; - const payload = { - access_password: accessPassword || null - }; + const payload = {}; + if (Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535) { + payload.port = parsedPort; + } + if (accessPassword) { + payload.access_password = accessPassword; + } try { await api.post('/settings/webui', payload); - toast.success('Web UI 设置已更新'); + toast.success('系统设置已更新'); document.getElementById('webui-access-password').value = ''; + await loadSettings(); } catch (error) { - console.error('保存 Web UI 设置失败:', error); - toast.error('保存 Web UI 设置失败'); + console.error('保存系统设置失败:', error); + toast.error('保存系统设置失败'); } } @@ -827,7 +867,7 @@ async function loadProxies() { console.error('加载代理列表失败:', error); elements.proxiesTable.innerHTML = ` - +
加载失败
@@ -843,7 +883,7 @@ function renderProxies(proxies) { if (!proxies || proxies.length === 0) { elements.proxiesTable.innerHTML = ` - +
🌐
暂无代理
@@ -935,6 +975,83 @@ function closeProxyModal() { elements.proxyItemForm.reset(); } +function openProxyBatchImportModal() { + if (!elements.proxyBatchImportModal) return; + if (elements.proxyBatchImportForm) { + elements.proxyBatchImportForm.reset(); + } + if (elements.proxyBatchImportResult) { + elements.proxyBatchImportResult.style.display = 'none'; + elements.proxyBatchImportResult.innerHTML = ''; + } + elements.proxyBatchImportModal.classList.add('active'); +} + +function closeProxyBatchImportModal() { + if (!elements.proxyBatchImportModal) return; + elements.proxyBatchImportModal.classList.remove('active'); +} + +function renderProxyBatchImportResult(result) { + if (!elements.proxyBatchImportResult) return; + const errors = Array.isArray(result?.errors) ? result.errors : []; + const preview = errors.slice(0, 20).map(item => { + const line = Number(item?.line || 0); + const raw = escapeHtml(String(item?.raw || '')); + const error = escapeHtml(String(item?.error || '解析失败')); + return `
  • 第 ${line} 行:${error}${raw ? `(${raw})` : ''}
  • `; + }).join(''); + elements.proxyBatchImportResult.innerHTML = ` +
    + ✅ 新增: ${result.created || 0} + 🔄 更新: ${result.updated || 0} + ⏭️ 跳过: ${result.skipped || 0} + ❌ 失败: ${result.failed || 0} +
    + ${preview ? `
    错误明细(最多 20 条):
      ${preview}
    ` : ''} + `; + elements.proxyBatchImportResult.style.display = ''; +} + +async function handleProxyBatchImport(e) { + e.preventDefault(); + const content = String(elements.proxyBatchImportText?.value || '').trim(); + if (!content) { + toast.warning('请先粘贴代理文本'); + return; + } + + const submitBtn = document.getElementById('submit-proxy-batch-import-btn'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = ' 导入中...'; + } + + try { + const payload = { + content, + default_type: String(document.getElementById('proxy-batch-default-type')?.value || 'http'), + enabled: Boolean(document.getElementById('proxy-batch-import-enabled')?.checked), + overwrite_existing: Boolean(document.getElementById('proxy-batch-overwrite-existing')?.checked), + }; + const result = await api.post('/settings/proxies/batch-import', payload); + renderProxyBatchImportResult(result); + await loadProxies(); + if ((result.failed || 0) > 0) { + toast.warning(`导入完成:新增 ${result.created || 0},更新 ${result.updated || 0},失败 ${result.failed || 0}`); + } else { + toast.success(`导入完成:新增 ${result.created || 0},更新 ${result.updated || 0},跳过 ${result.skipped || 0}`); + } + } catch (error) { + toast.error('批量导入失败: ' + error.message); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = '📥 开始导入'; + } + } +} + // 保存代理 async function handleSaveProxyItem(e) { e.preventDefault(); @@ -1280,19 +1397,20 @@ async function loadCpaServices() { const services = await api.get('/cpa-services'); renderCpaServicesTable(services); } catch (e) { - elements.cpaServicesTable.innerHTML = `${e.message}`; + elements.cpaServicesTable.innerHTML = `${e.message}`; } } function renderCpaServicesTable(services) { if (!services || services.length === 0) { - elements.cpaServicesTable.innerHTML = '暂无 CPA 服务,点击「添加服务」新增'; + elements.cpaServicesTable.innerHTML = '暂无 CPA 服务,点击「添加服务」新增'; return; } elements.cpaServicesTable.innerHTML = services.map(s => ` ${escapeHtml(s.name)} ${escapeHtml(s.api_url)} + ${escapeHtml(s.proxy_url || '-')} ${s.enabled ? '✅' : '⭕'} ${s.priority} @@ -1309,6 +1427,7 @@ function openCpaServiceModal(service = null) { document.getElementById('cpa-service-name').value = service ? service.name : ''; document.getElementById('cpa-service-url').value = service ? service.api_url : ''; document.getElementById('cpa-service-token').value = ''; + document.getElementById('cpa-service-proxy-url').value = service ? (service.proxy_url || '') : ''; document.getElementById('cpa-service-priority').value = service ? service.priority : 0; document.getElementById('cpa-service-enabled').checked = service ? service.enabled : true; elements.cpaServiceModalTitle.textContent = service ? '编辑 CPA 服务' : '添加 CPA 服务'; @@ -1334,6 +1453,7 @@ async function handleSaveCpaService(e) { const name = document.getElementById('cpa-service-name').value.trim(); const apiUrl = document.getElementById('cpa-service-url').value.trim(); const apiToken = document.getElementById('cpa-service-token').value.trim(); + const proxyUrl = document.getElementById('cpa-service-proxy-url').value.trim(); const priority = parseInt(document.getElementById('cpa-service-priority').value) || 0; const enabled = document.getElementById('cpa-service-enabled').checked; @@ -1347,7 +1467,7 @@ async function handleSaveCpaService(e) { } try { - const payload = { name, api_url: apiUrl, priority, enabled }; + const payload = { name, api_url: apiUrl, proxy_url: proxyUrl, priority, enabled }; if (apiToken) payload.api_token = apiToken; if (id) { @@ -1441,7 +1561,7 @@ async function loadSub2ApiServices() { renderSub2ApiServices(services); } catch (e) { if (elements.sub2ApiServicesTable) { - elements.sub2ApiServicesTable.innerHTML = '加载失败'; + elements.sub2ApiServicesTable.innerHTML = '加载失败'; } } } @@ -1449,13 +1569,14 @@ async function loadSub2ApiServices() { function renderSub2ApiServices(services) { if (!elements.sub2ApiServicesTable) return; if (!services || services.length === 0) { - elements.sub2ApiServicesTable.innerHTML = '暂无 Sub2API 服务,点击「添加服务」新增'; + elements.sub2ApiServicesTable.innerHTML = '暂无 Sub2API 服务,点击「添加服务」新增'; return; } elements.sub2ApiServicesTable.innerHTML = services.map(s => ` ${escapeHtml(s.name)} ${escapeHtml(s.api_url)} + ${String(s.target_type || 'sub2api').toLowerCase() === 'newapi' ? 'newApi' : 'Sub2Api'} ${s.enabled ? '✅' : '⭕'} ${s.priority} @@ -1477,7 +1598,10 @@ function openSub2ApiServiceModal(svc = null) { document.getElementById('sub2api-service-url').value = svc.api_url || ''; document.getElementById('sub2api-service-priority').value = svc.priority ?? 0; document.getElementById('sub2api-service-enabled').checked = svc.enabled !== false; + document.getElementById('sub2api-service-target-type').value = String(svc.target_type || 'sub2api').toLowerCase() === 'newapi' ? 'newapi' : 'sub2api'; document.getElementById('sub2api-service-key').placeholder = svc.has_key ? '已配置,留空保持不变' : '请输入 API Key'; + } else { + document.getElementById('sub2api-service-target-type').value = 'sub2api'; } elements.sub2ApiServiceEditModal.classList.add('active'); } @@ -1516,6 +1640,7 @@ async function handleSaveSub2ApiService(e) { name: document.getElementById('sub2api-service-name').value, api_url: document.getElementById('sub2api-service-url').value, api_key: document.getElementById('sub2api-service-key').value || undefined, + target_type: document.getElementById('sub2api-service-target-type').value || 'sub2api', priority: parseInt(document.getElementById('sub2api-service-priority').value) || 0, enabled: document.getElementById('sub2api-service-enabled').checked, }; diff --git a/static/js/utils.js b/static/js/utils.js index 570ce244..b600b7e2 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -175,10 +175,121 @@ const loading = new LoadingManager(); class ApiClient { constructor(baseUrl = '/api') { this.baseUrl = baseUrl; + this.inflightRequests = new Map(); + this.activeRequestCount = 0; + this.maxConcurrentRequests = 6; + this.requestQueue = []; + this.networkOnline = typeof navigator === 'undefined' ? true : navigator.onLine !== false; + this._networkToastState = { type: '', at: 0 }; + this.defaultTimeoutMs = 20000; + this.defaultRetryCount = 1; + this.defaultRetryDelayMs = 900; + this.setupNetworkListeners(); + } + + getAdaptiveTimeoutMs() { + const connection = navigator?.connection || navigator?.mozConnection || navigator?.webkitConnection; + const effectiveType = String(connection?.effectiveType || '').toLowerCase(); + if (effectiveType === 'slow-2g' || effectiveType === '2g') return 45000; + if (effectiveType === '3g') return 30000; + return this.defaultTimeoutMs; + } + + cleanupInflightRequest(requestKey, controller) { + if (!requestKey) return; + const current = this.inflightRequests.get(requestKey); + if (current === controller) { + this.inflightRequests.delete(requestKey); + } + } + + setupNetworkListeners() { + if (typeof window === 'undefined' || !window.addEventListener) return; + window.addEventListener('online', () => { + this.networkOnline = true; + this.notifyNetworkState('网络已恢复', 'success', 2000); + }); + window.addEventListener('offline', () => { + this.networkOnline = false; + this.notifyNetworkState('网络已断开,后台轮询将自动降频', 'warning', 6000); + }); + } + + notifyNetworkState(message, type, throttleMs = 3000) { + const now = Date.now(); + if ( + this._networkToastState.type === type && + now - Number(this._networkToastState.at || 0) < throttleMs + ) { + return; + } + this._networkToastState = { type, at: now }; + if (type === 'warning') { + toast.warning(message, 2500); + return; + } + if (type === 'success') { + toast.success(message, 1800); + return; + } + toast.info(message, 2000); + } + + runWithConcurrency(task, priority = 'normal') { + return new Promise((resolve, reject) => { + const run = async () => { + this.activeRequestCount += 1; + try { + const result = await task(); + resolve(result); + } catch (error) { + reject(error); + } finally { + this.activeRequestCount = Math.max(0, this.activeRequestCount - 1); + this.flushQueue(); + } + }; + + if (this.activeRequestCount < this.maxConcurrentRequests) { + run(); + return; + } + + if (priority === 'high') { + this.requestQueue.unshift(run); + } else { + this.requestQueue.push(run); + } + }); + } + + flushQueue() { + while (this.activeRequestCount < this.maxConcurrentRequests && this.requestQueue.length > 0) { + const next = this.requestQueue.shift(); + if (typeof next === 'function') { + next(); + } + } + } + + sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); } async request(path, options = {}) { const url = `${this.baseUrl}${path}`; + const { + timeoutMs, + retry, + retryDelayMs, + requestKey, + cancelPrevious, + priority, + silentNetworkError, + silentTimeoutError, + signal: externalSignal, + ...rawFetchOptions + } = options; const defaultOptions = { headers: { @@ -186,31 +297,111 @@ class ApiClient { }, }; - const finalOptions = { ...defaultOptions, ...options }; + const finalOptions = { ...defaultOptions, ...rawFetchOptions }; + const mergedHeaders = { + ...(defaultOptions.headers || {}), + ...(rawFetchOptions.headers || {}), + }; + if (Object.keys(mergedHeaders).length) { + finalOptions.headers = mergedHeaders; + } + + const effectiveTimeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : this.getAdaptiveTimeoutMs(); + const retryCount = Number.isInteger(retry) ? retry : this.defaultRetryCount; + const retryWaitMs = Number(retryDelayMs) > 0 ? Number(retryDelayMs) : this.defaultRetryDelayMs; + const requestPriority = String(priority || '').toLowerCase() || 'normal'; + const allowSilentNetworkError = Boolean(silentNetworkError); + const allowSilentTimeoutError = Boolean(silentTimeoutError); if (finalOptions.body && typeof finalOptions.body === 'object') { finalOptions.body = JSON.stringify(finalOptions.body); } - try { - const response = await fetch(url, finalOptions); - const data = await response.json().catch(() => ({})); - - if (!response.ok) { - const error = new Error(data.detail || `HTTP ${response.status}`); - error.response = response; - error.data = data; - throw error; + const runner = async () => { + for (let attempt = 0; attempt <= retryCount; attempt += 1) { + let timedOut = false; + let timeoutId = null; + const controller = new AbortController(); + + if (requestKey && cancelPrevious) { + const previousController = this.inflightRequests.get(requestKey); + if (previousController) { + previousController.__cancelReason = 'request_replaced'; + previousController.abort(); + } + } + if (requestKey) { + this.inflightRequests.set(requestKey, controller); + } + + if (externalSignal) { + if (externalSignal.aborted) { + controller.abort(); + } else { + externalSignal.addEventListener('abort', () => controller.abort(), { once: true }); + } + } + + if (effectiveTimeoutMs > 0) { + timeoutId = setTimeout(() => { + timedOut = true; + controller.__cancelReason = 'timeout'; + controller.abort(); + }, effectiveTimeoutMs); + } + + try { + if (!this.networkOnline && requestPriority === 'low') { + const offlineError = new Error('网络离线,后台请求已跳过'); + offlineError.name = 'NetworkOfflineError'; + throw offlineError; + } + + const response = await fetch(url, { ...finalOptions, signal: controller.signal }); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const error = new Error(data.detail || `HTTP ${response.status}`); + error.response = response; + error.data = data; + throw error; + } + + return data; + } catch (error) { + const isAbortError = error?.name === 'AbortError'; + const cancelReason = controller.__cancelReason || ''; + const isExpectedAbort = isAbortError && (cancelReason === 'request_replaced' || externalSignal?.aborted); + const isTimeoutError = isAbortError && (timedOut || cancelReason === 'timeout'); + const isOfflineError = error?.name === 'NetworkOfflineError'; + const isNetworkError = !error.response && !isAbortError && !isOfflineError; + const canRetry = attempt < retryCount && (isTimeoutError || isNetworkError || (error?.response?.status >= 500)); + if (isAbortError) { + error.cancelReason = cancelReason || (externalSignal?.aborted ? 'external_abort' : ''); + } + + if (canRetry) { + await this.sleep(retryWaitMs * (attempt + 1)); + continue; + } + + if (isTimeoutError && !allowSilentTimeoutError) { + this.notifyNetworkState('请求超时,请检查网络后重试', 'warning', 3500); + } else if ((isNetworkError || isOfflineError) && !allowSilentNetworkError) { + this.notifyNetworkState('网络连接异常,请检查网络', 'warning', 3500); + } else if (isExpectedAbort) { + // 同类请求被新请求替代,属于预期行为,不提示错误 + } + + throw error; + } finally { + if (timeoutId) clearTimeout(timeoutId); + this.cleanupInflightRequest(requestKey, controller); + } } + }; - return data; - } catch (error) { - // 网络错误处理 - if (!error.response) { - toast.error('网络连接失败,请检查网络'); - } - throw error; - } + return this.runWithConcurrency(runner, requestPriority); } get(path, options = {}) { @@ -236,6 +427,131 @@ class ApiClient { const api = new ApiClient(); +// ============================================ +// 弱网轮询与筛选协议 +// ============================================ + +class AdaptivePoller { + constructor(options = {}) { + const base = Number(options.baseIntervalMs ?? options.baseMs ?? 1200); + const max = Number(options.maxIntervalMs ?? options.maxMs ?? 12000); + this.baseIntervalMs = Math.max(300, Number.isFinite(base) ? base : 1200); + this.maxIntervalMs = Math.max(this.baseIntervalMs, Number.isFinite(max) ? max : 12000); + this.minIntervalMs = Math.max(250, Math.min(this.baseIntervalMs, Number(options.minIntervalMs || this.baseIntervalMs))); + this.jitterRatio = Math.min(0.2, Math.max(0, Number(options.jitterRatio || 0.08))); + this.failureCount = 0; + this.successCount = 0; + this.lastDelayMs = this.baseIntervalMs; + } + + getConnectionMultiplier() { + const connection = navigator?.connection || navigator?.mozConnection || navigator?.webkitConnection; + const effectiveType = String(connection?.effectiveType || '').toLowerCase(); + if (effectiveType === 'slow-2g' || effectiveType === '2g') return 3.0; + if (effectiveType === '3g') return 1.8; + if (connection?.saveData) return 1.5; + return 1.0; + } + + recordSuccess() { + this.failureCount = Math.max(0, this.failureCount - 1); + this.successCount = Math.min(8, this.successCount + 1); + } + + recordError() { + this.failureCount = Math.min(8, this.failureCount + 1); + this.successCount = 0; + } + + nextDelay(options = {}) { + const forceSlow = Boolean(options.forceSlow); + let delay = this.baseIntervalMs * this.getConnectionMultiplier(); + if (!api.networkOnline || forceSlow) { + delay = Math.max(delay, this.baseIntervalMs * 2.5); + } + if (this.failureCount > 0) { + delay *= Math.pow(1.55, Math.min(this.failureCount, 5)); + } else if (this.successCount >= 3) { + delay *= 0.88; + } + delay = Math.max(this.minIntervalMs, Math.min(this.maxIntervalMs, Math.round(delay))); + const jitter = Math.round(delay * this.jitterRatio * (Math.random() * 2 - 1)); + this.lastDelayMs = Math.max(this.minIntervalMs, Math.min(this.maxIntervalMs, delay + jitter)); + return this.lastDelayMs; + } +} + +function createAdaptivePoller(options = {}) { + return new AdaptivePoller(options); +} + +const filterProtocol = { + normalizeValue(value) { + if (value === null || value === undefined) return null; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (typeof value === 'boolean') { + return value; + } + if (Array.isArray(value)) { + const normalized = value + .map((item) => this.normalizeValue(item)) + .filter((item) => item !== null); + return normalized.length ? normalized : null; + } + return value; + }, + + normalize(filters = {}) { + const result = {}; + Object.entries(filters || {}).forEach(([key, raw]) => { + const value = this.normalizeValue(raw); + if (value === null) return; + result[key] = value; + }); + return result; + }, + + toQuery(filters = {}, mapping = {}) { + const normalized = this.normalize(filters); + const params = new URLSearchParams(); + Object.entries(normalized).forEach(([key, value]) => { + const targetKey = String(mapping[key] || key); + if (!targetKey) return; + if (Array.isArray(value)) { + value.forEach((item) => params.append(targetKey, String(item))); + return; + } + params.set(targetKey, String(value)); + }); + return params; + }, + + toPayload(filters = {}, mapping = {}) { + const normalized = this.normalize(filters); + const payload = {}; + Object.entries(normalized).forEach(([key, value]) => { + const targetKey = String(mapping[key] || key); + if (!targetKey) return; + payload[targetKey] = value; + }); + return payload; + }, + + pickSort(value, allowed = [], fallback = '') { + const candidate = String(value || '').trim(); + return allowed.includes(candidate) ? candidate : fallback; + }, +}; + +window.createAdaptivePoller = createAdaptivePoller; +window.filterProtocol = filterProtocol; + // ============================================ // 事件委托助手 // ============================================ @@ -350,7 +666,6 @@ const statusMap = { }, service: { tempmail: 'Tempmail.lol', - yyds_mail: 'YYDS Mail', outlook: 'Outlook', moe_mail: 'MoeMail', temp_mail: 'Temp-Mail(自部署)', diff --git a/templates/accounts.html b/templates/accounts.html index f16f7c6d..803154bd 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -7,6 +7,32 @@ - {% include "partials/site_notice.html" %}
    @@ -154,6 +294,14 @@

    账号管理

    0
    失败账号
    +
    +
    0
    +
    母号账号
    +
    +
    +
    0
    +
    子号账号
    +
    @@ -171,18 +319,21 @@

    账号管理

    + +
    - @@ -192,6 +343,12 @@

    账号管理

    + + +
    @@ -341,7 +502,69 @@

    🔗 选择 Sub2API 服务

    + + + + + + + + + + diff --git a/templates/accounts_overview.html b/templates/accounts_overview.html index b0c603f6..50af737a 100644 --- a/templates/accounts_overview.html +++ b/templates/accounts_overview.html @@ -63,7 +63,7 @@ .cards-block { margin-top: 0; - background: linear-gradient(130deg, #edf1ff 0%, #eef6f2 55%, #f8fafc 100%); + background: linear-gradient(130deg, #eef2ff 0%, #edf4ff 55%, #f8fafc 100%); border: 1px solid #d8e2f2; border-radius: 22px; padding: 16px; @@ -150,7 +150,7 @@ } .view-toggle-btn#view-grid-btn { - color: #7c3aed; + color: var(--primary-color); } .view-toggle-btn#view-grid-btn.active, @@ -159,9 +159,9 @@ } .view-toggle-btn.active { - background: linear-gradient(120deg, #6d28d9, #8b5cf6); + background: var(--brand-gradient); color: #fff; - box-shadow: 0 4px 12px rgba(109, 40, 217, 0.35); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.35); } .cards-icon-btn { @@ -179,8 +179,8 @@ .cards-icon-btn.primary { border: 0; color: #fff; - background: linear-gradient(120deg, #6d28d9, #8b5cf6); - box-shadow: 0 6px 14px rgba(109, 40, 217, 0.38); + background: var(--brand-gradient); + box-shadow: 0 6px 14px rgba(99, 102, 241, 0.38); } .cards-status-line { @@ -304,7 +304,7 @@ } .status-pill.current { - background: #22c55e; + background: var(--primary-color); } .plan-badge { @@ -320,11 +320,11 @@ } .plan-badge.plus { - background: #10b981; + background: var(--brand-gradient); } .plan-badge.team { - background: linear-gradient(120deg, #6d28d9, #8b5cf6); + background: var(--brand-gradient); } .plan-badge.free { @@ -370,8 +370,8 @@ transition: width .2s ease; } - .tone-green { color: #22c55e; } - .tone-green-bar { background: #22c55e; } + .tone-green { color: var(--primary-color); } + .tone-green-bar { background: var(--primary-color); } .tone-orange { color: #f59e0b; } .tone-orange-bar { background: #f59e0b; } .tone-red { color: #ef4444; } @@ -427,9 +427,9 @@ .card-action-btn:active { transform: scale(0.92); - background: #ddf3eb; - border-color: #86cfb8; - color: #0f8f70; + background: #e8efff; + border-color: #99b2ff; + color: #3759f0; } .card-action-btn:focus { @@ -438,8 +438,8 @@ .card-action-btn:focus-visible { outline: none; - border-color: #10a37f; - box-shadow: 0 0 0 3px rgba(16, 163, 127, 0.2); + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-light); } .card-action-btn.danger { @@ -449,15 +449,28 @@ .card-action-btn.is-loading { color: transparent; pointer-events: none; - background: #e7f7f2; - border-color: #86cfb8; - box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.16); + background: #e8efff; + border-color: #99b2ff; + box-shadow: 0 0 0 2px rgba(77, 107, 255, 0.16); } .card-action-btn.is-loading::after { content: "⟳"; display: inline-block; - color: #0f8f70; + color: #3759f0; + animation: cardRefreshSpin .75s linear infinite; + } + + .cards-icon-btn.is-loading { + pointer-events: none; + color: transparent; + position: relative; + } + + .cards-icon-btn.is-loading::after { + content: "⟳"; + position: absolute; + color: #3759f0; animation: cardRefreshSpin .75s linear infinite; } @@ -500,7 +513,6 @@ - {% include "partials/site_notice.html" %}
    @@ -600,6 +615,7 @@

    Codex 账号管理

    + @@ -619,6 +635,10 @@

    Codex 账号管理

    +
    @@ -739,3 +759,10 @@

    一键导入账号

    + + + + + + + diff --git a/templates/auto_team.html b/templates/auto_team.html index c15d2c4b..263d2355 100644 --- a/templates/auto_team.html +++ b/templates/auto_team.html @@ -3,27 +3,836 @@ - 自动进team - OpenAI 注册系统 + team - OpenAI 注册系统 - {% include "partials/site_notice.html" %}
    -
    功能未开发!
    + +
    + + +
    + +
    +
    +
    +
    +

    Team 自动邀请

    +
    +
    +
    + 流程:输入目标邮箱 -> 执行邀请(系统会自动进行轻量预检,仅使用 Team 管理账号作为母号发送邀请)。 +
    + +
    + +
    + + +
    +
    + +
    + + +
    + +
    + + +
    + + +
    +
    + +
    +
    +

    Team 管理账号列表(自动入池)

    +
    + + +
    +
    +
    +
    +
    正在加载可用 Team 邀请管理账号...
    +
    +
    +
    + +
    +
    +

    执行日志

    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    👥
    +
    +
    0
    +
    Team 总数
    +
    +
    +
    +
    +
    +
    0
    +
    可用 Team
    +
    +
    +
    + +
    +
    +

    Team 列表

    +
    + + +
    + +
    + + + +
    +
    + + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + +
    ID邮箱TEAM 名称成员数订阅计划到期时间状态操作
    正在加载...
    +
    +
    +
    +
    +
    +
    +

    选择目标邮箱账号(仅 free)

    + +
    +
    + + + +
    +
    +
    正在加载...
    +
    + +
    +
    + +
    +
    +
    +

    手动拉入 Team 管理池

    + +
    +
    + + + +
    +
    +
    正在加载候选账号...
    +
    + +
    +
    + +
    +
    +
    +
    +

    Team 成员管理

    +
    -
    +
    + +
    +
    +
    +
    + +
    + + +
    +
    +
    + +
    +

    已加入成员

    +
    + + + + + + + + + + + + +
    邮箱角色加入时间操作
    加载中...
    +
    +
    + +
    +

    待加入成员(邀请中)

    +
    + + + + + + + + + + + + +
    邮箱角色邀请时间操作
    加载中...
    +
    +
    +
    + +
    +
    + +
    +
    +
    +

    导入 Team

    + +
    +
    +
    + + +
    + +
    +
    + + +
    必填项,以 eyJ 开头的 JWT Token
    +
    +
    + + +
    可选,用于自动刷新 AT
    +
    +
    + + +
    可选,作为备选刷新方式
    +
    +
    + + +
    使用 Refresh Token 时建议填写
    +
    +
    + + +
    可选,不填将尝试从 AT 中自动提取
    +
    +
    + + +
    可选,不填将尝试从 AT 中自动提取
    +
    +
    + +
    +
    + + +
    每条至少要有 email + access_token;可附带 refresh_token/session_token/client_id/account_id。
    +
    +
    +
    + +
    +
    + + + + + + + + + diff --git a/templates/card_pool.html b/templates/card_pool.html index 90eb878b..d2d5e011 100644 --- a/templates/card_pool.html +++ b/templates/card_pool.html @@ -6,24 +6,223 @@ 卡池 - OpenAI 注册系统 - {% include "partials/site_notice.html" %}
    + +
    + +
    -
    功能未开发!
    + +
    +
    +
    +
    0
    +
    兑换码总数
    +
    +
    +
    0
    +
    未使用
    +
    +
    +
    0
    +
    已使用
    +
    +
    +
    0
    +
    已过期
    +
    +
    + +
    +
    +
    +
    + + + + +
    + + +
    + + +
    +
    +
    + + + +
    +
    +
    + +
    +
    +
    + + + + + + + + + + + + + + + + + + + +
    兑换码供应商状态创建时间过期时间使用者邮箱使用时间操作
    暂无兑换码,请点击「导入」添加。
    +
    + +
    +
    +
    + +
    +
    + 信用卡分类已预留,暂不启用功能。 +
    +
    + + + + + + + + + + + + + + diff --git a/templates/email_services.html b/templates/email_services.html index e69b5f4b..09de718d 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -8,7 +8,6 @@ - {% include "partials/site_notice.html" %}
    @@ -98,7 +97,7 @@

    📥 Outlook 批量导入

    - +

    🔗 自定义邮箱服务

    @@ -177,60 +176,22 @@

    📧 Outlook 账户列表

    🌐 临时邮箱配置

    -
    -
    -
    -

    Tempmail.lol

    -
    -
    -
    - - -
    -
    - -
    -
    - - -
    -
    -
    - -
    -
    -

    YYDS Mail

    -
    -
    -
    - - -
    -
    - - - 使用文档中的 `X-API-Key` 调用方式 -
    -
    - - -
    -
    - -
    -
    - - -
    -
    -
    -
    +
    +
    + + +
    +
    + +
    +
    + + +
    +
    @@ -253,8 +214,8 @@

    ➕ 添加自定义邮箱服务

    - - + - - +