From b1cc714d7550f10efdf9e0be039bf50647737ea2 Mon Sep 17 00:00:00 2001 From: doujiang <527191253@qq.com> Date: Tue, 31 Mar 2026 22:34:25 +0800 Subject: [PATCH 1/2] release: prepare v1.1.2 --- .env.example | 7 + .github/workflows/build.yml | 8 + .github/workflows/tests.yml | 31 + README.md | 264 +- alembic.ini | 38 + alembic/README.md | 25 + alembic/env.py | 61 + alembic/script.py.mako | 26 + alembic/versions/.gitkeep | 1 + docs/assets/alipay-pay.png | Bin 0 -> 280223 bytes docs/assets/wechat-pay.png | Bin 0 -> 103777 bytes pyproject.toml | 3 +- release/v1.1.2.md | 34 + requirements.txt | 1 + src/__init__.py | 23 +- src/config/__init__.py | 2 + src/config/constants.py | 78 +- src/config/project_notice.py | 21 +- src/config/settings.py | 230 +- src/core/__init__.py | 33 +- src/core/anyauto/__init__.py | 0 src/core/anyauto/chatgpt_client.py | 911 +++++ src/core/anyauto/oauth_client.py | 1614 ++++++++ src/core/anyauto/register_flow.py | 405 ++ src/core/anyauto/sentinel_token.py | 206 + src/core/anyauto/utils.py | 362 ++ src/core/auto_registration.py | 297 ++ src/core/circuit_breaker.py | 212 + src/core/db_logs.py | 4 +- src/core/openai/overview.py | 106 +- src/core/openai/payment.py | 96 +- src/core/openai/token_refresh.py | 7 +- src/core/register.py | 220 +- src/core/system_selfcheck.py | 1311 ++++++ src/core/timezone_utils.py | 6 +- src/core/upload/cpa_upload.py | 67 +- src/core/upload/new_api_upload.py | 225 ++ src/core/utils.py | 9 +- src/database/__init__.py | 14 +- src/database/crud.py | 535 ++- src/database/models.py | 201 +- src/database/session.py | 175 + src/services/__init__.py | 6 + src/services/luckmail_mail.py | 1051 +++++ src/services/outlook/account.py | 2 +- src/services/outlook_legacy_mail.py | 6 +- src/services/temp_mail.py | 122 +- src/services/tempmail.py | 2 +- src/services/yyds_mail.py | 448 +++ src/web/app.py | 370 +- src/web/auth.py | 95 + src/web/auto_quick_refresh_scheduler.py | 304 ++ src/web/repositories/__init__.py | 4 + src/web/repositories/account_repository.py | 56 + src/web/routes/__init__.py | 8 + src/web/routes/accounts.py | 252 +- src/web/routes/auto_team.py | 3516 +++++++++++++++++ src/web/routes/email.py | 142 +- src/web/routes/logs.py | 6 +- src/web/routes/payment.py | 102 +- src/web/routes/registration.py | 1119 +++++- src/web/routes/selfcheck.py | 440 +++ src/web/routes/settings.py | 232 +- src/web/routes/tasks.py | 193 + src/web/routes/upload/cpa_services.py | 5 +- src/web/routes/upload/new_api_services.py | 199 + src/web/routes/upload/sub2api_services.py | 5 +- src/web/routes/upload/tm_services.py | 5 +- src/web/routes/websocket.py | 9 + src/web/schedule_utils.py | 106 + src/web/scheduler.py | 125 + src/web/selfcheck_scheduler.py | 247 ++ src/web/services/__init__.py | 4 + src/web/services/accounts_service.py | 17 + src/web/task_manager.py | 278 +- static/css/style.css | 434 +- static/js/accounts.js | 1146 +++++- static/js/accounts_overview.js | 251 +- static/js/app.js | 1075 ++++- static/js/auto_team.js | 990 +++++ static/js/auto_team_manage.js | 1172 ++++++ static/js/card_pool.js | 831 ++++ static/js/email_services.js | 227 +- static/js/payment.js | 1251 +++++- static/js/selfcheck.js | 559 +++ static/js/settings.js | 360 +- static/js/utils.js | 352 +- tags/v1.1.2.json | 1 + templates/accounts.html | 284 +- templates/accounts_overview.html | 75 +- templates/auto_team.html | 1146 +++++- templates/card_pool.html | 402 +- templates/email_services.html | 175 +- templates/index.html | 258 +- templates/login.html | 13 + templates/logs.html | 12 +- templates/partials/site_footer.html | 25 + templates/partials/site_notice.html | 47 +- templates/payment.html | 224 +- templates/selfcheck.html | 349 ++ templates/settings.html | 179 +- templates/setup_password.html | 84 + tests/test_auto_quick_refresh_routes.py | 125 + tests/test_auto_registration_merge.py | 105 + tests/test_email_service_yyds_routes.py | 137 + tests/test_frontend_new_api_presence.py | 23 + tests/test_new_api_services.py | 17 + tests/test_new_api_upload.py | 117 + tests/test_registration_new_api.py | 117 + tests/test_registration_password_hardening.py | 51 + tests/test_retry_policy_markers.py | 21 + tests/test_security_and_task_routes.py | 35 + .../test_settings_registration_auto_fields.py | 149 + tests/test_yyds_mail_service.py | 147 + webui.py | 59 +- 115 files changed, 28970 insertions(+), 1400 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 alembic.ini create mode 100644 alembic/README.md create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/.gitkeep create mode 100644 docs/assets/alipay-pay.png create mode 100644 docs/assets/wechat-pay.png create mode 100644 release/v1.1.2.md create mode 100644 src/core/anyauto/__init__.py create mode 100644 src/core/anyauto/chatgpt_client.py create mode 100644 src/core/anyauto/oauth_client.py create mode 100644 src/core/anyauto/register_flow.py create mode 100644 src/core/anyauto/sentinel_token.py create mode 100644 src/core/anyauto/utils.py create mode 100644 src/core/auto_registration.py create mode 100644 src/core/circuit_breaker.py create mode 100644 src/core/system_selfcheck.py create mode 100644 src/core/upload/new_api_upload.py create mode 100644 src/services/luckmail_mail.py create mode 100644 src/services/yyds_mail.py create mode 100644 src/web/auth.py create mode 100644 src/web/auto_quick_refresh_scheduler.py create mode 100644 src/web/repositories/__init__.py create mode 100644 src/web/repositories/account_repository.py create mode 100644 src/web/routes/auto_team.py create mode 100644 src/web/routes/selfcheck.py create mode 100644 src/web/routes/tasks.py create mode 100644 src/web/routes/upload/new_api_services.py create mode 100644 src/web/schedule_utils.py create mode 100644 src/web/scheduler.py create mode 100644 src/web/selfcheck_scheduler.py create mode 100644 src/web/services/__init__.py create mode 100644 src/web/services/accounts_service.py create mode 100644 static/js/auto_team.js create mode 100644 static/js/auto_team_manage.js create mode 100644 static/js/card_pool.js create mode 100644 static/js/selfcheck.js create mode 100644 tags/v1.1.2.json create mode 100644 templates/partials/site_footer.html create mode 100644 templates/selfcheck.html create mode 100644 templates/setup_password.html create mode 100644 tests/test_auto_quick_refresh_routes.py create mode 100644 tests/test_auto_registration_merge.py create mode 100644 tests/test_email_service_yyds_routes.py create mode 100644 tests/test_frontend_new_api_presence.py create mode 100644 tests/test_new_api_services.py create mode 100644 tests/test_new_api_upload.py create mode 100644 tests/test_registration_new_api.py create mode 100644 tests/test_registration_password_hardening.py create mode 100644 tests/test_retry_policy_markers.py create mode 100644 tests/test_security_and_task_routes.py create mode 100644 tests/test_settings_registration_auto_fields.py create mode 100644 tests/test_yyds_mail_service.py 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/build.yml b/.github/workflows/build.yml index 803b6333..594c8160 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,9 +25,15 @@ jobs: - os: ubuntu-latest source_name: codex-console asset_name: codex-console-linux-x64 + - os: ubuntu-24.04-arm + source_name: codex-console + asset_name: codex-console-linux-arm64 - os: macos-latest source_name: codex-console asset_name: codex-console-macos-arm64 + - os: macos-26-intel + source_name: codex-console + asset_name: codex-console-macos-amd64 steps: - name: Checkout code @@ -89,8 +95,10 @@ jobs: | Platform | File | |------|------| | Windows x64 | `codex-console-windows-x64.exe` | + | Linux ARM64 | `codex-console-linux-arm64` | | Linux x64 | `codex-console-linux-x64` | | macOS ARM64 | `codex-console-macos-arm64` | + | macOS AMD64 | `codex-console-macos-amd64` | ### Usage ```bash 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/README.md b/README.md index 520234b4..cbd34484 100644 --- a/README.md +++ b/README.md @@ -1,188 +1,136 @@ # codex-console -基于 [cnlimiter/codex-manager](https://github.com/cnlimiter/codex-manager) 持续修复和维护的增强版本。 - -这个版本的目标很直接: 把近期 OpenAI 注册链路里那些“昨天还能跑,今天突然翻车”的坑补上,让注册、登录、拿 token、打包运行都更稳一点。 - [![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/) +[![Version](https://img.shields.io/badge/version-v1.1.2-2563eb.svg)](https://github.com/dou-jiang/codex-console/releases) -- 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) - -## 致谢 - -首先感谢上游项目作者 [cnlimiter](https://github.com/cnlimiter) 提供的优秀基础工程。 - -本仓库是在原项目思路和结构之上进行兼容性修复、流程调整和体验优化,适合作为一个“当前可用的修复维护版”继续使用。 - -## 版本更新 - -### v1.0 - -1. 新增 Sentinel POW 求解逻辑 - OpenAI 现在会强制校验 Sentinel POW,原先直接传空值已经不行了,这里补上了实际求解流程。 - -2. 注册和登录拆成两段 - 现在注册完成后通常不会直接返回可用 token,而是跳转到绑定手机或后续页面。 - 本分支改成“先注册成功,再单独走一次登录流程拿 token”,避免卡死在旧逻辑里。 - -3. 去掉重复发送验证码 - 登录流程里服务端本身会自动发送验证码邮件,旧逻辑再手动发一次,容易让新旧验证码打架。 - 现在改成直接等待系统自动发来的那封验证码邮件。 - -4. 修复重新登录流程的页面判断问题 - 针对重新登录时页面流转变化,调整了登录入口和密码提交逻辑,减少卡在错误页面的情况。 - -5. 优化终端和 Web UI 提示文案 - 保留可读性的前提下,把一些提示改得更友好一点,出错时至少不至于像在挨骂。 - -### v1.1 - -1. 修复注册流程中的问题,解决 Outlook 和临时邮箱收不到邮件导致注册卡住、无法完成注册的问题。 - -2. 修复无法检查订阅状态的问题,提升订阅识别和状态检查的可用性。 - -3. 新增绑卡半自动模式,支持自动随机地址;3DS 无法跳过,需按实际流程完成验证。 +基于 [cnlimiter/codex-manager](https://github.com/cnlimiter/codex-manager) 持续修复、整合并增强的维护版本,重点补齐当前 OpenAI / Codex CLI 注册链路、任务调度、邮箱服务、支付绑定、系统自检与 Web 管理能力。 -4. 新增已订阅账号管理功能,支持查看和管理账号额度。 +项目地址: -5. 新增后台日志功能,并补充数据导出与导入能力,方便排查问题和迁移数据。 +- GitHub: [https://github.com/dou-jiang/codex-console](https://github.com/dou-jiang/codex-console) +- Blog: [https://blog.cysq8.cn/](https://blog.cysq8.cn/) -6. 优化部分 UI 细节与交互体验,减少页面操作时的割裂感。 +## v1.1.2 更新内容 -7. 补充细节稳定性处理,尽量减少注册、订阅检测和账号管理过程中出现卡住或误判的情况。 +`v1.1.2` 主要整合并落地了多组 PR 与后续修复,当前版本重点包括: -### v1.1.1 +- 统一鉴权与安全基线:新增 `auth.py`、首次改密页、API 与 WebSocket 鉴权统一收口。 +- 自动注册链路增强:补齐自动补货、库存监控、批次取消感知、注册重试与 PR60 anyauto V2 回退链路。 +- 系统自检与修复中心:新增自检路由、页面、调度器和运行记录。 +- 统一任务中心:将注册、支付、自检、Auto Team 等任务状态统一管理。 +- 周期任务调度:支持计划任务创建、启停、立即执行与前端管理。 +- New-API 上传:支持独立服务配置、测试、单账号和批量上传,以及注册成功后自动上传。 +- Auto Team 模块:新增后端接口与前端管理页面。 +- 卡池与绑卡能力:增加卡池管理、上游对接与自动绑卡支持。 +- 邮箱服务扩展:补齐 CloudMail、LuckMail、YYDS Mail 等邮箱服务,并保留原有邮件链路。 +- 数据库迁移体系:引入 Alembic,补齐迁移目录与说明。 +- 测试与 CI:补充多组接口、任务、注册与上传相关测试,并加入测试工作流。 -1. 新增 `CloudMail` 邮箱服务实现,并完成服务注册、配置接入、邮件轮询、验证码提取和基础收件处理能力。 - -2. 新增上传目标 `newApi` 支持,可根据配置选择不同导入目标类型。 - -3. 新增 `Codex` 账号导出格式,支持后续登录、迁移和导入使用。 - -4. 新增 `CPA` 认证文件 `proxy_url` 支持,现可在 CPA 服务配置中保存和使用代理地址。 - -5. 优化 OAuth token 刷新兼容逻辑,完善异常返回与一次性令牌场景处理,降低刷新报错概率。 - -6. 优化批量验证流程,改为受控并发执行,减少长时间阻塞和卡死问题。 - -7. 修复模板渲染兼容问题,提升不同 Starlette 版本下页面渲染稳定性。 - -8. 修复六位数字误判为 OTP 的问题,避免邮箱域名或无关文本中的六位数字被错误识别为验证码。 +## 核心能力 -9. 新增 Outlook 账户“注册状态”识别与展示功能,可直接看到“已注册/未注册”,并支持显示关联账号编号(如“已注册 #1”)。 +- Web UI 管理注册、支付、账号、自检、日志、卡池、Auto Team 等功能。 +- 支持单任务、批量任务、自动补货、定时任务、任务暂停/继续/取消/重试。 +- 支持多种邮箱服务:Tempmail、YYDS Mail、Outlook、CloudMail、LuckMail、DuckMail、Freemail、IMAP、Temp-Mail 自部署等。 +- 支持支付链接、绑卡任务、卡池管理、账户快照保留、审计日志。 +- 支持 CPA、Sub2API、Team Manager、New-API 等上传链路。 +- 支持 SQLite 与 PostgreSQL。 +- 支持 Windows / Linux / macOS 打包。 -10. 修复 Outlook 邮箱匹配大小写问题,避免 Outlook.com 因大小写差异被误判为未注册。 +## Blog 说明 -11. 修复 Outlook 列表列错位、乱码和占位文案问题,恢复中文显示并优化列表信息布局。 +维护者会在 Blog 持续更新以下内容: -12. 优化 WebUI 端口冲突处理,默认端口占用时自动切换可用端口。 +- 部署教程与环境配置说明 +- 版本更新日志与 Release 说明 +- 常见问题、报错排查和修复记录 +- 邮箱服务、上传服务、任务调度等功能的使用说明 +- 与上游变化相关的兼容性调整说明 -13. 增加启动时轻量字段迁移逻辑,自动补齐新增字段,提升旧数据升级兼容性。 +访问地址: -14. 批量注册上限由 `100` 提升至 `1000`(前后端同步)。 +- [https://blog.cysq8.cn/](https://blog.cysq8.cn/) -15. 公告区改为固定文案与固定链接,强化“永久免费开源、禁止倒卖、付费请退款”提示,并新增爱发电支持入口。 +## 赞助支持 -## 核心能力 +如果这个项目对你有帮助,欢迎赞助支持项目继续维护与更新。 -- Web UI 管理注册任务和账号数据 -- 支持批量注册、日志实时查看、基础任务管理 -- 支持多种邮箱服务接码 -- 支持 SQLite 和远程 PostgreSQL -- 支持打包为 Windows/Linux/macOS 可执行文件 -- 更适配当前 OpenAI 注册与登录链路 + + + + + +
+ 微信赞助
+ 微信赞助二维码 +
+ 支付宝赞助
+ 支付宝赞助二维码 +
## 环境要求 - Python 3.10+ -- `uv`(推荐)或 `pip` +- 推荐使用 `uv` ## 安装依赖 ```bash -# 使用 uv(推荐) +# 推荐 uv sync # 或使用 pip pip install -r requirements.txt ``` -## 环境变量配置 - -可选。复制 `.env.example` 为 `.env` 后按需修改: +## 快速开始 ```bash -cp .env.example .env -``` - -常用变量如下: - -| 变量 | 说明 | 默认值 | -| --- | --- | --- | -| `APP_HOST` | 监听主机 | `0.0.0.0` | -| `APP_PORT` | 监听端口 | `8000` | -| `APP_ACCESS_PASSWORD` | Web UI 访问密钥 | `admin123` | -| `APP_DATABASE_URL` | 数据库连接字符串 | `data/database.db` | - -优先级: - -`命令行参数 > 环境变量(.env) > 数据库设置 > 默认值` - -## 启动 Web UI - -```bash -# 默认启动(127.0.0.1:8000) +# 默认启动 python webui.py -# 指定地址和端口 +# 指定监听地址和端口 python webui.py --host 0.0.0.0 --port 8080 -# 调试模式(热重载) +# 调试模式 python webui.py --debug -# 设置 Web UI 访问密钥 -python webui.py --access-password mypassword - -# 组合参数 -python webui.py --host 0.0.0.0 --port 8080 --access-password mypassword +# 设置 Web UI 访问密码 +python webui.py --access-password your_password ``` -说明: +启动后访问: -- `--access-password` 的优先级高于数据库中的密钥设置 -- 该参数只对本次启动生效 -- 打包后的 exe 也支持这个参数 +- [http://127.0.0.1:8000](http://127.0.0.1:8000) -例如: +## 常用环境变量 + +复制 `.env.example` 为 `.env` 后按需修改: ```bash -codex-console.exe --access-password mypassword +cp .env.example .env ``` -启动后访问: - -[http://127.0.0.1:8000](http://127.0.0.1:8000) +| 变量 | 说明 | 默认值 | +| --- | --- | --- | +| `APP_HOST` | 监听地址 | `0.0.0.0` | +| `APP_PORT` | 监听端口 | `8000` | +| `APP_ACCESS_PASSWORD` | Web UI 访问密码 | `admin123` | +| `APP_DATABASE_URL` | 数据库连接 | `data/database.db` | ## Docker 部署 -### 使用 docker-compose +### docker-compose ```bash docker-compose up -d ``` -你可以在 `docker-compose.yml` 中修改环境变量,比如端口和访问密码。 -如果需要看“全自动绑卡”的可视化浏览器,打开: +如果需要查看可视化浏览器: - noVNC: `http://127.0.0.1:6080` -### 使用 docker run +### docker run ```bash docker run -d \ @@ -200,74 +148,22 @@ docker run -d \ ghcr.io//codex-console:latest ``` -说明: - -- `WEBUI_HOST`: 监听主机,默认 `0.0.0.0` -- `WEBUI_PORT`: 监听端口,默认 `1455` -- `WEBUI_ACCESS_PASSWORD`: Web UI 访问密码 -- `DEBUG`: 设为 `1` 或 `true` 可开启调试模式 -- `LOG_LEVEL`: 日志级别,例如 `info`、`debug` - -注意: - -`-v $(pwd)/data:/app/data` 很重要,这会把数据库和账号数据持久化到宿主机。否则容器一重启,数据也可能跟着表演消失术。 - -## 使用远程 PostgreSQL - -```bash -export APP_DATABASE_URL="postgresql://user:password@host:5432/dbname" -python webui.py -``` - -也支持 `DATABASE_URL`,但优先级低于 `APP_DATABASE_URL`。 - -## 打包为可执行文件 +## 数据库迁移 ```bash -# Windows -build.bat - -# Linux/macOS -bash build.sh +alembic revision --autogenerate -m "your_change" +alembic upgrade head ``` -Windows 打包完成后,默认会在 `dist/` 目录生成类似下面的文件: - -```text -dist/codex-console-windows-X64.exe -``` - -如果打包失败,优先检查: - -- Python 是否已加入 PATH -- 依赖是否安装完整 -- 杀毒软件是否拦截了 PyInstaller 产物 -- 终端里是否有更具体的报错日志 - -## 项目定位 +更多说明见: -这个仓库更适合作为: +- [alembic/README.md](alembic/README.md) -- 原项目的修复增强版 -- 当前注册链路的兼容维护版 -- 自己二次开发的基础版本 - -如果你准备公开发布,建议在仓库描述里明确写上: - -`Forked and fixed from cnlimiter/codex-manager` - -这样既方便别人理解来源,也对上游作者更尊重。 - -## 仓库命名 - -当前仓库名: +## 致谢 -`codex-console` +感谢上游项目作者 [cnlimiter](https://github.com/cnlimiter) 提供的基础工程与思路。 ## 免责声明 本项目仅供学习、研究和技术交流使用,请遵守相关平台和服务条款,不要用于违规、滥用或非法用途。 - -因使用本项目产生的任何风险和后果,由使用者自行承担。 - - +因使用本项目产生的任何风险与后果,由使用者自行承担。 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/docs/assets/alipay-pay.png b/docs/assets/alipay-pay.png new file mode 100644 index 0000000000000000000000000000000000000000..0e23be5108c8599743252f6643da7a273c6c4450 GIT binary patch literal 280223 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 jK?f+jF`c}MeLe3*)a{y0 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 literal 0 HcmV?d00001 diff --git a/docs/assets/wechat-pay.png b/docs/assets/wechat-pay.png new file mode 100644 index 0000000000000000000000000000000000000000..2a4efd58632cfb04c9e43a0223a5be233dcf1722 GIT binary patch literal 103777 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
a&zjJ84{v+VOIxbP{e#@dmxn=boJ0^PKIC zHz}-rTVgnSj+`|~fjd1%aR;62Akz&JI(JXF7KrZ4WM9=A5S8Rl^XNm{P49@FKbVg_ z8SFZe1rwY#>{Bl;&cWmsyUQ|EVncvQKf?=SGV@rVlXhFKPm_{P;PdGDv;2HtMKPg8 zdU6aX{M0zYf4^NUv~t$Y!1=Pw{YUPPxt11Ran8i(2d2+9xDOOiZ9rH z+yJvZW#(dfYz(4LTzv-9MnwM6xtg8n?wXBxO7N{-_Tvuf)I09!cZCJA!ZJ(( z3__7tIo=^v?7!iEZcaREJsmx_-(kS6w?o!s0C4j?d|Pb_0Gbz>f~Zzt>OuiMNu4l< zW|(+&@!vj9iGt$_C^S2jpO*wak^fj;Z|Zhw+WJH>V0@teOESn3s|W%z;y3;yBmTI= zpEPe>m=^K(gMlf}=0MIS{(eaN_ zcrQ65*2RUO5lEmwZq-x3ZBqfpKVthE>dN9pvnEBn_&+=)%g@4TjEW$@oT3A{gb>+Ki@`hx} z0b4swAxh+Z?v_>mN#k2+<3=ARZZewcO~HjWoqcYVo0Vh0n^@c&53|ny^L^&Sj6_D| zwnyg%uZx~4&8c`1e;4eGt>x#;QaT}Vr^rv|I9rKG3$`*QViqFkTqlzomEWSf-FhVH zNtz%r#&irarM_$jp?m@JHOS;6-Y!oYe^wOY?%)zn^Li&bP=BmWCA*;e`l9#KKX+4O zVDIySi~btH<~_V~8?XAQ&GPTdG422X>Xt2JVN;nYSHZZrugbVNgoJ*Zo^<9f62|?2 z$}3LZ3bhpaJnO5&g9Api?FGV@gL%#>j|qrZr0>(w`wq{=#xM?fH@~@DRQ%g&opt4d zT=L_u=(JpuSKkq?OVXs5dj?!`aW6Xz>kD0RdE>eUvzm-}j1cON2I>HWTLA5MVhxyT zsBSq5>_2nud<`z11OXKa_+T@fhB8|RkEaoZ!h?~DM{6&}Y{5o|hGH-E9%XraJ_|N| zM@P5TLu2lu$@vY|AD%94&c;$rkZIY-ps^e>G?a15zG=Ao^L1bU=&c@+w8UHMAUm#g z`4w<9P{46V2gU+D9*1n7wEgs#!?075pK56eVTD3OTD1fc0Z zw}0xrZO|DTBaG%i8(F7oxZPYMJ!moxohX0ih2HJ3*H?BNxOlk{no49So_dP4?R>Iq z_RiXFHPz6r=N8(m4^j?!4)8)QJktWW_jMv4J~NG2QbGUQDjQiri@d%Wx^J!0ss&<8 zgdmQq3^lSqEkPHc5(w=!n=p>5NGiWv`1|RP(Z~fNy_yD><#=INGV+h za270-t7b|mZ)=QO{C#Wx{VYu(T^aZ9E6|mk-*giS=K^uzJ!BQE$&7Mg4E6;jBY{5g zz8FBe)bltH9^LyE04s>zmtAg>UV-s0vOwZsLnQA(77{|9ZLY#AWjUkN+h5&T9s7A~ z(`zc!pO2vt9um%It3>T@q8YF1QEW(6a^l9@iYp7``80(x%Nwg3F7M*+J>4#jrCI24 z01p^o05A�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 z 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index bc382c90..0fc0b11b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-console" -version = "1.1.1" +version = "1.1.2" 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/v1.1.2.md b/release/v1.1.2.md new file mode 100644 index 00000000..e655e4b3 --- /dev/null +++ b/release/v1.1.2.md @@ -0,0 +1,34 @@ +# v1.1.2 + +发布日期:2026-03-31 + +## 本次更新 + +- 整合自动注册、自动补货、库存监控、批次取消感知与 PR60 anyauto V2 回退链路。 +- 新增统一鉴权与安全基线,补齐首次改密页面、API 与 WebSocket 鉴权口径统一。 +- 新增系统自检能力、自检页面、自检调度与修复中心。 +- 新增统一任务中心,统一管理注册、支付、自检、Auto Team 等任务状态。 +- 新增周期任务调度,支持计划任务创建、启停、立即执行与轮询调度。 +- 新增 New-API 服务配置与上传能力,支持单账号、批量与注册后自动上传。 +- 新增 Auto Team 后端与前端管理模块。 +- 新增卡池功能与自动绑卡能力,对接上游服务链路。 +- 扩展邮箱服务,补齐 CloudMail、LuckMail、YYDS Mail,并保留原有邮箱链路。 +- 引入 Alembic 数据库迁移体系。 +- 补充多组测试与 CI 工作流。 + +## 优化与修复 + +- 优化前端请求、轮询、去重、超时与并发控制。 +- 修复自动补货首次检查后不再继续执行的问题。 +- 修复自动补货批次取消时子任务不同步取消的问题。 +- 修复并行模式与流水线模式下取消响应不及时、统计不准确的问题。 +- 修复监控页最近检查时间与库存未及时刷新的问题。 +- 修复注册页刷新后自动恢复监控的异常行为。 +- 修复定时自动一键刷新链路与相关页面行为。 +- 修复公告与 footer 外链、样式与文案问题。 + +## 文档更新 + +- README 已更新为 `v1.1.2`。 +- README 增加 Blog 地址与说明:[https://blog.cysq8.cn/](https://blog.cysq8.cn/) +- README 增加赞助说明与微信、支付宝二维码。 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/__init__.py b/src/__init__.py index e5ac097d..af0be34d 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,24 +1,9 @@ -""" -OpenAI/Codex CLI 自动注册系统 -""" +"""Project package.""" -from .config import get_settings, EmailServiceType -from .database import get_db, Account, EmailService, RegistrationTask -from .core import RegistrationEngine, RegistrationResult -from .services import EmailServiceFactory, BaseEmailService - -__version__ = "2.0.0" +__version__ = "1.1.2" __author__ = "Yasal" __all__ = [ - 'get_settings', - 'EmailServiceType', - 'get_db', - 'Account', - 'EmailService', - 'RegistrationTask', - 'RegistrationEngine', - 'RegistrationResult', - 'EmailServiceFactory', - 'BaseEmailService', + "__version__", + "__author__", ] diff --git a/src/config/__init__.py b/src/config/__init__.py index da2f93b7..d379ecf3 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -22,6 +22,7 @@ APP_VERSION, OTP_CODE_PATTERN, DEFAULT_PASSWORD_LENGTH, + PASSWORD_SPECIAL_CHARSET, PASSWORD_CHARSET, DEFAULT_USER_INFO, generate_random_user_info, @@ -46,6 +47,7 @@ 'APP_VERSION', 'OTP_CODE_PATTERN', 'DEFAULT_PASSWORD_LENGTH', + 'PASSWORD_SPECIAL_CHARSET', 'PASSWORD_CHARSET', 'DEFAULT_USER_INFO', 'generate_random_user_info', diff --git a/src/config/constants.py b/src/config/constants.py index 18e2964d..d2dfb489 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,21 +53,73 @@ class TaskStatus(str, Enum): class EmailServiceType(str, Enum): """邮箱服务类型""" TEMPMAIL = "tempmail" + YYDS_MAIL = "yyds_mail" OUTLOOK = "outlook" MOE_MAIL = "moe_mail" TEMP_MAIL = "temp_mail" DUCK_MAIL = "duck_mail" + LUCKMAIL = "luckmail" FREEMAIL = "freemail" IMAP_MAIL = "imap_mail" 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 = "1.1.2" APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统" # ============================================================================ @@ -177,7 +250,8 @@ class EmailServiceType(str, Enum): ] # 密码生成 -PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +PASSWORD_SPECIAL_CHARSET = "!@#$%^&*_-+=" +PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + PASSWORD_SPECIAL_CHARSET DEFAULT_PASSWORD_LENGTH = 12 # 用户信息生成(用于注册) diff --git a/src/config/project_notice.py b/src/config/project_notice.py index 8f8337d9..0ef221df 100644 --- a/src/config/project_notice.py +++ b/src/config/project_notice.py @@ -2,14 +2,19 @@ PROJECT_NOTICE = { "title": "项目声明", - "free_notice": "本项目永久免费开源,如果你是付费购买的,请立即找卖家退款。", + "free_notice": "本项目永久免费开源,若你是付费购买,请立即退款并反馈倒卖渠道。", "disclaimer": ( "免责声明:本工具仅供学习和研究使用,使用本工具产生的一切后果由使用者自行承担。" - "请遵守相关服务的使用条款,不要用于任何违法或不当用途。" - "如有侵权,请及时联系,会及时删除。" + "请遵守相关服务条款,不要用于违法或不当用途。如有侵权,请及时联系,将第一时间处理。" + ), + "support_notice": ( + "项目维护不易,服务器与开发都需要持续投入。" + "如果这个项目对你有帮助,欢迎在有条件的情况下赞助支持。" ), "github_repo_name": "dou-jiang/codex-console", "github_repo_url": "https://github.com/dou-jiang/codex-console", + "blog_name": "cysq8 Blog", + "blog_url": "https://blog.cysq8.cn/", "qq_group_id": "291638849", "qq_group_url": "https://qm.qq.com/q/4TETC3mWco", "telegram_name": "codex_console", @@ -23,11 +28,13 @@ def build_terminal_notice_lines() -> list[str]: """Build terminal-friendly notice lines.""" return [ "=" * 72, - "项目声明", + PROJECT_NOTICE["title"], PROJECT_NOTICE["free_notice"], - f"GitHub 仓库 {PROJECT_NOTICE['github_repo_name']}:{PROJECT_NOTICE['github_repo_url']}", - f"QQ交流群 {PROJECT_NOTICE['qq_group_id']}:{PROJECT_NOTICE['qq_group_url']}", - f"Telegram频道 {PROJECT_NOTICE['telegram_name']}:{PROJECT_NOTICE['telegram_url']}", PROJECT_NOTICE["disclaimer"], + PROJECT_NOTICE["support_notice"], + f"GitHub 仓库 {PROJECT_NOTICE['github_repo_name']}:{PROJECT_NOTICE['github_repo_url']}", + f"QQ 交流群 {PROJECT_NOTICE['qq_group_id']}:{PROJECT_NOTICE['qq_group_url']}", + f"Telegram 频道 {PROJECT_NOTICE['telegram_name']}:{PROJECT_NOTICE['telegram_url']}", + f"爱发电支持 {PROJECT_NOTICE['afdian_name']}:{PROJECT_NOTICE['afdian_url']}", "=" * 72, ] diff --git a/src/config/settings.py b/src/config/settings.py index 20d5e956..1bf49dc1 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="1.1.2", 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( @@ -256,9 +316,75 @@ class SettingDefinition: ), # 邮箱服务配置 + "registration_auto_enabled": SettingDefinition( + db_key="registration.auto.enabled", + default_value=False, + category=SettingCategory.REGISTRATION, + description="是否启用自动注册补货" + ), + "registration_auto_check_interval": SettingDefinition( + db_key="registration.auto.check_interval", + default_value=60, + category=SettingCategory.REGISTRATION, + description="自动注册库存检查间隔(秒)" + ), + "registration_auto_min_ready_auth_files": SettingDefinition( + db_key="registration.auto.min_ready_auth_files", + default_value=1, + category=SettingCategory.REGISTRATION, + description="自动注册保底可用认证文件数量" + ), + "registration_auto_email_service_type": SettingDefinition( + db_key="registration.auto.email_service_type", + default_value="tempmail", + category=SettingCategory.REGISTRATION, + description="自动注册使用的邮箱服务类型" + ), + "registration_auto_email_service_id": SettingDefinition( + db_key="registration.auto.email_service_id", + default_value=0, + category=SettingCategory.REGISTRATION, + description="自动注册绑定的邮箱服务 ID(0 表示自动选择)" + ), + "registration_auto_proxy": SettingDefinition( + db_key="registration.auto.proxy", + default_value="", + category=SettingCategory.REGISTRATION, + description="自动注册固定代理地址(留空则沿用系统策略)" + ), + "registration_auto_interval_min": SettingDefinition( + db_key="registration.auto.interval_min", + default_value=5, + category=SettingCategory.REGISTRATION, + description="自动注册批量任务最小启动间隔(秒)" + ), + "registration_auto_interval_max": SettingDefinition( + db_key="registration.auto.interval_max", + default_value=30, + category=SettingCategory.REGISTRATION, + description="自动注册批量任务最大启动间隔(秒)" + ), + "registration_auto_concurrency": SettingDefinition( + db_key="registration.auto.concurrency", + default_value=1, + category=SettingCategory.REGISTRATION, + description="自动注册批量任务并发数" + ), + "registration_auto_mode": SettingDefinition( + db_key="registration.auto.mode", + default_value="pipeline", + category=SettingCategory.REGISTRATION, + description="自动注册批量任务模式" + ), + "registration_auto_cpa_service_id": SettingDefinition( + db_key="registration.auto.cpa_service_id", + default_value=0, + category=SettingCategory.REGISTRATION, + description="自动注册监控并回传的 CPA 服务 ID" + ), "email_service_priority": SettingDefinition( db_key="email.service_priority", - default_value={"tempmail": 0, "outlook": 1, "moe_mail": 2}, + default_value={"tempmail": 0, "yyds_mail": 1, "outlook": 2, "moe_mail": 3}, category=SettingCategory.EMAIL, description="邮箱服务优先级" ), @@ -270,6 +396,12 @@ class SettingDefinition: category=SettingCategory.TEMPMAIL, description="Tempmail API 地址" ), + "tempmail_enabled": SettingDefinition( + db_key="tempmail.enabled", + default_value=True, + category=SettingCategory.TEMPMAIL, + description="是否启用 Tempmail.lol 渠道" + ), "tempmail_timeout": SettingDefinition( db_key="tempmail.timeout", default_value=30, @@ -282,6 +414,43 @@ 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( @@ -401,15 +570,40 @@ 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, "registration_sleep_min": int, "registration_sleep_max": int, "registration_entry_flow": str, + "registration_auto_enabled": bool, + "registration_auto_check_interval": int, + "registration_auto_min_ready_auth_files": int, + "registration_auto_email_service_type": str, + "registration_auto_email_service_id": int, + "registration_auto_proxy": str, + "registration_auto_interval_min": int, + "registration_auto_interval_max": int, + "registration_auto_concurrency": int, + "registration_auto_mode": str, + "registration_auto_cpa_service_id": int, "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, @@ -592,7 +786,7 @@ class Settings(BaseModel): # 应用信息 app_name: str = "OpenAI/Codex CLI 自动注册系统" - app_version: str = "1.1.1" + app_version: str = "1.1.2" debug: bool = False # 数据库配置 @@ -619,6 +813,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" @@ -671,14 +875,32 @@ def proxy_url(self) -> Optional[str]: registration_sleep_min: int = 5 registration_sleep_max: int = 30 registration_entry_flow: str = "native" + registration_auto_enabled: bool = False + registration_auto_check_interval: int = 60 + registration_auto_min_ready_auth_files: int = 1 + registration_auto_email_service_type: str = "tempmail" + registration_auto_email_service_id: int = 0 + registration_auto_proxy: str = "" + registration_auto_interval_min: int = 5 + registration_auto_interval_max: int = 30 + registration_auto_concurrency: int = 1 + registration_auto_mode: str = "pipeline" + registration_auto_cpa_service_id: int = 0 # 邮箱服务配置 - email_service_priority: Dict[str, int] = {"tempmail": 0, "outlook": 1, "moe_mail": 2} + email_service_priority: Dict[str, int] = {"tempmail": 0, "yyds_mail": 1, "outlook": 2, "moe_mail": 3} # Tempmail.lol 配置 tempmail_base_url: str = "https://api.tempmail.lol/v2" + tempmail_enabled: bool = True 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/__init__.py b/src/core/__init__.py index 7ec7c6f8..842d65dc 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -1,32 +1,3 @@ -""" -核心功能模块 -""" +"""Core package.""" -from .openai.oauth import OAuthManager, OAuthStart, generate_oauth_url, submit_callback_url -from .http_client import ( - OpenAIHTTPClient, - HTTPClient, - HTTPClientError, - RequestConfig, - create_http_client, - create_openai_client, -) -from .register import RegistrationEngine, RegistrationResult -from .utils import setup_logging, get_data_dir - -__all__ = [ - 'OAuthManager', - 'OAuthStart', - 'generate_oauth_url', - 'submit_callback_url', - 'OpenAIHTTPClient', - 'HTTPClient', - 'HTTPClientError', - 'RequestConfig', - 'create_http_client', - 'create_openai_client', - 'RegistrationEngine', - 'RegistrationResult', - 'setup_logging', - 'get_data_dir', -] +__all__ = [] diff --git a/src/core/anyauto/__init__.py b/src/core/anyauto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/core/anyauto/chatgpt_client.py b/src/core/anyauto/chatgpt_client.py new file mode 100644 index 00000000..13def6c0 --- /dev/null +++ b/src/core/anyauto/chatgpt_client.py @@ -0,0 +1,911 @@ +""" +ChatGPT 注册客户端模块 +使用 curl_cffi 模拟浏览器行为 +""" + +import random +import uuid +import time +from urllib.parse import urlparse + +try: + from curl_cffi import requests as curl_requests +except ImportError: + print("❌ 需要安装 curl_cffi: pip install curl_cffi") + import sys + sys.exit(1) + +from .sentinel_token import build_sentinel_token +from .utils import ( + FlowState, + build_browser_headers, + decode_jwt_payload, + describe_flow_state, + extract_flow_state, + generate_datadog_trace, + normalize_flow_url, + random_delay, + seed_oai_device_cookie, +) + + +# Chrome 指纹配置 +_CHROME_PROFILES = [ + { + "major": 131, "impersonate": "chrome131", + "build": 6778, "patch_range": (69, 205), + "sec_ch_ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', + }, + { + "major": 133, "impersonate": "chrome133a", + "build": 6943, "patch_range": (33, 153), + "sec_ch_ua": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"', + }, + { + "major": 136, "impersonate": "chrome136", + "build": 7103, "patch_range": (48, 175), + "sec_ch_ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"', + }, +] + + +def _random_chrome_version(): + """随机选择一个 Chrome 版本""" + profile = random.choice(_CHROME_PROFILES) + major = profile["major"] + build = profile["build"] + patch = random.randint(*profile["patch_range"]) + full_ver = f"{major}.0.{build}.{patch}" + ua = f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{full_ver} Safari/537.36" + return profile["impersonate"], major, full_ver, ua, profile["sec_ch_ua"] + + +class ChatGPTClient: + """ChatGPT 注册客户端""" + + BASE = "https://chatgpt.com" + AUTH = "https://auth.openai.com" + + def __init__(self, proxy=None, verbose=True, browser_mode="protocol"): + """ + 初始化 ChatGPT 客户端 + + Args: + proxy: 代理地址 + verbose: 是否输出详细日志 + browser_mode: protocol | headless | headed + """ + self.proxy = proxy + self.verbose = verbose + self.browser_mode = browser_mode or "protocol" + self.device_id = str(uuid.uuid4()) + self.accept_language = random.choice([ + "en-US,en;q=0.9", + "en-US,en;q=0.9,zh-CN;q=0.8", + "en,en-US;q=0.9", + "en-US,en;q=0.8", + ]) + + # 随机 Chrome 版本 + self.impersonate, self.chrome_major, self.chrome_full, self.ua, self.sec_ch_ua = _random_chrome_version() + + # 创建 session + self.session = curl_requests.Session(impersonate=self.impersonate) + + if self.proxy: + self.session.proxies = {"http": self.proxy, "https": self.proxy} + + # 设置基础 headers + self.session.headers.update({ + "User-Agent": self.ua, + "Accept-Language": self.accept_language, + "sec-ch-ua": self.sec_ch_ua, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version": f'"{self.chrome_full}"', + "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"', + }) + + # 设置 oai-did cookie + seed_oai_device_cookie(self.session, self.device_id) + self.last_registration_state = FlowState() + + def _log(self, msg): + """输出日志""" + if self.verbose: + print(f" {msg}") + + def _browser_pause(self, low=0.15, high=0.45): + """在 headed 模式下加入轻微停顿,模拟有头浏览器节奏。""" + if self.browser_mode == "headed": + random_delay(low, high) + + def _headers( + self, + url, + *, + accept, + referer=None, + origin=None, + content_type=None, + navigation=False, + fetch_mode=None, + fetch_dest=None, + fetch_site=None, + extra_headers=None, + ): + return build_browser_headers( + url=url, + user_agent=self.ua, + sec_ch_ua=self.sec_ch_ua, + chrome_full_version=self.chrome_full, + accept=accept, + accept_language=self.accept_language, + referer=referer, + origin=origin, + content_type=content_type, + navigation=navigation, + fetch_mode=fetch_mode, + fetch_dest=fetch_dest, + fetch_site=fetch_site, + headed=self.browser_mode == "headed", + extra_headers=extra_headers, + ) + + def _reset_session(self): + """重置浏览器指纹与会话,用于绕过偶发的 Cloudflare/SPA 中间页。""" + self.device_id = str(uuid.uuid4()) + self.impersonate, self.chrome_major, self.chrome_full, self.ua, self.sec_ch_ua = _random_chrome_version() + self.accept_language = random.choice([ + "en-US,en;q=0.9", + "en-US,en;q=0.9,zh-CN;q=0.8", + "en,en-US;q=0.9", + "en-US,en;q=0.8", + ]) + + self.session = curl_requests.Session(impersonate=self.impersonate) + if self.proxy: + self.session.proxies = {"http": self.proxy, "https": self.proxy} + + self.session.headers.update({ + "User-Agent": self.ua, + "Accept-Language": self.accept_language, + "sec-ch-ua": self.sec_ch_ua, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version": f'"{self.chrome_full}"', + "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"', + }) + seed_oai_device_cookie(self.session, self.device_id) + + def _state_from_url(self, url, method="GET"): + state = extract_flow_state( + current_url=normalize_flow_url(url, auth_base=self.AUTH), + auth_base=self.AUTH, + default_method=method, + ) + if method: + state.method = str(method).upper() + return state + + def _state_from_payload(self, data, current_url=""): + return extract_flow_state( + data=data, + current_url=current_url, + auth_base=self.AUTH, + ) + + def _state_signature(self, state: FlowState): + return ( + state.page_type or "", + state.method or "", + state.continue_url or "", + state.current_url or "", + ) + + def _is_registration_complete_state(self, state: FlowState): + current_url = (state.current_url or "").lower() + continue_url = (state.continue_url or "").lower() + page_type = state.page_type or "" + return ( + page_type in {"callback", "chatgpt_home", "oauth_callback"} + or ("chatgpt.com" in current_url and "redirect_uri" not in current_url) + or ("chatgpt.com" in continue_url and "redirect_uri" not in continue_url and page_type != "external_url") + ) + + def _state_is_password_registration(self, state: FlowState): + return state.page_type in {"create_account_password", "password"} + + def _state_is_email_otp(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + return state.page_type == "email_otp_verification" or "email-verification" in target or "email-otp" in target + + def _state_is_about_you(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + return state.page_type == "about_you" or "about-you" in target + + def _state_is_add_phone(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + return state.page_type == "add_phone" or "add-phone" in target + + def _state_requires_navigation(self, state: FlowState): + if (state.method or "GET").upper() != "GET": + return False + if state.page_type == "external_url" and state.continue_url: + return True + if state.continue_url and state.continue_url != state.current_url: + return True + return False + + def _follow_flow_state(self, state: FlowState, referer=None): + """跟随服务端返回的 continue_url,推进注册状态机。""" + target_url = state.continue_url or state.current_url + if not target_url: + return False, "缺少可跟随的 continue_url" + + try: + self._browser_pause() + r = self.session.get( + target_url, + headers=self._headers( + target_url, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=referer, + navigation=True, + ), + allow_redirects=True, + timeout=30, + ) + final_url = str(r.url) + self._log(f"follow -> {r.status_code} {final_url}") + + content_type = (r.headers.get("content-type", "") or "").lower() + if "application/json" in content_type: + try: + next_state = self._state_from_payload(r.json(), current_url=final_url) + except Exception: + next_state = self._state_from_url(final_url) + else: + next_state = self._state_from_url(final_url) + + self._log(f"follow state -> {describe_flow_state(next_state)}") + return True, next_state + except Exception as e: + self._log(f"跟随 continue_url 失败: {e}") + return False, str(e) + + def _get_cookie_value(self, name, domain_hint=None): + """读取当前会话中的 Cookie。""" + for cookie in self.session.cookies.jar: + if cookie.name != name: + continue + if domain_hint and domain_hint not in (cookie.domain or ""): + continue + return cookie.value + return "" + + def get_next_auth_session_token(self): + """获取 ChatGPT next-auth 会话 Cookie。""" + return self._get_cookie_value("__Secure-next-auth.session-token", "chatgpt.com") + + def fetch_chatgpt_session(self): + """请求 ChatGPT Session 接口并返回原始会话数据。""" + url = f"{self.BASE}/api/auth/session" + self._browser_pause() + response = self.session.get( + url, + headers=self._headers( + url, + accept="application/json", + referer=f"{self.BASE}/", + fetch_site="same-origin", + ), + timeout=30, + ) + if response.status_code != 200: + return False, f"/api/auth/session -> HTTP {response.status_code}" + + try: + data = response.json() + except Exception as exc: + return False, f"/api/auth/session 返回非 JSON: {exc}" + + access_token = str(data.get("accessToken") or "").strip() + if not access_token: + return False, "/api/auth/session 未返回 accessToken" + return True, data + + def reuse_session_and_get_tokens(self): + """ + 复用注册阶段已建立的 ChatGPT 会话,直接读取 Session / AccessToken。 + + Returns: + tuple[bool, dict|str]: 成功时返回标准化 token/session 数据;失败时返回错误信息。 + """ + state = self.last_registration_state or FlowState() + self._log("步骤 1/4: 跟随注册回调 external_url ...") + if state.page_type == "external_url" or self._state_requires_navigation(state): + ok, followed = self._follow_flow_state( + state, + referer=state.current_url or f"{self.AUTH}/about-you", + ) + if not ok: + return False, f"注册回调落地失败: {followed}" + self.last_registration_state = followed + else: + self._log("注册回调已落地,跳过额外跟随") + + self._log("步骤 2/4: 检查 __Secure-next-auth.session-token ...") + session_cookie = self.get_next_auth_session_token() + if not session_cookie: + return False, "缺少 __Secure-next-auth.session-token,注册回调可能未落地" + + self._log("步骤 3/4: 请求 ChatGPT /api/auth/session ...") + ok, session_or_error = self.fetch_chatgpt_session() + if not ok: + return False, session_or_error + + session_data = session_or_error + access_token = str(session_data.get("accessToken") or "").strip() + session_token = str(session_data.get("sessionToken") or session_cookie or "").strip() + user = session_data.get("user") or {} + account = session_data.get("account") or {} + jwt_payload = decode_jwt_payload(access_token) + auth_payload = jwt_payload.get("https://api.openai.com/auth") or {} + + account_id = ( + str(account.get("id") or "").strip() + or str(auth_payload.get("chatgpt_account_id") or "").strip() + ) + user_id = ( + str(user.get("id") or "").strip() + or str(auth_payload.get("chatgpt_user_id") or "").strip() + or str(auth_payload.get("user_id") or "").strip() + ) + + normalized = { + "access_token": access_token, + "session_token": session_token, + "account_id": account_id, + "user_id": user_id, + "workspace_id": account_id, + "expires": session_data.get("expires"), + "user": user, + "account": account, + "auth_provider": session_data.get("authProvider"), + "raw_session": session_data, + } + + self._log("步骤 4/4: 已从复用会话中提取 accessToken") + if account_id: + self._log(f"Session Account ID: {account_id}") + if user_id: + self._log(f"Session User ID: {user_id}") + return True, normalized + + def visit_homepage(self): + """访问首页,建立 session""" + self._log("访问 ChatGPT 首页...") + url = f"{self.BASE}/" + try: + self._browser_pause() + r = self.session.get( + url, + headers=self._headers( + url, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + navigation=True, + ), + allow_redirects=True, + timeout=30, + ) + return r.status_code == 200 + except Exception as e: + self._log(f"访问首页失败: {e}") + return False + + def get_csrf_token(self): + """获取 CSRF token""" + self._log("获取 CSRF token...") + url = f"{self.BASE}/api/auth/csrf" + try: + r = self.session.get( + url, + headers=self._headers( + url, + accept="application/json", + referer=f"{self.BASE}/", + fetch_site="same-origin", + ), + timeout=30, + ) + + if r.status_code == 200: + data = r.json() + token = data.get("csrfToken", "") + if token: + self._log(f"CSRF token: {token[:20]}...") + return token + except Exception as e: + self._log(f"获取 CSRF token 失败: {e}") + + return None + + def signin(self, email, csrf_token): + """ + 提交邮箱,获取 authorize URL + + Returns: + str: authorize URL + """ + self._log(f"提交邮箱: {email}") + url = f"{self.BASE}/api/auth/signin/openai" + + params = { + "prompt": "login", + "ext-oai-did": self.device_id, + "auth_session_logging_id": str(uuid.uuid4()), + "screen_hint": "login_or_signup", + "login_hint": email, + } + + form_data = { + "callbackUrl": f"{self.BASE}/", + "csrfToken": csrf_token, + "json": "true", + } + + try: + self._browser_pause() + r = self.session.post( + url, + params=params, + data=form_data, + headers=self._headers( + url, + accept="application/json", + referer=f"{self.BASE}/", + origin=self.BASE, + content_type="application/x-www-form-urlencoded", + fetch_site="same-origin", + ), + timeout=30 + ) + + if r.status_code == 200: + data = r.json() + authorize_url = data.get("url", "") + if authorize_url: + self._log(f"获取到 authorize URL") + return authorize_url + except Exception as e: + self._log(f"提交邮箱失败: {e}") + + return None + + def authorize(self, url, max_retries=3): + """ + 访问 authorize URL,跟随重定向(带重试机制) + 这是关键步骤,建立 auth.openai.com 的 session + + Returns: + str: 最终重定向的 URL + """ + for attempt in range(max_retries): + try: + if attempt > 0: + self._log(f"访问 authorize URL... (尝试 {attempt + 1}/{max_retries})") + time.sleep(1) # 重试前等待 + else: + self._log("访问 authorize URL...") + + self._browser_pause() + r = self.session.get( + url, + headers=self._headers( + url, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=f"{self.BASE}/", + navigation=True, + ), + allow_redirects=True, + timeout=30, + ) + + final_url = str(r.url) + self._log(f"重定向到: {final_url}") + return final_url + + except Exception as e: + error_msg = str(e) + is_tls_error = "TLS" in error_msg or "SSL" in error_msg or "curl: (35)" in error_msg + + if is_tls_error and attempt < max_retries - 1: + self._log(f"Authorize TLS 错误 (尝试 {attempt + 1}/{max_retries}): {error_msg[:100]}") + continue + else: + self._log(f"Authorize 失败: {e}") + return "" + + return "" + + def callback(self, callback_url=None, referer=None): + """完成注册回调""" + self._log("执行回调...") + url = callback_url or f"{self.AUTH}/api/accounts/authorize/callback" + ok, _ = self._follow_flow_state( + self._state_from_url(url), + referer=referer or f"{self.AUTH}/about-you", + ) + return ok + + def register_user(self, email, password): + """ + 注册用户(邮箱 + 密码) + + Returns: + tuple: (success, message) + """ + self._log(f"注册用户: {email}") + url = f"{self.AUTH}/api/accounts/user/register" + + headers = self._headers( + url, + accept="application/json", + referer=f"{self.AUTH}/create-account/password", + origin=self.AUTH, + content_type="application/json", + fetch_site="same-origin", + ) + headers.update(generate_datadog_trace()) + + payload = { + "username": email, + "password": password, + } + + try: + self._browser_pause() + r = self.session.post(url, json=payload, headers=headers, timeout=30) + + if r.status_code == 200: + data = r.json() + self._log("注册成功") + return True, "注册成功" + else: + try: + error_data = r.json() + error_msg = error_data.get("error", {}).get("message", r.text[:200]) + except: + error_msg = r.text[:200] + self._log(f"注册失败: {r.status_code} - {error_msg}") + return False, f"HTTP {r.status_code}: {error_msg}" + + except Exception as e: + self._log(f"注册异常: {e}") + return False, str(e) + + def send_email_otp(self): + """触发发送邮箱验证码""" + self._log("触发发送验证码...") + url = f"{self.AUTH}/api/accounts/email-otp/send" + + try: + self._browser_pause() + r = self.session.get( + url, + headers=self._headers( + url, + accept="application/json, text/plain, */*", + referer=f"{self.AUTH}/create-account/password", + fetch_site="same-origin", + ), + allow_redirects=True, + timeout=30, + ) + return r.status_code == 200 + except Exception as e: + self._log(f"发送验证码失败: {e}") + return False + + def verify_email_otp(self, otp_code, return_state=False): + """ + 验证邮箱 OTP 码 + + Args: + otp_code: 6位验证码 + + Returns: + tuple: (success, message) + """ + self._log(f"验证 OTP 码: {otp_code}") + url = f"{self.AUTH}/api/accounts/email-otp/validate" + + headers = self._headers( + url, + accept="application/json", + referer=f"{self.AUTH}/email-verification", + origin=self.AUTH, + content_type="application/json", + fetch_site="same-origin", + ) + headers.update(generate_datadog_trace()) + + payload = {"code": otp_code} + + try: + self._browser_pause() + r = self.session.post(url, json=payload, headers=headers, timeout=30) + + if r.status_code == 200: + try: + data = r.json() + except Exception: + data = {} + next_state = self._state_from_payload(data, current_url=str(r.url) or f"{self.AUTH}/about-you") + self._log(f"验证成功 {describe_flow_state(next_state)}") + return (True, next_state) if return_state else (True, "验证成功") + else: + try: + error_msg = r.text[:200] + except Exception: + error_msg = "" + self._log(f"验证失败: {r.status_code} - {error_msg}") + return False, f"HTTP {r.status_code}: {error_msg}".strip() + + except Exception as e: + self._log(f"验证异常: {e}") + return False, str(e) + + def create_account(self, first_name, last_name, birthdate, return_state=False): + """ + 完成账号创建(提交姓名和生日) + + Args: + first_name: 名 + last_name: 姓 + birthdate: 生日 (YYYY-MM-DD) + + Returns: + tuple: (success, message) + """ + name = f"{first_name} {last_name}" + self._log(f"完成账号创建: {name}") + url = f"{self.AUTH}/api/accounts/create_account" + + sentinel_token = build_sentinel_token( + self.session, + self.device_id, + flow="authorize_continue", + user_agent=self.ua, + sec_ch_ua=self.sec_ch_ua, + impersonate=self.impersonate, + ) + if sentinel_token: + self._log("create_account: 已生成 sentinel token") + else: + self._log("create_account: 未生成 sentinel token,降级继续请求") + + headers = self._headers( + url, + accept="application/json", + referer=f"{self.AUTH}/about-you", + origin=self.AUTH, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": self.device_id, + }, + ) + if sentinel_token: + headers["openai-sentinel-token"] = sentinel_token + headers.update(generate_datadog_trace()) + + payload = { + "name": name, + "birthdate": birthdate, + } + + try: + self._browser_pause() + r = self.session.post(url, json=payload, headers=headers, timeout=30) + + if r.status_code == 200: + try: + data = r.json() + except Exception: + data = {} + next_state = self._state_from_payload(data, current_url=str(r.url) or self.BASE) + self._log(f"账号创建成功 {describe_flow_state(next_state)}") + return (True, next_state) if return_state else (True, "账号创建成功") + else: + error_msg = r.text[:200] + self._log(f"创建失败: {r.status_code} - {error_msg}") + return False, f"HTTP {r.status_code}" + + except Exception as e: + self._log(f"创建异常: {e}") + return False, str(e) + + def register_complete_flow(self, email, password, first_name, last_name, birthdate, skymail_client): + """ + 完整的注册流程(基于原版 run_register 方法) + + Args: + email: 邮箱 + password: 密码 + first_name: 名 + last_name: 姓 + birthdate: 生日 + skymail_client: Skymail 客户端(用于获取验证码) + + Returns: + tuple: (success, message) + """ + from urllib.parse import urlparse + + max_auth_attempts = 3 + final_url = "" + final_path = "" + + for auth_attempt in range(max_auth_attempts): + if auth_attempt > 0: + self._log(f"预授权阶段重试 {auth_attempt + 1}/{max_auth_attempts}...") + self._reset_session() + + # 1. 访问首页 + if not self.visit_homepage(): + if auth_attempt < max_auth_attempts - 1: + continue + return False, "访问首页失败" + + # 2. 获取 CSRF token + csrf_token = self.get_csrf_token() + if not csrf_token: + if auth_attempt < max_auth_attempts - 1: + continue + return False, "获取 CSRF token 失败" + + # 3. 提交邮箱,获取 authorize URL + auth_url = self.signin(email, csrf_token) + if not auth_url: + if auth_attempt < max_auth_attempts - 1: + continue + return False, "提交邮箱失败" + + # 4. 访问 authorize URL(关键步骤!) + final_url = self.authorize(auth_url) + if not final_url: + if auth_attempt < max_auth_attempts - 1: + continue + return False, "Authorize 失败" + + final_path = urlparse(final_url).path + self._log(f"Authorize → {final_path}") + + # /api/accounts/authorize 实际上常对应 Cloudflare 403 中间页,不要继续走 authorize_continue。 + if "api/accounts/authorize" in final_path or final_path == "/error": + self._log(f"检测到 Cloudflare/SPA 中间页,准备重试预授权: {final_url[:160]}...") + if auth_attempt < max_auth_attempts - 1: + continue + return False, f"预授权被拦截: {final_path}" + + break + + state = self._state_from_url(final_url) + self._log(f"注册状态起点: {describe_flow_state(state)}") + + register_submitted = False + otp_verified = False + account_created = False + seen_states = {} + + for _ in range(12): + signature = self._state_signature(state) + seen_states[signature] = seen_states.get(signature, 0) + 1 + if seen_states[signature] > 2: + return False, f"注册状态卡住: {describe_flow_state(state)}" + + if self._is_registration_complete_state(state): + self.last_registration_state = state + self._log("✅ 注册流程完成") + return True, "注册成功" + + if self._state_is_password_registration(state): + self._log("全新注册流程") + if register_submitted: + return False, "注册密码阶段重复进入" + success, msg = self.register_user(email, password) + if not success: + return False, f"注册失败: {msg}" + register_submitted = True + if not self.send_email_otp(): + self._log("发送验证码接口返回失败,继续等待邮箱中的验证码...") + state = self._state_from_url(f"{self.AUTH}/email-verification") + continue + + if self._state_is_email_otp(state): + self._log("等待邮箱验证码...") + otp_code = skymail_client.wait_for_verification_code(email, timeout=90) + if not otp_code: + return False, "未收到验证码" + + tried_codes = {otp_code} + for _ in range(3): + success, next_state = self.verify_email_otp(otp_code, return_state=True) + if success: + otp_verified = True + state = next_state + self.last_registration_state = state + break + + err_text = str(next_state or "") + is_wrong_code = any( + marker in err_text.lower() + for marker in ( + "wrong_email_otp_code", + "wrong code", + "http 401", + ) + ) + if not is_wrong_code: + return False, f"验证码失败: {next_state}" + + self._log("验证码疑似过期/错误,尝试获取新验证码...") + otp_code = skymail_client.wait_for_verification_code( + email, + timeout=45, + exclude_codes=tried_codes, + ) + if not otp_code: + return False, "未收到新的验证码" + tried_codes.add(otp_code) + + if not otp_verified: + return False, "验证码失败: 多次尝试仍无效" + continue + + if self._state_is_about_you(state): + if account_created: + return False, "填写信息阶段重复进入" + success, next_state = self.create_account( + first_name, + last_name, + birthdate, + return_state=True, + ) + if not success: + return False, f"创建账号失败: {next_state}" + account_created = True + state = next_state + self.last_registration_state = state + continue + + if self._state_is_add_phone(state): + self._log("检测到 add_phone 阶段,交由后续登录补全流程处理") + self.last_registration_state = state + return True, "add_phone_required" + + if self._state_requires_navigation(state): + success, next_state = self._follow_flow_state( + state, + referer=state.current_url or f"{self.AUTH}/about-you", + ) + if not success: + return False, f"跳转失败: {next_state}" + state = next_state + self.last_registration_state = state + continue + + if (not register_submitted) and (not otp_verified) and (not account_created): + self._log(f"未知起始状态,回退为全新注册流程: {describe_flow_state(state)}") + state = self._state_from_url(f"{self.AUTH}/create-account/password") + continue + + return False, f"未支持的注册状态: {describe_flow_state(state)}" + + return False, "注册状态机超出最大步数" diff --git a/src/core/anyauto/oauth_client.py b/src/core/anyauto/oauth_client.py new file mode 100644 index 00000000..a891da9a --- /dev/null +++ b/src/core/anyauto/oauth_client.py @@ -0,0 +1,1614 @@ +""" +OAuth 客户端模块 - 处理 Codex OAuth 登录流程 +""" + +import time +import secrets +from urllib.parse import urlparse, parse_qs + +try: + from curl_cffi import requests as curl_requests +except ImportError: + import requests as curl_requests + +from .utils import ( + FlowState, + build_browser_headers, + describe_flow_state, + extract_flow_state, + generate_datadog_trace, + generate_pkce, + normalize_flow_url, + random_delay, + seed_oai_device_cookie, +) +from .sentinel_token import build_sentinel_token + + +class OAuthClient: + """OAuth 客户端 - 用于获取 Access Token 和 Refresh Token""" + + def __init__(self, config, proxy=None, verbose=True, browser_mode="protocol"): + """ + 初始化 OAuth 客户端 + + Args: + config: 配置字典 + proxy: 代理地址 + verbose: 是否输出详细日志 + browser_mode: protocol | headless | headed + """ + self.config = dict(config or {}) + self.oauth_issuer = self.config.get("oauth_issuer", "https://auth.openai.com") + self.oauth_client_id = self.config.get("oauth_client_id", "app_EMoamEEZ73f0CkXaXp7hrann") + self.oauth_redirect_uri = self.config.get("oauth_redirect_uri", "http://localhost:1455/auth/callback") + self.proxy = proxy + self.verbose = verbose + self.browser_mode = browser_mode or "protocol" + self.last_error = "" + + # 创建 session + self.session = curl_requests.Session() + if self.proxy: + self.session.proxies = {"http": self.proxy, "https": self.proxy} + + def _log(self, msg): + """输出日志""" + if self.verbose: + print(f" [OAuth] {msg}") + + def _set_error(self, message): + self.last_error = str(message or "").strip() + if self.last_error: + self._log(self.last_error) + + def _browser_pause(self, low=0.15, high=0.4): + """在 headed 模式下注入轻微延迟,模拟真实浏览器操作节奏。""" + if self.browser_mode == "headed": + random_delay(low, high) + + @staticmethod + def _iter_text_fragments(value): + if isinstance(value, str): + text = value.strip() + if text: + yield text + return + if isinstance(value, dict): + for item in value.values(): + yield from OAuthClient._iter_text_fragments(item) + return + if isinstance(value, (list, tuple, set)): + for item in value: + yield from OAuthClient._iter_text_fragments(item) + + @classmethod + def _should_blacklist_phone_failure(cls, detail="", state: FlowState | None = None): + fragments = [str(detail or "").strip()] + if state is not None: + fragments.extend( + cls._iter_text_fragments( + { + "page_type": state.page_type, + "continue_url": state.continue_url, + "current_url": state.current_url, + "payload": state.payload, + "raw": state.raw, + } + ) + ) + + combined = " | ".join(fragment for fragment in fragments if fragment).lower() + if not combined: + return False + + non_blacklist_markers = ( + "whatsapp", + "未收到短信验证码", + "手机号验证码错误", + "phone-otp/resend", + "phone-otp/validate 异常", + "phone-otp/validate 响应不是 json", + "phone-otp/validate 失败", + "timeout", + "timed out", + "network", + "connection", + "proxy", + "ssl", + "tls", + "captcha", + "too many phone", + "too many phone numbers", + "too many verification requests", + "验证请求过多", + "接受短信次数过多", + "session limit", + "rate limit", + ) + if any(marker in combined for marker in non_blacklist_markers): + return False + + blacklist_markers = ( + "phone number is invalid", + "invalid phone number", + "invalid phone", + "phone number invalid", + "sms verification failed", + "send sms verification failed", + "unable to send sms", + "not a valid mobile number", + "unsupported phone number", + "phone number not supported", + "carrier not supported", + "电话号码无效", + "手机号无效", + "发送短信验证失败", + "号码无效", + "号码不支持", + "手机号不支持", + ) + return any(marker in combined for marker in blacklist_markers) + + def _blacklist_phone_if_needed(self, phone_service, entry, detail="", state: FlowState | None = None): + if not entry or not self._should_blacklist_phone_failure(detail, state): + return False + try: + phone_service.mark_blacklisted(entry.phone) + self._log(f"已将手机号加入黑名单: {entry.phone}") + return True + except Exception as e: + self._log(f"写入手机号黑名单失败: {e}") + return False + + def _headers( + self, + url, + *, + user_agent=None, + sec_ch_ua=None, + accept, + referer=None, + origin=None, + content_type=None, + navigation=False, + fetch_mode=None, + fetch_dest=None, + fetch_site=None, + extra_headers=None, + ): + accept_language = None + try: + accept_language = self.session.headers.get("Accept-Language") + except Exception: + accept_language = None + + return build_browser_headers( + url=url, + user_agent=user_agent or "Mozilla/5.0", + sec_ch_ua=sec_ch_ua, + accept=accept, + accept_language=accept_language or "en-US,en;q=0.9", + referer=referer, + origin=origin, + content_type=content_type, + navigation=navigation, + fetch_mode=fetch_mode, + fetch_dest=fetch_dest, + fetch_site=fetch_site, + headed=self.browser_mode == "headed", + extra_headers=extra_headers, + ) + + def _state_from_url(self, url, method="GET"): + state = extract_flow_state( + current_url=normalize_flow_url(url, auth_base=self.oauth_issuer), + auth_base=self.oauth_issuer, + default_method=method, + ) + if method: + state.method = str(method).upper() + return state + + def _state_from_payload(self, data, current_url=""): + return extract_flow_state( + data=data, + current_url=current_url, + auth_base=self.oauth_issuer, + ) + + def _state_signature(self, state: FlowState): + return ( + state.page_type or "", + state.method or "", + state.continue_url or "", + state.current_url or "", + ) + + def _extract_code_from_state(self, state: FlowState): + for candidate in ( + state.continue_url, + state.current_url, + (state.payload or {}).get("url", ""), + ): + code = self._extract_code_from_url(candidate) + if code: + return code + return None + + def _state_is_login_password(self, state: FlowState): + return state.page_type == "login_password" + + def _state_is_email_otp(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + return state.page_type == "email_otp_verification" or "email-verification" in target or "email-otp" in target + + def _state_is_add_phone(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + return state.page_type == "add_phone" or "add-phone" in target + + def _state_requires_navigation(self, state: FlowState): + method = (state.method or "GET").upper() + if method != "GET": + return False + if ( + state.source == "api" + and state.current_url + and state.page_type not in {"login_password", "email_otp_verification"} + ): + return True + if state.page_type == "external_url" and state.continue_url: + return True + if state.continue_url and state.continue_url != state.current_url: + return True + return False + + def _state_supports_workspace_resolution(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + if state.page_type in {"consent", "workspace_selection", "organization_selection"}: + return True + if any(marker in target for marker in ("sign-in-with-chatgpt", "consent", "workspace", "organization")): + return True + session_data = self._decode_oauth_session_cookie() or {} + return bool(session_data.get("workspaces")) + + def _follow_flow_state(self, state: FlowState, referer=None, user_agent=None, impersonate=None, max_hops=16): + """跟随服务端返回的 continue_url / current_url,返回新的状态或 authorization code。""" + import re + + current_url = state.continue_url or state.current_url + last_url = current_url or "" + referer_url = referer + + if not current_url: + return None, state + + initial_code = self._extract_code_from_url(current_url) + if initial_code: + return initial_code, self._state_from_url(current_url) + + for hop in range(max_hops): + try: + headers = self._headers( + current_url, + user_agent=user_agent, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=referer_url, + navigation=True, + ) + kwargs = {"headers": headers, "allow_redirects": False, "timeout": 30} + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause(0.12, 0.3) + r = self.session.get(current_url, **kwargs) + last_url = str(r.url) + self._log(f"follow[{hop + 1}] {r.status_code} {last_url[:120]}") + except Exception as e: + maybe_localhost = re.search(r'(https?://localhost[^\s\'\"]+)', str(e)) + if maybe_localhost: + location = maybe_localhost.group(1) + code = self._extract_code_from_url(location) + if code: + self._log("从 localhost 异常提取到 authorization code") + return code, self._state_from_url(location) + self._log(f"follow[{hop + 1}] 异常: {str(e)[:160]}") + return None, self._state_from_url(last_url or current_url) + + code = self._extract_code_from_url(last_url) + if code: + return code, self._state_from_url(last_url) + + if r.status_code in (301, 302, 303, 307, 308): + location = normalize_flow_url(r.headers.get("Location", ""), auth_base=self.oauth_issuer) + if not location: + return None, self._state_from_url(last_url or current_url) + code = self._extract_code_from_url(location) + if code: + return code, self._state_from_url(location) + referer_url = last_url or referer_url + current_url = location + continue + + content_type = (r.headers.get("content-type", "") or "").lower() + if "application/json" in content_type: + try: + next_state = self._state_from_payload(r.json(), current_url=last_url or current_url) + except Exception: + next_state = self._state_from_url(last_url or current_url) + else: + next_state = self._state_from_url(last_url or current_url) + + return None, next_state + + return None, self._state_from_url(last_url or current_url) + + def _bootstrap_oauth_session(self, authorize_url, authorize_params, device_id=None, user_agent=None, sec_ch_ua=None, impersonate=None): + """启动 OAuth 会话,确保 auth 域上的 login_session 已建立。""" + if device_id: + seed_oai_device_cookie(self.session, device_id) + + has_login_session = False + authorize_final_url = "" + + try: + headers = self._headers( + authorize_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer="https://chatgpt.com/", + navigation=True, + ) + kwargs = {"params": authorize_params, "headers": headers, "allow_redirects": True, "timeout": 30} + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.get(authorize_url, **kwargs) + authorize_final_url = str(r.url) + redirects = len(getattr(r, "history", []) or []) + self._log(f"/oauth/authorize -> {r.status_code}, redirects={redirects}") + + has_login_session = any( + (cookie.name if hasattr(cookie, "name") else str(cookie)) == "login_session" + for cookie in self.session.cookies + ) + self._log(f"login_session: {'已获取' if has_login_session else '未获取'}") + except Exception as e: + self._log(f"/oauth/authorize 异常: {e}") + + if has_login_session: + return authorize_final_url + + self._log("未获取到 login_session,尝试 /api/oauth/oauth2/auth...") + try: + oauth2_url = f"{self.oauth_issuer}/api/oauth/oauth2/auth" + kwargs = { + "params": authorize_params, + "headers": self._headers( + oauth2_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer="https://chatgpt.com/", + navigation=True, + ), + "allow_redirects": True, + "timeout": 30, + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r2 = self.session.get(oauth2_url, **kwargs) + authorize_final_url = str(r2.url) + redirects2 = len(getattr(r2, "history", []) or []) + self._log(f"/api/oauth/oauth2/auth -> {r2.status_code}, redirects={redirects2}") + + has_login_session = any( + (cookie.name if hasattr(cookie, "name") else str(cookie)) == "login_session" + for cookie in self.session.cookies + ) + self._log(f"login_session(重试): {'已获取' if has_login_session else '未获取'}") + except Exception as e: + self._log(f"/api/oauth/oauth2/auth 异常: {e}") + + return authorize_final_url + + def _submit_authorize_continue( + self, + email, + device_id, + continue_referer, + *, + user_agent=None, + sec_ch_ua=None, + impersonate=None, + authorize_url=None, + authorize_params=None, + screen_hint: str | None = None, + ): + """提交邮箱,获取 OAuth 流程的第一页状态。""" + self._log("步骤2: POST /api/accounts/authorize/continue") + + sentinel_token = build_sentinel_token( + self.session, + device_id, + flow="authorize_continue", + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if not sentinel_token: + self._set_error("无法获取 sentinel token (authorize_continue)") + return None + + request_url = f"{self.oauth_issuer}/api/accounts/authorize/continue" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=continue_referer, + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": device_id, + "openai-sentinel-token": sentinel_token, + }, + ) + headers.update(generate_datadog_trace()) + payload = {"username": {"kind": "email", "value": email}} + if screen_hint: + payload["screen_hint"] = screen_hint + + try: + kwargs = {"json": payload, "headers": headers, "timeout": 30, "allow_redirects": False} + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.post(request_url, **kwargs) + self._log(f"/authorize/continue -> {r.status_code}") + + if r.status_code == 400 and "invalid_auth_step" in (r.text or "") and authorize_url and authorize_params: + self._log("invalid_auth_step,重新 bootstrap...") + authorize_final_url = self._bootstrap_oauth_session( + authorize_url, + authorize_params, + device_id=device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + continue_referer = ( + authorize_final_url + if authorize_final_url.startswith(self.oauth_issuer) + else f"{self.oauth_issuer}/log-in" + ) + headers["Referer"] = continue_referer + headers["Sec-Fetch-Site"] = "same-origin" + headers.update(generate_datadog_trace()) + kwargs = {"json": payload, "headers": headers, "timeout": 30, "allow_redirects": False} + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause() + r = self.session.post(request_url, **kwargs) + self._log(f"/authorize/continue(重试) -> {r.status_code}") + + if r.status_code != 200: + self._set_error(f"提交邮箱失败: {r.status_code} - {r.text[:180]}") + return None + + data = r.json() + flow_state = self._state_from_payload(data, current_url=str(r.url) or request_url) + self._log(describe_flow_state(flow_state)) + return flow_state + except Exception as e: + self._set_error(f"提交邮箱异常: {e}") + return None + + def _submit_password_verify(self, password, device_id, *, user_agent=None, sec_ch_ua=None, impersonate=None, referer=None): + """提交密码,获取下一步状态。""" + self._log("步骤3: POST /api/accounts/password/verify") + + sentinel_pwd = build_sentinel_token( + self.session, + device_id, + flow="password_verify", + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if not sentinel_pwd: + self._set_error("无法获取 sentinel token (password_verify)") + return None + + request_url = f"{self.oauth_issuer}/api/accounts/password/verify" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=referer or f"{self.oauth_issuer}/log-in/password", + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": device_id, + "openai-sentinel-token": sentinel_pwd, + }, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = {"json": {"password": password}, "headers": headers, "timeout": 30, "allow_redirects": False} + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.post(request_url, **kwargs) + self._log(f"/password/verify -> {r.status_code}") + + if r.status_code != 200: + self._set_error(f"密码验证失败: {r.status_code} - {r.text[:180]}") + return None + + data = r.json() + flow_state = self._state_from_payload(data, current_url=str(r.url) or request_url) + self._log(f"verify {describe_flow_state(flow_state)}") + return flow_state + except Exception as e: + self._set_error(f"密码验证异常: {e}") + return None + + def login_and_get_tokens(self, email, password, device_id, user_agent=None, sec_ch_ua=None, impersonate=None, skymail_client=None): + """ + 完整的 OAuth 登录流程,获取 tokens + + Args: + email: 邮箱 + password: 密码 + device_id: 设备 ID + user_agent: User-Agent + sec_ch_ua: sec-ch-ua header + impersonate: curl_cffi impersonate 参数 + skymail_client: Skymail 客户端(用于获取 OTP,如果需要) + + Returns: + dict: tokens 字典,包含 access_token, refresh_token, id_token + """ + self.last_error = "" + self._log("开始 OAuth 登录流程...") + + code_verifier, code_challenge = generate_pkce() + oauth_state = secrets.token_urlsafe(32) + authorize_params = { + "response_type": "code", + "client_id": self.oauth_client_id, + "redirect_uri": self.oauth_redirect_uri, + "scope": "openid profile email offline_access", + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": oauth_state, + } + authorize_url = f"{self.oauth_issuer}/oauth/authorize" + + seed_oai_device_cookie(self.session, device_id) + + self._log("步骤1: Bootstrap OAuth session...") + authorize_final_url = self._bootstrap_oauth_session( + authorize_url, + authorize_params, + device_id=device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if not authorize_final_url: + self._set_error("Bootstrap 失败") + return None + + continue_referer = ( + authorize_final_url + if authorize_final_url.startswith(self.oauth_issuer) + else f"{self.oauth_issuer}/log-in" + ) + + state = self._submit_authorize_continue( + email, + device_id, + continue_referer, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + authorize_url=authorize_url, + authorize_params=authorize_params, + ) + if not state: + if not self.last_error: + self._set_error("提交邮箱后未进入有效的 OAuth 状态") + return None + + self._log(f"OAuth 状态起点: {describe_flow_state(state)}") + seen_states = {} + referer = continue_referer + + for step in range(20): + signature = self._state_signature(state) + seen_states[signature] = seen_states.get(signature, 0) + 1 + if seen_states[signature] > 2: + self._set_error(f"OAuth 状态卡住: {describe_flow_state(state)}") + return None + + code = self._extract_code_from_state(state) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤7: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + + if self._state_is_login_password(state): + next_state = self._submit_password_verify( + password, + device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + referer=state.current_url or state.continue_url or referer, + ) + if not next_state: + if not self.last_error: + self._set_error("密码验证后未进入下一步 OAuth 状态") + return None + referer = state.current_url or referer + state = next_state + continue + + if self._state_is_email_otp(state): + if not skymail_client: + self._set_error("当前流程需要邮箱 OTP,但缺少接码客户端") + return None + next_state = self._handle_otp_verification( + email, + device_id, + user_agent, + sec_ch_ua, + impersonate, + skymail_client, + state, + ) + if not next_state: + if not self.last_error: + self._set_error("邮箱 OTP 验证后未进入下一步 OAuth 状态") + return None + referer = state.current_url or referer + state = next_state + continue + + if self._state_is_add_phone(state): + next_state = self._handle_add_phone_verification( + device_id, + user_agent, + sec_ch_ua, + impersonate, + state, + ) + if not next_state: + if not self.last_error: + self._set_error("手机号验证后未进入下一步 OAuth 状态") + return None + referer = state.current_url or referer + state = next_state + continue + + if self._state_requires_navigation(state): + code, next_state = self._follow_flow_state( + state, + referer=referer, + user_agent=user_agent, + impersonate=impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤7: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + referer = state.current_url or referer + state = next_state + self._log(f"follow state -> {describe_flow_state(state)}") + continue + + if self._state_supports_workspace_resolution(state): + self._log("步骤6: 执行 workspace/org 选择") + code, next_state = self._oauth_submit_workspace_and_org( + state.continue_url or state.current_url or f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent", + device_id, + user_agent, + impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤7: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + if next_state: + referer = state.current_url or referer + state = next_state + self._log(f"workspace state -> {describe_flow_state(state)}") + continue + + if not self.last_error: + self._set_error(f"workspace/org 选择失败: {describe_flow_state(state)}") + return None + + self._set_error(f"未支持的 OAuth 状态: {describe_flow_state(state)}") + return None + + self._set_error("OAuth 状态机超出最大步数") + return None + + def login_passwordless_and_get_tokens(self, email, device_id, user_agent=None, sec_ch_ua=None, impersonate=None, skymail_client=None): + """ + 使用 passwordless 邮箱 OTP 完成 OAuth 登录流程,获取 tokens。 + """ + self.last_error = "" + self._log("开始 OAuth Passwordless 登录流程...") + + if not skymail_client: + self._set_error("缺少接码客户端,无法执行 passwordless OTP") + return None + + code_verifier, code_challenge = generate_pkce() + oauth_state = secrets.token_urlsafe(32) + authorize_params = { + "response_type": "code", + "client_id": self.oauth_client_id, + "redirect_uri": self.oauth_redirect_uri, + "scope": "openid profile email offline_access", + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": oauth_state, + "prompt": "login", + "screen_hint": "login", + "login_hint": email, + } + authorize_url = f"{self.oauth_issuer}/oauth/authorize" + + seed_oai_device_cookie(self.session, device_id) + + self._log("步骤1: Bootstrap OAuth session (passwordless)...") + authorize_final_url = self._bootstrap_oauth_session( + authorize_url, + authorize_params, + device_id=device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if not authorize_final_url: + self._set_error("Bootstrap 失败") + return None + + continue_referer = ( + authorize_final_url + if authorize_final_url.startswith(self.oauth_issuer) + else f"{self.oauth_issuer}/log-in" + ) + + state = self._submit_authorize_continue( + email, + device_id, + continue_referer, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + authorize_url=authorize_url, + authorize_params=authorize_params, + screen_hint="login", + ) + if not state: + if not self.last_error: + self._set_error("提交邮箱后未进入有效的 OAuth 状态") + return None + + self._log(f"Passwordless OAuth 状态起点: {describe_flow_state(state)}") + + send_ok, send_detail = self._send_email_otp( + device_id, + user_agent, + sec_ch_ua, + impersonate, + referer=state.current_url or continue_referer, + ) + if not send_ok: + self._set_error(send_detail or "email-otp/send 失败") + return None + + otp_state = self._state_from_url(f"{self.oauth_issuer}/email-verification") + next_state = self._handle_otp_verification( + email, + device_id, + user_agent, + sec_ch_ua, + impersonate, + skymail_client, + otp_state, + ) + if not next_state: + if not self.last_error: + self._set_error("邮箱 OTP 验证后未进入下一步 OAuth 状态") + return None + + state = next_state + referer = state.current_url or continue_referer + + for step in range(20): + code = self._extract_code_from_state(state) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ Passwordless OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + + if self._state_is_add_phone(state): + self._set_error("add_phone_required") + return None + + if self._state_requires_navigation(state): + code, next_state = self._follow_flow_state( + state, + referer=referer, + user_agent=user_agent, + impersonate=impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ Passwordless OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + referer = state.current_url or referer + state = next_state + self._log(f"follow state -> {describe_flow_state(state)}") + continue + + if self._state_supports_workspace_resolution(state): + self._log("执行 workspace/org 选择") + code, next_state = self._oauth_submit_workspace_and_org( + state.continue_url or state.current_url or f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent", + device_id, + user_agent, + impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ Passwordless OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + if next_state: + referer = state.current_url or referer + state = next_state + self._log(f"workspace state -> {describe_flow_state(state)}") + continue + + self._set_error(f"未支持的 Passwordless OAuth 状态: {describe_flow_state(state)}") + return None + + self._set_error("Passwordless OAuth 状态机超出最大步数") + return None + + def _extract_code_from_url(self, url): + """从 URL 中提取 code""" + if not url or "code=" not in url: + return None + try: + return parse_qs(urlparse(url).query).get("code", [None])[0] + except Exception: + return None + + def _oauth_follow_for_code(self, start_url, referer, user_agent, impersonate, max_hops=16): + """跟随 URL 获取 authorization code(手动跟随重定向)""" + code, next_state = self._follow_flow_state( + self._state_from_url(start_url), + referer=referer, + user_agent=user_agent, + impersonate=impersonate, + max_hops=max_hops, + ) + return code, (next_state.current_url or next_state.continue_url or start_url) + + def _oauth_submit_workspace_and_org(self, consent_url, device_id, user_agent, impersonate, max_retries=3): + """提交 workspace 和 organization 选择(带重试)""" + session_data = None + + for attempt in range(max_retries): + session_data = self._load_workspace_session_data( + consent_url=consent_url, + user_agent=user_agent, + impersonate=impersonate, + ) + if session_data: + break + + if attempt < max_retries - 1: + self._log(f"无法获取 consent session 数据 (尝试 {attempt + 1}/{max_retries})") + time.sleep(0.3) + else: + self._set_error("无法获取 consent session 数据") + return None, None + + workspaces = session_data.get("workspaces", []) + if not workspaces: + self._set_error("session 中没有 workspace 信息") + return None, None + + workspace_id = (workspaces[0] or {}).get("id") + if not workspace_id: + self._set_error("workspace_id 为空") + return None, None + + self._log(f"选择 workspace: {workspace_id}") + + headers = self._headers( + f"{self.oauth_issuer}/api/accounts/workspace/select", + user_agent=user_agent, + accept="application/json", + referer=consent_url, + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": device_id, + }, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = { + "json": {"workspace_id": workspace_id}, + "headers": headers, + "allow_redirects": False, + "timeout": 30 + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.post( + f"{self.oauth_issuer}/api/accounts/workspace/select", + **kwargs + ) + + self._log(f"workspace/select -> {r.status_code}") + + # 检查重定向 + if r.status_code in (301, 302, 303, 307, 308): + location = normalize_flow_url(r.headers.get("Location", ""), auth_base=self.oauth_issuer) + if "code=" in location: + code = self._extract_code_from_url(location) + if code: + self._log("从 workspace/select 重定向获取到 code") + return code, self._state_from_url(location) + if location: + return None, self._state_from_url(location) + + # 如果返回 200,检查响应中的 orgs + if r.status_code == 200: + try: + data = r.json() + orgs = data.get("data", {}).get("orgs", []) + workspace_state = self._state_from_payload(data, current_url=str(r.url)) + continue_url = workspace_state.continue_url + + if orgs: + org_id = (orgs[0] or {}).get("id") + projects = (orgs[0] or {}).get("projects", []) + project_id = (projects[0] or {}).get("id") if projects else None + + if org_id: + self._log(f"选择 organization: {org_id}") + + org_body = {"org_id": org_id} + if project_id: + org_body["project_id"] = project_id + + org_referer = continue_url if continue_url and continue_url.startswith("http") else consent_url + headers = self._headers( + f"{self.oauth_issuer}/api/accounts/organization/select", + user_agent=user_agent, + accept="application/json", + referer=org_referer, + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": device_id, + }, + ) + headers.update(generate_datadog_trace()) + + kwargs = { + "json": org_body, + "headers": headers, + "allow_redirects": False, + "timeout": 30 + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r_org = self.session.post( + f"{self.oauth_issuer}/api/accounts/organization/select", + **kwargs + ) + + self._log(f"organization/select -> {r_org.status_code}") + + # 检查重定向 + if r_org.status_code in (301, 302, 303, 307, 308): + location = normalize_flow_url(r_org.headers.get("Location", ""), auth_base=self.oauth_issuer) + if "code=" in location: + code = self._extract_code_from_url(location) + if code: + self._log("从 organization/select 重定向获取到 code") + return code, self._state_from_url(location) + if location: + return None, self._state_from_url(location) + + # 检查 continue_url + if r_org.status_code == 200: + try: + org_state = self._state_from_payload(r_org.json(), current_url=str(r_org.url)) + self._log(f"organization/select -> {describe_flow_state(org_state)}") + if self._extract_code_from_state(org_state): + return self._extract_code_from_state(org_state), org_state + return None, org_state + except Exception as e: + self._set_error(f"解析 organization/select 响应异常: {e}") + + # 如果有 continue_url,跟随它 + if continue_url: + code, _ = self._oauth_follow_for_code(continue_url, consent_url, user_agent, impersonate) + if code: + return code, self._state_from_url(continue_url) + return None, workspace_state + + except Exception as e: + self._set_error(f"处理 workspace/select 响应异常: {e}") + return None, None + + except Exception as e: + self._set_error(f"workspace/select 异常: {e}") + return None, None + + return None, None + + def _load_workspace_session_data(self, consent_url, user_agent, impersonate): + """优先从 cookie 解码 session,失败时回退到 consent HTML 中提取 workspace 数据。""" + session_data = self._decode_oauth_session_cookie() + if session_data and session_data.get("workspaces"): + return session_data + + html = self._fetch_consent_page_html(consent_url, user_agent, impersonate) + if not html: + return session_data + + parsed = self._extract_session_data_from_consent_html(html) + if parsed and parsed.get("workspaces"): + self._log(f"从 consent HTML 提取到 {len(parsed.get('workspaces', []))} 个 workspace") + return parsed + + return session_data + + def _fetch_consent_page_html(self, consent_url, user_agent, impersonate): + """获取 consent 页 HTML,用于解析 React Router stream 中的 session 数据。""" + try: + headers = self._headers( + consent_url, + user_agent=user_agent, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=f"{self.oauth_issuer}/email-verification", + navigation=True, + ) + kwargs = {"headers": headers, "allow_redirects": False, "timeout": 30} + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause(0.12, 0.3) + r = self.session.get(consent_url, **kwargs) + if r.status_code == 200 and "text/html" in (r.headers.get("content-type", "").lower()): + return r.text + except Exception: + pass + return "" + + def _extract_session_data_from_consent_html(self, html): + """从 consent HTML 的 React Router stream 中提取 workspace session 数据。""" + import json + import re + + if not html or "workspaces" not in html: + return None + + def _first_match(patterns, text): + for pattern in patterns: + m = re.search(pattern, text, re.S) + if m: + return m.group(1) + return "" + + def _build_from_text(text): + if not text or "workspaces" not in text: + return None + + normalized = text.replace('\\"', '"') + + session_id = _first_match( + [ + r'"session_id","([^"]+)"', + r'"session_id":"([^"]+)"', + ], + normalized, + ) + client_id = _first_match( + [ + r'"openai_client_id","([^"]+)"', + r'"openai_client_id":"([^"]+)"', + ], + normalized, + ) + + start = normalized.find('"workspaces"') + if start < 0: + start = normalized.find('workspaces') + if start < 0: + return None + + end = normalized.find('"openai_client_id"', start) + if end < 0: + end = normalized.find('openai_client_id', start) + if end < 0: + end = min(len(normalized), start + 4000) + else: + end = min(len(normalized), end + 600) + + workspace_chunk = normalized[start:end] + ids = re.findall(r'"id"(?:,|:)"([0-9a-fA-F-]{36})"', workspace_chunk) + if not ids: + return None + + kinds = re.findall(r'"kind"(?:,|:)"([^"]+)"', workspace_chunk) + workspaces = [] + seen = set() + for idx, wid in enumerate(ids): + if wid in seen: + continue + seen.add(wid) + item = {"id": wid} + if idx < len(kinds): + item["kind"] = kinds[idx] + workspaces.append(item) + + if not workspaces: + return None + + return { + "session_id": session_id, + "openai_client_id": client_id, + "workspaces": workspaces, + } + + candidates = [html] + + for quoted in re.findall( + r'streamController\.enqueue\(("(?:\\.|[^"\\])*")\)', + html, + re.S, + ): + try: + decoded = json.loads(quoted) + except Exception: + continue + if decoded: + candidates.append(decoded) + + if '\\"' in html: + candidates.append(html.replace('\\"', '"')) + + for candidate in candidates: + parsed = _build_from_text(candidate) + if parsed and parsed.get("workspaces"): + return parsed + + return None + + def _decode_oauth_session_cookie(self): + """解码 oai-client-auth-session cookie""" + try: + for cookie in self.session.cookies: + try: + name = cookie.name if hasattr(cookie, 'name') else str(cookie) + if name == "oai-client-auth-session": + value = cookie.value if hasattr(cookie, 'value') else self.session.cookies.get(name) + if value: + data = self._decode_cookie_json_value(value) + if data: + return data + except Exception: + continue + except Exception: + pass + + return None + + @staticmethod + def _decode_cookie_json_value(value): + import base64 + import json + + raw_value = str(value or "").strip() + if not raw_value: + return None + + candidates = [raw_value] + if "." in raw_value: + candidates.insert(0, raw_value.split(".", 1)[0]) + + for candidate in candidates: + candidate = candidate.strip() + if not candidate: + continue + padded = candidate + "=" * (-len(candidate) % 4) + for decoder in (base64.urlsafe_b64decode, base64.b64decode): + try: + decoded = decoder(padded).decode("utf-8") + parsed = json.loads(decoded) + except Exception: + continue + if isinstance(parsed, dict): + return parsed + + return None + + def _exchange_code_for_tokens(self, code, code_verifier, user_agent, impersonate): + """用 authorization code 换取 tokens""" + url = f"{self.oauth_issuer}/oauth/token" + + payload = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.oauth_redirect_uri, + "client_id": self.oauth_client_id, + "code_verifier": code_verifier, + } + + headers = self._headers( + url, + user_agent=user_agent, + accept="application/json", + referer=f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent", + origin=self.oauth_issuer, + content_type="application/x-www-form-urlencoded", + fetch_site="same-origin", + ) + + try: + kwargs = {"data": payload, "headers": headers, "timeout": 60} + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.post(url, **kwargs) + + if r.status_code == 200: + return r.json() + else: + self._set_error(f"换取 tokens 失败: {r.status_code} - {r.text[:200]}") + + except Exception as e: + self._set_error(f"换取 tokens 异常: {e}") + + return None + + def _send_email_otp(self, device_id, user_agent, sec_ch_ua, impersonate, referer=None): + request_url = f"{self.oauth_issuer}/api/accounts/email-otp/send" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=referer or f"{self.oauth_issuer}/log-in", + origin=self.oauth_issuer, + fetch_site="same-origin", + extra_headers={"oai-device-id": device_id} if device_id else None, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = {"headers": headers, "timeout": 30, "allow_redirects": False} + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause(0.12, 0.25) + resp = self.session.get(request_url, **kwargs) + except Exception as e: + return False, f"email-otp/send 异常: {e}" + + self._log(f"/email-otp/send -> {resp.status_code}") + if resp.status_code != 200: + return False, f"email-otp/send 失败: {resp.status_code} - {resp.text[:180]}" + return True, "" + + def _send_phone_number(self, phone, device_id, user_agent, sec_ch_ua, impersonate): + request_url = f"{self.oauth_issuer}/api/accounts/add-phone/send" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=f"{self.oauth_issuer}/add-phone", + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={"oai-device-id": device_id}, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = { + "json": {"phone_number": phone}, + "headers": headers, + "timeout": 30, + "allow_redirects": False, + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause(0.12, 0.25) + resp = self.session.post(request_url, **kwargs) + except Exception as e: + return False, None, f"add-phone/send 异常: {e}" + + self._log(f"/add-phone/send -> {resp.status_code}") + if resp.status_code != 200: + return False, None, f"add-phone/send 失败: {resp.status_code} - {resp.text[:180]}" + + try: + data = resp.json() + except Exception: + return False, None, "add-phone/send 响应不是 JSON" + + next_state = self._state_from_payload(data, current_url=str(resp.url) or request_url) + self._log(f"add-phone/send {describe_flow_state(next_state)}") + return True, next_state, "" + + def _resend_phone_otp(self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState): + request_url = f"{self.oauth_issuer}/api/accounts/phone-otp/resend" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=state.current_url or state.continue_url or f"{self.oauth_issuer}/phone-verification", + origin=self.oauth_issuer, + fetch_site="same-origin", + extra_headers={"oai-device-id": device_id}, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = {"headers": headers, "timeout": 30, "allow_redirects": False} + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause(0.12, 0.25) + resp = self.session.post(request_url, **kwargs) + except Exception as e: + return False, f"phone-otp/resend 异常: {e}" + + self._log(f"/phone-otp/resend -> {resp.status_code}") + if resp.status_code == 200: + return True, "" + return False, f"phone-otp/resend 失败: {resp.status_code} - {resp.text[:180]}" + + def _validate_phone_otp(self, code, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState): + request_url = f"{self.oauth_issuer}/api/accounts/phone-otp/validate" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=state.current_url or state.continue_url or f"{self.oauth_issuer}/phone-verification", + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={"oai-device-id": device_id}, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = { + "json": {"code": code}, + "headers": headers, + "timeout": 30, + "allow_redirects": False, + } + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause(0.12, 0.25) + resp = self.session.post(request_url, **kwargs) + except Exception as e: + return False, None, f"phone-otp/validate 异常: {e}" + + self._log(f"/phone-otp/validate -> {resp.status_code}") + if resp.status_code != 200: + if resp.status_code == 401: + return False, None, "手机号验证码错误" + return False, None, f"phone-otp/validate 失败: {resp.status_code} - {resp.text[:180]}" + + try: + data = resp.json() + except Exception: + return False, None, "phone-otp/validate 响应不是 JSON" + + next_state = self._state_from_payload(data, current_url=str(resp.url) or request_url) + self._log(f"手机号 OTP 验证通过 {describe_flow_state(next_state)}") + return True, next_state, "" + + def _handle_add_phone_verification(self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState): + """ + add_phone 阶段处理(已禁用自动手机号验证)。 + 按要求:不将手机号验证失败视为硬失败,记录状态后交由上层处理。 + """ + self._set_error("add_phone_required") + return None + + def _handle_otp_verification(self, email, device_id, user_agent, sec_ch_ua, impersonate, skymail_client, state): + """处理 OAuth 阶段的邮箱 OTP 验证,返回服务端声明的下一步状态。""" + self._log("步骤4: 检测到邮箱 OTP 验证") + + request_url = f"{self.oauth_issuer}/api/accounts/email-otp/validate" + headers_otp = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=state.current_url or state.continue_url or f"{self.oauth_issuer}/email-verification", + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": device_id, + }, + ) + headers_otp.update(generate_datadog_trace()) + + if not hasattr(skymail_client, "_used_codes"): + skymail_client._used_codes = set() + + tried_codes = set(getattr(skymail_client, "_used_codes", set())) + otp_deadline = time.time() + 60 + otp_sent_at = time.time() + + def validate_otp(code): + tried_codes.add(code) + self._log(f"尝试 OTP: {code}") + + try: + kwargs = { + "json": {"code": code}, + "headers": headers_otp, + "timeout": 30, + "allow_redirects": False, + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause(0.12, 0.25) + resp_otp = self.session.post(request_url, **kwargs) + except Exception as e: + self._log(f"email-otp/validate 异常: {e}") + return None + + self._log(f"/email-otp/validate -> {resp_otp.status_code}") + if resp_otp.status_code != 200: + self._log(f"OTP 无效: {resp_otp.text[:160]}") + return None + + try: + otp_data = resp_otp.json() + except Exception: + self._log("email-otp/validate 响应不是 JSON") + return None + + next_state = self._state_from_payload( + otp_data, + current_url=str(resp_otp.url) or (state.current_url or state.continue_url or request_url), + ) + self._log(f"OTP 验证通过 {describe_flow_state(next_state)}") + skymail_client._used_codes.add(code) + return next_state + + if hasattr(skymail_client, "wait_for_verification_code"): + self._log("使用 wait_for_verification_code 进行阻塞式获取新验证码...") + while time.time() < otp_deadline: + remaining = max(1, int(otp_deadline - time.time())) + wait_time = min(10, remaining) + try: + code = skymail_client.wait_for_verification_code( + email, + timeout=wait_time, + otp_sent_at=otp_sent_at, + exclude_codes=tried_codes, + ) + except Exception as e: + self._log(f"等待 OTP 异常: {e}") + code = None + + if not code: + self._log("暂未收到新的 OTP,继续等待...") + if self.last_error: + break + continue + + if code in tried_codes: + self._log(f"跳过已尝试验证码: {code}") + continue + + next_state = validate_otp(code) + if next_state: + return next_state + if self.last_error: + break + else: + while time.time() < otp_deadline: + messages = skymail_client.fetch_emails(email) or [] + candidate_codes = [] + + for msg in messages[:12]: + content = msg.get("content") or msg.get("text") or "" + code = skymail_client.extract_verification_code(content) + if code and code not in tried_codes: + candidate_codes.append(code) + + if not candidate_codes: + elapsed = int(60 - max(0, otp_deadline - time.time())) + self._log(f"等待新的 OTP... ({elapsed}s/60s)") + time.sleep(2) + continue + + for otp_code in candidate_codes: + next_state = validate_otp(otp_code) + if next_state: + return next_state + + time.sleep(2) + if self.last_error: + break + + if not self.last_error: + self._set_error(f"OAuth 阶段 OTP 验证失败,已尝试 {len(tried_codes)} 个验证码") + return None diff --git a/src/core/anyauto/register_flow.py b/src/core/anyauto/register_flow.py new file mode 100644 index 00000000..f4ff1768 --- /dev/null +++ b/src/core/anyauto/register_flow.py @@ -0,0 +1,405 @@ +""" +Any-auto-register 风格注册流程(V2)。 +以状态机 + Session 复用为主,必要时回退 OAuth。 +""" + +from __future__ import annotations + +import secrets +import string +import time +from datetime import datetime +from typing import Optional, Callable, Dict, Any + +from .chatgpt_client import ChatGPTClient +from .oauth_client import OAuthClient +from .utils import generate_random_name, generate_random_birthday, decode_jwt_payload +from ...config.constants import PASSWORD_CHARSET, PASSWORD_SPECIAL_CHARSET, DEFAULT_PASSWORD_LENGTH +from ...config.settings import get_settings + + +class EmailServiceAdapter: + """将 codex-console 邮箱服务适配成 any-auto-register 预期接口。""" + + def __init__(self, email_service, email: str, email_id: Optional[str], log_fn: Callable[[str], None]): + self.es = email_service + self.email = email + self.email_id = email_id + self.log_fn = log_fn or (lambda _msg: None) + self._used_codes: set[str] = set() + + def wait_for_verification_code(self, email, timeout=60, otp_sent_at=None, exclude_codes=None): + exclude = set(exclude_codes or []) + exclude.update(self._used_codes) + deadline = time.time() + max(1, int(timeout)) + sent_at = otp_sent_at or time.time() + + while time.time() < deadline: + remaining = max(1, int(deadline - time.time())) + code = self.es.get_verification_code( + email=email, + email_id=self.email_id, + timeout=remaining, + otp_sent_at=sent_at, + ) + if not code: + return None + if code in exclude: + exclude.add(code) + continue + self._used_codes.add(code) + self.log_fn(f"成功获取验证码: {code}") + return code + return None + + +class AnyAutoRegistrationEngine: + def __init__( + self, + email_service, + proxy_url: Optional[str] = None, + callback_logger: Optional[Callable[[str], None]] = None, + max_retries: int = 3, + browser_mode: str = "protocol", + extra_config: Optional[Dict[str, Any]] = None, + ): + self.email_service = email_service + self.proxy_url = proxy_url + self.callback_logger = callback_logger or (lambda _msg: None) + self.max_retries = max(1, int(max_retries or 1)) + self.browser_mode = browser_mode or "protocol" + self.extra_config = dict(extra_config or {}) + + self.email: Optional[str] = None + self.inbox_email: Optional[str] = None + self.email_info: Optional[Dict[str, Any]] = None + self.password: Optional[str] = None + self.session = None + self.device_id: Optional[str] = None + + def _log(self, message: str): + if self.callback_logger: + self.callback_logger(message) + + @staticmethod + def _build_password(length: int) -> str: + length = max(8, int(length or DEFAULT_PASSWORD_LENGTH)) + password_chars = [ + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice(string.digits), + secrets.choice(PASSWORD_SPECIAL_CHARSET), + ] + password_chars.extend(secrets.choice(PASSWORD_CHARSET) for _ in range(length - len(password_chars))) + secrets.SystemRandom().shuffle(password_chars) + return "".join(password_chars) + + @staticmethod + def _should_retry(message: str) -> bool: + text = str(message or "").lower() + retriable_markers = [ + "tls", + "ssl", + "curl: (35)", + "预授权被拦截", + "authorize", + "registration_disallowed", + "http 400", + "创建账号失败", + "未获取到 authorization code", + "consent", + "workspace", + "organization", + "otp", + "验证码", + "session", + "accesstoken", + "next-auth", + ] + return any(marker.lower() in text for marker in retriable_markers) + + @staticmethod + def _extract_account_id_from_token(token: str) -> str: + payload = decode_jwt_payload(token) + if not isinstance(payload, dict): + return "" + auth_claims = payload.get("https://api.openai.com/auth") or {} + for key in ("chatgpt_account_id", "account_id", "workspace_id"): + value = str(auth_claims.get(key) or payload.get(key) or "").strip() + if value: + return value + return "" + + @staticmethod + def _is_phone_required_error(message: str) -> bool: + text = str(message or "").lower() + return any( + marker in text + for marker in ( + "add_phone", + "add-phone", + "phone", + "phone required", + "phone verification", + "手机号", + ) + ) + + def _passwordless_oauth_reauth( + self, + chatgpt_client: ChatGPTClient, + email: str, + skymail_adapter: EmailServiceAdapter, + oauth_config: Dict[str, Any], + ) -> Optional[Dict[str, Any]]: + self._log("检测到 add_phone,尝试 passwordless OTP 登录补全 workspace...") + oauth_client = OAuthClient( + config=oauth_config, + proxy=self.proxy_url, + verbose=False, + browser_mode=self.browser_mode, + ) + oauth_client._log = self._log + + tokens = oauth_client.login_passwordless_and_get_tokens( + email, + chatgpt_client.device_id, + chatgpt_client.ua, + chatgpt_client.sec_ch_ua, + chatgpt_client.impersonate, + skymail_adapter, + ) + if tokens and tokens.get("access_token"): + return { + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token", ""), + "id_token": tokens.get("id_token", ""), + "session": oauth_client.session, + } + + if oauth_client.last_error: + self._log(f"Passwordless OAuth 失败: {oauth_client.last_error}") + return None + + def run(self): + """ + 执行 any-auto-register 风格注册流程。 + 返回 dict:包含 result(RegistrationResult 填充所需字段) + 额外上下文。 + """ + last_error = "" + settings = get_settings() + password_len = int(getattr(settings, "registration_default_password_length", DEFAULT_PASSWORD_LENGTH) or DEFAULT_PASSWORD_LENGTH) + + oauth_config = dict(self.extra_config or {}) + if not oauth_config: + oauth_config = { + "oauth_issuer": str(getattr(settings, "openai_auth_url", "") or "https://auth.openai.com"), + "oauth_client_id": str(getattr(settings, "openai_client_id", "") or "app_EMoamEEZ73f0CkXaXp7hrann"), + "oauth_redirect_uri": str(getattr(settings, "openai_redirect_uri", "") or "http://localhost:1455/auth/callback"), + } + + for attempt in range(self.max_retries): + try: + if attempt == 0: + self._log("=" * 60) + self._log("开始注册流程 V2 (Session 复用直取 AccessToken)") + self._log(f"请求模式: {self.browser_mode}") + self._log("=" * 60) + else: + self._log(f"整流程重试 {attempt + 1}/{self.max_retries} ...") + time.sleep(1) + + # 1. 创建邮箱 + self.email_info = self.email_service.create_email() + raw_email = str((self.email_info or {}).get("email") or "").strip() + if not raw_email: + last_error = "创建邮箱失败" + return {"success": False, "error_message": last_error} + + normalized_email = raw_email.lower() + self.inbox_email = raw_email + self.email = normalized_email + try: + self.email_info["email"] = normalized_email + except Exception: + pass + + if raw_email != normalized_email: + self._log(f"邮箱规范化: {raw_email} -> {normalized_email}") + + # 2. 生成密码 & 用户信息 + self.password = self.password or self._build_password(password_len) + first_name, last_name = generate_random_name() + birthdate = generate_random_birthday() + self._log(f"邮箱: {normalized_email}, 密码: {self.password}") + self._log(f"注册信息: {first_name} {last_name}, 生日: {birthdate}") + + # 3. 邮箱适配器 + email_id = (self.email_info or {}).get("service_id") + skymail_adapter = EmailServiceAdapter(self.email_service, normalized_email, email_id, self._log) + + # 4. 注册状态机 + chatgpt_client = ChatGPTClient( + proxy=self.proxy_url, + verbose=False, + browser_mode=self.browser_mode, + ) + chatgpt_client._log = self._log + + self._log("步骤 1/2: 执行注册状态机...") + success, msg = chatgpt_client.register_complete_flow( + normalized_email, self.password, first_name, last_name, birthdate, skymail_adapter + ) + if not success: + last_error = f"注册流失败: {msg}" + if attempt < self.max_retries - 1 and self._should_retry(msg): + self._log(f"注册流失败,准备整流程重试: {msg}") + continue + return {"success": False, "error_message": last_error} + + add_phone_required = "add_phone" in str(msg or "").lower() + try: + state = getattr(chatgpt_client, "last_registration_state", None) + if state: + target = f"{getattr(state, 'continue_url', '')} {getattr(state, 'current_url', '')}".lower() + if "add-phone" in target or "add_phone" in str(getattr(state, "page_type", "")).lower(): + add_phone_required = True + except Exception: + pass + + # 保存会话与设备 + self.session = chatgpt_client.session + self.device_id = chatgpt_client.device_id + + if add_phone_required: + pwdless = self._passwordless_oauth_reauth( + chatgpt_client, + normalized_email, + skymail_adapter, + oauth_config, + ) + if pwdless and pwdless.get("access_token"): + self.session = pwdless.get("session") or self.session + return { + "success": True, + "access_token": pwdless.get("access_token", ""), + "refresh_token": pwdless.get("refresh_token", ""), + "id_token": pwdless.get("id_token", ""), + } + + # 5. 复用 session 取 token + self._log("步骤 2/2: 优先复用注册会话提取 ChatGPT Session / AccessToken...") + session_ok, session_result = chatgpt_client.reuse_session_and_get_tokens() + if session_ok: + self._log("Token 提取完成!") + account_id = str(session_result.get("account_id", "") or "").strip() + if not account_id: + account_id = str(session_result.get("workspace_id", "") or "").strip() + if not account_id: + account_id = self._extract_account_id_from_token(session_result.get("access_token", "")) + workspace_id = str(session_result.get("workspace_id", "") or "").strip() or account_id + return { + "success": True, + "access_token": session_result.get("access_token", ""), + "session_token": session_result.get("session_token", ""), + "account_id": account_id, + "workspace_id": workspace_id, + "metadata": { + "auth_provider": session_result.get("auth_provider", ""), + "expires": session_result.get("expires", ""), + "user_id": session_result.get("user_id", ""), + "user": session_result.get("user") or {}, + "account": session_result.get("account") or {}, + }, + } + + # 6. OAuth 回退 + self._log(f"复用会话失败,回退到 OAuth 登录补全流程: {session_result}") + tokens = None + oauth_client = None + for oauth_attempt in range(2): + if oauth_attempt > 0: + self._log(f"同账号 OAuth 重试 {oauth_attempt + 1}/2 ...") + time.sleep(1) + + oauth_client = OAuthClient( + config=oauth_config, + proxy=self.proxy_url, + verbose=False, + browser_mode=self.browser_mode, + ) + oauth_client._log = self._log + oauth_client.session = chatgpt_client.session + + tokens = oauth_client.login_and_get_tokens( + normalized_email, + self.password, + chatgpt_client.device_id, + chatgpt_client.ua, + chatgpt_client.sec_ch_ua, + chatgpt_client.impersonate, + skymail_adapter, + ) + if tokens and tokens.get("access_token"): + break + + if oauth_client.last_error and "add_phone" in oauth_client.last_error: + break + + if tokens and tokens.get("access_token"): + self._log("OAuth 回退补全成功!") + workspace_id = "" + session_cookie = "" + try: + session_data = oauth_client._decode_oauth_session_cookie() + if session_data: + workspaces = session_data.get("workspaces", []) + if workspaces: + workspace_id = str((workspaces[0] or {}).get("id") or "") + if workspace_id: + self._log(f"成功萃取 Workspace ID: {workspace_id}") + except Exception: + pass + + try: + for cookie in oauth_client.session.cookies.jar: + if cookie.name == "__Secure-next-auth.session-token": + session_cookie = cookie.value + break + except Exception: + pass + + account_id = self._extract_account_id_from_token(tokens.get("access_token", "")) or workspace_id + return { + "success": True, + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token", ""), + "id_token": tokens.get("id_token", ""), + "account_id": account_id or ("v2_acct_" + chatgpt_client.device_id[:8]), + "workspace_id": workspace_id or account_id, + "session_token": session_cookie, + } + + # 7. 手机号验证需求:按成功返回,但标记为待补全 + if oauth_client and self._is_phone_required_error(oauth_client.last_error): + self._log("检测到手机号验证需求,按成功返回并标记待补全") + return { + "success": True, + "metadata": { + "phone_verification_required": True, + "token_pending": True, + "oauth_error": oauth_client.last_error, + }, + } + + last_error = str(getattr(oauth_client, "last_error", "") or "").strip() or "获取最终 OAuth Tokens 失败" + return {"success": False, "error_message": f"账号已创建成功,但 {last_error}"} + + except Exception as attempt_error: + last_error = str(attempt_error) + if attempt < self.max_retries - 1 and self._should_retry(last_error): + self._log(f"本轮出现异常,准备整流程重试: {last_error}") + continue + return {"success": False, "error_message": last_error} + + return {"success": False, "error_message": last_error or "注册失败"} diff --git a/src/core/anyauto/sentinel_token.py b/src/core/anyauto/sentinel_token.py new file mode 100644 index 00000000..bf4bd1c9 --- /dev/null +++ b/src/core/anyauto/sentinel_token.py @@ -0,0 +1,206 @@ +""" +Sentinel Token 生成器模块 +基于对 sentinel.openai.com SDK 的逆向分析 +""" + +import json +import time +import uuid +import random +import base64 +import hashlib + + +class SentinelTokenGenerator: + """ + Sentinel Token 纯 Python 生成器 + + 通过逆向 sentinel SDK 的 PoW 算法,纯 Python 构造合法的 openai-sentinel-token。 + """ + + MAX_ATTEMPTS = 500000 # 最大 PoW 尝试次数 + ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" # SDK 中的错误前缀常量 + + def __init__(self, device_id=None, user_agent=None): + self.device_id = device_id or str(uuid.uuid4()) + self.user_agent = user_agent or ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/145.0.0.0 Safari/537.36" + ) + self.requirements_seed = str(random.random()) + self.sid = str(uuid.uuid4()) + + @staticmethod + def _fnv1a_32(text): + """ + FNV-1a 32位哈希算法(从 SDK JS 逆向还原) + """ + h = 2166136261 # FNV offset basis + for ch in text: + code = ord(ch) + h ^= code + h = (h * 16777619) & 0xFFFFFFFF + + # xorshift 混合(murmurhash3 finalizer) + h ^= h >> 16 + h = (h * 2246822507) & 0xFFFFFFFF + h ^= h >> 13 + h = (h * 3266489909) & 0xFFFFFFFF + h ^= h >> 16 + h = h & 0xFFFFFFFF + + return format(h, "08x") + + def _get_config(self): + """构造浏览器环境数据数组""" + from datetime import datetime, timezone + + screen_info = "1920x1080" + now = datetime.now(timezone.utc) + date_str = now.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)") + js_heap_limit = 4294705152 + nav_random1 = random.random() + ua = self.user_agent + script_src = "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js" + script_version = None + data_build = None + language = "en-US" + languages = "en-US,en" + nav_random2 = random.random() + + nav_props = [ + "vendorSub", "productSub", "vendor", "maxTouchPoints", + "scheduling", "userActivation", "doNotTrack", "geolocation", + "connection", "plugins", "mimeTypes", "pdfViewerEnabled", + "webkitTemporaryStorage", "webkitPersistentStorage", + "hardwareConcurrency", "cookieEnabled", "credentials", + "mediaDevices", "permissions", "locks", "ink", + ] + nav_prop = random.choice(nav_props) + nav_val = f"{nav_prop}−undefined" + + doc_key = random.choice(["location", "implementation", "URL", "documentURI", "compatMode"]) + win_key = random.choice(["Object", "Function", "Array", "Number", "parseFloat", "undefined"]) + perf_now = random.uniform(1000, 50000) + hardware_concurrency = random.choice([4, 8, 12, 16]) + time_origin = time.time() * 1000 - perf_now + + config = [ + screen_info, date_str, js_heap_limit, nav_random1, ua, + script_src, script_version, data_build, language, languages, + nav_random2, nav_val, doc_key, win_key, perf_now, + self.sid, "", hardware_concurrency, time_origin, + ] + return config + + @staticmethod + def _base64_encode(data): + """模拟 SDK 的 E() 函数:JSON.stringify → TextEncoder.encode → btoa""" + json_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + encoded = json_str.encode("utf-8") + return base64.b64encode(encoded).decode("ascii") + + def _run_check(self, start_time, seed, difficulty, config, nonce): + """单次 PoW 检查""" + config[3] = nonce + config[9] = round((time.time() - start_time) * 1000) + data = self._base64_encode(config) + hash_input = seed + data + hash_hex = self._fnv1a_32(hash_input) + diff_len = len(difficulty) + if hash_hex[:diff_len] <= difficulty: + return data + "~S" + return None + + def generate_token(self, seed=None, difficulty=None): + """生成 sentinel token(完整 PoW 流程)""" + if seed is None: + seed = self.requirements_seed + difficulty = difficulty or "0" + + start_time = time.time() + config = self._get_config() + + for i in range(self.MAX_ATTEMPTS): + result = self._run_check(start_time, seed, difficulty, config, i) + if result: + return "gAAAAAB" + result + + return "gAAAAAB" + self.ERROR_PREFIX + self._base64_encode(str(None)) + + def generate_requirements_token(self): + """生成 requirements token(不需要服务端参数)""" + config = self._get_config() + config[3] = 1 + config[9] = round(random.uniform(5, 50)) + data = self._base64_encode(config) + return "gAAAAAC" + data + + +def fetch_sentinel_challenge(session, device_id, flow="authorize_continue", user_agent=None, sec_ch_ua=None, impersonate=None): + """调用 sentinel 后端 API 获取 challenge 数据""" + generator = SentinelTokenGenerator(device_id=device_id, user_agent=user_agent) + req_body = { + "p": generator.generate_requirements_token(), + "id": device_id, + "flow": flow, + } + + headers = { + "Content-Type": "text/plain;charset=UTF-8", + "Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html", + "Origin": "https://sentinel.openai.com", + "User-Agent": user_agent or "Mozilla/5.0", + "sec-ch-ua": sec_ch_ua or '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + } + + kwargs = { + "data": json.dumps(req_body), + "headers": headers, + "timeout": 20, + } + if impersonate: + kwargs["impersonate"] = impersonate + + try: + resp = session.post("https://sentinel.openai.com/backend-api/sentinel/req", **kwargs) + if resp.status_code == 200: + return resp.json() + except Exception: + pass + + return None + + +def build_sentinel_token(session, device_id, flow="authorize_continue", user_agent=None, sec_ch_ua=None, impersonate=None): + """构建完整的 openai-sentinel-token JSON 字符串""" + challenge = fetch_sentinel_challenge(session, device_id, flow=flow, user_agent=user_agent, sec_ch_ua=sec_ch_ua, impersonate=impersonate) + + if not challenge: + return None + + c_value = challenge.get("token", "") + if not c_value: + return None + + pow_data = challenge.get("proofofwork") or {} + generator = SentinelTokenGenerator(device_id=device_id, user_agent=user_agent) + + if pow_data.get("required") and pow_data.get("seed"): + p_value = generator.generate_token( + seed=pow_data.get("seed"), + difficulty=pow_data.get("difficulty", "0"), + ) + else: + p_value = generator.generate_requirements_token() + + return json.dumps({ + "p": p_value, + "t": "", + "c": c_value, + "id": device_id, + "flow": flow, + }, separators=(",", ":")) diff --git a/src/core/anyauto/utils.py b/src/core/anyauto/utils.py new file mode 100644 index 00000000..7d2a4a39 --- /dev/null +++ b/src/core/anyauto/utils.py @@ -0,0 +1,362 @@ +""" +通用工具函数模块 +""" + +from dataclasses import dataclass, field +import random +import string +import secrets +import hashlib +import base64 +import uuid +import re +from urllib.parse import urlparse +from typing import Any, Dict + + +@dataclass +class FlowState: + """OpenAI Auth/Registration 流程中的页面状态。""" + + page_type: str = "" + continue_url: str = "" + method: str = "GET" + current_url: str = "" + source: str = "" + payload: Dict[str, Any] = field(default_factory=dict) + raw: Dict[str, Any] = field(default_factory=dict) + + +def generate_device_id(): + """生成设备唯一标识(oai-did),UUID v4 格式""" + return str(uuid.uuid4()) + + +def generate_random_password(length=16): + """生成符合 OpenAI 要求的随机密码""" + chars = string.ascii_letters + string.digits + "!@#$%" + pwd = list( + random.choice(string.ascii_uppercase) + + random.choice(string.ascii_lowercase) + + random.choice(string.digits) + + random.choice("!@#$%") + + "".join(random.choice(chars) for _ in range(length - 4)) + ) + random.shuffle(pwd) + return "".join(pwd) + + +def generate_random_name(): + """随机生成自然的英文姓名,返回 (first_name, last_name)""" + first = [ + "James", "Robert", "John", "Michael", "David", "William", "Richard", + "Mary", "Jennifer", "Linda", "Elizabeth", "Susan", "Jessica", "Sarah", + "Emily", "Emma", "Olivia", "Sophia", "Liam", "Noah", "Oliver", "Ethan", + ] + last = [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", + "Davis", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Martin", + ] + return random.choice(first), random.choice(last) + + +def generate_random_birthday(): + """生成随机生日字符串,格式 YYYY-MM-DD(20~30岁)""" + year = random.randint(1996, 2006) + month = random.randint(1, 12) + day = random.randint(1, 28) + return f"{year:04d}-{month:02d}-{day:02d}" + + +def generate_datadog_trace(): + """生成 Datadog APM 追踪头""" + trace_id = str(random.getrandbits(64)) + parent_id = str(random.getrandbits(64)) + trace_hex = format(int(trace_id), "016x") + parent_hex = format(int(parent_id), "016x") + return { + "traceparent": f"00-0000000000000000{trace_hex}-{parent_hex}-01", + "tracestate": "dd=s:1;o:rum", + "x-datadog-origin": "rum", + "x-datadog-parent-id": parent_id, + "x-datadog-sampling-priority": "1", + "x-datadog-trace-id": trace_id, + } + + +def generate_pkce(): + """生成 PKCE code_verifier 和 code_challenge""" + code_verifier = ( + base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii") + ) + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return code_verifier, code_challenge + + +def decode_jwt_payload(token): + """解析 JWT token 的 payload 部分""" + try: + parts = token.split(".") + if len(parts) != 3: + return {} + payload = parts[1] + padding = 4 - len(payload) % 4 + if padding != 4: + payload += "=" * padding + decoded = base64.urlsafe_b64decode(payload) + import json + return json.loads(decoded) + except Exception: + return {} + + +def extract_code_from_url(url): + """从 URL 中提取 authorization code""" + if not url or "code=" not in url: + return None + try: + from urllib.parse import urlparse, parse_qs + return parse_qs(urlparse(url).query).get("code", [None])[0] + except Exception: + return None + + +def normalize_page_type(value): + """将 page.type 归一化为便于分支判断的 snake_case。""" + return str(value or "").strip().lower().replace("-", "_").replace("/", "_").replace(" ", "_") + + +def normalize_flow_url(url, auth_base="https://auth.openai.com"): + """将 continue_url / payload.url 归一化成绝对 URL。""" + value = str(url or "").strip() + if not value: + return "" + if value.startswith("//"): + return f"https:{value}" + if value.startswith("/"): + return f"{auth_base.rstrip('/')}{value}" + return value + + +def infer_page_type_from_url(url): + """从 URL 推断流程状态,用于服务端未返回 page.type 时兜底。""" + if not url: + return "" + + try: + parsed = urlparse(url) + except Exception: + return "" + + host = (parsed.netloc or "").lower() + path = (parsed.path or "").lower() + + if "code=" in (parsed.query or ""): + return "oauth_callback" + if "chatgpt.com" in host and "/api/auth/callback/" in path: + return "callback" + if "create-account/password" in path: + return "create_account_password" + if "email-verification" in path or "email-otp" in path: + return "email_otp_verification" + if "about-you" in path: + return "about_you" + if "log-in/password" in path: + return "login_password" + if "sign-in-with-chatgpt" in path and "consent" in path: + return "consent" + if "workspace" in path and "select" in path: + return "workspace_selection" + if "organization" in path and "select" in path: + return "organization_selection" + if "add-phone" in path: + return "add_phone" + if "callback" in path: + return "callback" + if "chatgpt.com" in host and path in {"", "/"}: + return "chatgpt_home" + if path: + return normalize_page_type(path.strip("/").replace("/", "_")) + return "" + + +def extract_flow_state(data=None, current_url="", auth_base="https://auth.openai.com", default_method="GET"): + """从 API 响应或 URL 中提取统一的流程状态。""" + raw = data if isinstance(data, dict) else {} + page = raw.get("page") or {} + payload = page.get("payload") or {} + + continue_url = normalize_flow_url( + raw.get("continue_url") or payload.get("url") or "", + auth_base=auth_base, + ) + effective_current_url = continue_url if raw and continue_url else current_url + current = normalize_flow_url(effective_current_url or continue_url, auth_base=auth_base) + page_type = normalize_page_type(page.get("type")) or infer_page_type_from_url(continue_url or current) + method = str(raw.get("method") or payload.get("method") or default_method or "GET").upper() + + return FlowState( + page_type=page_type, + continue_url=continue_url, + method=method, + current_url=current, + source="api" if raw else "url", + payload=payload if isinstance(payload, dict) else {}, + raw=raw, + ) + + +def describe_flow_state(state: FlowState): + """生成简短的流程状态描述,便于记录日志。""" + target = state.continue_url or state.current_url or "-" + return f"page={state.page_type or '-'} method={state.method or '-'} next={target[:80]}..." + + +def random_delay(low=0.3, high=1.0): + """随机延迟""" + import time + time.sleep(random.uniform(low, high)) + + +def extract_chrome_full_version(user_agent): + """从 UA 中提取完整的 Chrome 版本号。""" + if not user_agent: + return "" + match = re.search(r"Chrome/([0-9.]+)", user_agent) + return match.group(1) if match else "" + + +def _registrable_domain(hostname): + """粗略提取可注册域名,用于推断 Sec-Fetch-Site。""" + if not hostname: + return "" + host = hostname.split(":")[0].strip(".").lower() + parts = [part for part in host.split(".") if part] + if len(parts) <= 2: + return ".".join(parts) + return ".".join(parts[-2:]) + + +def infer_sec_fetch_site(url, referer=None, navigation=False): + """根据目标 URL 和 Referer 推断 Sec-Fetch-Site。""" + if not referer: + return "none" if navigation else "same-origin" + + try: + target = urlparse(url or "") + source = urlparse(referer or "") + + if not target.scheme or not target.netloc or not source.netloc: + return "none" if navigation else "same-origin" + + if (target.scheme, target.netloc) == (source.scheme, source.netloc): + return "same-origin" + + if _registrable_domain(target.hostname) == _registrable_domain(source.hostname): + return "same-site" + except Exception: + pass + + return "cross-site" + + +def build_sec_ch_ua_full_version_list(sec_ch_ua, chrome_full_version): + """根据 sec-ch-ua 生成 sec-ch-ua-full-version-list。""" + if not sec_ch_ua or not chrome_full_version: + return "" + + entries = [] + for brand, version in re.findall(r'"([^"]+)";v="([^"]+)"', sec_ch_ua): + full_version = chrome_full_version if brand in {"Chromium", "Google Chrome"} else f"{version}.0.0.0" + entries.append(f'"{brand}";v="{full_version}"') + + return ", ".join(entries) + + +def build_browser_headers( + *, + url, + user_agent, + sec_ch_ua=None, + chrome_full_version=None, + accept=None, + accept_language="en-US,en;q=0.9", + referer=None, + origin=None, + content_type=None, + navigation=False, + fetch_mode=None, + fetch_dest=None, + fetch_site=None, + headed=False, + extra_headers=None, +): + """构造更接近真实 Chrome 有头浏览器的请求头。""" + chrome_full = chrome_full_version or extract_chrome_full_version(user_agent) + full_version_list = build_sec_ch_ua_full_version_list(sec_ch_ua, chrome_full) + + headers = { + "User-Agent": user_agent or "Mozilla/5.0", + "Accept-Language": accept_language, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + } + + if accept: + headers["Accept"] = accept + if referer: + headers["Referer"] = referer + if origin: + headers["Origin"] = origin + if content_type: + headers["Content-Type"] = content_type + if sec_ch_ua: + headers["sec-ch-ua"] = sec_ch_ua + if chrome_full: + headers["sec-ch-ua-full-version"] = f'"{chrome_full}"' + headers["sec-ch-ua-platform-version"] = '"15.0.0"' + if full_version_list: + headers["sec-ch-ua-full-version-list"] = full_version_list + + if navigation: + headers["Sec-Fetch-Dest"] = "document" + headers["Sec-Fetch-Mode"] = "navigate" + headers["Sec-Fetch-User"] = "?1" + headers["Upgrade-Insecure-Requests"] = "1" + headers["Cache-Control"] = "max-age=0" + else: + headers["Sec-Fetch-Dest"] = fetch_dest or "empty" + headers["Sec-Fetch-Mode"] = fetch_mode or "cors" + + headers["Sec-Fetch-Site"] = fetch_site or infer_sec_fetch_site(url, referer, navigation=navigation) + + if headed: + headers.setdefault("Priority", "u=0, i" if navigation else "u=1, i") + headers.setdefault("DNT", "1") + headers.setdefault("Sec-GPC", "1") + + if extra_headers: + for key, value in extra_headers.items(): + if value is not None: + headers[key] = value + + return headers + + +def seed_oai_device_cookie(session, device_id): + """在 ChatGPT / OpenAI 相关域上同步设置 oai-did。""" + for domain in ( + "chatgpt.com", + ".chatgpt.com", + "openai.com", + ".openai.com", + "auth.openai.com", + ".auth.openai.com", + ): + try: + session.cookies.set("oai-did", device_id, domain=domain) + except Exception: + continue diff --git a/src/core/auto_registration.py b/src/core/auto_registration.py new file mode 100644 index 00000000..8535e9cd --- /dev/null +++ b/src/core/auto_registration.py @@ -0,0 +1,297 @@ +import asyncio +import logging +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Awaitable, Callable, Optional + +from ..config.settings import Settings, get_settings +from .upload.cpa_upload import count_ready_cpa_auth_files, list_cpa_auth_files +from ..database import crud +from ..database.session import get_db + +logger = logging.getLogger(__name__) +AUTO_REGISTRATION_CHANNEL = "auto-registration" +_auto_registration_state = { + "enabled": False, + "status": "idle", + "message": "自动注册未启动", + "current_batch_id": None, + "current_ready_count": None, + "target_ready_count": None, + "last_checked_at": None, + "last_triggered_at": None, +} +_coordinator_instance = None + + +def _timestamp() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _remaining_delay(target_time: float, now: float) -> float: + return max(0.0, target_time - now) + + +def update_auto_registration_state(**kwargs) -> dict: + _auto_registration_state.update(kwargs) + return get_auto_registration_state() + + +def get_auto_registration_state() -> dict: + return dict(_auto_registration_state) + + +def register_auto_registration_coordinator( + coordinator: Optional["AutoRegistrationCoordinator"], +) -> None: + global _coordinator_instance + _coordinator_instance = coordinator + + +def trigger_auto_registration_check() -> None: + coordinator = _coordinator_instance + if coordinator is not None: + coordinator.request_immediate_check() + + +def add_auto_registration_log(message: str) -> None: + from ..web.task_manager import task_manager + + task_manager.add_batch_log(AUTO_REGISTRATION_CHANNEL, message) + + +def get_auto_registration_logs() -> list[str]: + from ..web.task_manager import task_manager + + return task_manager.get_batch_logs(AUTO_REGISTRATION_CHANNEL) + + +@dataclass +class AutoRegistrationPlan: + deficit: int + ready_count: int + min_ready_auth_files: int + cpa_service_id: int + + +def get_auto_registration_inventory( + settings: Settings, +) -> Optional[tuple[int, int, int]]: + cpa_service_id = int(settings.registration_auto_cpa_service_id or 0) + if cpa_service_id <= 0: + logger.warning("自动注册已启用,但未配置 CPA 服务 ID,跳过库存检查") + return None + + with get_db() as db: + cpa_service = crud.get_cpa_service_by_id(db, cpa_service_id) + + if not cpa_service: + logger.warning("自动注册目标 CPA 服务不存在: %s", cpa_service_id) + return None + + if not cpa_service.enabled: + logger.warning("自动注册目标 CPA 服务已禁用: %s", cpa_service.name) + return None + + success, payload, message = list_cpa_auth_files( + str(cpa_service.api_url), + str(cpa_service.api_token), + ) + if not success: + logger.warning("自动注册读取 auth-files 库存失败: %s", message) + return None + + ready_count = count_ready_cpa_auth_files(payload) + min_ready_auth_files = max(1, int(settings.registration_auto_min_ready_auth_files)) + deficit = max(0, min_ready_auth_files - ready_count) + return ready_count, min_ready_auth_files, deficit + + +def build_auto_registration_plan(settings: Settings) -> Optional[AutoRegistrationPlan]: + if not settings.registration_auto_enabled: + return None + + cpa_service_id = int(settings.registration_auto_cpa_service_id or 0) + inventory = get_auto_registration_inventory(settings) + if inventory is None: + return None + + ready_count, min_ready_auth_files, deficit = inventory + if deficit <= 0: + logger.info( + "自动注册库存充足,当前可用 %s / 目标 %s", + ready_count, + min_ready_auth_files, + ) + + return AutoRegistrationPlan( + deficit=deficit, + ready_count=ready_count, + min_ready_auth_files=min_ready_auth_files, + cpa_service_id=cpa_service_id, + ) + + +class AutoRegistrationCoordinator: + def __init__( + self, + trigger_callback: Callable[[AutoRegistrationPlan, Settings], Awaitable[Any]], + settings_getter: Callable[[], Settings] = get_settings, + plan_builder: Callable[ + [Settings], Optional[AutoRegistrationPlan] + ] = build_auto_registration_plan, + ): + self._trigger_callback = trigger_callback + self._settings_getter = settings_getter + self._plan_builder = plan_builder + self._task: Optional[asyncio.Task] = None + self._cycle_lock = asyncio.Lock() + self._wake_event = asyncio.Event() + + def start(self) -> None: + if self._task and not self._task.done(): + return + update_auto_registration_state( + enabled=bool(self._settings_getter().registration_auto_enabled), + status="idle", + message="自动注册协调器已启动", + ) + self._task = asyncio.create_task( + self._run_forever(), name="auto-registration-loop" + ) + + async def stop(self) -> None: + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + finally: + self._task = None + + def request_immediate_check(self) -> None: + self._wake_event.set() + + async def run_once(self) -> Optional[AutoRegistrationPlan]: + if self._cycle_lock.locked(): + logger.info("自动注册上一轮仍在执行,跳过重入检查") + add_auto_registration_log("[自动注册] 上一轮补货仍在执行,跳过本次重入检查") + return None + + async with self._cycle_lock: + settings = self._settings_getter() + update_auto_registration_state( + enabled=bool(settings.registration_auto_enabled), + status="disabled" + if not settings.registration_auto_enabled + else "checking", + message="自动注册已禁用" + if not settings.registration_auto_enabled + else "正在检查 auth-files 库存", + last_checked_at=_timestamp() + if not settings.registration_auto_enabled + else None, + current_batch_id=None + if not settings.registration_auto_enabled + else _auto_registration_state.get("current_batch_id"), + current_ready_count=None + if not settings.registration_auto_enabled + else _auto_registration_state.get("current_ready_count"), + ) + if not settings.registration_auto_enabled: + return None + + add_auto_registration_log("[自动注册] 开始检查 CPA auth-files 库存") + plan = await asyncio.to_thread(self._plan_builder, settings) + if not plan: + update_auto_registration_state( + status="idle", + message="检查完成,当前无需补货或配置不可用", + last_checked_at=_timestamp(), + current_batch_id=None, + current_ready_count=None, + ) + add_auto_registration_log("[自动注册] 检查完成,当前无需补货") + return None + + if plan.deficit <= 0: + update_auto_registration_state( + status="idle", + message=f"检查完成,当前 codex 库存充足 ({plan.ready_count}/{plan.min_ready_auth_files})", + current_ready_count=plan.ready_count, + target_ready_count=plan.min_ready_auth_files, + last_checked_at=_timestamp(), + current_batch_id=None, + ) + add_auto_registration_log( + f"[自动注册] 检查完成,当前 codex 库存充足 ({plan.ready_count}/{plan.min_ready_auth_files})" + ) + return None + + logger.info( + "自动注册准备补货,当前可用 %s / 目标 %s,计划新增 %s", + plan.ready_count, + plan.min_ready_auth_files, + plan.deficit, + ) + add_auto_registration_log( + f"[自动注册] 库存不足,当前可用 {plan.ready_count} / 目标 {plan.min_ready_auth_files},开始补货 {plan.deficit} 个" + ) + update_auto_registration_state( + status="running", + message="自动补货任务运行中", + current_ready_count=plan.ready_count, + target_ready_count=plan.min_ready_auth_files, + last_checked_at=_timestamp(), + last_triggered_at=_timestamp(), + ) + await self._trigger_callback(plan, settings) + return plan + + async def _run_forever(self) -> None: + loop = asyncio.get_running_loop() + next_check_at: Optional[float] = None + + while True: + settings = self._settings_getter() + interval = max(5, int(settings.registration_auto_check_interval or 60)) + update_auto_registration_state( + enabled=bool(settings.registration_auto_enabled), + target_ready_count=max( + 1, int(settings.registration_auto_min_ready_auth_files or 1) + ), + ) + + scheduled_start = ( + next_check_at if next_check_at is not None else loop.time() + ) + wait_seconds = _remaining_delay(scheduled_start, loop.time()) + if wait_seconds > 0: + try: + await asyncio.wait_for( + self._wake_event.wait(), timeout=wait_seconds + ) + self._wake_event.clear() + except asyncio.TimeoutError: + pass + elif self._wake_event.is_set(): + self._wake_event.clear() + + try: + await self.run_once() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("自动注册循环执行失败") + update_auto_registration_state( + status="error", + message="自动注册循环执行失败,请查看服务端日志", + last_checked_at=_timestamp(), + ) + add_auto_registration_log( + "[自动注册] 自动注册循环执行失败,请检查服务端日志" + ) + + next_check_at = loop.time() + interval diff --git a/src/core/circuit_breaker.py b/src/core/circuit_breaker.py new file mode 100644 index 00000000..8a25d669 --- /dev/null +++ b/src/core/circuit_breaker.py @@ -0,0 +1,212 @@ +""" +轻量级失败熔断器(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 .timezone_utils import utcnow_naive +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 utcnow_naive() + + +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/db_logs.py b/src/core/db_logs.py index 00257d98..e9974d4f 100644 --- a/src/core/db_logs.py +++ b/src/core/db_logs.py @@ -13,6 +13,7 @@ from sqlalchemy import func from ..config.settings import get_settings +from .timezone_utils import utcnow_naive from ..database.models import AppLog from ..database.session import get_db @@ -120,7 +121,7 @@ def cleanup_database_logs( keep_days = int(retention_days if retention_days is not None else settings.log_retention_days or 30) keep_days = max(1, keep_days) max_rows = max(1000, int(max_rows)) - cutoff = datetime.utcnow() - timedelta(days=keep_days) + cutoff = utcnow_naive() - timedelta(days=keep_days) deleted_by_age = 0 deleted_by_limit = 0 @@ -161,4 +162,3 @@ def cleanup_database_logs( "deleted_total": int((deleted_by_age or 0) + (deleted_by_limit or 0)), "remaining": int(remaining or 0), } - diff --git a/src/core/openai/overview.py b/src/core/openai/overview.py index 640c2b20..05875e22 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 @@ -17,6 +19,10 @@ logger = logging.getLogger(__name__) + +class AccountDeactivatedError(RuntimeError): + """Raised when an account is deactivated while fetching overview data.""" + _USAGE_ENDPOINTS: Tuple[Tuple[str, str, bool], ...] = ( # required=True: 核心稳定端点,失败计入 errors ("me", "https://chatgpt.com/backend-api/me", True), @@ -42,6 +48,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 +163,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 +198,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 +218,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 +796,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/payment.py b/src/core/openai/payment.py index 5abfeda4..38adb536 100644 --- a/src/core/openai/payment.py +++ b/src/core/openai/payment.py @@ -15,7 +15,7 @@ from curl_cffi import requests as cffi_requests from ...database.models import Account -from .overview import fetch_codex_overview +from .overview import fetch_codex_overview, AccountDeactivatedError logger = logging.getLogger(__name__) @@ -50,6 +50,34 @@ def _build_proxies(proxy: Optional[str]) -> Optional[dict]: return None +def _raise_if_deactivated(resp, source: str) -> None: + if resp is None: + return + if getattr(resp, "status_code", None) != 401: + return + text = str(getattr(resp, "text", "") or "") + if "deactivated" in text.lower(): + raise AccountDeactivatedError(f"account_deactivated({source}): {text[:200]}") + + +def _request_json_with_deactivated( + url: str, + headers: Dict[str, str], + proxy: Optional[str], + source: str, +) -> Dict[str, Any]: + resp = cffi_requests.get( + url, + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + _raise_if_deactivated(resp, source) + resp.raise_for_status() + return resp.json() if resp.content else {} + + def _is_connectivity_error(err: Any) -> bool: text = str(err or "").strip().lower() if not text: @@ -1048,19 +1076,18 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, # 1) me 接口 try: - resp = cffi_requests.get( + data = _request_json_with_deactivated( "https://chatgpt.com/backend-api/me", headers=headers, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="me", ) - resp.raise_for_status() successful_sources.append("me") - data = resp.json() if resp.content else {} detected = _analyze_me_payload(data, "me") if detected: return detected + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"me: {exc}") @@ -1069,16 +1096,13 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, try: headers_no_scope = dict(headers) headers_no_scope.pop("ChatGPT-Account-Id", None) - resp_no_scope = cffi_requests.get( + data_no_scope = _request_json_with_deactivated( "https://chatgpt.com/backend-api/me", headers=headers_no_scope, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="me_no_scope", ) - resp_no_scope.raise_for_status() successful_sources.append("me_no_scope") - data_no_scope = resp_no_scope.json() if resp_no_scope.content else {} detected = _analyze_me_payload(data_no_scope, "me.no_scope") if detected: logger.info( @@ -1088,39 +1112,37 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, detected.get("confidence"), ) return detected + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"me_no_scope: {exc}") # 2) wham/usage(Cockpit-tools 同款核心) try: - usage_resp = cffi_requests.get( + usage_data = _request_json_with_deactivated( "https://chatgpt.com/backend-api/wham/usage", headers=headers, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="wham_usage", ) - usage_resp.raise_for_status() successful_sources.append("wham_usage") - usage_data = usage_resp.json() if usage_resp.content else {} detected = _analyze_usage_payload(usage_data, "wham_usage") if detected: return detected + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"wham_usage: {exc}") # 3) wham/accounts/check(Cockpit-tools 用于账号资料同步的官方口径) try: - account_check_resp = cffi_requests.get( + account_check_data = _request_json_with_deactivated( ACCOUNT_CHECK_URL, headers=headers, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="wham_accounts_check", ) - account_check_resp.raise_for_status() successful_sources.append("wham_accounts_check") - account_check_data = account_check_resp.json() if account_check_resp.content else {} for raw in _collect_plan_candidates(account_check_data): mapped = _map_plan_to_subscription(raw) if mapped in ("plus", "team"): @@ -1128,6 +1150,8 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, if mapped == "free": weak_free_source = weak_free_source or "wham_accounts_check.plan" explicit_free_value = explicit_free_value or str(raw) + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"wham_accounts_check: {exc}") @@ -1136,16 +1160,13 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, headers_no_scope = dict(headers) headers_no_scope.pop("ChatGPT-Account-Id", None) try: - usage_no_scope_resp = cffi_requests.get( + usage_no_scope_data = _request_json_with_deactivated( "https://chatgpt.com/backend-api/wham/usage", headers=headers_no_scope, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="wham_usage_no_scope", ) - usage_no_scope_resp.raise_for_status() successful_sources.append("wham_usage_no_scope") - usage_no_scope_data = usage_no_scope_resp.json() if usage_no_scope_resp.content else {} detected = _analyze_usage_payload(usage_no_scope_data, "wham_usage.no_scope") if detected: logger.info( @@ -1155,20 +1176,19 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, detected.get("confidence"), ) return detected + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"wham_usage_no_scope: {exc}") try: - account_check_no_scope_resp = cffi_requests.get( + account_check_no_scope_data = _request_json_with_deactivated( ACCOUNT_CHECK_URL, headers=headers_no_scope, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="wham_accounts_check_no_scope", ) - account_check_no_scope_resp.raise_for_status() successful_sources.append("wham_accounts_check_no_scope") - account_check_no_scope_data = account_check_no_scope_resp.json() if account_check_no_scope_resp.content else {} for raw in _collect_plan_candidates(account_check_no_scope_data): mapped = _map_plan_to_subscription(raw) if mapped in ("plus", "team"): @@ -1176,6 +1196,8 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, if mapped == "free": weak_free_source = weak_free_source or "wham_accounts_check.no_scope.plan" explicit_free_value = explicit_free_value or str(raw) + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"wham_accounts_check_no_scope: {exc}") diff --git a/src/core/openai/token_refresh.py b/src/core/openai/token_refresh.py index 13e8ec8c..f192d04e 100644 --- a/src/core/openai/token_refresh.py +++ b/src/core/openai/token_refresh.py @@ -16,6 +16,7 @@ from ...config.settings import get_settings from ...config.constants import AccountStatus from ...database.session import get_db +from ..timezone_utils import utcnow_naive from ...database import crud from ...database.models import Account @@ -250,7 +251,7 @@ def _request_once(session: cffi_requests.Session): return result # 计算过期时间 - expires_at = datetime.utcnow() + timedelta(seconds=expires_in) + expires_at = utcnow_naive() + timedelta(seconds=expires_in) result.success = True result.access_token = access_token @@ -370,7 +371,7 @@ def refresh_account_token(account_id: int, proxy_url: Optional[str] = None) -> T # 更新数据库 update_data = { "access_token": result.access_token, - "last_refresh": datetime.utcnow() + "last_refresh": utcnow_naive() } if result.refresh_token: @@ -433,4 +434,4 @@ def validate_account_token(account_id: int, proxy_url: Optional[str] = None) -> if account.status != next_status: crud.update_account(db, account_id, status=next_status) - return is_valid, error + return is_valid, error \ No newline at end of file diff --git a/src/core/register.py b/src/core/register.py index f4a098c9..08450164 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -16,6 +16,7 @@ from curl_cffi import requests as cffi_requests +from .anyauto.register_flow import AnyAutoRegistrationEngine from .openai.oauth import OAuthManager, OAuthStart from .http_client import OpenAIHTTPClient, HTTPClientError from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType @@ -27,6 +28,7 @@ generate_random_user_info, OTP_CODE_PATTERN, DEFAULT_PASSWORD_LENGTH, + PASSWORD_SPECIAL_CHARSET, PASSWORD_CHARSET, AccountStatus, TaskStatus, @@ -345,7 +347,16 @@ def _extract_request_cookie_header(response) -> str: def _generate_password(self, length: int = DEFAULT_PASSWORD_LENGTH) -> str: """生成随机密码""" - return ''.join(secrets.choice(PASSWORD_CHARSET) for _ in range(length)) + length = max(8, int(length or DEFAULT_PASSWORD_LENGTH)) + password_chars = [ + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice(string.digits), + secrets.choice(PASSWORD_SPECIAL_CHARSET), + ] + password_chars.extend(secrets.choice(PASSWORD_CHARSET) for _ in range(length - len(password_chars))) + secrets.SystemRandom().shuffle(password_chars) + return ''.join(password_chars) def _check_ip_location(self) -> Tuple[bool, Optional[str]]: """检查 IP 地理位置""" @@ -2057,6 +2068,39 @@ def _register_password(self, did: Optional[str] = None, sen_token: Optional[str] self._last_register_password_error = str(e) return False, None + def _register_password_with_retry( + self, + did: Optional[str] = None, + sen_token: Optional[str] = None, + ) -> Tuple[bool, Optional[str]]: + """Retry password registration when OpenAI returns a generic recoverable 400.""" + max_attempts = 3 + retryable_markers = ( + "failed to create account", + "create account", + "invalid_request_error", + "http 400", + ) + + for attempt in range(1, max_attempts + 1): + success, password = self._register_password(did, sen_token) + if success: + return True, password + + error_text = str(self._last_register_password_error or "").strip().lower() + if attempt >= max_attempts: + break + if not any(marker in error_text for marker in retryable_markers): + break + + self._log( + f"密码注册命中可重试 400,准备重新生成密码后重试 ({attempt}/{max_attempts})...", + "warning", + ) + time.sleep(min(2 * attempt, 4)) + + return False, None + def _mark_email_as_registered(self): """标记邮箱为已注册状态(用于防止重复尝试)""" try: @@ -2614,7 +2658,7 @@ def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]: self._log(f"处理 OAuth 回调失败: {e}", "error") return None - def run(self) -> RegistrationResult: + def _run_primary_registration(self) -> RegistrationResult: """ 执行完整的注册流程 @@ -2690,7 +2734,7 @@ def run(self) -> RegistrationResult: self._log("检测到这是老朋友账号,直接切去登录拿 token,不走弯路") else: self._log("5. 设置密码,别让小偷偷笑...") - password_ok, _ = self._register_password(did, sen_token) + password_ok, _ = self._register_password_with_retry(did, sen_token) if not password_ok: result.error_message = self._last_register_password_error or "注册密码失败" return result @@ -2771,7 +2815,171 @@ def run(self) -> RegistrationResult: result.error_message = str(e) return result - def save_to_database(self, result: RegistrationResult) -> bool: + def _build_anyauto_fallback_result( + self, + flow_result: Optional[Dict[str, Any]], + primary_error: str = "", + ) -> RegistrationResult: + """Map PR60 AnyAuto V2 output into the current RegistrationResult structure.""" + result = RegistrationResult(success=False, logs=self.logs) + result.email = str(self.email or "") + result.password = str(self.password or "") + result.device_id = str(self.device_id or "") + + if not flow_result or not flow_result.get("success"): + fallback_error = str((flow_result or {}).get("error_message") or "注册失败").strip() + if primary_error and fallback_error and fallback_error != primary_error: + result.error_message = f"{primary_error} | anyauto fallback: {fallback_error}" + else: + result.error_message = fallback_error or primary_error or "注册失败" + result.metadata = { + "registration_flow": "any-auto-register-fallback", + "fallback_attempted": True, + "primary_error": primary_error, + "fallback_success": False, + } + return result + + result.success = True + result.access_token = str(flow_result.get("access_token") or "") + result.refresh_token = str(flow_result.get("refresh_token") or "") + result.id_token = str(flow_result.get("id_token") or "") + result.session_token = str(flow_result.get("session_token") or "") + result.account_id = str(flow_result.get("account_id") or "") + result.workspace_id = str(flow_result.get("workspace_id") or "") + result.source = "register" + + if not result.account_id: + token_payload = result.access_token or result.id_token + result.account_id = str(self._extract_account_id_from_access_token(token_payload) or "").strip() + if (not result.account_id) and result.id_token: + try: + account_info = self.oauth_manager.extract_account_info(result.id_token) + result.account_id = str(account_info.get("account_id") or "").strip() + except Exception: + pass + + settings = get_settings() + client_id = str( + getattr(settings, "openai_client_id", "") + or getattr(self.oauth_manager, "client_id", "") + or "" + ).strip() + metadata = dict(flow_result.get("metadata") or {}) + metadata.update( + { + "email_service": self.email_service.service_type.value, + "proxy_used": self.proxy_url, + "registered_at": datetime.now().isoformat(), + "registration_flow": "any-auto-register-fallback", + "fallback_attempted": True, + "primary_error": primary_error, + "client_id": client_id, + "device_id": result.device_id, + "has_session_token": bool(result.session_token), + "has_access_token": bool(result.access_token), + "has_refresh_token": bool(result.refresh_token), + } + ) + result.metadata = metadata + return result + + def _run_anyauto_fallback(self, primary_error: str = "") -> RegistrationResult: + """Run the PR60 AnyAuto V2 engine as a controlled fallback.""" + settings = get_settings() + max_retries = int(getattr(settings, "registration_max_retries", 3) or 3) + browser_mode = str( + getattr(settings, "registration_anyauto_browser_mode", "protocol") or "protocol" + ).strip() + + flow_engine = AnyAutoRegistrationEngine( + email_service=self.email_service, + proxy_url=self.proxy_url, + callback_logger=self._log, + max_retries=max_retries, + browser_mode=browser_mode or "protocol", + extra_config=None, + ) + flow_result = flow_engine.run() + + self.email_info = flow_engine.email_info + self.email = flow_engine.email + self.inbox_email = flow_engine.inbox_email + self.password = flow_engine.password + self.session = flow_engine.session + self.device_id = flow_engine.device_id + + fallback_result = self._build_anyauto_fallback_result(flow_result, primary_error=primary_error) + if fallback_result.session_token: + self.session_token = fallback_result.session_token + return fallback_result + + def _should_try_anyauto_fallback(self, result: RegistrationResult) -> bool: + settings = get_settings() + enabled = bool(getattr(settings, "registration_enable_anyauto_fallback", True)) + if not enabled or result.success: + return False + + error_text = str(result.error_message or "").strip().lower() + if not error_text: + return True + + non_retryable_markers = ( + "unsupported country", + "invalid email service", + "email service not found", + ) + if any(marker in error_text for marker in non_retryable_markers): + return False + + retryable_markers = ( + "access_token", + "refresh_token", + "session", + "oauth", + "callback", + "authorization code", + "workspace", + "consent", + "otp", + "verification code", + "phone", + "add_phone", + "add-phone", + "sentinel", + "failed to create account", + "create account", + "invalid_request_error", + "http 400", + "registration failed", + ) + return any(marker in error_text for marker in retryable_markers) + + def run(self) -> RegistrationResult: + """Run the current primary flow first, then selectively fall back to PR60 AnyAuto V2.""" + primary_result = self._run_primary_registration() + if primary_result.success: + return primary_result + + if not self._should_try_anyauto_fallback(primary_result): + return primary_result + + primary_error = str(primary_result.error_message or "").strip() + self._log("主注册链路未成功,开始尝试 PR60 anyauto V2 回退流程...", "warning") + fallback_result = self._run_anyauto_fallback(primary_error=primary_error) + if fallback_result.success: + self._log("PR60 anyauto V2 回退流程成功,已补上 V2 注册兜底能力") + return fallback_result + + self._log(f"PR60 anyauto V2 回退流程也失败了: {fallback_result.error_message}", "warning") + return fallback_result + + def save_to_database( + self, + result: RegistrationResult, + account_label: Optional[str] = None, + role_tag: Optional[str] = None, + ) -> bool: """ 保存注册结果到数据库 @@ -2806,7 +3014,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..3db35961 --- /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, utcnow_naive +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 utcnow_naive() + + +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/core/timezone_utils.py b/src/core/timezone_utils.py index 73051c92..2537f77b 100644 --- a/src/core/timezone_utils.py +++ b/src/core/timezone_utils.py @@ -38,6 +38,11 @@ def now_shanghai() -> datetime: return datetime.now(UTC).astimezone(SHANGHAI_TZ) +def utcnow_naive() -> datetime: + """Return a naive UTC datetime for legacy ORM fields.""" + return datetime.now(UTC).replace(tzinfo=None) + + def to_utc(dt: datetime | None) -> datetime | None: if dt is None: return None @@ -58,4 +63,3 @@ def to_shanghai(dt: datetime | None) -> datetime | None: def to_shanghai_iso(dt: datetime | None) -> str | None: local_dt = to_shanghai(dt) return local_dt.isoformat() if local_dt else None - diff --git a/src/core/upload/cpa_upload.py b/src/core/upload/cpa_upload.py index 900cff6a..a244eff4 100644 --- a/src/core/upload/cpa_upload.py +++ b/src/core/upload/cpa_upload.py @@ -14,6 +14,7 @@ from ...database.session import get_db from ...database.models import Account from ...config.settings import get_settings +from ..timezone_utils import utcnow_naive logger = logging.getLogger(__name__) @@ -239,7 +240,7 @@ def batch_upload_to_cpa( if success: # 更新数据库状态 account.cpa_uploaded = True - account.cpa_uploaded_at = datetime.utcnow() + account.cpa_uploaded_at = utcnow_naive() db.commit() results["success_count"] += 1 @@ -261,6 +262,70 @@ def batch_upload_to_cpa( return results +def list_cpa_auth_files(api_url: str, api_token: str) -> Tuple[bool, Any, str]: + """鍒楀嚭杩滅 CPA auth-files 娓呭崟銆?""" + if not api_url: + return False, None, "API URL 涓嶈兘涓虹┖" + + if not api_token: + return False, None, "API Token 涓嶈兘涓虹┖" + + list_url = _normalize_cpa_auth_files_url(api_url) + headers = _build_cpa_headers(api_token) + + try: + response = cffi_requests.get( + list_url, + headers=headers, + proxies=None, + timeout=10, + impersonate="chrome110", + ) + if response.status_code != 200: + return False, None, _extract_cpa_error(response) + return True, response.json(), "ok" + except cffi_requests.exceptions.ConnectionError as e: + return False, None, f"鏃犳硶杩炴帴鍒版湇鍔″櫒: {str(e)}" + except cffi_requests.exceptions.Timeout: + return False, None, "杩炴帴瓒呮椂锛岃妫€鏌ョ綉缁滈厤缃?" + except Exception as e: + logger.error("鑾峰彇 CPA auth-files 娓呭崟寮傚父: %s", e) + return False, None, f"鑾峰彇 auth-files 澶辫触: {str(e)}" + + +def count_ready_cpa_auth_files(payload: Any) -> int: + """缁熻鍙敤浜庤ˉ璐у垽鏂殑璁よ瘉鏂囦欢鏁伴噺銆?""" + if isinstance(payload, dict): + files = payload.get("files", []) + elif isinstance(payload, list): + files = payload + else: + return 0 + + ready_count = 0 + for item in files: + if not isinstance(item, dict): + continue + + status = str(item.get("status", "")).strip().lower() + provider = str(item.get("provider") or item.get("type") or "").strip().lower() + disabled = bool(item.get("disabled", False)) + unavailable = bool(item.get("unavailable", False)) + + if disabled or unavailable: + continue + + if provider != "codex": + continue + + if status and status not in {"ready", "active"}: + continue + + ready_count += 1 + + return ready_count + + def test_cpa_connection(api_url: str, api_token: str, proxy: str = None) -> Tuple[bool, str]: """ 测试 CPA 连接(不走代理) diff --git a/src/core/upload/new_api_upload.py b/src/core/upload/new_api_upload.py new file mode 100644 index 00000000..bdd8aedb --- /dev/null +++ b/src/core/upload/new_api_upload.py @@ -0,0 +1,225 @@ +""" +new-api 账号上传功能 +""" + +import json +import logging +from datetime import datetime, timezone +from typing import List, Tuple + +from curl_cffi import requests as cffi_requests + +from ...database.models import Account +from ...database.session import get_db + +logger = logging.getLogger(__name__) + +CHANNEL_TYPE_CODEX = 57 +CHANNEL_GROUP_DEFAULT = "default" +DEFAULT_CODEX_MODELS = ['gpt-5', 'gpt-5-codex', 'gpt-5-codex-mini', 'gpt-5.1', 'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini', 'gpt-5.2', 'gpt-5.2-codex', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.4', 'gpt-5.4-mini'] + + +def normalize_new_api_url(api_url: str) -> str: + """规范化 new-api 根地址。""" + return (api_url or "").rstrip("/") + + +def resolve_new_api_account_type(account: Account) -> str: + """解析 new-api 账号类型。""" + subscription_type = (getattr(account, "subscription_type", None) or "").lower() + if subscription_type == "team": + return "team" + + extra_data = getattr(account, "extra_data", None) or {} + if isinstance(extra_data, dict): + overview = extra_data.get("codex_overview") or {} + if isinstance(overview, dict): + plan_type = str(overview.get("plan_type") or "").lower() + if "codex" in plan_type: + return "codex" + for key in ("account_type", "type", "plan_type", "subscription_type"): + value = str(extra_data.get(key) or "").lower() + if "codex" in value: + return "codex" + if value in {"team", "plus", "pro", "oauth"}: + return value + + if subscription_type in {"plus", "pro"}: + return subscription_type + return "oauth" + + +def build_new_api_channel_key(account: Account) -> str: + """构建 new-api Codex 渠道 key。""" + expired = account.expires_at.astimezone(timezone.utc).isoformat() if account.expires_at else "" + payload = { + "access_token": account.access_token or "", + "refresh_token": account.refresh_token or "", + "account_id": account.account_id or "", + "email": account.email, + "type": resolve_new_api_account_type(account), + "expired": expired, + "last_refresh": datetime.now(timezone.utc).isoformat(), + } + return json.dumps(payload, ensure_ascii=False) + + +def build_new_api_channel_payload(account: Account) -> dict: + """构建 new-api 渠道创建载荷。""" + return { + "name": account.email, + "type": CHANNEL_TYPE_CODEX, + "key": build_new_api_channel_key(account), + "group": CHANNEL_GROUP_DEFAULT, + "models": ",".join(DEFAULT_CODEX_MODELS), + "status": 1, + } + + +def create_new_api_session(api_url: str, username: str, password: str): + """创建已登录的 new-api 会话。""" + session = cffi_requests.Session(impersonate="chrome110") + url = normalize_new_api_url(api_url) + "/api/user/login" + response = session.post( + url, + json={"username": username, "password": password}, + headers={"Content-Type": "application/json"}, + proxies=None, + timeout=15, + ) + return session, response + + +def ensure_new_api_login(api_url: str, username: str, password: str): + """校验 new-api 管理员登录状态。""" + if not api_url: + return False, "new-api URL 未配置", None, None + if not username: + return False, "new-api 用户名未配置", None, None + if not password: + return False, "new-api 密码未配置", None, None + + session, response = create_new_api_session(api_url, username, password) + if response.status_code != 200: + return False, f"登录失败: HTTP {response.status_code}", None, None + + try: + data = response.json() + except Exception: + return False, f"登录失败: {response.text[:200]}", None, None + + if not isinstance(data, dict) or not data.get("success"): + return False, data.get("message", "登录失败") if isinstance(data, dict) else "登录失败", None, None + + user_data = data.get("data") or {} + user_id = user_data.get("id") if isinstance(user_data, dict) else None + if user_id is None: + return False, "登录成功,但未返回用户 ID", None, None + + session.headers.update({"New-Api-User": str(user_id)}) + return True, "登录成功", session, user_id + + +def create_new_api_channel(api_url: str, session, account: Account) -> Tuple[bool, str]: + """在 new-api 中创建 Codex 渠道。""" + url = normalize_new_api_url(api_url) + "/api/channel/" + payload = { + "mode": "single", + "channel": build_new_api_channel_payload(account), + } + response = session.post( + url, + json=payload, + headers={"Content-Type": "application/json"}, + proxies=None, + timeout=20, + ) + if response.status_code != 200: + return False, f"创建渠道失败: HTTP {response.status_code}" + + try: + data = response.json() + except Exception: + return False, f"创建渠道失败: {response.text[:200]}" + + if isinstance(data, dict) and data.get("success"): + return True, "渠道创建成功" + if isinstance(data, dict): + return False, data.get("message", "创建渠道失败") + return False, "创建渠道失败" + + +def upload_to_new_api(accounts: List[Account], api_url: str, username: str, password: str) -> Tuple[bool, str]: + """上传账号列表到 new-api 平台。""" + if not accounts: + return False, "无可上传的账号" + + valid_accounts = [account for account in accounts if account.access_token] + if not valid_accounts: + return False, "所有账号均缺少 access_token,无法上传" + + ok, message, session, _user_id = ensure_new_api_login(api_url, username, password) + if not ok: + return False, message + + success_count = 0 + errors = [] + for account in valid_accounts: + created, created_message = create_new_api_channel(api_url, session, account) + if created: + success_count += 1 + else: + errors.append(f"{account.email}: {created_message}") + + if success_count == len(valid_accounts): + return True, f"成功上传 {success_count} 个账号" + if success_count == 0: + return False, "; ".join(errors[:3]) if errors else "上传失败" + return False, f"部分上传成功({success_count}/{len(valid_accounts)}): {'; '.join(errors[:3])}" + + +def batch_upload_to_new_api(account_ids: List[int], api_url: str, username: str, password: str) -> dict: + """批量上传指定 ID 的账号到 new-api 平台。""" + results = { + "success_count": 0, + "failed_count": 0, + "skipped_count": 0, + "details": [], + } + + with get_db() as db: + accounts = [] + for account_id in account_ids: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + results["failed_count"] += 1 + results["details"].append({"id": account_id, "email": None, "success": False, "error": "账号不存在"}) + continue + if not account.access_token: + results["skipped_count"] += 1 + results["details"].append({"id": account_id, "email": account.email, "success": False, "error": "缺少 access_token"}) + continue + accounts.append(account) + + if not accounts: + return results + + success, message = upload_to_new_api(accounts, api_url, username, password) + if success: + for account in accounts: + results["success_count"] += 1 + results["details"].append({"id": account.id, "email": account.email, "success": True, "message": message}) + else: + for account in accounts: + results["failed_count"] += 1 + results["details"].append({"id": account.id, "email": account.email, "success": False, "error": message}) + + return results + + +def test_new_api_connection(api_url: str, username: str, password: str) -> Tuple[bool, str]: + """测试 new-api 连接。""" + ok, message, _session, _user_id = ensure_new_api_login(api_url, username, password) + if not ok: + return False, message + return True, "new-api 连接测试成功" diff --git a/src/core/utils.py b/src/core/utils.py index b2bd4ff5..d3e6b3f3 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -18,7 +18,7 @@ from typing import Any, Dict, List, Optional, Union, Callable from pathlib import Path -from ..config.constants import PASSWORD_CHARSET, DEFAULT_PASSWORD_LENGTH +from ..config.constants import PASSWORD_CHARSET, PASSWORD_SPECIAL_CHARSET, DEFAULT_PASSWORD_LENGTH from ..config.settings import get_settings from .timezone_utils import SHANGHAI_TZ @@ -97,18 +97,19 @@ def generate_password(length: int = DEFAULT_PASSWORD_LENGTH) -> str: Returns: 随机密码字符串 """ - if length < 4: - length = 4 + if length < 8: + length = 8 # 确保密码包含至少一个大写字母、一个小写字母和一个数字 password = [ secrets.choice(string.ascii_lowercase), secrets.choice(string.ascii_uppercase), secrets.choice(string.digits), + secrets.choice(PASSWORD_SPECIAL_CHARSET), ] # 添加剩余字符 - password.extend(secrets.choice(PASSWORD_CHARSET) for _ in range(length - 3)) + password.extend(secrets.choice(PASSWORD_CHARSET) for _ in range(length - len(password))) # 随机打乱 secrets.SystemRandom().shuffle(password) diff --git a/src/database/__init__.py b/src/database/__init__.py index 1ee05b91..56db2d0f 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -2,7 +2,16 @@ 数据库模块 """ -from .models import Base, Account, EmailService, RegistrationTask, Setting +from .models import ( + Base, + Account, + EmailService, + RegistrationTask, + Setting, + SelfCheckRun, + NewApiService, + ScheduledRegistrationJob, +) from .session import get_db, init_database, get_session_manager, DatabaseSessionManager from . import crud @@ -12,6 +21,9 @@ 'EmailService', 'RegistrationTask', 'Setting', + 'SelfCheckRun', + 'NewApiService', + 'ScheduledRegistrationJob', 'get_db', 'init_database', 'get_session_manager', diff --git a/src/database/crud.py b/src/database/crud.py index 3c6c8492..8b712b93 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -7,7 +7,30 @@ from sqlalchemy.orm import Session from sqlalchemy import and_, or_, desc, asc, func -from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService +from ..core.timezone_utils import utcnow_naive +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, + TeamManagerService, + NewApiService, + ScheduledRegistrationJob, + BindCardTask, + TeamInviteRecord, + OperationAuditLog, +) # ============================================================================ @@ -32,9 +55,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,7 +92,14 @@ def create_account( extra_data=extra_data or {}, status=status or 'active', source=source or 'register', - registered_at=datetime.utcnow() + 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=utcnow_naive() ) db.add(db_account) db.commit() @@ -111,9 +157,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 +204,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( @@ -360,7 +492,7 @@ def set_setting( db_setting.value = value db_setting.description = description or db_setting.description db_setting.category = category - db_setting.updated_at = datetime.utcnow() + db_setting.updated_at = utcnow_naive() else: db_setting = Setting( key=key, @@ -386,10 +518,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 +622,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 +641,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 +683,7 @@ def delete_proxy(db: Session, proxy_id: int) -> bool: db.delete(db_proxy) db.commit() + _ensure_single_default_proxy(db) return True @@ -480,34 +693,44 @@ def update_proxy_last_used(db: Session, proxy_id: int) -> bool: if not db_proxy: return False - db_proxy.last_used = datetime.utcnow() + db_proxy.last_used = utcnow_naive() db.commit() return True 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 +831,7 @@ def create_sub2api_service( name=name, api_url=api_url, api_key=api_key, + target_type=target_type, enabled=enabled, priority=priority, ) @@ -655,6 +879,265 @@ def delete_sub2api_service(db: Session, service_id: int) -> bool: return True +# ============================================================================ +# new-api 鏈嶅姟 CRUD +# ============================================================================ + +def create_new_api_service( + db: Session, + name: str, + api_url: str, + username: str, + password: str, + enabled: bool = True, + priority: int = 0, +) -> NewApiService: + """鍒涘缓 new-api 鏈嶅姟閰嶇疆""" + svc = NewApiService( + name=name, + api_url=api_url, + username=username, + password=password, + api_key='', + enabled=enabled, + priority=priority, + ) + db.add(svc) + db.commit() + db.refresh(svc) + return svc + + +def get_new_api_service_by_id(db: Session, service_id: int) -> Optional[NewApiService]: + """鎸?ID 鑾峰彇 new-api 鏈嶅姟""" + return db.query(NewApiService).filter(NewApiService.id == service_id).first() + + +def get_new_api_services( + db: Session, + enabled: Optional[bool] = None, +) -> List[NewApiService]: + """鑾峰彇 new-api 鏈嶅姟鍒楄〃""" + query = db.query(NewApiService) + if enabled is not None: + query = query.filter(NewApiService.enabled == enabled) + return query.order_by(asc(NewApiService.priority), asc(NewApiService.id)).all() + + +def update_new_api_service( + db: Session, + service_id: int, + **kwargs, +) -> Optional[NewApiService]: + """鏇存柊 new-api 鏈嶅姟閰嶇疆""" + svc = get_new_api_service_by_id(db, service_id) + if not svc: + return None + for key, value in kwargs.items(): + if hasattr(svc, key): + setattr(svc, key, value) + db.commit() + db.refresh(svc) + return svc + + +def delete_new_api_service(db: Session, service_id: int) -> bool: + """鍒犻櫎 new-api 鏈嶅姟閰嶇疆""" + svc = get_new_api_service_by_id(db, service_id) + if not svc: + return False + db.delete(svc) + db.commit() + return True + + +# ============================================================================ +# 璁″垝娉ㄥ唽浠诲姟 CRUD +# ============================================================================ + +def create_scheduled_registration_job( + db: Session, + job_uuid: str, + name: str, + schedule_type: str, + schedule_config: Dict[str, Any], + registration_config: Dict[str, Any], + next_run_at: Optional[datetime], + enabled: bool = True, + timezone: str = 'local', + status: str = 'idle', +) -> ScheduledRegistrationJob: + """鍒涘缓璁″垝娉ㄥ唽浠诲姟""" + job = ScheduledRegistrationJob( + job_uuid=job_uuid, + name=name, + enabled=enabled, + status=status, + schedule_type=schedule_type, + schedule_config=schedule_config, + registration_config=registration_config, + timezone=timezone, + next_run_at=next_run_at, + ) + db.add(job) + db.commit() + db.refresh(job) + return job + + +def get_scheduled_registration_job_by_uuid(db: Session, job_uuid: str) -> Optional[ScheduledRegistrationJob]: + """鎸?UUID 鑾峰彇璁″垝娉ㄥ唽浠诲姟""" + return db.query(ScheduledRegistrationJob).filter(ScheduledRegistrationJob.job_uuid == job_uuid).first() + + +def get_scheduled_registration_job_by_id(db: Session, job_id: int) -> Optional[ScheduledRegistrationJob]: + """鎸?ID 鑾峰彇璁″垝娉ㄥ唽浠诲姟""" + return db.query(ScheduledRegistrationJob).filter(ScheduledRegistrationJob.id == job_id).first() + + +def get_scheduled_registration_jobs( + db: Session, + enabled: Optional[bool] = None, + skip: int = 0, + limit: int = 100, +) -> List[ScheduledRegistrationJob]: + """鑾峰彇璁″垝娉ㄥ唽浠诲姟鍒楄〃""" + query = db.query(ScheduledRegistrationJob) + if enabled is not None: + query = query.filter(ScheduledRegistrationJob.enabled == enabled) + return query.order_by(desc(ScheduledRegistrationJob.created_at)).offset(skip).limit(limit).all() + + +def get_due_scheduled_registration_jobs(db: Session, now: datetime) -> List[ScheduledRegistrationJob]: + """鑾峰彇宸插埌鏈熺殑璁″垝娉ㄥ唽浠诲姟""" + return db.query(ScheduledRegistrationJob).filter( + ScheduledRegistrationJob.enabled == True, + ScheduledRegistrationJob.is_running == False, + ScheduledRegistrationJob.next_run_at.isnot(None), + ScheduledRegistrationJob.next_run_at <= now, + ).order_by(asc(ScheduledRegistrationJob.next_run_at), asc(ScheduledRegistrationJob.id)).all() + + +def get_running_scheduled_registration_jobs(db: Session) -> List[ScheduledRegistrationJob]: + """鑾峰彇姝e湪鎵ц鐨勮鍒掓敞鍐屼换鍔?""" + return db.query(ScheduledRegistrationJob).filter( + ScheduledRegistrationJob.is_running == True, + ).order_by(asc(ScheduledRegistrationJob.updated_at), asc(ScheduledRegistrationJob.id)).all() + + +def update_scheduled_registration_job( + db: Session, + job_uuid: str, + **kwargs, +) -> Optional[ScheduledRegistrationJob]: + """鏇存柊璁″垝娉ㄥ唽浠诲姟""" + job = get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + return None + for key, value in kwargs.items(): + if hasattr(job, key): + setattr(job, key, value) + db.commit() + db.refresh(job) + return job + + +def delete_scheduled_registration_job(db: Session, job_uuid: str) -> bool: + """鍒犻櫎璁″垝娉ㄥ唽浠诲姟""" + job = get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + return False + db.delete(job) + db.commit() + return True + + +def claim_scheduled_registration_job( + db: Session, + job_uuid: str, + next_run_at: Optional[datetime], + now: datetime, +) -> Optional[ScheduledRegistrationJob]: + """鎶㈠崰璁″垝娉ㄥ唽浠诲姟鎵ц鏉?""" + updated = db.query(ScheduledRegistrationJob).filter( + ScheduledRegistrationJob.job_uuid == job_uuid, + ScheduledRegistrationJob.enabled == True, + ScheduledRegistrationJob.is_running == False, + ).update({ + 'is_running': True, + 'status': 'running', + 'last_run_at': now, + 'next_run_at': next_run_at, + 'updated_at': now, + }) + if not updated: + db.rollback() + return None + db.commit() + return get_scheduled_registration_job_by_uuid(db, job_uuid) + + +def mark_scheduled_registration_job_success( + db: Session, + job_uuid: str, + now: datetime, + task_uuid: Optional[str] = None, + batch_id: Optional[str] = None, + status: str = 'scheduled', +) -> Optional[ScheduledRegistrationJob]: + """鏍囪璁″垝娉ㄥ唽浠诲姟鎵ц鎴愬姛""" + job = get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + return None + job.is_running = False + job.status = status + job.last_success_at = now + job.last_error = None + job.run_count = (job.run_count or 0) + 1 + job.consecutive_failures = 0 + job.last_triggered_task_uuid = task_uuid + job.last_triggered_batch_id = batch_id + db.commit() + db.refresh(job) + return job + + +def mark_scheduled_registration_job_failure( + db: Session, + job_uuid: str, + error_message: str, + now: datetime, +) -> Optional[ScheduledRegistrationJob]: + """鏍囪璁″垝娉ㄥ唽浠诲姟鎵ц澶辫触""" + job = get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + return None + job.is_running = False + job.status = 'failed' + job.last_error = error_message + job.run_count = (job.run_count or 0) + 1 + job.consecutive_failures = (job.consecutive_failures or 0) + 1 + db.commit() + db.refresh(job) + return job + + +def mark_scheduled_registration_job_skipped( + db: Session, + job_uuid: str, + error_message: str, +) -> Optional[ScheduledRegistrationJob]: + """鏍囪璁″垝娉ㄥ唽浠诲姟琚烦杩?""" + job = get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + return None + job.last_error = error_message + job.status = 'idle' if job.enabled else 'paused' + db.commit() + db.refresh(job) + return job + + # ============================================================================ # Team Manager 服务 CRUD # ============================================================================ diff --git a/src/database/models.py b/src/database/models.py index ff0e4443..0fb42814 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -6,9 +6,12 @@ from typing import Optional, Dict, Any import json from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.types import TypeDecorator from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base +from sqlalchemy.types import TypeDecorator + +from ..config.constants import AccountLabel, PoolState, RoleTag +from ..core.timezone_utils import utcnow_naive Base = declarative_base() @@ -45,7 +48,7 @@ class Account(Base): email_service = Column(String(50), nullable=False) # 'tempmail', 'outlook', 'moe_mail' email_service_id = Column(String(255)) # 邮箱服务中的ID proxy_used = Column(String(255)) - registered_at = Column(DateTime, default=datetime.utcnow) + registered_at = Column(DateTime, default=utcnow_naive) last_refresh = Column(DateTime) # 最后刷新时间 expires_at = Column(DateTime) # Token 过期时间 status = Column(String(20), default='active') # 'active', 'expired', 'banned', 'failed' @@ -53,12 +56,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) + created_at = Column(DateTime, default=utcnow_naive) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) 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 +90,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, @@ -96,8 +116,8 @@ class EmailService(Base): enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 使用优先级 last_used = Column(DateTime) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow_naive) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) class RegistrationTask(Base): @@ -112,7 +132,7 @@ class RegistrationTask(Base): logs = Column(Text) # 注册过程日志 result = Column(JSONEncodedDict) # 注册结果 error_message = Column(Text) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=utcnow_naive) started_at = Column(DateTime) completed_at = Column(DateTime) @@ -125,7 +145,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)) @@ -143,13 +165,34 @@ class BindCardTask(Base): opened_at = Column(DateTime) last_checked_at = Column(DateTime) completed_at = Column(DateTime) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow_naive) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) # 关系 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=utcnow_naive) + accepted_at = Column(DateTime) + expires_at = Column(DateTime) + created_at = Column(DateTime, default=utcnow_naive) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) + + inviter_account = relationship("Account", back_populates="team_invite_records") + + class AppLog(Base): """应用日志表(后台日志监控)""" __tablename__ = "app_logs" @@ -162,7 +205,7 @@ class AppLog(Base): lineno = Column(Integer) message = Column(Text, nullable=False) exception = Column(Text) - created_at = Column(DateTime, default=datetime.utcnow, index=True) + created_at = Column(DateTime, default=utcnow_naive, index=True) def to_dict(self) -> Dict[str, Any]: return { @@ -178,6 +221,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=utcnow_naive, 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=utcnow_naive, index=True) + started_at = Column(DateTime) + finished_at = Column(DateTime) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) + + 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' @@ -186,7 +301,7 @@ class Setting(Base): value = Column(Text) description = Column(Text) category = Column(String(50), default='general') # 'general', 'email', 'proxy', 'openai' - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) class CpaService(Base): @@ -197,11 +312,11 @@ 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) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow_naive) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) class Sub2ApiService(Base): @@ -215,8 +330,8 @@ class Sub2ApiService(Base): target_type = Column(String(50), nullable=False, default='sub2api') # sub2api/newapi enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow_naive) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) class TeamManagerService(Base): @@ -229,8 +344,50 @@ class TeamManagerService(Base): api_key = Column(Text, nullable=False) # X-API-Key enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow_naive) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) + + +class NewApiService(Base): + """new-api 服务配置表""" + __tablename__ = 'new_api_services' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + api_url = Column(String(500), nullable=False) + username = Column(String(100), nullable=False) + password = Column(Text) + api_key = Column(Text) + enabled = Column(Boolean, default=True) + priority = Column(Integer, default=0) + created_at = Column(DateTime, default=utcnow_naive) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) + + +class ScheduledRegistrationJob(Base): + """计划注册任务表""" + __tablename__ = 'scheduled_registration_jobs' + + id = Column(Integer, primary_key=True, autoincrement=True) + job_uuid = Column(String(36), unique=True, nullable=False, index=True) + name = Column(String(100), nullable=False) + enabled = Column(Boolean, default=True, index=True) + status = Column(String(20), default='idle') + schedule_type = Column(String(20), nullable=False) + schedule_config = Column(JSONEncodedDict, nullable=False) + registration_config = Column(JSONEncodedDict, nullable=False) + timezone = Column(String(50), default='local') + next_run_at = Column(DateTime, index=True) + last_run_at = Column(DateTime) + last_success_at = Column(DateTime) + last_error = Column(Text) + run_count = Column(Integer, default=0) + consecutive_failures = Column(Integer, default=0) + is_running = Column(Boolean, default=False, index=True) + last_triggered_task_uuid = Column(String(36)) + last_triggered_batch_id = Column(String(36)) + created_at = Column(DateTime, default=utcnow_naive) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) class Proxy(Base): @@ -248,8 +405,8 @@ class Proxy(Base): is_default = Column(Boolean, default=False) # 是否为默认代理 priority = Column(Integer, default=0) # 优先级(保留字段) last_used = Column(DateTime) # 最后使用时间 - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow_naive) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) def to_dict(self, include_password: bool = False) -> Dict[str, Any]: """转换为字典""" 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 575e0050..c89b47ad 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -11,20 +11,24 @@ EmailServiceType ) from .tempmail import TempmailService +from .yyds_mail import YYDSMailService from .outlook import OutlookService from .moe_mail import MeoMailEmailService from .temp_mail import TempMailService from .duck_mail import DuckMailService +from .luckmail_mail import LuckMailService from .freemail import FreemailService from .imap_mail import ImapMailService from .cloudmail import CloudMailService # 注册服务 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) EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService) +EmailServiceFactory.register(EmailServiceType.LUCKMAIL, LuckMailService) EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService) EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService) EmailServiceFactory.register(EmailServiceType.CLOUDMAIL, CloudMailService) @@ -55,10 +59,12 @@ 'EmailServiceType', # 服务类 'TempmailService', + 'YYDSMailService', 'OutlookService', 'MeoMailEmailService', 'TempMailService', 'DuckMailService', + 'LuckMailService', 'FreemailService', 'ImapMailService', 'CloudMailService', diff --git a/src/services/luckmail_mail.py b/src/services/luckmail_mail.py new file mode 100644 index 00000000..97cfd8a0 --- /dev/null +++ b/src/services/luckmail_mail.py @@ -0,0 +1,1051 @@ +""" +LuckMail 邮箱服务实现 +""" + +import logging +import json +import re +import sys +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..config.constants import OTP_CODE_PATTERN + + +logger = logging.getLogger(__name__) +_STATE_LOCK = threading.RLock() +# 申诉硬编码开关(临时):False=关闭申诉提交;True=开启申诉提交。 +LUCKMAIL_APPEAL_ENABLED = False + + +def _load_luckmail_client_class(): + """ + 兼容两种来源: + 1) 环境已安装 luckmail 包 + 2) 本地 vendored 目录(优先 codex-console/luckmail,其次 ../tools/luckmail) + """ + try: + from luckmail import LuckMailClient # type: ignore + + return LuckMailClient + except Exception: + pass + + candidates = [ + Path(__file__).resolve().parents[2] / "luckmail", + Path(__file__).resolve().parents[3] / "tools" / "luckmail", + ] + for path in candidates: + if not path.is_dir(): + continue + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + try: + from luckmail import LuckMailClient # type: ignore + + return LuckMailClient + except Exception: + continue + return None + + +class LuckMailService(BaseEmailService): + """LuckMail 接码邮箱服务""" + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + super().__init__(EmailServiceType.LUCKMAIL, name) + + default_config = { + "base_url": "https://mails.luckyous.com/", + "api_key": "", + "project_code": "openai", + "email_type": "ms_graph", + "preferred_domain": "", + # purchase: 购买邮箱 + token 拉码(可多次) + # order: 创建接码订单 + 订单拉码(通常一次) + "inbox_mode": "purchase", + # 任务开始时优先复用“未在账号库且不在本地黑名单”的已购邮箱 + "reuse_existing_purchases": True, + "purchase_scan_pages": 5, + "purchase_scan_page_size": 100, + "timeout": 30, + "max_retries": 3, + "poll_interval": 3.0, + "code_reuse_ttl": 600, + } + self.config = {**default_config, **(config or {})} + + self.config["base_url"] = str(self.config.get("base_url") or "").strip() + if not self.config["base_url"]: + raise ValueError("LuckMail 配置缺少 base_url") + self.config["api_key"] = str(self.config.get("api_key") or "").strip() + self.config["project_code"] = str(self.config.get("project_code") or "openai").strip() + self.config["email_type"] = str(self.config.get("email_type") or "ms_graph").strip() + self.config["preferred_domain"] = str(self.config.get("preferred_domain") or "").strip().lstrip("@") + self.config["inbox_mode"] = self._normalize_inbox_mode(self.config.get("inbox_mode")) + self.config["reuse_existing_purchases"] = bool(self.config.get("reuse_existing_purchases", True)) + self.config["purchase_scan_pages"] = max(int(self.config.get("purchase_scan_pages") or 5), 1) + self.config["purchase_scan_page_size"] = max(int(self.config.get("purchase_scan_page_size") or 100), 1) + self.config["poll_interval"] = float(self.config.get("poll_interval") or 3.0) + self.config["code_reuse_ttl"] = int(self.config.get("code_reuse_ttl") or 600) + + if not self.config["api_key"]: + raise ValueError("LuckMail 配置缺少 api_key") + if not self.config["project_code"]: + raise ValueError("LuckMail 配置缺少 project_code") + + client_cls = _load_luckmail_client_class() + if client_cls is None: + raise ValueError( + "未找到 LuckMail SDK,请先安装 luckmail 包或确保本地存在 tools/luckmail" + ) + + try: + self.client = client_cls( + base_url=self.config["base_url"], + api_key=self.config["api_key"], + ) + except Exception as exc: + raise ValueError(f"初始化 LuckMail 客户端失败: {exc}") + + self._orders_by_no: Dict[str, Dict[str, Any]] = {} + self._orders_by_email: Dict[str, Dict[str, Any]] = {} + # 记录每个订单/Token 最近返回过的验证码,避免后续阶段反复拿到旧码。 + self._recent_codes_by_order: Dict[str, Dict[str, float]] = {} + self._data_dir = Path(__file__).resolve().parents[2] / "data" + self._registered_file = self._data_dir / "luckmail_registered_emails.json" + self._failed_file = self._data_dir / "luckmail_failed_emails.json" + + def _normalize_inbox_mode(self, raw: Any) -> str: + mode = str(raw or "").strip().lower() + aliases = { + "purchase": "purchase", + "token": "purchase", + "buy": "purchase", + "purchased": "purchase", + "order": "order", + "code": "order", + } + return aliases.get(mode, "purchase") + + def _extract_field(self, obj: Any, *keys: str) -> Any: + if obj is None: + return None + if isinstance(obj, dict): + for k in keys: + if k in obj: + return obj.get(k) + return None + for k in keys: + if hasattr(obj, k): + return getattr(obj, k) + return None + + def _cache_order(self, info: Dict[str, Any]) -> None: + order_key = str(info.get("order_no") or info.get("service_id") or "").strip() + email = str(info.get("email") or "").strip().lower() + if order_key: + self._orders_by_no[order_key] = info + if email: + self._orders_by_email[email] = info + + def _find_order(self, email: Optional[str], email_id: Optional[str]) -> Optional[Dict[str, Any]]: + if email_id: + item = self._orders_by_no.get(str(email_id).strip()) + if item: + return item + if email: + item = self._orders_by_email.get(str(email).strip().lower()) + if item: + return item + return None + + def _is_recent_code(self, order_key: str, code: str, now: Optional[float] = None) -> bool: + if not order_key or not code: + return False + now_ts = now or time.time() + ttl = max(int(self.config.get("code_reuse_ttl") or 600), 0) + order_cache = self._recent_codes_by_order.get(order_key) or {} + if ttl <= 0: + return code in order_cache + used_at = order_cache.get(code) + if used_at is None: + return False + return (now_ts - used_at) <= ttl + + def _remember_code(self, order_key: str, code: str, now: Optional[float] = None) -> None: + if not order_key or not code: + return + now_ts = now or time.time() + ttl = max(int(self.config.get("code_reuse_ttl") or 600), 0) + order_cache = self._recent_codes_by_order.setdefault(order_key, {}) + order_cache[code] = now_ts + if ttl > 0: + expire_before = now_ts - ttl + stale = [k for k, v in order_cache.items() if v < expire_before] + for key in stale: + order_cache.pop(key, None) + + def _now_iso(self) -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + def _normalize_email(self, email: Optional[str]) -> str: + return str(email or "").strip().lower() + + def _is_resumable_failure_reason(self, reason: str) -> bool: + text = str(reason or "").strip().lower() + if not text: + return False + keywords = ( + "该邮箱已存在 openai", + "邮箱已存在 openai", + "user_already_exists", + "already exists", + "创建用户账户失败", + ) + return any(k in text for k in keywords) + + def _extract_password_from_task_logs(self, logs_text: str) -> str: + if not logs_text: + return "" + matches = re.findall(r"生成密码[::]\s*([^\s]+)", str(logs_text)) + if not matches: + return "" + return str(matches[-1] or "").strip() + + def _recover_password_from_recent_task_logs(self, email: str, max_tasks: int = 30) -> str: + email_norm = self._normalize_email(email) + if not email_norm: + return "" + try: + from sqlalchemy import desc + from ..database.models import RegistrationTask as RegistrationTaskModel + from ..database.session import get_db + + with get_db() as db: + tasks = ( + db.query(RegistrationTaskModel) + .filter(RegistrationTaskModel.logs.isnot(None)) + .order_by(desc(RegistrationTaskModel.created_at)) + .limit(max_tasks) + .all() + ) + + for task in tasks: + logs_text = str(getattr(task, "logs", "") or "") + if email_norm not in logs_text.lower(): + continue + recovered = self._extract_password_from_task_logs(logs_text) + if recovered: + return recovered + except Exception as exc: + logger.warning(f"LuckMail 从任务日志恢复密码失败: {exc}") + return "" + + def _load_email_index(self, path: Path) -> Dict[str, Dict[str, Any]]: + with _STATE_LOCK: + try: + if not path.exists(): + return {} + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, list): + return { + self._normalize_email(e): {"email": self._normalize_email(e), "updated_at": self._now_iso()} + for e in raw + if self._normalize_email(e) + } + if not isinstance(raw, dict): + return {} + payload = raw.get("emails", raw) + if isinstance(payload, list): + return { + self._normalize_email(e): {"email": self._normalize_email(e), "updated_at": self._now_iso()} + for e in payload + if self._normalize_email(e) + } + if isinstance(payload, dict): + result: Dict[str, Dict[str, Any]] = {} + for email_key, meta in payload.items(): + email_norm = self._normalize_email(email_key) + if not email_norm: + continue + if isinstance(meta, dict): + record = meta.copy() + else: + record = {"value": meta} + record["email"] = email_norm + if "updated_at" not in record: + record["updated_at"] = self._now_iso() + result[email_norm] = record + return result + except Exception as exc: + logger.warning(f"LuckMail 读取状态文件失败: {path} - {exc}") + return {} + return {} + + def _save_email_index(self, path: Path, index: Dict[str, Dict[str, Any]]) -> None: + with _STATE_LOCK: + try: + self._data_dir.mkdir(parents=True, exist_ok=True) + payload = { + "updated_at": self._now_iso(), + "count": len(index), + "emails": index, + } + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + tmp_path.replace(path) + except Exception as exc: + logger.warning(f"LuckMail 写入状态文件失败: {path} - {exc}") + + def _mark_registered_email(self, email: str, extra: Optional[Dict[str, Any]] = None) -> None: + email_norm = self._normalize_email(email) + if not email_norm: + return + registered = self._load_email_index(self._registered_file) + failed = self._load_email_index(self._failed_file) + record = registered.get(email_norm, {"email": email_norm}) + record["updated_at"] = self._now_iso() + if extra: + for k, v in extra.items(): + if v is not None and v != "": + record[k] = v + registered[email_norm] = record + failed.pop(email_norm, None) + self._save_email_index(self._registered_file, registered) + self._save_email_index(self._failed_file, failed) + + def _should_force_failed_record(self, reason: str) -> bool: + text = str(reason or "").strip().lower() + if not text: + return False + keywords = ( + "该邮箱已存在 openai", + "邮箱已存在 openai", + "user_already_exists", + "already exists", + "failed to register username", + "用户名注册失败", + "创建用户账户失败", + ) + return any(k in text for k in keywords) + + def _reconcile_failed_over_registered( + self, + registered: Dict[str, Dict[str, Any]], + failed: Dict[str, Dict[str, Any]], + ) -> bool: + changed = False + for email, failed_meta in list(failed.items()): + if email not in registered: + continue + failed_reason = str((failed_meta or {}).get("reason") or "") + if self._should_force_failed_record(failed_reason): + registered.pop(email, None) + changed = True + return changed + + def _mark_failed_email( + self, + email: str, + reason: str = "", + extra: Optional[Dict[str, Any]] = None, + prefer_failed: bool = False, + ) -> Dict[str, Any]: + email_norm = self._normalize_email(email) + if not email_norm: + return {} + + registered = self._load_email_index(self._registered_file) + registered_record: Dict[str, Any] = {} + if email_norm in registered: + if not prefer_failed: + return registered.get(email_norm) or {} + registered_record = dict(registered.get(email_norm) or {}) + registered.pop(email_norm, None) + self._save_email_index(self._registered_file, registered) + + failed = self._load_email_index(self._failed_file) + record = failed.get(email_norm, {"email": email_norm, "fail_count": 0}) + for k, v in registered_record.items(): + if k not in record and v not in (None, ""): + record[k] = v + record["fail_count"] = int(record.get("fail_count") or 0) + 1 + record["updated_at"] = self._now_iso() + if reason: + record["reason"] = reason[:500] + if extra: + for k, v in extra.items(): + if v is not None and v != "": + record[k] = v + failed[email_norm] = record + self._save_email_index(self._failed_file, failed) + return record + + def mark_registration_outcome( + self, + email: str, + success: bool, + reason: str = "", + context: Optional[Dict[str, Any]] = None, + ) -> None: + """供任务调度层调用:把注册结果落盘,避免后续重复尝试同邮箱。""" + if success: + self._mark_registered_email(email, extra=context) + else: + prefer_failed = self._should_force_failed_record(reason) + context_copy = dict(context or {}) + password = str(context_copy.get("generated_password") or context_copy.get("password") or "").strip() + if password: + context_copy["password"] = password + record = self._mark_failed_email( + email, + reason=reason, + extra=context_copy, + prefer_failed=prefer_failed, + ) + self._try_submit_appeal(email=email, reason=reason, context=context_copy, failed_record=record) + + def _resolve_order_id_by_order_no(self, order_no: str, max_pages: int = 3, page_size: int = 50) -> Optional[int]: + order_no_text = str(order_no or "").strip() + if not order_no_text: + return None + try: + for page in range(1, max_pages + 1): + result = self.client.user.get_orders(page=page, page_size=page_size) + items = list(getattr(result, "list", []) or []) + if not items: + break + for item in items: + current_order_no = str(self._extract_field(item, "order_no") or "").strip() + if current_order_no != order_no_text: + continue + order_id_raw = self._extract_field(item, "id", "order_id") + if order_id_raw in (None, ""): + continue + try: + return int(order_id_raw) + except Exception: + continue + if len(items) < page_size: + break + except Exception as exc: + logger.warning(f"LuckMail 查询订单ID失败: {exc}") + return None + + def _build_appeal_payload( + self, + reason: str, + context: Dict[str, Any], + ) -> Optional[Dict[str, Any]]: + reason_text = str(reason or "").strip() + reason_lower = reason_text.lower() + + purchase_id_raw = context.get("purchase_id") + order_id_raw = context.get("order_id") + order_no = str(context.get("order_no") or "").strip() + + appeal_type = None + order_id = None + purchase_id = None + + if purchase_id_raw not in (None, ""): + try: + purchase_id = int(purchase_id_raw) + appeal_type = 2 + except Exception: + purchase_id = None + + if appeal_type is None and order_id_raw not in (None, ""): + try: + order_id = int(order_id_raw) + appeal_type = 1 + except Exception: + order_id = None + + if appeal_type is None and order_no: + order_id = self._resolve_order_id_by_order_no(order_no) + if order_id is not None: + appeal_type = 1 + + if appeal_type is None: + return None + + if "429" in reason_lower or "limit" in reason_lower or "限流" in reason_text: + appeal_reason = "no_code" + elif "exists" in reason_lower or "already" in reason_lower or "已存在" in reason_text: + appeal_reason = "email_invalid" + elif "验证码" in reason_text or "otp" in reason_lower: + appeal_reason = "wrong_code" + else: + appeal_reason = "no_code" + + desc = reason_text or "注册任务失败,申请人工核查并处理。" + payload: Dict[str, Any] = { + "appeal_type": appeal_type, + "reason": appeal_reason, + "description": desc[:300], + } + if appeal_type == 1 and order_id is not None: + payload["order_id"] = int(order_id) + if appeal_type == 2 and purchase_id is not None: + payload["purchase_id"] = int(purchase_id) + return payload + + def _try_submit_appeal( + self, + email: str, + reason: str, + context: Dict[str, Any], + failed_record: Optional[Dict[str, Any]] = None, + ) -> None: + if not LUCKMAIL_APPEAL_ENABLED: + return + + reason_text = str(reason or "").strip() + if not reason_text: + return + + reason_lower = reason_text.lower() + should_appeal = ( + "429" in reason_lower + or "限流" in reason_text + or "验证码" in reason_text + or "otp" in reason_lower + or "failed to register username" in reason_lower + or "用户名注册失败" in reason_text + or "创建用户账户失败" in reason_text + or "该邮箱已存在 openai" in reason_lower + or "user_already_exists" in reason_lower + or "already exists" in reason_lower + ) + if not should_appeal: + return + + email_norm = self._normalize_email(email) + if not email_norm: + return + + failed_index = self._load_email_index(self._failed_file) + current = failed_index.get(email_norm, {}) + if not current and failed_record: + current = failed_record + + # 申诉不代表删除:仅记录状态,不从 failed 名单移除。 + last_appeal_status = str(current.get("appeal_status") or "").strip().lower() + if last_appeal_status == "submitted": + return + + payload = self._build_appeal_payload(reason_text, context) + if not payload: + return + + try: + response = self.client.user.create_appeal(**payload) + appeal_no = str(self._extract_field(response, "appeal_no") or "").strip() + current["appeal_status"] = "submitted" + current["appeal_at"] = self._now_iso() + if appeal_no: + current["appeal_no"] = appeal_no + failed_index[email_norm] = current + self._save_email_index(self._failed_file, failed_index) + logger.info(f"LuckMail 已提交申诉: email={email_norm}, appeal_no={appeal_no or '-'}") + except Exception as exc: + current["appeal_status"] = "failed" + current["appeal_error"] = str(exc)[:500] + current["appeal_at"] = self._now_iso() + failed_index[email_norm] = current + self._save_email_index(self._failed_file, failed_index) + logger.warning(f"LuckMail 提交申诉失败: email={email_norm}, error={exc}") + + def _query_existing_account_emails(self, emails: Set[str]) -> Set[str]: + if not emails: + return set() + try: + from sqlalchemy import func + from ..database.models import Account as AccountModel + from ..database.session import get_db + + normalized = [self._normalize_email(e) for e in emails if self._normalize_email(e)] + if not normalized: + return set() + + with get_db() as db: + rows = ( + db.query(func.lower(AccountModel.email)) + .filter(func.lower(AccountModel.email).in_(normalized)) + .all() + ) + result = set() + for row in rows: + try: + value = row[0] + except Exception: + value = "" + email_norm = self._normalize_email(value) + if email_norm: + result.add(email_norm) + return result + except Exception as exc: + logger.warning(f"LuckMail 查询账号库邮箱失败: {exc}") + return set() + + def _iter_purchase_items(self, scan_pages: int, page_size: int): + for page in range(1, scan_pages + 1): + try: + page_result = self.client.user.get_purchases( + page=page, + page_size=page_size, + user_disabled=0, + ) + except Exception as exc: + logger.warning(f"LuckMail 拉取已购邮箱失败: page={page}, error={exc}") + break + + items = list(getattr(page_result, "list", []) or []) + if not items: + break + + for item in items: + yield item + + if len(items) < page_size: + break + + def _build_purchase_order_info( + self, + item: Any, + project_code: str, + email_type: str, + preferred_domain: str, + source: str, + ) -> Optional[Dict[str, Any]]: + email = self._normalize_email(self._extract_field(item, "email_address", "address", "email")) + token = str(self._extract_field(item, "token") or "").strip() + purchase_id_raw = self._extract_field(item, "id", "purchase_id") + purchase_id = str(purchase_id_raw).strip() if purchase_id_raw not in (None, "") else "" + + if not email or not token: + return None + + if preferred_domain: + domain = email.split("@", 1)[1] if "@" in email else "" + if domain != preferred_domain.lower(): + return None + + return { + "id": purchase_id or token, + "service_id": token, + "order_no": "", + "email": email, + "token": token, + "purchase_id": purchase_id or None, + "inbox_mode": "purchase", + "project_code": project_code, + "email_type": email_type, + "preferred_domain": preferred_domain, + "expired_at": "", + "created_at": time.time(), + "source": source, + } + + def _pick_reusable_purchase_inbox( + self, + project_code: str, + email_type: str, + preferred_domain: str, + ) -> Optional[Dict[str, Any]]: + registered = self._load_email_index(self._registered_file) + failed = self._load_email_index(self._failed_file) + if self._reconcile_failed_over_registered(registered, failed): + self._save_email_index(self._registered_file, registered) + + candidates: List[Dict[str, Any]] = [] + for item in self._iter_purchase_items( + scan_pages=int(self.config.get("purchase_scan_pages") or 5), + page_size=int(self.config.get("purchase_scan_page_size") or 100), + ): + info = self._build_purchase_order_info( + item=item, + project_code=project_code, + email_type=email_type, + preferred_domain=preferred_domain, + source="reuse_purchase", + ) + if not info: + continue + email = self._normalize_email(info.get("email")) + if not email: + continue + if email in registered: + continue + if email in failed: + failed_meta = failed.get(email) or {} + failed_reason = str(failed_meta.get("reason") or "") + if not self._is_resumable_failure_reason(failed_reason): + continue + + resume_password = str( + failed_meta.get("password") + or failed_meta.get("generated_password") + or "" + ).strip() + if not resume_password: + resume_password = self._recover_password_from_recent_task_logs(email) + if not resume_password: + continue + + info["resume_password"] = resume_password + info["source"] = "resume_failed" + candidates.append(info) + + if not candidates: + return None + + existing_in_db = self._query_existing_account_emails({self._normalize_email(c.get("email")) for c in candidates}) + for info in candidates: + email = self._normalize_email(info.get("email")) + if email in existing_in_db: + self._mark_registered_email( + email, + extra={ + "source": "accounts_db", + "token": info.get("token"), + "purchase_id": info.get("purchase_id"), + }, + ) + continue + return info + return None + + def _create_order_inbox( + self, + project_code: str, + email_type: str, + preferred_domain: str, + specified_email: Optional[str] = None, + ) -> Dict[str, Any]: + try: + kwargs: Dict[str, Any] = { + "project_code": project_code, + "email_type": email_type, + } + if preferred_domain: + kwargs["domain"] = preferred_domain + if specified_email: + kwargs["specified_email"] = specified_email + order = self.client.user.create_order(**kwargs) + except Exception as exc: + self.update_status(False, exc) + raise EmailServiceError(f"LuckMail 创建订单失败: {exc}") + + order_no = str(self._extract_field(order, "order_no") or "").strip() + email = str(self._extract_field(order, "email_address", "email") or "").strip().lower() + if not order_no or not email: + raise EmailServiceError("LuckMail 返回订单信息不完整") + + return { + "id": order_no, + "service_id": order_no, + "order_no": order_no, + "email": email, + "token": "", + "purchase_id": None, + "inbox_mode": "order", + "project_code": project_code, + "email_type": email_type, + "preferred_domain": preferred_domain, + "expired_at": str(self._extract_field(order, "expired_at") or "").strip(), + "created_at": time.time(), + "source": "new_order", + } + + def _extract_first_purchase_item(self, purchased: Any) -> Any: + if purchased is None: + return None + + if isinstance(purchased, list): + return purchased[0] if purchased else None + + if isinstance(purchased, dict): + for key in ("purchases", "list", "items"): + arr = purchased.get(key) + if isinstance(arr, list) and arr: + return arr[0] + data = purchased.get("data") + if isinstance(data, dict): + for key in ("purchases", "list", "items"): + arr = data.get(key) + if isinstance(arr, list) and arr: + return arr[0] + return None + + for key in ("purchases", "list", "items"): + arr = getattr(purchased, key, None) + if isinstance(arr, list) and arr: + return arr[0] + + return None + + def _create_purchase_inbox( + self, + project_code: str, + email_type: str, + preferred_domain: str, + ) -> Dict[str, Any]: + try: + kwargs: Dict[str, Any] = { + "project_code": project_code, + "quantity": 1, + "email_type": email_type, + } + if preferred_domain: + kwargs["domain"] = preferred_domain + purchased = self.client.user.purchase_emails(**kwargs) + except Exception as exc: + self.update_status(False, exc) + raise EmailServiceError(f"LuckMail 购买邮箱失败: {exc}") + + item = self._extract_first_purchase_item(purchased) + if item is None: + raise EmailServiceError("LuckMail 购买邮箱返回为空") + + email = str(self._extract_field(item, "email_address", "address", "email") or "").strip().lower() + token = str(self._extract_field(item, "token") or "").strip() + purchase_id_raw = self._extract_field(item, "id", "purchase_id") + purchase_id = str(purchase_id_raw).strip() if purchase_id_raw not in (None, "") else None + + if not email or not token: + raise EmailServiceError("LuckMail 购买邮箱返回字段不完整(缺少 email/token)") + + return { + "id": purchase_id or token, + "service_id": token, + "order_no": "", + "email": email, + "token": token, + "purchase_id": purchase_id, + "inbox_mode": "purchase", + "project_code": project_code, + "email_type": email_type, + "preferred_domain": preferred_domain, + "expired_at": "", + "created_at": time.time(), + "source": "new_purchase", + } + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + request_config = config or {} + project_code = str(request_config.get("project_code") or self.config["project_code"]).strip() + email_type = str(request_config.get("email_type") or self.config["email_type"]).strip() + preferred_domain = str( + request_config.get("preferred_domain") + or request_config.get("domain") + or self.config.get("preferred_domain") + or "" + ).strip().lstrip("@") + + inbox_mode = self._normalize_inbox_mode( + request_config.get("inbox_mode") or request_config.get("mode") or self.config.get("inbox_mode") + ) + + if inbox_mode == "order": + order_info = self._create_order_inbox( + project_code=project_code, + email_type=email_type, + preferred_domain=preferred_domain, + ) + else: + if bool(self.config.get("reuse_existing_purchases", True)): + reused = self._pick_reusable_purchase_inbox( + project_code=project_code, + email_type=email_type, + preferred_domain=preferred_domain, + ) + if reused: + order_info = reused + else: + order_info = self._create_purchase_inbox( + project_code=project_code, + email_type=email_type, + preferred_domain=preferred_domain, + ) + else: + order_info = self._create_purchase_inbox( + project_code=project_code, + email_type=email_type, + preferred_domain=preferred_domain, + ) + + self._cache_order(order_info) + self.update_status(True) + return order_info + + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 120, + pattern: str = OTP_CODE_PATTERN, + otp_sent_at: Optional[float] = None, + ) -> Optional[str]: + order_info = self._find_order(email=email, email_id=email_id) + + token = "" + order_no = "" + inbox_mode = self._normalize_inbox_mode(self.config.get("inbox_mode")) + if order_info: + token = str(order_info.get("token") or "").strip() + order_no = str(order_info.get("order_no") or order_info.get("service_id") or "").strip() + inbox_mode = self._normalize_inbox_mode(order_info.get("inbox_mode") or inbox_mode) + + if not token and email_id and str(email_id).strip().startswith("tok_"): + token = str(email_id).strip() + inbox_mode = "purchase" + + if not order_no and email_id and not token: + order_no = str(email_id).strip() + + if inbox_mode == "purchase": + if not token: + logger.warning(f"LuckMail 未找到 token,无法拉取验证码: email={email}, email_id={email_id}") + return None + code_key = f"token:{token}" + else: + if not order_no: + logger.warning(f"LuckMail 未找到订单号,无法拉取验证码: email={email}, email_id={email_id}") + return None + code_key = f"order:{order_no}" + + poll_interval = float(self.config.get("poll_interval") or 3.0) + timeout_s = max(int(timeout or 120), 1) + deadline = time.time() + timeout_s + # OTP 刚发送后的短窗口内更容易读到旧码;配合“最近已用验证码”一起过滤。 + otp_guard_until = (float(otp_sent_at) + 1.5) if otp_sent_at else None + + while time.time() < deadline: + try: + if inbox_mode == "purchase": + result = self.client.user.get_token_code(token) + status = "success" if bool(self._extract_field(result, "has_new_mail")) else "pending" + else: + result = self.client.user.get_order_code(order_no) + status = str(self._extract_field(result, "status") or "").strip().lower() + except Exception as exc: + logger.warning(f"LuckMail 拉取验证码失败: {exc}") + self.update_status(False, exc) + time.sleep(min(poll_interval, 1.0)) + continue + + code = str(self._extract_field(result, "verification_code") or "").strip() + + # token 模式下,部分平台会在 has_new_mail=false 时也返回最近一次 code。 + # 这里以 code 为准,再配合“最近已用验证码”过滤旧码。 + if inbox_mode == "purchase" and code: + status = "success" + + if status in ("timeout", "cancelled"): + ref = token if inbox_mode == "purchase" else order_no + logger.info(f"LuckMail 未拿到验证码: {ref}, status={status}") + return None + + if status == "success" and code: + if pattern and not re.search(pattern, code): + logger.warning(f"LuckMail 返回验证码格式不匹配: {code}") + return None + + now_ts = time.time() + if otp_guard_until and now_ts < otp_guard_until and self._is_recent_code(code_key, code, now_ts): + time.sleep(poll_interval) + continue + + if self._is_recent_code(code_key, code, now_ts): + # 同一 token/订单在不同流程阶段会复用查询接口,这里阻断旧码重复返回。 + time.sleep(poll_interval) + continue + + self._remember_code(code_key, code, now_ts) + self.update_status(True) + return code + + time.sleep(poll_interval) + + return None + + def list_emails(self, **kwargs) -> List[Dict[str, Any]]: + _ = kwargs + return list(self._orders_by_no.values()) + + def delete_email(self, email_id: str) -> bool: + order_info = self._find_order(email=email_id, email_id=email_id) + token = str((order_info or {}).get("token") or "").strip() + purchase_id = str((order_info or {}).get("purchase_id") or "").strip() + order_no = str((order_info or {}).get("order_no") or "").strip() + + if not token and not order_no: + raw_id = str(email_id or "").strip() + if raw_id.startswith("tok_"): + token = raw_id + else: + order_no = raw_id + + if not token and not order_no: + return False + + try: + if token and purchase_id.isdigit(): + # 购买邮箱通常不支持直接删除,标记禁用即可。 + try: + self.client.user.set_purchase_disabled(int(purchase_id), 1) + except Exception: + pass + elif order_no: + self.client.user.cancel_order(order_no) + + key = token or order_no + item = self._orders_by_no.pop(key, None) + if item: + email = str(item.get("email") or "").strip().lower() + if email: + self._orders_by_email.pop(email, None) + if token: + self._recent_codes_by_order.pop(f"token:{token}", None) + if order_no: + self._recent_codes_by_order.pop(f"order:{order_no}", None) + self.update_status(True) + return True + except Exception as exc: + logger.warning(f"LuckMail 删除邮箱失败: {exc}") + self.update_status(False, exc) + return False + + def check_health(self) -> bool: + try: + self.client.user.get_balance() + self.update_status(True) + return True + except Exception as exc: + logger.warning(f"LuckMail 健康检查失败: {exc}") + self.update_status(False, exc) + return False + + def get_service_info(self) -> Dict[str, Any]: + return { + "service_type": self.service_type.value, + "name": self.name, + "base_url": self.config.get("base_url"), + "project_code": self.config.get("project_code"), + "email_type": self.config.get("email_type"), + "preferred_domain": self.config.get("preferred_domain"), + "inbox_mode": self.config.get("inbox_mode"), + "cached_orders": len(self._orders_by_no), + "status": self.status.value, + } 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 e5d0f2b9..a2320593 100644 --- a/src/services/tempmail.py +++ b/src/services/tempmail.py @@ -397,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/services/yyds_mail.py b/src/services/yyds_mail.py new file mode 100644 index 00000000..e79410ea --- /dev/null +++ b/src/services/yyds_mail.py @@ -0,0 +1,448 @@ +""" +YYDS Mail 邮箱服务实现 +基于 YYDS Mail REST API(/v1/accounts、/v1/token、/v1/messages) +""" + +import logging +import random +import re +import string +import time +from datetime import datetime, timezone +from html import unescape +from typing import Any, Dict, List, Optional + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..config.constants import OTP_CODE_PATTERN, OTP_CODE_SEMANTIC_PATTERN +from ..core.http_client import HTTPClient, RequestConfig + + +logger = logging.getLogger(__name__) + + +class YYDSMailService(BaseEmailService): + """YYDS Mail 临时邮箱服务""" + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + super().__init__(EmailServiceType.YYDS_MAIL, name) + + required_keys = ["base_url", "api_key"] + missing_keys = [key for key in required_keys if not (config or {}).get(key)] + if missing_keys: + raise ValueError(f"缺少必需配置: {missing_keys}") + + default_config = { + "default_domain": "", + "timeout": 30, + "max_retries": 3, + "proxy_url": None, + } + self.config = {**default_config, **(config or {})} + self.config["base_url"] = str(self.config["base_url"]).rstrip("/") + self.config["default_domain"] = str(self.config.get("default_domain") or "").strip().lstrip("@") + + http_config = RequestConfig( + timeout=self.config["timeout"], + max_retries=self.config["max_retries"], + ) + self.http_client = HTTPClient( + proxy_url=self.config.get("proxy_url"), + config=http_config, + ) + + self._accounts_by_id: Dict[str, Dict[str, Any]] = {} + self._accounts_by_email: Dict[str, Dict[str, Any]] = {} + self._last_used_message_ids: Dict[str, str] = {} + + def _build_headers( + self, + *, + token: Optional[str] = None, + use_api_key: bool = False, + extra_headers: Optional[Dict[str, str]] = None, + ) -> Dict[str, str]: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + if token: + headers["Authorization"] = f"Bearer {token}" + elif use_api_key: + headers["X-API-Key"] = str(self.config["api_key"]).strip() + + if extra_headers: + headers.update(extra_headers) + + return headers + + def _unwrap_payload(self, payload: Any) -> Any: + if not isinstance(payload, dict): + return payload + + if payload.get("success") is False: + message = str(payload.get("error") or payload.get("message") or "请求失败").strip() + raise EmailServiceError(message) + + if "data" in payload: + return payload.get("data") + return payload + + def _make_request( + self, + method: str, + path: str, + *, + token: Optional[str] = None, + use_api_key: bool = False, + **kwargs, + ) -> Any: + url = f"{self.config['base_url']}{path}" + kwargs["headers"] = self._build_headers( + token=token, + use_api_key=use_api_key, + extra_headers=kwargs.get("headers"), + ) + + try: + response = self.http_client.request(method, url, **kwargs) + if response.status_code >= 400: + error_message = f"API 请求失败: {response.status_code}" + try: + error_payload = response.json() + error_message = str( + error_payload.get("error") + or error_payload.get("message") + or error_payload + ) + except Exception: + error_message = response.text[:200] or error_message + raise EmailServiceError(error_message) + + try: + payload = response.json() + except Exception: + payload = {} + + return self._unwrap_payload(payload) + except Exception as e: + self.update_status(False, e) + if isinstance(e, EmailServiceError): + raise + raise EmailServiceError(f"请求失败: {method} {path} - {e}") + + def _generate_local_part(self) -> str: + prefix = "".join(random.choices(string.ascii_lowercase, k=7)) + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=3)) + return f"{prefix}{suffix}" + + def _cache_account(self, account_info: Dict[str, Any]) -> None: + account_id = str(account_info.get("account_id") or account_info.get("service_id") or "").strip() + email = str(account_info.get("email") or "").strip().lower() + + if account_id: + self._accounts_by_id[account_id] = account_info + if email: + self._accounts_by_email[email] = account_info + + def _get_cached_account( + self, + *, + email: Optional[str] = None, + email_id: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + if email_id: + cached = self._accounts_by_id.get(str(email_id).strip()) + if cached: + return cached + + if email: + cached = self._accounts_by_email.get(str(email).strip().lower()) + if cached: + return cached + + return None + + def _request_temp_token(self, email: str) -> Dict[str, Any]: + payload = self._make_request( + "POST", + "/token", + json={"address": str(email).strip()}, + ) + + resolved_email = str(payload.get("address") or email).strip() + account_id = str(payload.get("id") or "").strip() + token = str(payload.get("token") or "").strip() + if not resolved_email or not token: + raise EmailServiceError("YYDS Mail 返回的临时 Token 数据不完整") + + account_info = { + "email": resolved_email, + "service_id": account_id or resolved_email, + "id": account_id or resolved_email, + "account_id": account_id or resolved_email, + "token": token, + "created_at": time.time(), + } + self._cache_account(account_info) + return account_info + + def _parse_message_time(self, value: Any) -> Optional[float]: + if value is None: + return None + + if isinstance(value, (int, float)): + ts = float(value) + if ts > 10**12: + ts /= 1000.0 + return ts if ts > 0 else None + + text = str(value or "").strip() + if not text: + return None + + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except Exception: + return None + + def _html_to_text(self, html_content: Any) -> str: + if isinstance(html_content, list): + html_content = "\n".join(str(item) for item in html_content if item) + text = str(html_content or "") + return unescape(re.sub(r"<[^>]+>", " ", text)) + + def _sender_text(self, sender: Any) -> str: + if isinstance(sender, dict): + return " ".join( + str(sender.get(key) or "") for key in ("name", "address") + ).strip() + return str(sender or "").strip() + + def _message_search_text(self, summary: Dict[str, Any], detail: Dict[str, Any]) -> str: + sender_text = self._sender_text(detail.get("from") or summary.get("from")) + subject = str(detail.get("subject") or summary.get("subject") or "") + text_body = str(detail.get("text") or "") + html_body = self._html_to_text(detail.get("html")) + summary_blob = " ".join( + str(summary.get(key) or "") + for key in ("subject", "snippet", "preview") + ).strip() + return "\n".join( + part for part in [sender_text, subject, summary_blob, text_body, html_body] if part + ).strip() + + def _is_openai_otp_mail(self, content: str) -> bool: + text = str(content or "").lower() + if "openai" not in text: + return False + keywords = ( + "verification code", + "verify", + "one-time code", + "one time code", + "security code", + "your openai code", + "验证码", + "code is", + ) + return any(keyword in text for keyword in keywords) + + def _extract_otp_code(self, content: str, pattern: str) -> Optional[str]: + text = str(content or "") + if not text: + return None + + semantic_match = re.search(OTP_CODE_SEMANTIC_PATTERN, text, re.IGNORECASE) + if semantic_match: + return semantic_match.group(1) + + simple_match = re.search(pattern, text) + if simple_match: + return simple_match.group(1) + return None + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + request_config = config or {} + local_part = str( + request_config.get("address") + or request_config.get("prefix") + or request_config.get("name") + or self._generate_local_part() + ).strip() + domain = str( + request_config.get("default_domain") + or request_config.get("domain") + or self.config.get("default_domain") + or "" + ).strip().lstrip("@") + + payload: Dict[str, Any] = {"address": local_part} + if domain: + payload["domain"] = domain + + account_response = self._make_request( + "POST", + "/accounts", + json=payload, + use_api_key=True, + ) + + account_id = str(account_response.get("id") or "").strip() + email = str(account_response.get("address") or "").strip() + token = str(account_response.get("token") or "").strip() + + if not account_id or not email or not token: + raise EmailServiceError("YYDS Mail 返回数据不完整") + + email_info = { + "email": email, + "service_id": account_id, + "id": account_id, + "account_id": account_id, + "token": token, + "created_at": time.time(), + "raw_account": account_response, + } + self._cache_account(email_info) + self.update_status(True) + return email_info + + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 120, + pattern: str = OTP_CODE_PATTERN, + otp_sent_at: Optional[float] = None, + ) -> Optional[str]: + account_info = self._get_cached_account(email=email, email_id=email_id) + if not account_info: + try: + account_info = self._request_temp_token(email) + except Exception as e: + logger.warning(f"YYDS Mail 获取临时 Token 失败: {email} - {e}") + return None + + token = str(account_info.get("token") or "").strip() + if not token: + return None + + start_time = time.time() + seen_message_ids = set() + last_used_message_id = self._last_used_message_ids.get(str(email).strip().lower()) + + while time.time() - start_time < timeout: + try: + response = self._make_request( + "GET", + "/messages", + token=token, + params={ + "address": str(email).strip(), + "limit": 50, + }, + ) + messages = [] + if isinstance(response, dict): + messages = response.get("messages") or [] + elif isinstance(response, list): + messages = response + + for message in messages: + message_id = str(message.get("id") or "").strip() + if not message_id or message_id in seen_message_ids: + continue + if last_used_message_id and message_id == last_used_message_id: + continue + + created_at = self._parse_message_time(message.get("createdAt")) + if otp_sent_at and created_at and created_at + 1 < otp_sent_at: + continue + + seen_message_ids.add(message_id) + + detail = self._make_request( + "GET", + f"/messages/{message_id}", + token=token, + ) + + content = self._message_search_text(message, detail) + if not self._is_openai_otp_mail(content): + continue + + code = self._extract_otp_code(content, pattern) + if code: + self._last_used_message_ids[str(email).strip().lower()] = message_id + self.update_status(True) + return code + except Exception as e: + logger.debug(f"YYDS Mail 轮询验证码失败: {e}") + + time.sleep(3) + + return None + + def list_emails(self, **kwargs) -> List[Dict[str, Any]]: + return list(self._accounts_by_email.values()) + + def delete_email(self, email_id: str) -> bool: + account_info = self._get_cached_account(email_id=email_id) or self._get_cached_account(email=email_id) + if not account_info and email_id and "@" in str(email_id): + try: + account_info = self._request_temp_token(str(email_id)) + except Exception: + account_info = None + + if not account_info: + return False + + account_id = str(account_info.get("account_id") or account_info.get("service_id") or "").strip() + token = str(account_info.get("token") or "").strip() + if not account_id or not token: + return False + + try: + self._make_request( + "DELETE", + f"/accounts/{account_id}", + token=token, + ) + self._accounts_by_id.pop(account_id, None) + self._accounts_by_email.pop(str(account_info.get("email") or "").strip().lower(), None) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"YYDS Mail 删除邮箱失败: {e}") + self.update_status(False, e) + return False + + def check_health(self) -> bool: + try: + self._make_request( + "GET", + "/me", + use_api_key=True, + ) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"YYDS Mail 健康检查失败: {e}") + self.update_status(False, e) + return False + + def get_service_info(self) -> Dict[str, Any]: + return { + "service_type": self.service_type.value, + "name": self.name, + "base_url": self.config["base_url"], + "default_domain": self.config.get("default_domain") or "", + "cached_accounts": len(self._accounts_by_email), + "status": self.status.value, + } diff --git a/src/web/app.py b/src/web/app.py index b679341d..40bbef8e 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -1,44 +1,46 @@ -""" -FastAPI 应用主文件 -轻量级 Web UI,支持注册、账号管理、设置 -""" +"""FastAPI app entrypoint.""" import logging -import sys import secrets -import hmac -import hashlib -from typing import Optional, Dict, Any +import sys +from contextlib import asynccontextmanager from pathlib import Path +from typing import Any, Dict, Optional -from fastapi import FastAPI, Request, Form -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates +from fastapi import Depends, FastAPI, Form, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates -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 .scheduler import scheduled_registration_service from .task_manager import task_manager logger = logging.getLogger(__name__) +auto_registration_coordinator = None -# 获取项目根目录 -# PyInstaller 打包后静态资源在 sys._MEIPASS,开发时在源码根目录 -if getattr(sys, 'frozen', False): +if getattr(sys, "frozen", False): _RESOURCE_ROOT = Path(sys._MEIPASS) else: _RESOURCE_ROOT = Path(__file__).parent.parent.parent -# 静态文件和模板目录 STATIC_DIR = _RESOURCE_ROOT / "static" TEMPLATES_DIR = _RESOURCE_ROOT / "templates" def _build_static_asset_version(static_dir: Path) -> str: - """基于静态文件最后修改时间生成版本号,避免部署后浏览器继续使用旧缓存。""" latest_mtime = 0 if static_dir.exists(): for path in static_dir.rglob("*"): @@ -48,18 +50,108 @@ def _build_static_asset_version(static_dir: Path) -> str: def create_app() -> FastAPI: - """创建 FastAPI 应用实例""" settings = get_settings() + @asynccontextmanager + async def lifespan(app: FastAPI): + import asyncio + + from ..core.auto_registration import ( + AutoRegistrationCoordinator, + register_auto_registration_coordinator, + ) + from ..core.db_logs import cleanup_database_logs + from ..database.init_db import initialize_database + from .auto_quick_refresh_scheduler import auto_quick_refresh_scheduler + from .routes.registration import run_auto_registration_batch + from .selfcheck_scheduler import selfcheck_scheduler + + try: + initialize_database() + except Exception as exc: + logger.warning("Database init failed: %s", exc) + + loop = asyncio.get_running_loop() + task_manager.set_loop(loop) + + global auto_registration_coordinator + auto_registration_coordinator = AutoRegistrationCoordinator( + trigger_callback=run_auto_registration_batch, + ) + register_auto_registration_coordinator(auto_registration_coordinator) + auto_registration_coordinator.start() + + async def run_log_cleanup_once() -> None: + try: + result = await asyncio.to_thread(cleanup_database_logs) + logger.info( + "Log cleanup done: deleted=%s remaining=%s", + result.get("deleted_total", 0), + result.get("remaining", 0), + ) + except Exception as exc: + logger.warning("Log cleanup failed: %s", exc) + + async def periodic_log_cleanup() -> None: + while True: + try: + await asyncio.sleep(3600) + await run_log_cleanup_once() + except asyncio.CancelledError: + break + except Exception as exc: + logger.warning("Periodic log cleanup failed: %s", exc) + + await run_log_cleanup_once() + app.state.log_cleanup_task = asyncio.create_task(periodic_log_cleanup()) + await scheduled_registration_service.start() + app.state.scheduled_registration_service = scheduled_registration_service + 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("%s v%s starting", settings.app_name, settings.app_version) + logger.info("Debug mode: %s", settings.debug) + logger.info("Database URL: %s", settings.database_url) + if is_default_security_config_active(): + logger.warning("Default security config detected, visit /setup-password") + logger.info("=" * 50) + + try: + yield + finally: + cleanup_task = getattr(app.state, "log_cleanup_task", None) + if cleanup_task: + cleanup_task.cancel() + + scheduler_service = getattr(app.state, "scheduled_registration_service", None) + if scheduler_service: + await scheduler_service.stop() + + 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() + + if auto_registration_coordinator is not None: + await auto_registration_coordinator.stop() + register_auto_registration_coordinator(None) + auto_registration_coordinator = None + + logger.info("Application stopped") + app = FastAPI( title=settings.app_name, version=settings.app_version, - description="OpenAI/Codex CLI 自动注册系统 Web UI", + description="OpenAI/Codex CLI Web UI", docs_url="/api/docs" if settings.debug else None, redoc_url="/api/redoc" if settings.debug else None, + lifespan=lifespan, ) - # CORS 中间件 app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -68,28 +160,18 @@ def create_app() -> FastAPI: allow_headers=["*"], ) - # 挂载静态文件 if STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") - logger.info(f"静态文件目录: {STATIC_DIR}") else: - # 创建静态目录 STATIC_DIR.mkdir(parents=True, exist_ok=True) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") - logger.info(f"创建静态文件目录: {STATIC_DIR}") - # 创建模板目录 if not TEMPLATES_DIR.exists(): TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) - logger.info(f"创建模板目录: {TEMPLATES_DIR}") - - # 注册 API 路由 - app.include_router(api_router, prefix="/api") - # 注册 WebSocket 路由 + app.include_router(api_router, prefix="/api", dependencies=[Depends(require_api_auth)]) app.include_router(ws_router, prefix="/api") - # 模板引擎 templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates.env.globals["static_version"] = _build_static_asset_version(STATIC_DIR) templates.env.globals["project_notice"] = PROJECT_NOTICE @@ -100,11 +182,6 @@ def _render_template( context: Optional[Dict[str, Any]] = None, status_code: int = 200, ) -> HTMLResponse: - """ - 兼容不同 Starlette 版本的 TemplateResponse 签名: - - 旧版: TemplateResponse(name, context, status_code=...) - - 新版: TemplateResponse(request, name, context, status_code=...) - """ template_context: Dict[str, Any] = {"request": request} if context: template_context.update(context) @@ -117,175 +194,188 @@ def _render_template( status_code=status_code, ) except TypeError: - return templates.TemplateResponse( - name, - template_context, - 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) + return templates.TemplateResponse(name, template_context, status_code=status_code) - 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": "Invalid password", "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": "Current password is incorrect", "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": "Password must be at least 8 characters", "message": ""}, + status_code=400, + ) + if new_value != confirm_value: + return _render_template( + request, + "setup_password.html", + {"error": "Passwords do not match", "message": ""}, + status_code=400, + ) + if new_value == "admin123": + return _render_template( + request, + "setup_password.html", + {"error": "Default password is not allowed", "message": ""}, + status_code=400, + ) + + update_settings( + webui_access_password=new_value, + webui_secret_key=secrets.token_urlsafe(48), + ) + response = RedirectResponse( + url="/login?notice=Password updated, please sign in again", + status_code=302, + ) + response.delete_cookie("webui_auth") return response @app.get("/logout") async def logout(request: Request, next: Optional[str] = "/login"): - """退出登录""" response = RedirectResponse(url=next or "/login", status_code=302) response.delete_cookie("webui_auth") return response @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) + 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.on_event("startup") - async def startup_event(): - """应用启动事件""" - import asyncio - from ..database.init_db import initialize_database - from ..core.db_logs import cleanup_database_logs - - # 确保数据库已初始化(reload 模式下子进程也需要初始化) - try: - initialize_database() - except Exception as e: - logger.warning(f"数据库初始化: {e}") - - # 设置 TaskManager 的事件循环 - loop = asyncio.get_event_loop() - task_manager.set_loop(loop) - - async def run_log_cleanup_once(): - try: - result = await asyncio.to_thread(cleanup_database_logs) - logger.info( - "后台日志清理完成: 删除 %s 条,剩余 %s 条", - result.get("deleted_total", 0), - result.get("remaining", 0), - ) - except Exception as exc: - logger.warning(f"后台日志清理失败: {exc}") - - async def periodic_log_cleanup(): - while True: - try: - await asyncio.sleep(3600) # 每小时清理一次 - await run_log_cleanup_once() - except asyncio.CancelledError: - break - except Exception as exc: - logger.warning(f"后台日志定时清理异常: {exc}") - - # 启动时先执行一次,再开启定时任务 - await run_log_cleanup_once() - app.state.log_cleanup_task = asyncio.create_task(periodic_log_cleanup()) - - logger.info("=" * 50) - logger.info(f"{settings.app_name} v{settings.app_version} 启动中,程序正在伸懒腰...") - logger.info(f"调试模式: {settings.debug}") - logger.info(f"数据库连接已接好线: {settings.database_url}") - logger.info("=" * 50) - - @app.on_event("shutdown") - async def shutdown_event(): - """应用关闭事件""" - cleanup_task = getattr(app.state, "log_cleanup_task", None) - if cleanup_task: - cleanup_task.cancel() - logger.info("应用关闭,今天先收摊啦") + @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") return app -# 创建全局应用实例 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..58c80bbd 100644 --- a/src/web/routes/__init__.py +++ b/src/web/routes/__init__.py @@ -10,9 +10,13 @@ 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.new_api_services import router as new_api_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 +27,10 @@ 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(new_api_services_router, prefix="/new-api-services", tags=["new-api-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 631b063c..104e27a3 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -6,6 +6,7 @@ import json import logging import re +import threading import zipfile import base64 from datetime import datetime, timedelta, timezone @@ -14,22 +15,25 @@ from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body from fastapi.responses import StreamingResponse -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from sqlalchemy import func from ...config.constants import AccountStatus from ...config.settings import get_settings -from ...core.openai.overview import fetch_codex_overview +from ...core.openai.overview import fetch_codex_overview, AccountDeactivatedError from ...core.openai.token_refresh import refresh_account_token as do_refresh from ...core.openai.token_refresh import validate_account_token as do_validate from ...core.upload.cpa_upload import generate_token_json, batch_upload_to_cpa, upload_to_cpa from ...core.upload.team_manager_upload import upload_to_team_manager, batch_upload_to_team_manager from ...core.upload.sub2api_upload import batch_upload_to_sub2api, upload_to_sub2api +from ...core.upload.new_api_upload import batch_upload_to_new_api, upload_to_new_api from ...core.dynamic_proxy import get_proxy_url_for_task +from ...core.timezone_utils import utcnow_naive from ...database import crud from ...database.models import Account from ...database.session import get_db +from ..task_manager import task_manager logger = logging.getLogger(__name__) router = APIRouter() @@ -45,6 +49,30 @@ AccountStatus.BANNED.value, ) +_QUICK_REFRESH_WORKFLOW_LOCK = threading.Lock() + + +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 _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]: """获取代理 URL,策略与注册流程一致:代理列表 → 动态代理 → 静态配置""" @@ -74,6 +102,34 @@ def _apply_status_filter(query, status: Optional[str]): return query.filter(Account.status == normalized) +def _get_quick_refresh_candidate_ids() -> List[int]: + with get_db() as db: + query = ( + db.query(Account.id) + .filter(func.length(func.trim(func.coalesce(Account.access_token, ""))) > 0) + .filter(~Account.status.in_((AccountStatus.FAILED.value, AccountStatus.BANNED.value))) + .order_by(Account.id.asc()) + ) + return [int(row[0]) for row in query.all()] + + +def has_active_batch_operations() -> bool: + if _QUICK_REFRESH_WORKFLOW_LOCK.locked(): + return True + + busy_statuses = {"pending", "running", "paused"} + for domain in ("accounts", "payment"): + try: + tasks = task_manager.list_domain_tasks(domain=domain, limit=50) + except Exception: + continue + for task in tasks: + status = str(task.get("status") or "").strip().lower() + if status in busy_statuses: + return True + return False + + # ============== Pydantic Models ============== class AccountResponse(BaseModel): @@ -99,8 +155,7 @@ class AccountResponse(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class AccountListResponse(BaseModel): @@ -587,7 +642,7 @@ def _get_account_overview_data( # 避免把本地已确认的付费订阅(plus/team)被远端偶发 free/basic 覆盖降级。 if detected_sub and current_sub != detected_sub: account.subscription_type = detected_sub - account.subscription_at = datetime.utcnow() if detected_sub else None + account.subscription_at = utcnow_naive() if detected_sub else None updated = True elif not detected_sub and current_sub in PAID_SUBSCRIPTION_TYPES: logger.info( @@ -603,6 +658,17 @@ def _get_account_overview_data( account.extra_data = merged_extra updated = True return overview, updated + except AccountDeactivatedError as exc: + logger.warning("账号被停用: email=%s err=%s", account.email, exc) + account.status = AccountStatus.BANNED.value + merged_extra = dict(extra_data) + merged_extra[OVERVIEW_EXTRA_DATA_KEY] = _fallback_overview( + account, error_message="account_deactivated", stale=True + ) + merged_extra["account_deactivated_at"] = datetime.now(timezone.utc).isoformat() + account.extra_data = merged_extra + updated = True + return merged_extra[OVERVIEW_EXTRA_DATA_KEY], updated except Exception as exc: logger.warning(f"刷新账号[{account.email}]总览失败: {exc}") if cached: @@ -660,7 +726,7 @@ async def create_manual_account(request: ManualAccountCreateRequest): ) if subscription_type: account.subscription_type = subscription_type - account.subscription_at = datetime.utcnow() + account.subscription_at = utcnow_naive() db.commit() db.refresh(account) except Exception as exc: @@ -821,14 +887,14 @@ def _safe_text(value: Optional[str]) -> Optional[str]: "proxy_used": _safe_text(item.proxy_used), "source": source, "extra_data": metadata, - "last_refresh": datetime.utcnow(), + "last_refresh": utcnow_naive(), } clean_update_payload = {k: v for k, v in update_payload.items() if v is not None} account = crud.update_account(db, exists.id, **clean_update_payload) if account is None: raise RuntimeError("更新账号失败") account.subscription_type = subscription_type - account.subscription_at = datetime.utcnow() if subscription_type else None + account.subscription_at = utcnow_naive() if subscription_type else None db.commit() result["updated"] += 1 continue @@ -853,7 +919,7 @@ def _safe_text(value: Optional[str]) -> Optional[str]: ) if subscription_type: account.subscription_type = subscription_type - account.subscription_at = datetime.utcnow() + account.subscription_at = utcnow_naive() db.commit() result["created"] += 1 except Exception as exc: @@ -1341,7 +1407,7 @@ async def get_account_tokens(account_id: int): # 若 DB 为空但 cookies 可解析到 session_token,自动回写,避免后续重复解析。 if resolved_session_token and not str(account.session_token or "").strip(): account.session_token = resolved_session_token - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() db.commit() db.refresh(account) @@ -1384,7 +1450,7 @@ async def update_account(account_id: int, request: AccountUpdateRequest): if request.session_token is not None: # 留空则清空,非空则更新 update_data["session_token"] = request.session_token or None - update_data["last_refresh"] = datetime.utcnow() + update_data["last_refresh"] = utcnow_naive() account = crud.update_account(db, account_id, **update_data) return account_to_response(account) @@ -1901,9 +1967,8 @@ async def refresh_account_token(account_id: int, request: Optional[TokenRefreshR } -@router.post("/batch-validate") -async def batch_validate_tokens(request: BatchValidateRequest): - """批量验证账号 Token 有效性""" +def _run_batch_validate_tokens(request: BatchValidateRequest) -> Dict[str, Any]: + """Run token validation synchronously so it can be reused by schedulers.""" proxy = _get_proxy(request.proxy) results = { @@ -1949,6 +2014,76 @@ async def batch_validate_tokens(request: BatchValidateRequest): return results +@router.post("/batch-validate") +async def batch_validate_tokens(request: BatchValidateRequest): + """批量验证账号 Token 有效性""" + return _run_batch_validate_tokens(request) + + +def run_quick_refresh_workflow(source: str = "manual") -> Dict[str, Any]: + if not _QUICK_REFRESH_WORKFLOW_LOCK.acquire(blocking=False): + raise RuntimeError("quick_refresh_workflow_busy") + + started_at = utcnow_naive() + try: + candidate_ids = _get_quick_refresh_candidate_ids() + proxy = _get_proxy() + + validate_summary: Dict[str, Any] = { + "total": len(candidate_ids), + "valid_count": 0, + "invalid_count": 0, + "details": [], + } + subscription_summary: Dict[str, Any] = { + "total": 0, + "success_count": 0, + "failed_count": 0, + "details": [], + } + + if candidate_ids: + validate_result = _run_batch_validate_tokens( + BatchValidateRequest(ids=candidate_ids, proxy=proxy, select_all=False) + ) + validate_summary.update(validate_result or {}) + validate_summary["total"] = len(candidate_ids) + + valid_ids = [ + int(detail.get("id")) + for detail in (validate_result or {}).get("details", []) + if detail.get("valid") and detail.get("id") is not None + ] + + if valid_ids: + from . import payment as payment_routes + + subscription_result = payment_routes.batch_check_subscription( + payment_routes.BatchCheckSubscriptionRequest( + ids=valid_ids, + proxy=proxy, + select_all=False, + ) + ) + subscription_summary.update(subscription_result or {}) + subscription_summary["total"] = len(valid_ids) + + finished_at = utcnow_naive() + duration_ms = max(0, int((finished_at - started_at).total_seconds() * 1000)) + return { + "source": str(source or "manual"), + "started_at": started_at.isoformat(), + "finished_at": finished_at.isoformat(), + "duration_ms": duration_ms, + "candidate_count": len(candidate_ids), + "proxy_used": proxy, + "validate": validate_summary, + "subscription": subscription_summary, + } + finally: + _QUICK_REFRESH_WORKFLOW_LOCK.release() + + @router.post("/{account_id}/validate") async def validate_account_token(account_id: int, request: Optional[TokenValidateRequest] = Body(default=None)): """验证单个账号的 Token 有效性""" @@ -2045,7 +2180,7 @@ async def upload_account_to_cpa(account_id: int, request: Optional[CPAUploadRequ if success: account.cpa_uploaded = True - account.cpa_uploaded_at = datetime.utcnow() + account.cpa_uploaded_at = utcnow_naive() db.commit() return {"success": True, "message": message} else: @@ -2105,7 +2240,6 @@ 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"), ) return results @@ -2147,7 +2281,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="sub2api" ) if success: return {"success": True, "message": message} @@ -2155,6 +2289,77 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp return {"success": False, "error": message} +class NewApiUploadRequest(BaseModel): + """单账号 new-api 上传请求""" + service_id: Optional[int] = None + + +class BatchNewApiUploadRequest(BaseModel): + """批量 new-api 上传请求""" + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + service_id: Optional[int] = None + + +@router.post("/batch-upload-new-api") +async def batch_upload_accounts_to_new_api(request: BatchNewApiUploadRequest): + """批量上传账号到 new-api。""" + with get_db() as db: + if request.service_id: + service = crud.get_new_api_service_by_id(db, request.service_id) + else: + services = crud.get_new_api_services(db, enabled=True) + service = services[0] if services else None + + if not service: + raise HTTPException(status_code=400, detail="未找到可用的 new-api 服务,请先在设置中配置") + + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + return batch_upload_to_new_api( + ids, + service.api_url, + getattr(service, 'username', None), + getattr(service, 'password', None), + ) + + +@router.post("/{account_id}/upload-new-api") +async def upload_account_to_new_api(account_id: int, request: Optional[NewApiUploadRequest] = Body(default=None)): + """上传单个账号到 new-api。""" + service_id = request.service_id if request else None + + with get_db() as db: + if service_id: + service = crud.get_new_api_service_by_id(db, service_id) + else: + services = crud.get_new_api_services(db, enabled=True) + service = services[0] if services else None + + if not service: + raise HTTPException(status_code=400, detail="未找到可用的 new-api 服务,请先在设置中配置") + + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + if not account.access_token: + return {"success": False, "error": "账号缺少 Token,无法上传"} + + success, message = upload_to_new_api( + [account], + service.api_url, + getattr(service, 'username', None), + getattr(service, 'password', None), + ) + return {"success": success, "message": message if success else None, "error": None if success else message} + + # ============== Team Manager 上传 ============== class UploadTMRequest(BaseModel): @@ -2186,7 +2391,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 +2419,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,6 +2443,16 @@ 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 "" @@ -2269,6 +2482,7 @@ def _build_inbox_config(db, service_type, email: str) -> dict: EST.FREEMAIL: "freemail", EST.IMAP_MAIL: "imap_mail", EST.OUTLOOK: "outlook", + EST.LUCKMAIL: "luckmail", } db_type = type_map.get(service_type) if not db_type: diff --git a/src/web/routes/auto_team.py b/src/web/routes/auto_team.py new file mode 100644 index 00000000..10a62eb3 --- /dev/null +++ b/src/web/routes/auto_team.py @@ -0,0 +1,3516 @@ +""" +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 305efeff..8dfb1b45 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -1,4 +1,4 @@ -""" +""" 邮箱服务配置 API 路由 """ @@ -6,13 +6,14 @@ from typing import List, Optional, Dict, Any from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from sqlalchemy import func from ...database import crud 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__) @@ -52,8 +53,7 @@ class EmailServiceResponse(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class EmailServiceListResponse(BaseModel): @@ -98,6 +98,20 @@ class OutlookBatchImportResponse(BaseModel): 'custom_auth', } +def normalize_email_service_config(service_type: str, config: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """兼容历史配置字段,避免不同入口写入的键名不一致。""" + normalized = dict(config or {}) + + if service_type in {"temp_mail", "cloudmail", "freemail"}: + if normalized.get("default_domain") and not normalized.get("domain"): + normalized["domain"] = normalized.pop("default_domain") + + if service_type == "cloudmail" and normalized.get("api_key") and not normalized.get("admin_password"): + normalized["admin_password"] = normalized.pop("api_key") + + return normalized + + def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]: """过滤敏感配置信息""" if not config: @@ -120,10 +134,11 @@ def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]: def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: """?????????""" + normalized_config = normalize_email_service_config(service.service_type, service.config) registration_status = None registered_account_id = None if service.service_type == "outlook": - email = str((service.config or {}).get("email") or service.name or "").strip() + email = str(normalized_config.get("email") or service.name or "").strip() normalized_email = email.lower() if email: with get_db() as db: @@ -144,7 +159,7 @@ def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: name=service.name, enabled=service.enabled, priority=service.priority, - config=filter_sensitive_config(service.config), + config=filter_sensitive_config(normalized_config), registration_status=registration_status, registered_account_id=registered_account_id, last_used=service.last_used.isoformat() if service.last_used else None, @@ -159,8 +174,6 @@ 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, @@ -172,15 +185,27 @@ 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, + 'tempmail_builtin_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': True, # 临时邮箱始终可用 + 'luckmail_count': 0, + 'tempmail_available': tempmail_enabled or yyds_enabled, + 'yyds_mail_available': yyds_enabled, 'enabled_count': enabled_count } @@ -189,6 +214,10 @@ async def get_email_services_stats(): stats['outlook_count'] = count elif service_type == 'moe_mail': stats['custom_count'] = count + elif service_type == 'tempmail': + stats['tempmail_builtin_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': @@ -199,6 +228,8 @@ async def get_email_services_stats(): stats['imap_mail_count'] = count elif service_type == 'cloudmail': stats['cloudmail_count'] = count + elif service_type == 'luckmail': + stats['luckmail_count'] = count return stats @@ -211,12 +242,23 @@ 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", @@ -271,6 +313,18 @@ async def get_service_types(): {"name": "domain", "label": "邮箱域名", "required": False, "placeholder": "example.com"}, ] }, + { + "value": "cloudmail", + "label": "CloudMail", + "description": "CloudMail 自部署 Cloudflare Worker 邮箱服务,使用管理口令创建邮箱并轮询验证码", + "config_fields": [ + {"name": "base_url", "label": "API 地址", "required": True, "placeholder": "https://cloudmail.example.com"}, + {"name": "admin_password", "label": "Admin 密码", "required": True, "secret": True}, + {"name": "domain", "label": "邮箱域名", "required": True, "placeholder": "example.com"}, + {"name": "enable_prefix", "label": "启用前缀", "required": False, "default": True}, + {"name": "timeout", "label": "超时时间", "required": False, "default": 30}, + ] + }, { "value": "imap_mail", "label": "IMAP 邮箱", @@ -282,6 +336,19 @@ async def get_service_types(): {"name": "email", "label": "邮箱地址", "required": True}, {"name": "password", "label": "密码/授权码", "required": True, "secret": True}, ] + }, + { + "value": "luckmail", + "label": "LuckMail", + "description": "LuckMail 接码服务(下单 + 轮询验证码)", + "config_fields": [ + {"name": "base_url", "label": "平台地址", "required": False, "default": "https://mails.luckyous.com/"}, + {"name": "api_key", "label": "API Key", "required": True, "secret": True}, + {"name": "project_code", "label": "项目编码", "required": False, "default": "openai"}, + {"name": "email_type", "label": "邮箱类型", "required": False, "default": "ms_graph"}, + {"name": "preferred_domain", "label": "优先域名", "required": False, "placeholder": "outlook.com"}, + {"name": "poll_interval", "label": "轮询间隔(秒)", "required": False, "default": 3.0}, + ] } ] } @@ -334,7 +401,7 @@ async def get_email_service_full(service_id: int): "name": service.name, "enabled": service.enabled, "priority": service.priority, - "config": service.config or {}, # 返回完整配置 + "config": normalize_email_service_config(service.service_type, service.config), # 返回完整配置 "last_used": service.last_used.isoformat() if service.last_used else None, "created_at": service.created_at.isoformat() if service.created_at else None, "updated_at": service.updated_at.isoformat() if service.updated_at else None, @@ -359,7 +426,7 @@ async def create_email_service(request: EmailServiceCreate): service = EmailServiceModel( service_type=request.service_type, name=request.name, - config=request.config, + config=normalize_email_service_config(request.service_type, request.config), enabled=request.enabled, priority=request.priority ) @@ -383,11 +450,11 @@ async def update_email_service(service_id: int, request: EmailServiceUpdate): update_data["name"] = request.name if request.config is not None: # 合并配置而不是替换 - current_config = service.config or {} + current_config = normalize_email_service_config(service.service_type, service.config) merged_config = {**current_config, **request.config} # 移除空值 merged_config = {k: v for k, v in merged_config.items() if v} - update_data["config"] = merged_config + update_data["config"] = normalize_email_service_config(service.service_type, merged_config) if request.enabled is not None: update_data["enabled"] = request.enabled if request.priority is not None: @@ -426,7 +493,11 @@ async def test_email_service(service_id: int): try: service_type = EmailServiceType(service.service_type) - email_service = EmailServiceFactory.create(service_type, service.config, name=service.name) + email_service = EmailServiceFactory.create( + service_type, + normalize_email_service_config(service.service_type, service.config), + name=service.name, + ) health = email_service.check_health() @@ -617,29 +688,52 @@ 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: - from ...services import EmailServiceFactory, EmailServiceType - from ...config.settings import get_settings - settings = get_settings() - base_url = request.api_url or settings.tempmail_base_url + provider = str(request.provider or "tempmail").strip().lower() - config = {"base_url": base_url} - tempmail = EmailServiceFactory.create(EmailServiceType.TEMPMAIL, config) + 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() + + 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 = "临时邮箱连接失败" # 检查服务健康状态 - health = tempmail.check_health() + health = service.check_health() if health: - return {"success": True, "message": "临时邮箱连接正常"} + return {"success": True, "message": success_message} else: - return {"success": False, "message": "临时邮箱连接失败"} + return {"success": False, "message": fail_message} except Exception as e: logger.error(f"测试临时邮箱失败: {e}") diff --git a/src/web/routes/logs.py b/src/web/routes/logs.py index 5a4131ac..462dac87 100644 --- a/src/web/routes/logs.py +++ b/src/web/routes/logs.py @@ -12,7 +12,7 @@ from sqlalchemy import func, or_ from ...core.db_logs import cleanup_database_logs -from ...core.timezone_utils import to_shanghai_iso +from ...core.timezone_utils import to_shanghai_iso, utcnow_naive from ...database.models import AppLog from ...database.session import get_db @@ -60,7 +60,7 @@ def list_logs( ) if since_minutes: - since_at = datetime.utcnow() - timedelta(minutes=since_minutes) + since_at = utcnow_naive() - timedelta(minutes=since_minutes) query = query.filter(AppLog.created_at >= since_at) total = query.count() @@ -120,4 +120,4 @@ def clear_logs(confirm: bool = Query(False, description="确认清空日志")): "success": True, "deleted_total": int(deleted or 0), "remaining": 0, - } + } \ No newline at end of file diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index ee27ce57..c1dbbcd1 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -31,6 +31,7 @@ open_url_incognito, check_subscription_status_detail, ) +from ...core.timezone_utils import utcnow_naive from ...core.openai.browser_bind import auto_bind_checkout_with_playwright from ...core.openai.random_billing import generate_random_billing_profile from ...core.openai.token_refresh import TokenRefreshManager @@ -63,6 +64,29 @@ "country, region, or territory not supported", "request_forbidden", ) + + +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) + CHECKOUT_COUNTRY_CURRENCY_MAP = { "US": "USD", "GB": "GBP", @@ -737,12 +761,18 @@ 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") elif service_type == EmailServiceType.DUCK_MAIL: if "domain" in normalized and "default_domain" not in normalized: normalized["default_domain"] = normalized.pop("domain") + elif service_type == EmailServiceType.LUCKMAIL: + if "domain" in normalized and "preferred_domain" not in normalized: + normalized["preferred_domain"] = normalized.pop("domain") # IMAP/Outlook 等可按需使用代理;Temp-Mail/Freemail 强制直连。 if proxy_url and "proxy_url" not in normalized and service_type not in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): @@ -790,6 +820,18 @@ 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}),无法自动获取登录验证码" @@ -930,7 +972,7 @@ def _bootstrap_session_token_by_relogin(db, account: Account, proxy: Optional[st account.cookies = fresh_cookies if forced_access: account.access_token = forced_access - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() db.commit() return "" @@ -939,7 +981,7 @@ def _bootstrap_session_token_by_relogin(db, account: Account, proxy: Optional[st if fresh_cookies: account.cookies = fresh_cookies account.session_token = session_token - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() db.commit() db.refresh(account) logger.info("会话补全登录成功: account_id=%s email=%s", account.id, account.email) @@ -1195,7 +1237,7 @@ def _request_session_token( account.refresh_token = refresh_result.refresh_token if refresh_result.expires_at: account.expires_at = refresh_result.expires_at - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() db.commit() db.refresh(account) for proxy_item in proxy_candidates: @@ -1269,7 +1311,7 @@ def _mask_card_number(number: Optional[str]) -> str: def _mark_task_paid_pending_sync(task: BindCardTask, reason: str) -> None: - now = datetime.utcnow() + now = utcnow_naive() task.status = "paid_pending_sync" task.completed_at = None task.last_checked_at = now @@ -1776,7 +1818,7 @@ def _refresh_account_token_for_subscription_check(account: Account, proxy: Optio account.refresh_token = refresh_result.refresh_token if refresh_result.expires_at: account.expires_at = refresh_result.expires_at - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() return True, None @@ -2152,7 +2194,7 @@ def get_account_session_diagnostic( "probe": probe_result, "notes": notes, "recommendation": recommendation, - "checked_at": datetime.utcnow().isoformat(), + "checked_at": utcnow_naive().isoformat(), }, } @@ -2212,7 +2254,7 @@ def save_account_session_token( account.session_token = token if request.merge_cookie: account.cookies = _upsert_cookie(account.cookies, "__Secure-next-auth.session-token", token) - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() db.commit() db.refresh(account) @@ -2361,7 +2403,7 @@ def create_bind_card_task(request: CreateBindCardTaskRequest): opened = open_url_incognito(link, account.cookies if account else None) if opened: task.status = "opened" - task.opened_at = datetime.utcnow() + task.opened_at = utcnow_naive() db.commit() db.refresh(task) logger.info("绑卡任务自动打开成功: task_id=%s mode=%s", task.id, bind_mode) @@ -2405,7 +2447,7 @@ def list_bind_card_tasks( tasks = query.order_by(BindCardTask.created_at.desc()).offset(offset).limit(page_size).all() # 自动收敛任务状态:如果账号已是 plus/team,任务自动标记完成。 - now = datetime.utcnow() + now = utcnow_naive() changed = False changed_count = 0 for task in tasks: @@ -2445,7 +2487,7 @@ def open_bind_card_task(task_id: int): if opened: if str(task.status or "") not in ("paid_pending_sync", "completed"): task.status = "opened" - task.opened_at = datetime.utcnow() + task.opened_at = utcnow_naive() task.last_error = None db.commit() db.refresh(task) @@ -2532,7 +2574,7 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi task.bind_mode = "third_party" task.status = "verifying" task.last_error = None - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() try: @@ -2565,7 +2607,7 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi if assess_state == "failed": task.status = "failed" task.last_error = f"第三方返回失败: {assess_reason or 'unknown'}" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() logger.warning( "第三方自动绑卡返回业务失败: task_id=%s account_id=%s endpoint=%s reason=%s response=%s", @@ -2585,7 +2627,7 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi # 若第三方明确返回 challenge/requires_action,直接切换待用户完成,避免无意义轮询超时。 if _is_third_party_challenge_pending(third_party_assessment): task.status = "waiting_user_action" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() hint_reason = assess_reason or "requires_action" hint_payment_status = payment_status or "unknown" task.last_error = ( @@ -2645,13 +2687,13 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi if poll_state == "failed": task.status = "failed" task.last_error = f"第三方状态失败: {poll_reason or 'unknown'}" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() raise HTTPException(status_code=400, detail=f"第三方状态失败: {poll_reason or 'unknown'}") if poll_state != "success": task.status = "waiting_user_action" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() hint_reason = poll_reason or assess_reason or "pending_confirmation" hint_payment_status = poll_payment_status or payment_status or "unknown" task.last_error = ( @@ -2720,7 +2762,7 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi except Exception as exc: task.status = "failed" task.last_error = f"第三方绑卡提交失败: {exc}" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() logger.warning("第三方自动绑卡提交失败: task_id=%s error=%s", task.id, exc) raise HTTPException(status_code=500, detail=str(exc)) @@ -2758,7 +2800,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): task.bind_mode = "local_auto" task.status = "verifying" task.last_error = None - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() logger.info( @@ -2779,7 +2821,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): ) if not resolved_session_token and not runtime_proxy: task.status = "failed" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.last_error = ( "当前账号缺少 session_token,且未检测到可用代理。" "请在设置中配置代理(或为本次任务传入 proxy)后重试全自动。" @@ -2800,7 +2842,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): ) if not resolved_session_token: task.status = "failed" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.last_error = ( "会话补全未拿到 session_token。" "请先在支付页执行“会话诊断/自动补会话”," @@ -2840,7 +2882,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): except Exception as exc: task.status = "failed" task.last_error = f"本地自动绑卡执行异常: {exc}" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() logger.warning("本地自动绑卡执行异常: task_id=%s account_id=%s error=%s", task.id, account.id, exc) raise HTTPException(status_code=500, detail=f"本地自动绑卡执行失败: {exc}") @@ -2855,7 +2897,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): if not success: if "playwright not installed" in message.lower(): task.status = "failed" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.last_error = ( "本地自动绑卡环境缺少 Playwright/Chromium。" "请先执行: pip install playwright && playwright install chromium" @@ -2890,7 +2932,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): if manual_opened: hint += " 已自动为你打开手动验证窗口。" task.status = "waiting_user_action" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.last_error = hint db.commit() db.refresh(task) @@ -2916,7 +2958,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): } task.status = "failed" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.last_error = f"本地自动绑卡失败: {message or 'unknown_error'}" db.commit() logger.warning( @@ -2968,7 +3010,7 @@ def sync_bind_card_task_subscription(task_id: int, request: SyncBindCardTaskRequ raise HTTPException(status_code=404, detail="任务关联账号不存在") proxy = _resolve_runtime_proxy(request.proxy, account) - now = datetime.utcnow() + now = utcnow_naive() try: detail, refreshed = _check_subscription_detail_with_retry( db=db, @@ -3078,7 +3120,7 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest ) previous_status = str(task.status or "") - now = datetime.utcnow() + now = utcnow_naive() task.status = "verifying" task.last_error = None task.last_checked_at = now @@ -3107,7 +3149,7 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest task.id, checks, status, detail.get("source"), detail.get("confidence"), bool(detail.get("token_refreshed")) ) except Exception as exc: - failed_at = datetime.utcnow() + failed_at = utcnow_naive() task.status = "failed" task.last_error = f"订阅检测失败: {exc}" task.last_checked_at = failed_at @@ -3115,7 +3157,7 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest logger.warning("绑卡任务验证失败: task_id=%s attempt=%s error=%s", task.id, checks, exc) raise HTTPException(status_code=500, detail=f"订阅检测失败: {exc}") - checked_at = datetime.utcnow() + checked_at = utcnow_naive() if status in ("plus", "team"): account.subscription_type = status account.subscription_at = checked_at @@ -3182,7 +3224,7 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest else: task.status = "waiting_user_action" task.last_error = timeout_msg + "请稍后点击“同步订阅”重试。" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.completed_at = None db.commit() db.refresh(task) @@ -3253,7 +3295,7 @@ def batch_check_subscription(request: BatchCheckSubscriptionRequest): if status in ("plus", "team"): account.subscription_type = status - account.subscription_at = datetime.utcnow() + account.subscription_at = utcnow_naive() elif status == "free" and confidence == "high": account.subscription_type = None account.subscription_at = None @@ -3293,7 +3335,7 @@ def mark_subscription(account_id: int, request: MarkSubscriptionRequest): raise HTTPException(status_code=404, detail="账号不存在") 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 + account.subscription_at = utcnow_naive() if request.subscription_type != "free" else None db.commit() return {"success": True, "subscription_type": request.subscription_type} diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index d428262e..0a82c773 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -1,4 +1,4 @@ -""" +""" 注册任务 API 路由 """ @@ -6,19 +6,33 @@ import logging import uuid import random -from datetime import datetime -from typing import List, Optional, Dict, Tuple +from datetime import datetime, timezone +from typing import List, Optional, Dict, Tuple, Any from fastapi import APIRouter, HTTPException, Query, BackgroundTasks -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field +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 +from ...database.models import RegistrationTask, ScheduledRegistrationJob, Proxy from ...core.register import RegistrationEngine, RegistrationResult from ...services import EmailServiceFactory, EmailServiceType -from ...config.settings import get_settings +from ...config.settings import get_settings, Settings +from ...core.auto_registration import ( + add_auto_registration_log, + get_auto_registration_inventory, + get_auto_registration_logs, + get_auto_registration_state, + update_auto_registration_state, +) +from ...core.timezone_utils import utcnow_naive from ..task_manager import task_manager +from ..schedule_utils import normalize_schedule_config, compute_next_run_at, describe_schedule logger = logging.getLogger(__name__) router = APIRouter() @@ -29,6 +43,23 @@ batch_tasks: Dict[str, dict] = {} +def _cancel_batch_tasks(batch_id: str) -> None: + batch = batch_tasks.get(batch_id) + if not batch: + return + + for task_uuid in batch.get("task_uuids", []): + task_manager.cancel_task(task_uuid) + + auto_state = get_auto_registration_state() + if auto_state.get("current_batch_id") == batch_id: + update_auto_registration_state( + status="cancelling", + message=f"自动补货取消中: {batch_id}", + ) + add_auto_registration_log(f"[自动注册] 已提交补货批量任务取消请求: {batch_id}") + + # ============== Proxy Helper Functions ============== def get_proxy_for_registration(db) -> Tuple[Optional[str], Optional[int]]: @@ -72,11 +103,14 @@ class RegistrationTaskCreate(BaseModel): email_service_config: Optional[dict] = None email_service_id: Optional[int] = None auto_upload_cpa: bool = False - cpa_service_ids: List[int] = [] # 指定 CPA 服务 ID 列表,空则取第一个启用的 + cpa_service_ids: List[int] = [] auto_upload_sub2api: bool = False - sub2api_service_ids: List[int] = [] # 指定 Sub2API 服务 ID 列表 + sub2api_service_ids: List[int] = [] auto_upload_tm: bool = False - tm_service_ids: List[int] = [] # 指定 TM 服务 ID 列表 + tm_service_ids: List[int] = [] + auto_upload_new_api: bool = False + new_api_service_ids: List[int] = [] + registration_type: str = RoleTag.CHILD.value class BatchRegistrationRequest(BaseModel): @@ -96,6 +130,9 @@ class BatchRegistrationRequest(BaseModel): sub2api_service_ids: List[int] = [] auto_upload_tm: bool = False tm_service_ids: List[int] = [] + auto_upload_new_api: bool = False + new_api_service_ids: List[int] = [] + registration_type: str = RoleTag.CHILD.value class RegistrationTaskResponse(BaseModel): @@ -112,8 +149,7 @@ class RegistrationTaskResponse(BaseModel): started_at: Optional[str] = None completed_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class BatchRegistrationResponse(BaseModel): @@ -164,6 +200,9 @@ class OutlookBatchRegistrationRequest(BaseModel): sub2api_service_ids: List[int] = [] auto_upload_tm: bool = False tm_service_ids: List[int] = [] + auto_upload_new_api: bool = False + new_api_service_ids: List[int] = [] + registration_type: str = RoleTag.CHILD.value class OutlookBatchRegistrationResponse(BaseModel): @@ -175,6 +214,49 @@ class OutlookBatchRegistrationResponse(BaseModel): service_ids: List[int] # 实际要注册的服务 ID +class ScheduledRegistrationRequest(BaseModel): + """创建或更新计划注册任务请求""" + name: str = Field(..., min_length=1, max_length=100) + enabled: bool = True + schedule_type: str + schedule_config: Dict[str, Any] + registration_config: Dict[str, Any] + timezone: str = "local" + + +class ScheduledRegistrationJobResponse(BaseModel): + """计划注册任务响应""" + id: int + job_uuid: str + name: str + enabled: bool + status: str + schedule_type: str + schedule_config: Dict[str, Any] + schedule_description: str + registration_config: Dict[str, Any] + timezone: Optional[str] = None + next_run_at: Optional[str] = None + last_run_at: Optional[str] = None + last_success_at: Optional[str] = None + last_error: Optional[str] = None + run_count: int + consecutive_failures: int + is_running: bool + last_triggered_task_uuid: Optional[str] = None + last_triggered_batch_id: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class ScheduledRegistrationJobListResponse(BaseModel): + """计划注册任务列表响应""" + total: int + jobs: List[ScheduledRegistrationJobResponse] + + # ============== Helper Functions ============== def task_to_response(task: RegistrationTask) -> RegistrationTaskResponse: @@ -194,6 +276,34 @@ def task_to_response(task: RegistrationTask) -> RegistrationTaskResponse: ) +def scheduled_job_to_response(job: ScheduledRegistrationJob) -> ScheduledRegistrationJobResponse: + """转换计划任务模型为响应""" + schedule_config = job.schedule_config or {} + return ScheduledRegistrationJobResponse( + id=job.id, + job_uuid=job.job_uuid, + name=job.name, + enabled=job.enabled, + status=job.status, + schedule_type=job.schedule_type, + schedule_config=schedule_config, + schedule_description=describe_schedule(job.schedule_type, schedule_config), + registration_config=job.registration_config or {}, + timezone=job.timezone, + next_run_at=job.next_run_at.isoformat() if job.next_run_at else None, + last_run_at=job.last_run_at.isoformat() if job.last_run_at else None, + last_success_at=job.last_success_at.isoformat() if job.last_success_at else None, + last_error=job.last_error, + run_count=job.run_count or 0, + consecutive_failures=job.consecutive_failures or 0, + is_running=bool(job.is_running), + last_triggered_task_uuid=job.last_triggered_task_uuid, + last_triggered_batch_id=job.last_triggered_batch_id, + created_at=job.created_at.isoformat() if job.created_at else None, + updated_at=job.updated_at.isoformat() if job.updated_at else None, + ) + + def _normalize_email_service_config( service_type: EmailServiceType, config: Optional[dict], @@ -208,12 +318,20 @@ 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 in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): + 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.CLOUDMAIL, EmailServiceType.FREEMAIL): if 'default_domain' in normalized and 'domain' not in normalized: normalized['domain'] = normalized.pop('default_domain') + if service_type == EmailServiceType.CLOUDMAIL and 'api_key' in normalized and 'admin_password' not in normalized: + normalized['admin_password'] = normalized.pop('api_key') elif service_type == EmailServiceType.DUCK_MAIL: if 'domain' in normalized and 'default_domain' not in normalized: normalized['default_domain'] = normalized.pop('domain') + elif service_type == EmailServiceType.LUCKMAIL: + if 'domain' in normalized and 'preferred_domain' not in normalized: + normalized['preferred_domain'] = normalized.pop('domain') if proxy_url and 'proxy_url' not in normalized: normalized['proxy_url'] = proxy_url @@ -221,7 +339,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, auto_upload_new_api: bool = False, new_api_service_ids: List[int] = None, registration_type: str = RoleTag.CHILD.value): """ 在线程池中执行的同步注册任务 @@ -238,7 +356,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: task = crud.update_registration_task( db, task_uuid, status="running", - started_at=datetime.utcnow() + started_at=utcnow_naive() ) if not task: @@ -285,12 +403,26 @@ 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 @@ -344,6 +476,40 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: logger.info(f"使用数据库 Outlook 账户: {selected_service.name}") else: raise ValueError("所有 Outlook 账户都已注册过 OpenAI 账号,请添加新的 Outlook 账户") + elif service_type == EmailServiceType.TEMP_MAIL: + from ...database.models import EmailService as EmailServiceModel + + db_service = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "temp_mail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).first() + + if db_service and db_service.config: + config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url) + crud.update_registration_task(db, task_uuid, email_service_id=db_service.id) + logger.info(f"使用数据库 Temp-Mail 自部署服务: {db_service.name}") + else: + config = _normalize_email_service_config(service_type, email_service_config or {}, actual_proxy_url) + missing_keys = [key for key in ("base_url", "admin_password", "domain") if not config.get(key)] + if missing_keys: + raise ValueError("没有可用的 Temp-Mail 自部署服务,请先在邮箱服务页面添加服务") + elif service_type == EmailServiceType.CLOUDMAIL: + from ...database.models import EmailService as EmailServiceModel + + db_service = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "cloudmail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).first() + + if db_service and db_service.config: + config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url) + crud.update_registration_task(db, task_uuid, email_service_id=db_service.id) + logger.info(f"使用数据库 CloudMail 服务: {db_service.name}") + else: + config = _normalize_email_service_config(service_type, email_service_config or {}, actual_proxy_url) + missing_keys = [key for key in ("base_url", "admin_password", "domain") if not config.get(key)] + if missing_keys: + raise ValueError("没有可用的 CloudMail 服务,请先在邮箱服务页面添加服务") elif service_type == EmailServiceType.DUCK_MAIL: from ...database.models import EmailService as EmailServiceModel @@ -386,6 +552,22 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: logger.info(f"使用数据库 IMAP 邮箱服务: {db_service.name}") else: raise ValueError("没有可用的 IMAP 邮箱服务,请先在邮箱服务中添加") + elif service_type == EmailServiceType.LUCKMAIL: + from ...database.models import EmailService as EmailServiceModel + + db_service = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "luckmail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).first() + + if db_service and db_service.config: + config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url) + crud.update_registration_task(db, task_uuid, email_service_id=db_service.id) + logger.info(f"使用数据库 LuckMail 服务: {db_service.name}") + else: + config = _normalize_email_service_config(service_type, email_service_config or {}, actual_proxy_url) + if not config.get("api_key"): + raise ValueError("没有可用的 LuckMail 服务,请先在邮箱服务中添加并填写 API Key") else: config = email_service_config or {} @@ -402,14 +584,42 @@ 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() + marker = getattr(email_service, "mark_registration_outcome", None) + marker_context = {} + try: + info = getattr(engine, "email_info", None) or {} + for key in ("service_id", "order_no", "token", "purchase_id", "source"): + value = info.get(key) if isinstance(info, dict) else None + if value not in (None, ""): + marker_context[key] = value + except Exception: + marker_context = {} 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) + + if callable(marker) and result.email: + try: + marker( + email=result.email, + success=True, + context=marker_context, + ) + except Exception as mark_err: + logger.warning(f"记录邮箱成功状态失败: {mark_err}") # 自动上传到 CPA(可多服务) if auto_upload_cpa: @@ -434,7 +644,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: _ok, _msg = upload_to_cpa(token_data, api_url=_svc.api_url, api_token=_svc.api_token) if _ok: saved_account.cpa_uploaded = True - saved_account.cpa_uploaded_at = datetime.utcnow() + saved_account.cpa_uploaded_at = utcnow_naive() db.commit() log_callback(f"[CPA] 投递成功,服务站已签收: {_svc.name}") else: @@ -494,11 +704,40 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: except Exception as tm_err: log_callback(f"[TM] 上传异常: {tm_err}") + if auto_upload_new_api: + try: + from ...core.upload.new_api_upload import upload_to_new_api + from ...database.models import Account as AccountModel + saved_account = db.query(AccountModel).filter_by(email=result.email).first() + if saved_account and saved_account.access_token: + _new_api_ids = new_api_service_ids or [] + if not _new_api_ids: + _new_api_ids = [s.id for s in crud.get_new_api_services(db, enabled=True)] + if not _new_api_ids: + log_callback("[NewAPI] 无可用 new-api 服务,跳过上传") + for _sid in _new_api_ids: + try: + _svc = crud.get_new_api_service_by_id(db, _sid) + if not _svc: + continue + log_callback(f"[NewAPI] 正在把账号发往服务站: {_svc.name}") + _ok, _msg = upload_to_new_api( + [saved_account], + _svc.api_url, + getattr(_svc, 'username', None), + getattr(_svc, 'password', None), + ) + log_callback(f"[NewAPI] {'成功' if _ok else '失败'}({_svc.name}): {_msg}") + except Exception as _e: + log_callback(f"[NewAPI] 异常({_sid}): {_e}") + except Exception as new_api_err: + log_callback(f"[NewAPI] 上传异常: {new_api_err}") + # 更新任务状态 crud.update_registration_task( db, task_uuid, status="completed", - completed_at=datetime.utcnow(), + completed_at=utcnow_naive(), result=result.to_dict() ) @@ -507,11 +746,22 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: logger.info(f"注册任务完成: {task_uuid}, 邮箱: {result.email}") else: + if callable(marker) and result.email: + try: + marker( + email=result.email, + success=False, + reason=result.error_message or "", + context=marker_context, + ) + except Exception as mark_err: + logger.warning(f"记录邮箱失败状态失败: {mark_err}") + # 更新任务状态为失败 crud.update_registration_task( db, task_uuid, status="failed", - completed_at=datetime.utcnow(), + completed_at=utcnow_naive(), error_message=result.error_message ) @@ -528,7 +778,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: crud.update_registration_task( db, task_uuid, status="failed", - completed_at=datetime.utcnow(), + completed_at=utcnow_naive(), error_message=str(e) ) @@ -538,7 +788,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, auto_upload_new_api: bool = False, new_api_service_ids: List[int] = None, registration_type: str = RoleTag.CHILD.value): """ 异步执行注册任务 @@ -571,6 +821,9 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: sub2api_service_ids or [], auto_upload_tm, tm_service_ids or [], + auto_upload_new_api, + new_api_service_ids or [], + registration_type, ) except Exception as e: logger.error(f"线程池执行异常: {task_uuid}, 错误: {e}") @@ -623,6 +876,9 @@ async def run_batch_parallel( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + auto_upload_new_api: bool = False, + new_api_service_ids: List[int] = None, + registration_type: str = RoleTag.CHILD.value, ): """ 并行模式:所有任务同时提交,Semaphore 控制最大并发数 @@ -642,6 +898,8 @@ 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 [], + auto_upload_new_api=auto_upload_new_api, new_api_service_ids=new_api_service_ids or [], + registration_type=registration_type, ) with get_db() as db: t = crud.get_registration_task(db, uuid) @@ -689,6 +947,9 @@ async def run_batch_pipeline( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + auto_upload_new_api: bool = False, + new_api_service_ids: List[int] = None, + registration_type: str = RoleTag.CHILD.value, ): """ 流水线模式:每隔 interval 秒启动一个新任务,Semaphore 限制最大并发数 @@ -708,6 +969,8 @@ 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 [], + auto_upload_new_api=auto_upload_new_api, new_api_service_ids=new_api_service_ids or [], + registration_type=registration_type, ) with get_db() as db: t = crud.get_registration_task(db, uuid) @@ -779,6 +1042,9 @@ async def run_batch_registration( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + auto_upload_new_api: bool = False, + new_api_service_ids: List[int] = None, + registration_type: str = RoleTag.CHILD.value, ): """根据 mode 分发到并行或流水线执行""" if mode == "parallel": @@ -788,6 +1054,8 @@ 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, + auto_upload_new_api=auto_upload_new_api, new_api_service_ids=new_api_service_ids, + registration_type=registration_type, ) else: await run_batch_pipeline( @@ -797,44 +1065,160 @@ 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, + auto_upload_new_api=auto_upload_new_api, new_api_service_ids=new_api_service_ids, + registration_type=registration_type, ) -# ============== API Endpoints ============== +async def run_auto_registration_batch(plan, settings: Settings) -> str: + email_service_type = settings.registration_auto_email_service_type + try: + EmailServiceType(email_service_type) + except ValueError as exc: + raise ValueError(f"自动注册邮箱服务类型无效: {email_service_type}") from exc -@router.post("/start", response_model=RegistrationTaskResponse) -async def start_registration( - request: RegistrationTaskCreate, - background_tasks: BackgroundTasks -): - """ - 启动注册任务 + mode = settings.registration_auto_mode or "pipeline" + if mode not in ("parallel", "pipeline"): + raise ValueError(f"自动注册模式无效: {mode}") - - email_service_type: 邮箱服务类型 (tempmail, outlook, moe_mail) - - proxy: 代理地址 - - email_service_config: 邮箱服务配置(outlook 需要提供账户信息) - """ - # 验证邮箱服务类型 + interval_min = max(0, int(settings.registration_auto_interval_min)) + interval_max = max(interval_min, int(settings.registration_auto_interval_max)) + concurrency = max(1, int(settings.registration_auto_concurrency)) + email_service_id = int(settings.registration_auto_email_service_id or 0) or None + proxy = settings.registration_auto_proxy.strip() or None + + batch_id = str(uuid.uuid4()) + task_uuids = [] + + with get_db() as db: + for _ in range(plan.deficit): + task_uuid = str(uuid.uuid4()) + crud.create_registration_task( + db, + task_uuid=task_uuid, + proxy=proxy, + email_service_id=email_service_id, + ) + task_uuids.append(task_uuid) + + update_auto_registration_state( + status="running", + message=f"自动补货任务运行中: {batch_id}", + current_batch_id=batch_id, + ) + add_auto_registration_log( + f"[自动注册] 已创建补货批量任务 {batch_id},计划注册 {len(task_uuids)} 个账号" + ) + logger.info( + "自动注册批量任务已创建: batch=%s, count=%s, cpa_service_id=%s", + batch_id, + len(task_uuids), + plan.cpa_service_id, + ) + + await run_batch_registration( + batch_id=batch_id, + task_uuids=task_uuids, + email_service_type=email_service_type, + proxy=proxy, + email_service_config=None, + email_service_id=email_service_id, + interval_min=interval_min, + interval_max=interval_max, + concurrency=concurrency, + mode=mode, + auto_upload_cpa=True, + cpa_service_ids=[plan.cpa_service_id], + auto_upload_sub2api=False, + sub2api_service_ids=[], + auto_upload_tm=False, + tm_service_ids=[], + auto_upload_new_api=False, + new_api_service_ids=[], + ) + + batch = batch_tasks.get(batch_id) + if batch: + batch_cancelled = bool(batch.get("cancelled")) + current_auto_state = get_auto_registration_state() + refreshed_inventory = await asyncio.to_thread( + get_auto_registration_inventory, settings + ) + refreshed_ready_count = ( + refreshed_inventory[0] + if refreshed_inventory + else current_auto_state.get("current_ready_count") + ) + refreshed_target_count = ( + refreshed_inventory[1] + if refreshed_inventory + else max(1, int(settings.registration_auto_min_ready_auth_files or 1)) + ) + final_status = "cancelled" if batch_cancelled else "idle" + final_message = ( + f"自动补货批量任务已取消: {batch_id}" + if batch_cancelled + else f"自动补货批量任务已完成: {batch_id}" + ) + final_log_message = ( + f"[自动注册] 补货批量任务已取消:成功 {batch.get('success', 0)},失败 {batch.get('failed', 0)}" + if batch_cancelled + else f"[自动注册] 补货批量任务已完成:成功 {batch.get('success', 0)},失败 {batch.get('failed', 0)}" + ) + update_auto_registration_state( + status=final_status, + message=final_message, + current_batch_id=None, + current_ready_count=refreshed_ready_count, + target_ready_count=refreshed_target_count, + last_checked_at=datetime.now(timezone.utc).isoformat(), + ) + add_auto_registration_log(final_log_message) + + return batch_id + + +def _validate_registration_request(email_service_type: str): + """校验邮箱服务类型。""" try: - EmailServiceType(request.email_service_type) - except ValueError: + EmailServiceType(email_service_type) + except ValueError as exc: raise HTTPException( status_code=400, - detail=f"无效的邮箱服务类型: {request.email_service_type}" - ) + detail=f"无效的邮箱服务类型: {email_service_type}" + ) from exc - # 创建任务 - task_uuid = str(uuid.uuid4()) +def _schedule_async_job(background_tasks: Optional[BackgroundTasks], coroutine_func, *args): + """统一调度后台异步任务。""" + if background_tasks is not None: + background_tasks.add_task(coroutine_func, *args) + return + + loop = task_manager.get_loop() + if loop is None: + loop = asyncio.get_event_loop() + task_manager.set_loop(loop) + loop.create_task(coroutine_func(*args)) + + +async def _start_single_registration_internal( + request: RegistrationTaskCreate, + background_tasks: Optional[BackgroundTasks] = None, +) -> RegistrationTaskResponse: + """启动单次注册任务。""" + _validate_registration_request(request.email_service_type) + + task_uuid = str(uuid.uuid4()) with get_db() as db: task = crud.create_registration_task( db, task_uuid=task_uuid, - proxy=request.proxy + proxy=request.proxy, ) - # 在后台运行注册任务 - background_tasks.add_task( + _schedule_async_job( + background_tasks, run_registration_task, task_uuid, request.email_service_type, @@ -849,36 +1233,22 @@ async def start_registration( request.sub2api_service_ids, request.auto_upload_tm, request.tm_service_ids, + request.auto_upload_new_api, + request.new_api_service_ids, + request.registration_type, ) - return task_to_response(task) -@router.post("/batch", response_model=BatchRegistrationResponse) -async def start_batch_registration( +async def _start_batch_registration_internal( request: BatchRegistrationRequest, - background_tasks: BackgroundTasks -): - """ - 启动批量注册任务 - - - count: 注册数量 (1-1000) - - email_service_type: 邮箱服务类型 - - proxy: 代理地址 - - interval_min: 最小间隔秒数 - - interval_max: 最大间隔秒数 - """ - # 验证参数 + background_tasks: Optional[BackgroundTasks] = None, +) -> BatchRegistrationResponse: + """启动普通批量注册任务。""" if request.count < 1 or request.count > 1000: raise HTTPException(status_code=400, detail="注册数量必须在 1-1000 之间") - try: - EmailServiceType(request.email_service_type) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"无效的邮箱服务类型: {request.email_service_type}" - ) + _validate_registration_request(request.email_service_type) if request.interval_min < 0 or request.interval_max < request.interval_min: raise HTTPException(status_code=400, detail="间隔时间参数无效") @@ -889,26 +1259,24 @@ async def start_batch_registration( if request.mode not in ("parallel", "pipeline"): raise HTTPException(status_code=400, detail="模式必须为 parallel 或 pipeline") - # 创建批量任务 batch_id = str(uuid.uuid4()) task_uuids = [] with get_db() as db: for _ in range(request.count): task_uuid = str(uuid.uuid4()) - task = crud.create_registration_task( + crud.create_registration_task( db, task_uuid=task_uuid, - proxy=request.proxy + proxy=request.proxy, ) task_uuids.append(task_uuid) - # 获取所有任务 with get_db() as db: - tasks = [crud.get_registration_task(db, uuid) for uuid in task_uuids] + tasks = [crud.get_registration_task(db, item_uuid) for item_uuid in task_uuids] - # 在后台运行批量注册 - background_tasks.add_task( + _schedule_async_job( + background_tasks, run_batch_registration, batch_id, task_uuids, @@ -926,15 +1294,234 @@ async def start_batch_registration( request.sub2api_service_ids, request.auto_upload_tm, request.tm_service_ids, + request.auto_upload_new_api, + request.new_api_service_ids, + request.registration_type, ) return BatchRegistrationResponse( batch_id=batch_id, count=request.count, - tasks=[task_to_response(t) for t in tasks if t] + tasks=[task_to_response(task) for task in tasks if task], ) +async def _start_outlook_batch_registration_internal( + request: OutlookBatchRegistrationRequest, + background_tasks: Optional[BackgroundTasks] = None, +) -> OutlookBatchRegistrationResponse: + """启动 Outlook 批量注册任务。""" + from ...database.models import EmailService as EmailServiceModel + from ...database.models import Account + + if not request.service_ids: + raise HTTPException(status_code=400, detail="请选择至少一个 Outlook 账户") + + if request.interval_min < 0 or request.interval_max < request.interval_min: + raise HTTPException(status_code=400, detail="间隔时间参数无效") + + if not 1 <= request.concurrency <= 50: + raise HTTPException(status_code=400, detail="并发数必须在 1-50 之间") + + if request.mode not in ("parallel", "pipeline"): + raise HTTPException(status_code=400, detail="模式必须为 parallel 或 pipeline") + + actual_service_ids = request.service_ids + skipped_count = 0 + + if request.skip_registered: + actual_service_ids = [] + with get_db() as db: + for service_id in request.service_ids: + service = db.query(EmailServiceModel).filter( + EmailServiceModel.id == service_id + ).first() + if not service: + continue + + config = service.config or {} + email = config.get("email") or service.name + existing_account = db.query(Account).filter(Account.email == email).first() + if existing_account: + skipped_count += 1 + else: + actual_service_ids.append(service_id) + + if not actual_service_ids: + return OutlookBatchRegistrationResponse( + batch_id="", + total=len(request.service_ids), + skipped=skipped_count, + to_register=0, + service_ids=[], + ) + + batch_id = str(uuid.uuid4()) + batch_tasks[batch_id] = { + "total": len(actual_service_ids), + "completed": 0, + "success": 0, + "failed": 0, + "skipped": 0, + "cancelled": False, + "service_ids": actual_service_ids, + "current_index": 0, + "logs": [], + "finished": False, + } + + _schedule_async_job( + background_tasks, + run_outlook_batch_registration, + batch_id, + actual_service_ids, + request.skip_registered, + request.proxy, + request.interval_min, + request.interval_max, + request.concurrency, + request.mode, + request.auto_upload_cpa, + request.cpa_service_ids, + request.auto_upload_sub2api, + request.sub2api_service_ids, + request.auto_upload_tm, + request.tm_service_ids, + request.auto_upload_new_api, + request.new_api_service_ids, + request.registration_type, + ) + + return OutlookBatchRegistrationResponse( + batch_id=batch_id, + total=len(request.service_ids), + skipped=skipped_count, + to_register=len(actual_service_ids), + service_ids=actual_service_ids, + ) + + +async def dispatch_registration_config( + registration_config: Dict[str, Any], + background_tasks: Optional[BackgroundTasks] = None, +) -> Dict[str, Any]: + """按统一注册配置分发执行注册任务。""" + config = dict(registration_config or {}) + reg_mode = config.get('reg_mode') or 'single' + email_service_type = config.get('email_service_type') + if not email_service_type: + raise HTTPException(status_code=400, detail='缺少邮箱服务类型') + + if email_service_type == 'outlook_batch': + request = OutlookBatchRegistrationRequest( + service_ids=config.get('service_ids') or [], + skip_registered=bool(config.get('skip_registered', True)), + proxy=config.get('proxy'), + interval_min=int(config.get('interval_min') or 5), + interval_max=int(config.get('interval_max') or 30), + concurrency=int(config.get('concurrency') or 1), + mode=config.get('mode') or 'pipeline', + auto_upload_cpa=bool(config.get('auto_upload_cpa', False)), + cpa_service_ids=config.get('cpa_service_ids') or [], + auto_upload_sub2api=bool(config.get('auto_upload_sub2api', False)), + sub2api_service_ids=config.get('sub2api_service_ids') or [], + auto_upload_tm=bool(config.get('auto_upload_tm', False)), + tm_service_ids=config.get('tm_service_ids') or [], + auto_upload_new_api=bool(config.get('auto_upload_new_api', False)), + new_api_service_ids=config.get('new_api_service_ids') or [], + ) + response = await _start_outlook_batch_registration_internal(request, background_tasks) + return { + 'kind': 'batch', + 'batch_id': response.batch_id, + 'payload': response.model_dump(), + } + + _validate_registration_request(email_service_type) + + if reg_mode == 'batch': + request = BatchRegistrationRequest( + count=int(config.get('batch_count') or 1), + email_service_type=email_service_type, + proxy=config.get('proxy'), + email_service_config=config.get('email_service_config'), + email_service_id=config.get('email_service_id'), + interval_min=int(config.get('interval_min') or 5), + interval_max=int(config.get('interval_max') or 30), + concurrency=int(config.get('concurrency') or 1), + mode=config.get('mode') or 'pipeline', + auto_upload_cpa=bool(config.get('auto_upload_cpa', False)), + cpa_service_ids=config.get('cpa_service_ids') or [], + auto_upload_sub2api=bool(config.get('auto_upload_sub2api', False)), + sub2api_service_ids=config.get('sub2api_service_ids') or [], + auto_upload_tm=bool(config.get('auto_upload_tm', False)), + tm_service_ids=config.get('tm_service_ids') or [], + auto_upload_new_api=bool(config.get('auto_upload_new_api', False)), + new_api_service_ids=config.get('new_api_service_ids') or [], + ) + response = await _start_batch_registration_internal(request, background_tasks) + return { + 'kind': 'batch', + 'batch_id': response.batch_id, + 'payload': response.model_dump(), + } + + request = RegistrationTaskCreate( + email_service_type=email_service_type, + proxy=config.get('proxy'), + email_service_config=config.get('email_service_config'), + email_service_id=config.get('email_service_id'), + auto_upload_cpa=bool(config.get('auto_upload_cpa', False)), + cpa_service_ids=config.get('cpa_service_ids') or [], + auto_upload_sub2api=bool(config.get('auto_upload_sub2api', False)), + sub2api_service_ids=config.get('sub2api_service_ids') or [], + auto_upload_tm=bool(config.get('auto_upload_tm', False)), + tm_service_ids=config.get('tm_service_ids') or [], + auto_upload_new_api=bool(config.get('auto_upload_new_api', False)), + new_api_service_ids=config.get('new_api_service_ids') or [], + ) + response = await _start_single_registration_internal(request, background_tasks) + return { + 'kind': 'single', + 'task_uuid': response.task_uuid, + 'payload': response.model_dump(), + } + + +# ============== API Endpoints ============== + +@router.post("/start", response_model=RegistrationTaskResponse) +async def start_registration( + request: RegistrationTaskCreate, + background_tasks: BackgroundTasks +): + """ + 启动注册任务 + + - email_service_type: 邮箱服务类型 (tempmail, outlook, moe_mail) + - proxy: 代理地址 + - email_service_config: 邮箱服务配置(outlook 需要提供账户信息) + """ + return await _start_single_registration_internal(request, background_tasks) + + +@router.post("/batch", response_model=BatchRegistrationResponse) +async def start_batch_registration( + request: BatchRegistrationRequest, + background_tasks: BackgroundTasks +): + """ + 启动批量注册任务 + + - count: 注册数量 (1-1000) + - email_service_type: 邮箱服务类型 + - proxy: 代理地址 + - interval_min: 最小间隔秒数 + - interval_max: 最大间隔秒数 + """ + return await _start_batch_registration_internal(request, background_tasks) + + @router.get("/batch/{batch_id}") async def get_batch_status(batch_id: str): """获取批量任务状态""" @@ -955,6 +1542,20 @@ async def get_batch_status(batch_id: str): } +@router.get("/auto-monitor") +async def get_auto_registration_monitor(): + auto_state = get_auto_registration_state() + current_batch_id = auto_state.get("current_batch_id") + batch = batch_tasks.get(current_batch_id) if current_batch_id else None + logs = get_auto_registration_logs().copy() + + return { + **auto_state, + "batch": batch, + "logs": logs, + } + + @router.post("/batch/{batch_id}/cancel") async def cancel_batch(batch_id: str): """取消批量任务""" @@ -967,6 +1568,7 @@ async def cancel_batch(batch_id: str): batch["cancelled"] = True task_manager.cancel_batch(batch_id) + _cancel_batch_tasks(batch_id) return {"success": True, "message": "批量任务取消请求已提交,正在让它们有序收工"} @@ -1069,7 +1671,7 @@ async def get_registration_stats(): ).group_by(RegistrationTask.status).all() # 今日统计 - today = datetime.utcnow().date() + today = utcnow_naive().date() today_status_stats = db.query( RegistrationTask.status, func.count(RegistrationTask.id) @@ -1105,6 +1707,7 @@ async def get_available_email_services(): 返回所有已启用的邮箱服务,包括: - tempmail: 临时邮箱(无需配置) + - yyds_mail: YYDS Mail 临时邮箱(需 API Key) - outlook: 已导入的 Outlook 账户 - moe_mail: 已配置的自定义域名服务 """ @@ -1114,14 +1717,19 @@ async def get_available_email_services(): settings = get_settings() result = { "tempmail": { - "available": True, - "count": 1, - "services": [{ + "available": bool(settings.tempmail_enabled), + "count": 1 if settings.tempmail_enabled else 0, + "services": ([{ "id": None, "name": "Tempmail.lol", "type": "tempmail", "description": "临时邮箱,自动创建" - }] + }] if settings.tempmail_enabled else []) + }, + "yyds_mail": { + "available": False, + "count": 0, + "services": [] }, "outlook": { "available": False, @@ -1138,6 +1746,11 @@ async def get_available_email_services(): "count": 0, "services": [] }, + "cloudmail": { + "available": False, + "count": 0, + "services": [] + }, "duck_mail": { "available": False, "count": 0, @@ -1152,10 +1765,45 @@ async def get_available_email_services(): "available": False, "count": 0, "services": [] + }, + "luckmail": { + "available": False, + "count": 0, + "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", @@ -1225,6 +1873,24 @@ async def get_available_email_services(): result["temp_mail"]["count"] = len(temp_mail_services) result["temp_mail"]["available"] = len(temp_mail_services) > 0 + cloudmail_services = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "cloudmail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).all() + + for service in cloudmail_services: + config = service.config or {} + result["cloudmail"]["services"].append({ + "id": service.id, + "name": service.name, + "type": "cloudmail", + "domain": config.get("domain"), + "priority": service.priority + }) + + result["cloudmail"]["count"] = len(cloudmail_services) + result["cloudmail"]["available"] = len(cloudmail_services) > 0 + duck_mail_services = db.query(EmailServiceModel).filter( EmailServiceModel.service_type == "duck_mail", EmailServiceModel.enabled == True @@ -1280,6 +1946,26 @@ async def get_available_email_services(): result["imap_mail"]["count"] = len(imap_mail_services) result["imap_mail"]["available"] = len(imap_mail_services) > 0 + luckmail_services = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "luckmail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).all() + + for service in luckmail_services: + config = service.config or {} + result["luckmail"]["services"].append({ + "id": service.id, + "name": service.name, + "type": "luckmail", + "project_code": config.get("project_code"), + "email_type": config.get("email_type"), + "preferred_domain": config.get("preferred_domain"), + "priority": service.priority + }) + + result["luckmail"]["count"] = len(luckmail_services) + result["luckmail"]["available"] = len(luckmail_services) > 0 + return result @@ -1353,6 +2039,8 @@ async def run_outlook_batch_registration( sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None, + auto_upload_new_api: bool = False, + new_api_service_ids: List[int] = None, ): """ 异步执行 Outlook 批量注册任务,复用通用并发逻辑 @@ -1396,6 +2084,8 @@ async def run_outlook_batch_registration( sub2api_service_ids=sub2api_service_ids, auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids, + auto_upload_new_api=auto_upload_new_api, + new_api_service_ids=new_api_service_ids, ) @@ -1413,102 +2103,7 @@ async def start_outlook_batch_registration( - interval_min: 最小间隔秒数 - interval_max: 最大间隔秒数 """ - from ...database.models import EmailService as EmailServiceModel - from ...database.models import Account - - # 验证参数 - if not request.service_ids: - raise HTTPException(status_code=400, detail="请选择至少一个 Outlook 账户") - - if request.interval_min < 0 or request.interval_max < request.interval_min: - raise HTTPException(status_code=400, detail="间隔时间参数无效") - - if not 1 <= request.concurrency <= 50: - raise HTTPException(status_code=400, detail="并发数必须在 1-50 之间") - - if request.mode not in ("parallel", "pipeline"): - raise HTTPException(status_code=400, detail="模式必须为 parallel 或 pipeline") - - # 过滤掉已注册的邮箱 - actual_service_ids = request.service_ids - skipped_count = 0 - - if request.skip_registered: - actual_service_ids = [] - with get_db() as db: - for service_id in request.service_ids: - service = db.query(EmailServiceModel).filter( - EmailServiceModel.id == service_id - ).first() - - if not service: - continue - - config = service.config or {} - email = config.get("email") or service.name - - # 检查是否已注册 - existing_account = db.query(Account).filter( - Account.email == email - ).first() - - if existing_account: - skipped_count += 1 - else: - actual_service_ids.append(service_id) - - if not actual_service_ids: - return OutlookBatchRegistrationResponse( - batch_id="", - total=len(request.service_ids), - skipped=skipped_count, - to_register=0, - service_ids=[] - ) - - # 创建批量任务 - batch_id = str(uuid.uuid4()) - - # 初始化批量任务状态 - batch_tasks[batch_id] = { - "total": len(actual_service_ids), - "completed": 0, - "success": 0, - "failed": 0, - "skipped": 0, - "cancelled": False, - "service_ids": actual_service_ids, - "current_index": 0, - "logs": [], - "finished": False - } - - # 在后台运行批量注册 - background_tasks.add_task( - run_outlook_batch_registration, - batch_id, - actual_service_ids, - request.skip_registered, - request.proxy, - request.interval_min, - request.interval_max, - request.concurrency, - request.mode, - request.auto_upload_cpa, - request.cpa_service_ids, - request.auto_upload_sub2api, - request.sub2api_service_ids, - request.auto_upload_tm, - request.tm_service_ids, - ) - - return OutlookBatchRegistrationResponse( - batch_id=batch_id, - total=len(request.service_ids), - skipped=skipped_count, - to_register=len(actual_service_ids), - service_ids=actual_service_ids - ) + return await _start_outlook_batch_registration_internal(request, background_tasks) @router.get("/outlook-batch/{batch_id}") @@ -1548,3 +2143,181 @@ async def cancel_outlook_batch(batch_id: str): task_manager.cancel_batch(batch_id) return {"success": True, "message": "批量任务取消请求已提交,正在让它们有序收工"} + + +@router.get("/schedules", response_model=ScheduledRegistrationJobListResponse) +async def list_scheduled_registration_jobs( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + enabled: Optional[bool] = Query(None), +): + """获取计划注册任务列表。""" + offset = (page - 1) * page_size + with get_db() as db: + jobs = crud.get_scheduled_registration_jobs(db, enabled=enabled, skip=offset, limit=page_size) + total_query = db.query(ScheduledRegistrationJob) + if enabled is not None: + total_query = total_query.filter(ScheduledRegistrationJob.enabled == enabled) + total = total_query.count() + return ScheduledRegistrationJobListResponse( + total=total, + jobs=[scheduled_job_to_response(job) for job in jobs], + ) + + +@router.get("/schedules/{job_uuid}", response_model=ScheduledRegistrationJobResponse) +async def get_scheduled_registration_job(job_uuid: str): + """获取计划注册任务详情。""" + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + raise HTTPException(status_code=404, detail="计划任务不存在") + return scheduled_job_to_response(job) + + +@router.post("/schedules", response_model=ScheduledRegistrationJobResponse) +async def create_scheduled_registration_job(request: ScheduledRegistrationRequest): + """创建计划注册任务。""" + now = utcnow_naive() + normalized_schedule_config = normalize_schedule_config(request.schedule_type, request.schedule_config, now) + next_run_at = compute_next_run_at(request.schedule_type, normalized_schedule_config, now) + registration_config = dict(request.registration_config or {}) + + with get_db() as db: + job = crud.create_scheduled_registration_job( + db, + job_uuid=str(uuid.uuid4()), + name=request.name.strip(), + enabled=request.enabled, + status='idle' if request.enabled else 'paused', + schedule_type=request.schedule_type, + schedule_config=normalized_schedule_config, + registration_config=registration_config, + timezone=request.timezone, + next_run_at=next_run_at if request.enabled else None, + ) + return scheduled_job_to_response(job) + + +@router.put("/schedules/{job_uuid}", response_model=ScheduledRegistrationJobResponse) +async def update_scheduled_registration_job(job_uuid: str, request: ScheduledRegistrationRequest): + """更新计划注册任务。""" + now = utcnow_naive() + normalized_schedule_config = normalize_schedule_config(request.schedule_type, request.schedule_config, now) + next_run_at = compute_next_run_at(request.schedule_type, normalized_schedule_config, now) + + with get_db() as db: + existing = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not existing: + raise HTTPException(status_code=404, detail="计划任务不存在") + + job = crud.update_scheduled_registration_job( + db, + job_uuid, + name=request.name.strip(), + enabled=request.enabled, + status='idle' if request.enabled else 'paused', + schedule_type=request.schedule_type, + schedule_config=normalized_schedule_config, + registration_config=dict(request.registration_config or {}), + timezone=request.timezone, + next_run_at=next_run_at if request.enabled else None, + last_error=None, + ) + return scheduled_job_to_response(job) + + +@router.post("/schedules/{job_uuid}/enable", response_model=ScheduledRegistrationJobResponse) +async def enable_scheduled_registration_job(job_uuid: str): + """启用计划注册任务。""" + now = utcnow_naive() + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + raise HTTPException(status_code=404, detail="计划任务不存在") + next_run_at = compute_next_run_at(job.schedule_type, job.schedule_config or {}, now) + updated = crud.update_scheduled_registration_job( + db, + job_uuid, + enabled=True, + status='idle', + next_run_at=next_run_at, + ) + return scheduled_job_to_response(updated) + + +@router.post("/schedules/{job_uuid}/pause", response_model=ScheduledRegistrationJobResponse) +async def pause_scheduled_registration_job(job_uuid: str): + """暂停计划注册任务。""" + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + raise HTTPException(status_code=404, detail="计划任务不存在") + updated = crud.update_scheduled_registration_job( + db, + job_uuid, + enabled=False, + status='paused', + next_run_at=None, + is_running=False, + ) + return scheduled_job_to_response(updated) + + +@router.post("/schedules/{job_uuid}/run") +async def run_scheduled_registration_job_now(job_uuid: str, background_tasks: BackgroundTasks): + """立即执行一次计划注册任务。""" + now = utcnow_naive() + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + raise HTTPException(status_code=404, detail="计划任务不存在") + if job.is_running: + raise HTTPException(status_code=400, detail="计划任务正在执行中") + if job.enabled: + next_run_at = compute_next_run_at(job.schedule_type, job.schedule_config or {}, now) + else: + next_run_at = None + claimed = crud.claim_scheduled_registration_job(db, job_uuid, next_run_at, now) + if not claimed: + raise HTTPException(status_code=409, detail="计划任务状态已变化,请刷新后重试") + + try: + result = await dispatch_registration_config(claimed.registration_config or {}, background_tasks) + with get_db() as db: + crud.mark_scheduled_registration_job_success( + db, + job_uuid, + utcnow_naive(), + task_uuid=result.get('task_uuid'), + batch_id=result.get('batch_id'), + ) + return { + 'success': True, + 'message': '计划任务已触发执行', + 'task_uuid': result.get('task_uuid'), + 'batch_id': result.get('batch_id'), + } + except Exception as exc: + with get_db() as db: + crud.mark_scheduled_registration_job_failure( + db, + job_uuid, + str(exc), + utcnow_naive(), + ) + raise + + +@router.delete("/schedules/{job_uuid}") +async def delete_scheduled_registration_job(job_uuid: str): + """删除计划注册任务。""" + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job: + raise HTTPException(status_code=404, detail="计划任务不存在") + if job.is_running: + raise HTTPException(status_code=400, detail="无法删除执行中的计划任务") + crud.delete_scheduled_registration_job(db, job_uuid) + return {'success': True, 'message': '计划任务已删除'} + diff --git a/src/web/routes/selfcheck.py b/src/web/routes/selfcheck.py new file mode 100644 index 00000000..04735f50 --- /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() \ No newline at end of file diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index 692e7f1e..2f34acf6 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -10,8 +10,13 @@ from pydantic import BaseModel from ...config.settings import get_settings, update_settings +from ...core.auto_registration import ( + trigger_auto_registration_check, + update_auto_registration_state, +) from ...database import crud from ...database.session import get_db +from ...services import EmailServiceType logger = logging.getLogger(__name__) router = APIRouter() @@ -50,6 +55,17 @@ class RegistrationSettings(BaseModel): sleep_min: int = 5 sleep_max: int = 30 entry_flow: str = "native" + auto_enabled: bool = False + auto_check_interval: int = 60 + auto_min_ready_auth_files: int = 1 + auto_email_service_type: str = "tempmail" + auto_email_service_id: int = 0 + auto_proxy: Optional[str] = None + auto_interval_min: int = 5 + auto_interval_max: int = 30 + auto_concurrency: int = 1 + auto_mode: str = "pipeline" + auto_cpa_service_id: int = 0 class WebUISettings(BaseModel): @@ -67,6 +83,13 @@ class AllSettings(BaseModel): webui: WebUISettings +class AutoQuickRefreshSettingsRequest(BaseModel): + enabled: bool = False + interval_minutes: int = 30 + retry_limit: int = 2 + run_now: bool = False + + # ============== API Endpoints ============== @router.get("") @@ -98,6 +121,17 @@ async def get_all_settings(): "sleep_min": settings.registration_sleep_min, "sleep_max": settings.registration_sleep_max, "entry_flow": entry_flow, + "auto_enabled": settings.registration_auto_enabled, + "auto_check_interval": settings.registration_auto_check_interval, + "auto_min_ready_auth_files": settings.registration_auto_min_ready_auth_files, + "auto_email_service_type": settings.registration_auto_email_service_type, + "auto_email_service_id": settings.registration_auto_email_service_id, + "auto_proxy": settings.registration_auto_proxy, + "auto_interval_min": settings.registration_auto_interval_min, + "auto_interval_max": settings.registration_auto_interval_max, + "auto_concurrency": settings.registration_auto_concurrency, + "auto_mode": settings.registration_auto_mode, + "auto_cpa_service_id": settings.registration_auto_cpa_service_id, }, "webui": { "host": settings.webui_host, @@ -106,10 +140,21 @@ async def get_all_settings(): "has_access_password": bool(settings.webui_access_password and settings.webui_access_password.get_secret_value()), }, "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, @@ -117,6 +162,62 @@ async def get_all_settings(): } +@router.get("/auto-quick-refresh") +async def get_auto_quick_refresh_settings(): + settings = get_settings() + from ..auto_quick_refresh_scheduler import auto_quick_refresh_scheduler + + runtime = auto_quick_refresh_scheduler.snapshot() + return { + "enabled": bool(settings.auto_quick_refresh_enabled), + "interval_minutes": int(settings.auto_quick_refresh_interval_minutes), + "retry_limit": int(settings.auto_quick_refresh_retry_limit), + "runtime": runtime, + } + + +@router.post("/auto-quick-refresh") +async def update_auto_quick_refresh_settings(request: AutoQuickRefreshSettingsRequest): + from ..auto_quick_refresh_scheduler import ( + AUTO_MAX_RETRY_LIMIT, + AUTO_MAX_INTERVAL_MINUTES, + AUTO_MIN_INTERVAL_MINUTES, + auto_quick_refresh_scheduler, + ) + + interval_minutes = int(request.interval_minutes) + retry_limit = int(request.retry_limit) + + if interval_minutes < AUTO_MIN_INTERVAL_MINUTES or interval_minutes > AUTO_MAX_INTERVAL_MINUTES: + raise HTTPException( + status_code=400, + detail=f"interval_minutes must be between {AUTO_MIN_INTERVAL_MINUTES} and {AUTO_MAX_INTERVAL_MINUTES}", + ) + if retry_limit < 0 or retry_limit > AUTO_MAX_RETRY_LIMIT: + raise HTTPException( + status_code=400, + detail=f"retry_limit must be between 0 and {AUTO_MAX_RETRY_LIMIT}", + ) + + update_settings( + auto_quick_refresh_enabled=bool(request.enabled), + auto_quick_refresh_interval_minutes=interval_minutes, + auto_quick_refresh_retry_limit=retry_limit, + ) + + runtime = auto_quick_refresh_scheduler.notify_schedule_updated() + if request.enabled and bool(request.run_now): + runtime = auto_quick_refresh_scheduler.request_run_now(reason="settings_save") + + return { + "success": True, + "enabled": bool(request.enabled), + "interval_minutes": interval_minutes, + "retry_limit": retry_limit, + "runtime": runtime, + } + + @router.get("/proxy/dynamic") async def get_dynamic_proxy_settings(): """获取动态代理设置""" @@ -217,18 +318,81 @@ async def get_registration_settings(): "sleep_min": settings.registration_sleep_min, "sleep_max": settings.registration_sleep_max, "entry_flow": entry_flow, + "auto_enabled": settings.registration_auto_enabled, + "auto_check_interval": settings.registration_auto_check_interval, + "auto_min_ready_auth_files": settings.registration_auto_min_ready_auth_files, + "auto_email_service_type": settings.registration_auto_email_service_type, + "auto_email_service_id": settings.registration_auto_email_service_id, + "auto_proxy": settings.registration_auto_proxy, + "auto_interval_min": settings.registration_auto_interval_min, + "auto_interval_max": settings.registration_auto_interval_max, + "auto_concurrency": settings.registration_auto_concurrency, + "auto_mode": settings.registration_auto_mode, + "auto_cpa_service_id": settings.registration_auto_cpa_service_id, } @router.post("/registration") async def update_registration_settings(request: RegistrationSettings): """更新注册设置""" + if request.timeout < 30 or request.timeout > 600: + raise HTTPException(status_code=400, detail="注册超时时间必须在 30-600 秒之间") + + if request.default_password_length < 8 or request.default_password_length > 64: + raise HTTPException(status_code=400, detail="密码长度必须在 8-64 之间") + + if request.sleep_min < 1 or request.sleep_max < request.sleep_min: + raise HTTPException(status_code=400, detail="注册等待时间参数无效") + flow_raw = (request.entry_flow or "native").strip().lower() # 兼容旧前端历史值:outlook -> native(Outlook 邮箱会在运行时自动走 outlook 链路)。 flow = "native" if flow_raw == "outlook" else flow_raw if flow not in {"native", "abcard"}: raise HTTPException(status_code=400, detail="entry_flow 仅支持 native / abcard") + if request.auto_check_interval < 5 or request.auto_check_interval > 3600: + raise HTTPException(status_code=400, detail="自动注册检查间隔必须在 5-3600 秒之间") + + if request.auto_min_ready_auth_files < 1 or request.auto_min_ready_auth_files > 10000: + raise HTTPException(status_code=400, detail="自动注册保底数量必须在 1-10000 之间") + + try: + EmailServiceType(request.auto_email_service_type) + except ValueError as exc: + raise HTTPException(status_code=400, detail="自动注册邮箱服务类型无效") from exc + + normalized_auto_email_service_type = ( + "imap_mail" if request.auto_email_service_type == "catchall_imap" else request.auto_email_service_type + ) + + if request.auto_interval_min < 0 or request.auto_interval_max < request.auto_interval_min: + raise HTTPException(status_code=400, detail="自动注册间隔时间参数无效") + + if request.auto_concurrency < 1 or request.auto_concurrency > 100: + raise HTTPException(status_code=400, detail="自动注册并发数必须在 1-100 之间") + + if request.auto_mode not in ("parallel", "pipeline"): + raise HTTPException(status_code=400, detail="自动注册模式必须为 parallel 或 pipeline") + + if request.auto_enabled and request.auto_cpa_service_id <= 0: + raise HTTPException(status_code=400, detail="启用自动注册时必须选择一个 CPA 服务") + + with get_db() as db: + if request.auto_enabled: + cpa_service = crud.get_cpa_service_by_id(db, request.auto_cpa_service_id) + if not cpa_service or not cpa_service.enabled: + raise HTTPException(status_code=400, detail="自动注册选择的 CPA 服务不存在或已禁用") + + if request.auto_email_service_id > 0: + email_service = crud.get_email_service_by_id(db, request.auto_email_service_id) + if not email_service or not email_service.enabled: + raise HTTPException(status_code=400, detail="自动注册选择的邮箱服务不存在或已禁用") + normalized_service_type = ( + "imap_mail" if email_service.service_type == "catchall_imap" else email_service.service_type + ) + if normalized_service_type != normalized_auto_email_service_type: + raise HTTPException(status_code=400, detail="自动注册邮箱服务类型与指定服务不匹配") + update_settings( registration_max_retries=request.max_retries, registration_timeout=request.timeout, @@ -236,8 +400,37 @@ async def update_registration_settings(request: RegistrationSettings): registration_sleep_min=request.sleep_min, registration_sleep_max=request.sleep_max, registration_entry_flow=flow, + registration_auto_enabled=request.auto_enabled, + registration_auto_check_interval=request.auto_check_interval, + registration_auto_min_ready_auth_files=request.auto_min_ready_auth_files, + registration_auto_email_service_type=normalized_auto_email_service_type, + registration_auto_email_service_id=max(0, request.auto_email_service_id), + registration_auto_proxy=(request.auto_proxy or "").strip(), + registration_auto_interval_min=request.auto_interval_min, + registration_auto_interval_max=request.auto_interval_max, + registration_auto_concurrency=request.auto_concurrency, + registration_auto_mode=request.auto_mode, + registration_auto_cpa_service_id=max(0, request.auto_cpa_service_id), ) + if request.auto_enabled: + update_auto_registration_state( + enabled=True, + status="checking", + message="自动注册设置已更新,正在立即检查库存", + target_ready_count=request.auto_min_ready_auth_files, + ) + trigger_auto_registration_check() + else: + update_auto_registration_state( + enabled=False, + status="disabled", + message="自动注册已禁用", + current_batch_id=None, + current_ready_count=None, + target_ready_count=request.auto_min_ready_auth_files, + ) + return {"success": True, "message": "注册设置已更新"} @@ -421,9 +614,10 @@ async def cleanup_database( keep_failed: bool = True ): """清理过期数据""" - from datetime import datetime, timedelta + from datetime import timedelta + from ...core.timezone_utils import utcnow_naive - cutoff_date = datetime.utcnow() - timedelta(days=days) + cutoff_date = utcnow_naive() - timedelta(days=days) with get_db() as db: from ...database.models import RegistrationTask @@ -486,7 +680,11 @@ async def get_recent_logs( class TempmailSettings(BaseModel): """临时邮箱设置""" api_url: Optional[str] = None - enabled: bool = True + 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 class EmailCodeSettings(BaseModel): @@ -501,10 +699,20 @@ async def get_tempmail_settings(): settings = get_settings() return { - "api_url": settings.tempmail_base_url, - "timeout": settings.tempmail_timeout, - "max_retries": settings.tempmail_max_retries, - "enabled": True # 临时邮箱默认可用 + "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()), + }, } @@ -515,6 +723,16 @@ 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) diff --git a/src/web/routes/tasks.py b/src/web/routes/tasks.py new file mode 100644 index 00000000..dc41e6b1 --- /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, + } \ No newline at end of file diff --git a/src/web/routes/upload/cpa_services.py b/src/web/routes/upload/cpa_services.py index 9c636cb7..29076046 100644 --- a/src/web/routes/upload/cpa_services.py +++ b/src/web/routes/upload/cpa_services.py @@ -4,7 +4,7 @@ from typing import List, Optional from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from ....database import crud from ....database.session import get_db @@ -44,8 +44,7 @@ class CpaServiceResponse(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CpaServiceTestRequest(BaseModel): diff --git a/src/web/routes/upload/new_api_services.py b/src/web/routes/upload/new_api_services.py new file mode 100644 index 00000000..905cfdac --- /dev/null +++ b/src/web/routes/upload/new_api_services.py @@ -0,0 +1,199 @@ +""" +new-api 服务管理 API 路由 +""" + +from typing import List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, ConfigDict + +from ....core.upload.new_api_upload import batch_upload_to_new_api, test_new_api_connection +from ....database import crud +from ....database.session import get_db + +router = APIRouter() + + +class NewApiServiceCreate(BaseModel): + name: str + api_url: str + username: str + password: str + enabled: bool = True + priority: int = 0 + + +class NewApiServiceUpdate(BaseModel): + name: Optional[str] = None + api_url: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + enabled: Optional[bool] = None + priority: Optional[int] = None + + +class NewApiServiceResponse(BaseModel): + id: int + name: str + api_url: str + username: Optional[str] = None + has_password: bool + enabled: bool + priority: int + created_at: Optional[str] = None + updated_at: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class NewApiTestRequest(BaseModel): + api_url: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + + +class NewApiUploadRequest(BaseModel): + account_ids: List[int] + service_id: Optional[int] = None + + +def _to_response(service) -> NewApiServiceResponse: + return NewApiServiceResponse( + id=service.id, + name=service.name, + api_url=service.api_url, + username=getattr(service, "username", None), + has_password=bool(getattr(service, "password", None)), + enabled=service.enabled, + priority=service.priority, + created_at=service.created_at.isoformat() if service.created_at else None, + updated_at=service.updated_at.isoformat() if service.updated_at else None, + ) + + +@router.get("", response_model=List[NewApiServiceResponse]) +async def list_new_api_services(enabled: Optional[bool] = None): + """获取 new-api 服务列表。""" + with get_db() as db: + services = crud.get_new_api_services(db, enabled=enabled) + return [_to_response(service) for service in services] + + +@router.post("", response_model=NewApiServiceResponse) +async def create_new_api_service(request: NewApiServiceCreate): + """新增 new-api 服务。""" + with get_db() as db: + service = crud.create_new_api_service( + db, + name=request.name, + api_url=request.api_url, + username=request.username, + password=request.password, + enabled=request.enabled, + priority=request.priority, + ) + return _to_response(service) + + +@router.get("/{service_id}", response_model=NewApiServiceResponse) +async def get_new_api_service(service_id: int): + """获取单个 new-api 服务详情。""" + with get_db() as db: + service = crud.get_new_api_service_by_id(db, service_id) + if not service: + raise HTTPException(status_code=404, detail="new-api 服务不存在") + return _to_response(service) + + +@router.get("/{service_id}/full") +async def get_new_api_service_full(service_id: int): + """获取 new-api 服务完整配置。""" + with get_db() as db: + service = crud.get_new_api_service_by_id(db, service_id) + if not service: + raise HTTPException(status_code=404, detail="new-api 服务不存在") + return { + "id": service.id, + "name": service.name, + "api_url": service.api_url, + "username": getattr(service, "username", None), + "password": getattr(service, "password", None), + "enabled": service.enabled, + "priority": service.priority, + } + + +@router.patch("/{service_id}", response_model=NewApiServiceResponse) +async def update_new_api_service(service_id: int, request: NewApiServiceUpdate): + """更新 new-api 服务配置。""" + with get_db() as db: + service = crud.get_new_api_service_by_id(db, service_id) + if not service: + raise HTTPException(status_code=404, detail="new-api 服务不存在") + + update_data = {} + if request.name is not None: + update_data["name"] = request.name + if request.api_url is not None: + update_data["api_url"] = request.api_url + if request.username is not None: + update_data["username"] = request.username + if request.password: + update_data["password"] = request.password + if request.enabled is not None: + update_data["enabled"] = request.enabled + if request.priority is not None: + update_data["priority"] = request.priority + + updated = crud.update_new_api_service(db, service_id, **update_data) + return _to_response(updated) + + +@router.delete("/{service_id}") +async def delete_new_api_service(service_id: int): + """删除 new-api 服务。""" + with get_db() as db: + service = crud.get_new_api_service_by_id(db, service_id) + if not service: + raise HTTPException(status_code=404, detail="new-api 服务不存在") + crud.delete_new_api_service(db, service_id) + return {"success": True, "message": f"new-api 服务 {service.name} 已删除"} + + +@router.post("/{service_id}/test") +async def test_new_api_service(service_id: int): + """测试 new-api 服务连接。""" + with get_db() as db: + service = crud.get_new_api_service_by_id(db, service_id) + if not service: + raise HTTPException(status_code=404, detail="new-api 服务不存在") + success, message = test_new_api_connection(service.api_url, getattr(service, 'username', None), getattr(service, 'password', None)) + return {"success": success, "message": message} + + +@router.post("/test-connection") +async def test_new_api_connection_direct(request: NewApiTestRequest): + """直接测试 new-api 连接。""" + if not request.api_url or not request.username or not request.password: + raise HTTPException(status_code=400, detail="api_url、username 和 password 不能为空") + success, message = test_new_api_connection(request.api_url, request.username, request.password) + return {"success": success, "message": message} + + +@router.post("/upload") +async def upload_accounts_to_new_api(request: NewApiUploadRequest): + """批量上传账号到 new-api 平台。""" + if not request.account_ids: + raise HTTPException(status_code=400, detail="账号 ID 列表不能为空") + + with get_db() as db: + if request.service_id: + service = crud.get_new_api_service_by_id(db, request.service_id) + else: + services = crud.get_new_api_services(db, enabled=True) + service = services[0] if services else None + + if not service: + raise HTTPException(status_code=400, detail="未找到可用的 new-api 服务") + + return batch_upload_to_new_api(request.account_ids, service.api_url, getattr(service, 'username', None), getattr(service, 'password', None)) diff --git a/src/web/routes/upload/sub2api_services.py b/src/web/routes/upload/sub2api_services.py index 653f4b19..91a23236 100644 --- a/src/web/routes/upload/sub2api_services.py +++ b/src/web/routes/upload/sub2api_services.py @@ -4,7 +4,7 @@ from typing import List, Optional from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from ....database import crud from ....database.session import get_db @@ -43,8 +43,7 @@ class Sub2ApiServiceResponse(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class Sub2ApiTestRequest(BaseModel): diff --git a/src/web/routes/upload/tm_services.py b/src/web/routes/upload/tm_services.py index b363139e..9fe61e20 100644 --- a/src/web/routes/upload/tm_services.py +++ b/src/web/routes/upload/tm_services.py @@ -4,7 +4,7 @@ from typing import List, Optional from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from ....database import crud from ....database.session import get_db @@ -41,8 +41,7 @@ class TmServiceResponse(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class TmTestRequest(BaseModel): 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/schedule_utils.py b/src/web/schedule_utils.py new file mode 100644 index 00000000..8153aa90 --- /dev/null +++ b/src/web/schedule_utils.py @@ -0,0 +1,106 @@ +"""计划任务时间计算工具。""" + +from datetime import datetime, timedelta, time +from typing import Any, Dict, Optional + +from ..core.timezone_utils import utcnow_naive + + +VALID_SCHEDULE_TYPES = {"interval", "timepoint"} + + +def parse_time_of_day(value: str) -> time: + """解析 HH:MM 格式的时间字符串。""" + try: + hour_text, minute_text = value.split(":", 1) + hour = int(hour_text) + minute = int(minute_text) + except Exception as exc: + raise ValueError("时间点格式必须为 HH:MM") from exc + + if not 0 <= hour <= 23 or not 0 <= minute <= 59: + raise ValueError("时间点必须在 00:00-23:59 之间") + + return time(hour=hour, minute=minute) + + +def parse_start_date(value: Optional[str], now: datetime) -> datetime.date: + """解析计划开始日期。""" + if not value: + return now.date() + + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except ValueError as exc: + raise ValueError("开始日期格式必须为 YYYY-MM-DD") from exc + + +def normalize_schedule_config( + schedule_type: str, + schedule_config: Optional[Dict[str, Any]], + now: Optional[datetime] = None, +) -> Dict[str, Any]: + """校验并标准化计划配置。""" + current_time = now or utcnow_naive() + config = dict(schedule_config or {}) + + if schedule_type not in VALID_SCHEDULE_TYPES: + raise ValueError("计划类型必须为 interval 或 timepoint") + + if schedule_type == "interval": + interval_minutes = int(config.get("interval_minutes") or 0) + if interval_minutes < 1: + raise ValueError("固定间隔必须大于等于 1 分钟") + return {"interval_minutes": interval_minutes} + + every_n_days = int(config.get("every_n_days") or 0) + if every_n_days < 1: + raise ValueError("周期天数必须大于等于 1") + + time_of_day = config.get("time_of_day") or "" + parsed_time = parse_time_of_day(time_of_day) + start_date = parse_start_date(config.get("start_date"), current_time) + + return { + "every_n_days": every_n_days, + "time_of_day": parsed_time.strftime("%H:%M"), + "start_date": start_date.isoformat(), + } + + +def compute_next_run_at( + schedule_type: str, + schedule_config: Dict[str, Any], + now: Optional[datetime] = None, + reference_time: Optional[datetime] = None, +) -> datetime: + """根据计划配置计算下一次执行时间。""" + current_time = now or utcnow_naive() + normalized = normalize_schedule_config(schedule_type, schedule_config, current_time) + + if schedule_type == "interval": + interval_delta = timedelta(minutes=normalized["interval_minutes"]) + candidate = (reference_time or current_time) + interval_delta + while candidate <= current_time: + candidate += interval_delta + return candidate + + every_n_days = normalized["every_n_days"] + time_of_day = parse_time_of_day(normalized["time_of_day"]) + start_date = parse_start_date(normalized.get("start_date"), current_time) + + candidate = datetime.combine(start_date, time_of_day) + anchor_time = reference_time or current_time + while candidate <= anchor_time: + candidate += timedelta(days=every_n_days) + while candidate <= current_time: + candidate += timedelta(days=every_n_days) + return candidate + + +def describe_schedule(schedule_type: str, schedule_config: Dict[str, Any]) -> str: + """生成人类可读的计划描述。""" + normalized = normalize_schedule_config(schedule_type, schedule_config) + if schedule_type == "interval": + return f"每 {normalized['interval_minutes']} 分钟触发" + return f"每 {normalized['every_n_days']} 天 {normalized['time_of_day']} 触发" diff --git a/src/web/scheduler.py b/src/web/scheduler.py new file mode 100644 index 00000000..cd84aaa9 --- /dev/null +++ b/src/web/scheduler.py @@ -0,0 +1,125 @@ +"""计划注册任务调度器。""" + +import asyncio +import logging +from datetime import datetime +from typing import Optional + +from ..database import crud +from ..database.session import get_db +from ..core.timezone_utils import utcnow_naive +from .routes.registration import dispatch_registration_config +from .schedule_utils import compute_next_run_at + +logger = logging.getLogger(__name__) + + +class ScheduledRegistrationService: + """计划注册任务调度服务。""" + + def __init__(self, poll_interval_seconds: int = 15): + self.poll_interval_seconds = max(5, poll_interval_seconds) + self._task: Optional[asyncio.Task] = None + self._running = False + + async def start(self): + """启动计划任务调度器。""" + if self._task and not self._task.done(): + return + self._running = True + self._task = asyncio.create_task(self._run_loop()) + logger.info("计划任务调度器已启动,轮询间隔 %s 秒", self.poll_interval_seconds) + + async def stop(self): + """停止计划任务调度器。""" + self._running = False + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + logger.info("计划任务调度器已停止") + + async def _run_loop(self): + """执行调度轮询循环。""" + while self._running: + try: + await self.poll_due_jobs() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning(f"计划任务轮询异常: {exc}") + await asyncio.sleep(self.poll_interval_seconds) + + async def poll_due_jobs(self): + """扫描并执行到期计划任务。""" + now = utcnow_naive() + with get_db() as db: + due_jobs = crud.get_due_scheduled_registration_jobs(db, now) + due_job_uuids = [job.job_uuid for job in due_jobs] + running_jobs = crud.get_running_scheduled_registration_jobs(db) + running_job_uuids = [job.job_uuid for job in running_jobs if job.next_run_at and job.next_run_at <= now] + + for job_uuid in running_job_uuids: + with get_db() as db: + crud.mark_scheduled_registration_job_skipped( + db, + job_uuid, + "上一次执行尚未结束,已跳过本次触发", + ) + + for job_uuid in due_job_uuids: + await self.run_job(job_uuid) + + async def run_job(self, job_uuid: str): + """执行单个计划任务。""" + now = utcnow_naive() + with get_db() as db: + job = crud.get_scheduled_registration_job_by_uuid(db, job_uuid) + if not job or not job.enabled: + return + + if job.is_running: + crud.mark_scheduled_registration_job_skipped( + db, + job_uuid, + "上一次执行尚未结束,已跳过本次触发", + ) + return + + next_run_at = compute_next_run_at( + job.schedule_type, + job.schedule_config or {}, + now, + reference_time=job.next_run_at or now, + ) + claimed_job = crud.claim_scheduled_registration_job(db, job_uuid, next_run_at, now) + if not claimed_job: + return + registration_config = claimed_job.registration_config or {} + + try: + result = await dispatch_registration_config(registration_config, None) + with get_db() as db: + crud.mark_scheduled_registration_job_success( + db, + job_uuid, + utcnow_naive(), + task_uuid=result.get("task_uuid"), + batch_id=result.get("batch_id"), + ) + except Exception as exc: + logger.warning(f"计划任务执行失败 {job_uuid}: {exc}") + with get_db() as db: + crud.mark_scheduled_registration_job_failure( + db, + job_uuid, + str(exc), + utcnow_naive(), + ) + + +scheduled_registration_service = ScheduledRegistrationService() 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..82049f37 100644 --- a/src/web/task_manager.py +++ b/src/web/task_manager.py @@ -7,10 +7,12 @@ 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 +from ..core.timezone_utils import utcnow_naive + logger = logging.getLogger(__name__) # 全局线程池(支持最多 50 个并发注册任务) @@ -41,6 +43,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: """线程安全地获取或创建任务日志锁""" @@ -115,7 +129,7 @@ async def _broadcast_log(self, task_uuid: str, log_message: str): "type": "log", "task_uuid": task_uuid, "message": log_message, - "timestamp": datetime.utcnow().isoformat() + "timestamp": utcnow_naive().isoformat() }) # 发送成功后更新 sent_index with _ws_lock: @@ -134,7 +148,7 @@ async def broadcast_status(self, task_uuid: str, status: str, **kwargs): "type": "status", "task_uuid": task_uuid, "status": status, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": utcnow_naive().isoformat(), **kwargs } @@ -264,7 +278,7 @@ async def _broadcast_batch_log(self, batch_id: str, log_message: str): "type": "log", "batch_id": batch_id, "message": log_message, - "timestamp": datetime.utcnow().isoformat() + "timestamp": utcnow_naive().isoformat() }) # 发送成功后更新 sent_index with _ws_lock: @@ -304,7 +318,7 @@ async def _broadcast_batch_status(self, batch_id: str): await ws.send_json({ "type": "status", "batch_id": batch_id, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": utcnow_naive().isoformat(), **status }) except Exception as e: @@ -391,6 +405,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": utcnow_naive().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": utcnow_naive().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 utcnow_naive().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..9fc6ad43 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,75 +127,269 @@ body { -moz-osx-font-smoothing: grayscale; } +/* ============================================ + 布局 + ============================================ */ + +.container { + max-width: 1280px; + margin: 0 auto; + padding: 0 var(--spacing-lg); +} + .site-notice-wrapper { - padding: var(--spacing-md) 0 0; + padding-top: var(--spacing-md); } .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); + margin: 0 0 var(--spacing-lg); + padding: 18px 20px; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); + border: 1px solid #dbe4f0; + border-left: 4px solid #1d4ed8; + border-radius: 12px; + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06); } -.site-notice-heading { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; - color: var(--text-primary); - font-size: 0.95rem; +.site-notice-text { + margin: 0 0 10px; + color: #0f172a; + font-size: 1rem; + line-height: 1.65; } -.site-notice-heading strong { - color: #b45309; +.site-notice-text:last-of-type { + margin-bottom: 0; } -.site-notice-text { - color: var(--text-secondary); - font-size: 0.875rem; - margin: 0; +.site-notice-lead strong { + font-weight: 800; } .site-notice-links { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 12px; + margin-top: 16px; +} + +.site-notice-link { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 210px; + min-height: 72px; + padding: 14px 42px 14px 16px; + border-radius: 14px; + border: 1px solid #bfdbfe; + background: linear-gradient(180deg, #f8fbff 0%, #e8f1ff 100%); + color: #1d4ed8; + text-decoration: none; + box-shadow: 0 8px 20px rgba(29, 78, 216, 0.10); + transition: transform var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); +} + +.site-notice-link::after { + content: "↗"; + position: absolute; + top: 50%; + right: 16px; + transform: translateY(-50%); + font-size: 1rem; + font-weight: 700; + opacity: 0.72; +} + +.site-notice-link:hover { + color: #1e40af; + border-color: #60a5fa; + background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%); + box-shadow: 0 12px 24px rgba(29, 78, 216, 0.16); + transform: translateY(-2px); +} + +.site-notice-link:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.20), 0 12px 24px rgba(29, 78, 216, 0.16); } -.site-notice-links a { +.site-notice-link-title { + display: block; + font-size: 0.98rem; + font-weight: 700; + line-height: 1.2; +} + +.site-notice-link-meta { + display: block; + margin-top: 6px; + color: #64748b; + font-size: 0.8rem; + line-height: 1.4; + word-break: break-all; +} + +.site-notice-link-accent { + border-color: #fbcfe8; + background: linear-gradient(180deg, #fff7ed 0%, #ffedd5 100%); + color: #c2410c; + box-shadow: 0 8px 20px rgba(251, 146, 60, 0.12); +} + +.site-notice-link-accent:hover { + border-color: #fdba74; + background: linear-gradient(180deg, #ffedd5 0%, #fed7aa 100%); + color: #9a3412; + box-shadow: 0 12px 24px rgba(251, 146, 60, 0.18); +} + +.site-footer-wrapper { + padding: 0 0 var(--spacing-xl); +} + +.site-footer { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 1.25fr); + align-items: stretch; + gap: 16px; + padding: 18px 20px; + border: 1px solid var(--border); + border-radius: 18px; + background: + radial-gradient(circle at top right, rgba(96, 165, 250, 0.14), transparent 34%), + linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); + box-shadow: 0 14px 36px rgba(15, 23, 42, 0.08); + overflow: hidden; +} + +.site-footer::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.36), transparent 46%); + pointer-events: none; +} + +.site-footer-brand { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + min-width: 0; +} + +.site-footer-kicker { 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); + width: fit-content; + padding: 4px 10px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.08); + color: #2563eb; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.site-footer-heading { + display: flex; + flex-direction: column; + gap: 4px; +} + +.site-footer-title { + font-size: 1rem; + font-weight: 800; + color: #0f172a; +} + +.site-footer-text { + font-size: 0.82rem; + color: #64748b; +} + +.site-footer-actions { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + width: 100%; +} + +.site-footer-link { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 4px; + min-height: 82px; + padding: 14px 18px; + border: 1px solid #dbe5f1; + border-radius: 16px; + background: rgba(255, 255, 255, 0.92); + color: #0f172a; text-decoration: none; - font-size: 0.875rem; - font-weight: 600; - transition: all var(--transition); + box-shadow: 0 8px 20px rgba(148, 163, 184, 0.14); + transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), background var(--transition-fast); + pointer-events: auto; } -.site-notice-links a:hover { - color: var(--primary-hover); - border-color: var(--primary-color); - transform: translateY(-1px); +.site-footer-link::after { + content: "↗"; + position: absolute; + top: 14px; + right: 16px; + color: rgba(37, 99, 235, 0.72); + font-size: 0.95rem; + font-weight: 700; } -/* ============================================ - 布局 - ============================================ */ +.site-footer-link-label { + font-size: 0.96rem; + font-weight: 700; + line-height: 1.2; +} -.container { - max-width: 1280px; - margin: 0 auto; - padding: 0 var(--spacing-lg); +.site-footer-link-sub { + font-size: 0.8rem; + line-height: 1.4; + color: #64748b; +} + +.site-footer-link:hover { + color: #1d4ed8; + border-color: #93c5fd; + background: linear-gradient(180deg, #ffffff 0%, #eff6ff 100%); + box-shadow: 0 14px 28px rgba(59, 130, 246, 0.18); + transform: translateY(-2px); +} + +.site-footer-link:focus-visible { + outline: none; + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18), 0 14px 28px rgba(59, 130, 246, 0.18); +} + +.site-footer-link-blog { + border-color: #c7d2fe; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(238, 242, 255, 0.96) 100%); +} + +.site-footer-link-blog:hover { + border-color: #818cf8; + background: linear-gradient(180deg, #ffffff 0%, #e0e7ff 100%); +} + +.site-footer-link-blog::after { + color: rgba(79, 70, 229, 0.72); } /* ============================================ @@ -264,7 +464,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 +482,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 +607,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 +667,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 +684,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 +720,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 +775,7 @@ body { .btn-sm { padding: 6px 12px; + min-height: var(--control-height-sm); font-size: 0.75rem; } @@ -1122,7 +1356,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 +1366,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; } @@ -1310,18 +1544,82 @@ body { } } +@media (min-width: 1200px) { + .site-notice-links { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + align-items: stretch; + } + + .site-notice-link { + min-width: 0; + width: auto; + } +} + +@media (max-width: 1199px) { + .site-notice-links { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .site-notice-link { + min-width: 0; + width: auto; + } +} + +@media (max-width: 900px) { + .site-notice-links { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .site-notice-link { + min-width: 0; + width: auto; + } +} + @media (max-width: 768px) { .site-notice { - padding: 12px 14px; + padding: 14px 16px; + } + + .site-notice-text, + .site-notice-link { + font-size: 0.92rem; + } + + .site-notice-links { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + } + + .site-notice-link { + width: 100%; + min-width: 0; + min-height: 64px; + padding: 12px 38px 12px 14px; + } + + .site-notice-link-title { + font-size: 0.92rem; + } + + .site-notice-link-meta { + font-size: 0.76rem; } - .site-notice-heading { - font-size: 0.9rem; + .site-footer { + grid-template-columns: 1fr; + padding: 16px; } - .site-notice-links a { + .site-footer-actions { width: 100%; - justify-content: center; + grid-template-columns: 1fr; } .container { 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 d96295b5..d4d84193 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,4 +1,4 @@ -/** +/** * 注册页面 JavaScript * 使用 utils.js 中的工具库 */ @@ -8,12 +8,16 @@ let currentTask = null; let currentBatch = null; let logPollingInterval = null; let batchPollingInterval = null; +let autoMonitorPollingInterval = null; let accountsPollingInterval = null; let todayStatsPollingInterval = null; let todayStatsResetInterval = null; let isBatchMode = false; let isOutlookBatchMode = false; +let isAutoMode = false; let outlookAccounts = []; +let editingScheduleJobUuid = null; +let scheduledJobs = []; let taskCompleted = false; // 标记任务是否已完成 let batchCompleted = false; // 标记批量任务是否已完成 let taskFinalStatus = null; // 保存任务的最终状态 @@ -22,11 +26,15 @@ 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: [] }, + cloudmail: { available: false, services: [] }, duck_mail: { available: false, services: [] }, - freemail: { available: false, services: [] } + luckmail: { available: false, services: [] }, + freemail: { available: false, services: [] }, + imap_mail: { available: false, services: [] } }; // WebSocket 相关变量 @@ -37,12 +45,24 @@ 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; +let autoMonitorLastLogIndex = 0; + +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'), + emailServiceGroup: document.getElementById('email-service')?.closest('.form-group'), 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'), @@ -61,6 +81,10 @@ const elements = { taskStatus: document.getElementById('task-status'), taskService: document.getElementById('task-service'), taskStatusBadge: document.getElementById('task-status-badge'), + autoMonitorStatusBadge: document.getElementById('auto-monitor-status-badge'), + autoMonitorLastChecked: document.getElementById('auto-monitor-last-checked'), + taskLastChecked: document.getElementById('task-last-checked'), + taskInventory: document.getElementById('task-inventory'), // 批量状态 batchProgressText: document.getElementById('batch-progress-text'), batchProgressPercent: document.getElementById('batch-progress-percent'), @@ -102,13 +126,46 @@ const elements = { autoUploadTm: document.getElementById('auto-upload-tm'), tmServiceSelectGroup: document.getElementById('tm-service-select-group'), tmServiceSelect: document.getElementById('tm-service-select'), + autoUploadNewApi: document.getElementById('auto-upload-new-api'), + newApiServiceSelectGroup: document.getElementById('new-api-service-select-group'), + newApiServiceSelect: document.getElementById('new-api-service-select'), + scheduleForm: document.getElementById('schedule-form'), + scheduleName: document.getElementById('schedule-name'), + scheduleTriggerType: document.getElementById('schedule-trigger-type'), + scheduleEnabled: document.getElementById('schedule-enabled'), + scheduleIntervalFields: document.getElementById('schedule-interval-fields'), + scheduleIntervalMinutes: document.getElementById('schedule-interval-minutes'), + scheduleTimepointFields: document.getElementById('schedule-timepoint-fields'), + scheduleEveryNDays: document.getElementById('schedule-every-n-days'), + scheduleTimeOfDay: document.getElementById('schedule-time-of-day'), + scheduleStartDate: document.getElementById('schedule-start-date'), + saveScheduleBtn: document.getElementById('save-schedule-btn'), + cancelScheduleEditBtn: document.getElementById('cancel-schedule-edit-btn'), + refreshSchedulesBtn: document.getElementById('refresh-schedules-btn'), + scheduleJobsTable: document.getElementById('schedule-jobs-table'), + autoRegistrationSection: document.getElementById('auto-registration-section'), + autoRegistrationEnabled: document.getElementById('auto-registration-enabled'), + autoRegistrationCheckInterval: document.getElementById('auto-registration-check-interval'), + autoRegistrationMinReady: document.getElementById('auto-registration-min-ready'), + autoRegistrationCpaServiceId: document.getElementById('auto-registration-cpa-service-id'), + autoRegistrationEmailServiceType: document.getElementById('auto-registration-email-service-type'), + autoRegistrationEmailServiceId: document.getElementById('auto-registration-email-service-id'), + autoRegistrationProxy: document.getElementById('auto-registration-proxy'), + autoRegistrationMode: document.getElementById('auto-registration-mode'), + autoRegistrationConcurrency: document.getElementById('auto-registration-concurrency'), + autoRegistrationIntervalGroup: document.getElementById('auto-registration-interval-group'), + autoRegistrationIntervalMin: document.getElementById('auto-registration-interval-min'), + autoRegistrationIntervalMax: document.getElementById('auto-registration-interval-max'), }; // 初始化 document.addEventListener('DOMContentLoaded', () => { initEventListeners(); + handleModeChange({ target: elements.regMode }); loadAvailableServices(); loadRecentAccounts(); + loadAutoRegistrationSettings(); + loadAutoRegistrationCpaOptions(); startAccountsPolling(); loadTodayStats(true); startTodayStatsPolling(); @@ -116,14 +173,17 @@ document.addEventListener('DOMContentLoaded', () => { initVisibilityReconnect(); restoreActiveTask(); initAutoUploadOptions(); + initScheduleForm(); + loadScheduledJobs(); }); -// 初始化注册后自动操作选项(CPA / Sub2API / TM) +// 初始化注册后自动操作选项(CPA / Sub2API / TM / new-api) async function initAutoUploadOptions() { await Promise.all([ loadServiceSelect('/cpa-services?enabled=true', elements.cpaServiceSelect, elements.autoUploadCpa, elements.cpaServiceSelectGroup), loadServiceSelect('/sub2api-services?enabled=true', elements.sub2apiServiceSelect, elements.autoUploadSub2api, elements.sub2apiServiceSelectGroup), loadServiceSelect('/tm-services?enabled=true', elements.tmServiceSelect, elements.autoUploadTm, elements.tmServiceSelectGroup), + loadServiceSelect('/new-api-services?enabled=true', elements.newApiServiceSelect, elements.autoUploadNewApi, elements.newApiServiceSelectGroup), ]); } @@ -206,6 +266,18 @@ function initEventListeners() { // 邮箱服务切换 elements.emailService.addEventListener('change', handleServiceChange); + if (elements.autoRegistrationEmailServiceType) { + elements.autoRegistrationEmailServiceType.addEventListener('change', () => populateAutoRegistrationEmailServiceOptions(0)); + } + if (elements.autoRegistrationMode) { + elements.autoRegistrationMode.addEventListener('change', () => { + handleConcurrencyModeChange( + elements.autoRegistrationMode, + elements.concurrencyHint, + elements.autoRegistrationIntervalGroup + ); + }); + } // 取消按钮 elements.cancelBtn.addEventListener('click', handleCancelTask); @@ -229,6 +301,10 @@ function initEventListeners() { elements.outlookConcurrencyMode.addEventListener('change', () => { handleConcurrencyModeChange(elements.outlookConcurrencyMode, elements.outlookConcurrencyHint, elements.outlookIntervalGroup); }); + + if (elements.refreshSchedulesBtn) { + elements.refreshSchedulesBtn.addEventListener('click', () => loadScheduledJobs()); + } } // 加载可用的邮箱服务 @@ -239,6 +315,14 @@ async function loadAvailableServices() { // 更新邮箱服务选择框 updateEmailServiceOptions(); + populateAutoRegistrationEmailServiceOptions(parseInt(elements.autoRegistrationEmailServiceId?.value || '0', 10) || 0); + + if (editingScheduleJobUuid) { + const currentJob = scheduledJobs.find(item => item.job_uuid === editingScheduleJobUuid); + if (currentJob) { + setRegistrationConfigToForm(currentJob.registration_config || {}); + } + } addLog('info', '[系统] 邮箱服务列表已加载'); } catch (error) { @@ -252,18 +336,31 @@ function updateEmailServiceOptions() { const select = elements.emailService; select.innerHTML = ''; - // Tempmail - if (availableServices.tempmail.available) { + // 官方临时邮箱渠道 + if ((availableServices.tempmail && availableServices.tempmail.available) || + (availableServices.yyds_mail && availableServices.yyds_mail.available)) { const optgroup = document.createElement('optgroup'); optgroup.label = '🌐 临时邮箱'; - 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.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); + }); + } select.appendChild(optgroup); } @@ -350,6 +447,23 @@ function updateEmailServiceOptions() { select.appendChild(optgroup); } + // CloudMail + if (availableServices.cloudmail && availableServices.cloudmail.available) { + const optgroup = document.createElement('optgroup'); + optgroup.label = `☁️ CloudMail (${availableServices.cloudmail.count} 个服务)`; + + availableServices.cloudmail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `cloudmail:${service.id}`; + option.textContent = service.name + (service.domain ? ` (@${service.domain})` : ''); + option.dataset.type = 'cloudmail'; + option.dataset.serviceId = service.id; + optgroup.appendChild(option); + }); + + select.appendChild(optgroup); + } + // DuckMail if (availableServices.duck_mail && availableServices.duck_mail.available) { const optgroup = document.createElement('optgroup'); @@ -367,6 +481,23 @@ function updateEmailServiceOptions() { select.appendChild(optgroup); } + // LuckMail + if (availableServices.luckmail && availableServices.luckmail.available) { + const optgroup = document.createElement('optgroup'); + optgroup.label = `✉️ LuckMail (${availableServices.luckmail.count} 个服务)`; + + availableServices.luckmail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `luckmail:${service.id}`; + option.textContent = service.name + (service.preferred_domain ? ` (@${service.preferred_domain})` : ''); + option.dataset.type = 'luckmail'; + option.dataset.serviceId = service.id; + optgroup.appendChild(option); + }); + + select.appendChild(optgroup); + } + // Freemail if (availableServices.freemail && availableServices.freemail.available) { const optgroup = document.createElement('optgroup'); @@ -383,6 +514,23 @@ function updateEmailServiceOptions() { select.appendChild(optgroup); } + + // IMAP 邮箱 + if (availableServices.imap_mail && availableServices.imap_mail.available) { + const optgroup = document.createElement('optgroup'); + optgroup.label = `📨 IMAP 邮箱 (${availableServices.imap_mail.count} 个服务)`; + + availableServices.imap_mail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `imap_mail:${service.id}`; + option.textContent = service.name + (service.email ? ` (${service.email})` : ''); + option.dataset.type = 'imap_mail'; + option.dataset.serviceId = service.id; + optgroup.appendChild(option); + }); + + select.appendChild(optgroup); + } } // 处理邮箱服务切换 @@ -413,6 +561,11 @@ 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) { @@ -423,26 +576,61 @@ function handleServiceChange(e) { if (service) { addLog('info', `[系统] 已选择 Temp-Mail 自部署服务: ${service.name}`); } + } else if (type === 'cloudmail') { + const service = availableServices.cloudmail.services.find(s => s.id == id); + if (service) { + addLog('info', `[系统] 已选择 CloudMail 服务: ${service.name}`); + } } else if (type === 'duck_mail') { const service = availableServices.duck_mail.services.find(s => s.id == id); if (service) { addLog('info', `[系统] 已选择 DuckMail 服务: ${service.name}`); } + } else if (type === 'luckmail') { + const service = availableServices.luckmail.services.find(s => s.id == id); + if (service) { + addLog('info', `[系统] 已选择 LuckMail 服务: ${service.name}`); + } } else if (type === 'freemail') { const service = availableServices.freemail.services.find(s => s.id == id); if (service) { addLog('info', `[系统] 已选择 Freemail 服务: ${service.name}`); } + } else if (type === 'imap_mail') { + const service = availableServices.imap_mail.services.find(s => s.id == id); + if (service) { + addLog('info', `[系统] 已选择 IMAP 邮箱服务: ${service.name}`); + } } } // 模式切换 function handleModeChange(e) { const mode = e.target.value; + isAutoMode = mode === 'auto'; isBatchMode = mode === 'batch'; elements.batchCountGroup.style.display = isBatchMode ? 'block' : 'none'; elements.batchOptions.style.display = isBatchMode ? 'block' : 'none'; + if (elements.autoRegistrationSection) { + elements.autoRegistrationSection.style.display = isAutoMode ? 'block' : 'none'; + } + if (elements.emailServiceGroup) { + elements.emailServiceGroup.style.display = isAutoMode ? 'none' : 'block'; + } + const autoUploadGroup = elements.autoUploadCpa?.closest('#auto-upload-group'); + if (autoUploadGroup) { + autoUploadGroup.style.display = isAutoMode ? 'none' : 'block'; + } + elements.startBtn.textContent = isAutoMode ? '💾 保存自动注册设置' : '🚀 开始注册'; + + if (isAutoMode) { + elements.cancelBtn.disabled = false; + } else { + stopAutoRegistrationMonitor(); + updateAutoMonitorHeader('idle', null); + elements.cancelBtn.disabled = true; + } } // 并发模式切换(批量) @@ -457,10 +645,361 @@ function handleConcurrencyModeChange(selectEl, hintEl, intervalGroupEl) { } } +function initScheduleForm() { + if (!elements.scheduleForm) return; + if (!elements.scheduleStartDate.value) { + elements.scheduleStartDate.value = new Date().toISOString().slice(0, 10); + } + elements.scheduleForm.addEventListener('submit', handleScheduleSubmit); + elements.scheduleTriggerType.addEventListener('change', updateScheduleTriggerFields); + elements.cancelScheduleEditBtn.addEventListener('click', resetScheduleForm); + updateScheduleTriggerFields(); +} + +function updateScheduleTriggerFields() { + if (!elements.scheduleTriggerType) return; + const triggerType = elements.scheduleTriggerType.value; + elements.scheduleIntervalFields.style.display = triggerType === 'interval' ? 'block' : 'none'; + elements.scheduleTimepointFields.style.display = triggerType === 'timepoint' ? 'block' : 'none'; +} + +function buildCurrentRegistrationConfig() { + const selectedValue = elements.emailService.value; + if (!selectedValue) { + throw new Error('请选择一个邮箱服务'); + } + + const [emailServiceType, serviceId] = selectedValue.split(':'); + const baseConfig = { + email_service_type: emailServiceType, + registration_type: elements.registrationType ? elements.registrationType.value : 'child', + reg_mode: isOutlookBatchMode ? 'outlook_batch' : (isBatchMode ? 'batch' : 'single'), + 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, + sub2api_service_ids: elements.autoUploadSub2api && elements.autoUploadSub2api.checked ? getSelectedServiceIds(elements.sub2apiServiceSelect) : [], + auto_upload_tm: elements.autoUploadTm ? elements.autoUploadTm.checked : false, + tm_service_ids: elements.autoUploadTm && elements.autoUploadTm.checked ? getSelectedServiceIds(elements.tmServiceSelect) : [], + auto_upload_new_api: elements.autoUploadNewApi ? elements.autoUploadNewApi.checked : false, + new_api_service_ids: elements.autoUploadNewApi && elements.autoUploadNewApi.checked ? getSelectedServiceIds(elements.newApiServiceSelect) : [], + }; + + if (isOutlookBatchMode) { + const selectedIds = []; + document.querySelectorAll('.outlook-account-checkbox:checked').forEach(cb => { + selectedIds.push(parseInt(cb.value)); + }); + return { + ...baseConfig, + email_service_type: 'outlook_batch', + service_ids: selectedIds, + skip_registered: elements.outlookSkipRegistered.checked, + interval_min: parseInt(elements.outlookIntervalMin.value) || 5, + interval_max: parseInt(elements.outlookIntervalMax.value) || 30, + concurrency: Math.min(50, Math.max(1, parseInt(elements.outlookConcurrencyCount.value) || 3)), + mode: elements.outlookConcurrencyMode.value || 'pipeline', + }; + } + + if (serviceId && serviceId !== 'default') { + baseConfig.email_service_id = parseInt(serviceId); + } + + if (isBatchMode) { + return { + ...baseConfig, + batch_count: parseInt(elements.batchCount.value) || 1, + interval_min: parseInt(elements.intervalMin.value) || 5, + interval_max: parseInt(elements.intervalMax.value) || 30, + concurrency: Math.min(50, Math.max(1, parseInt(elements.concurrencyCount.value) || 1)), + mode: elements.concurrencyMode.value || 'pipeline', + }; + } + + return baseConfig; +} + +function buildScheduleConfig() { + const triggerType = elements.scheduleTriggerType.value; + if (triggerType === 'interval') { + return { + schedule_type: 'interval', + schedule_config: { + interval_minutes: parseInt(elements.scheduleIntervalMinutes.value) || 1, + }, + }; + } + + return { + schedule_type: 'timepoint', + schedule_config: { + every_n_days: parseInt(elements.scheduleEveryNDays.value) || 1, + time_of_day: elements.scheduleTimeOfDay.value || '09:00', + start_date: elements.scheduleStartDate.value || null, + }, + }; +} + +async function handleScheduleSubmit(e) { + e.preventDefault(); + + try { + const registrationConfig = buildCurrentRegistrationConfig(); + if (registrationConfig.email_service_type === 'outlook_batch' && (!registrationConfig.service_ids || registrationConfig.service_ids.length === 0)) { + toast.error('请至少选择一个 Outlook 账户后再保存计划'); + return; + } + + const { schedule_type, schedule_config } = buildScheduleConfig(); + const payload = { + name: (elements.scheduleName.value || '').trim(), + enabled: elements.scheduleEnabled.value === 'true', + schedule_type, + schedule_config, + registration_config: registrationConfig, + timezone: 'local', + }; + + if (!payload.name) { + toast.error('请输入计划名称'); + return; + } + + const endpoint = editingScheduleJobUuid + ? `/registration/schedules/${editingScheduleJobUuid}` + : '/registration/schedules'; + const method = editingScheduleJobUuid ? 'put' : 'post'; + + await api[method](endpoint, payload); + toast.success(editingScheduleJobUuid ? '计划任务已更新' : '计划任务已创建'); + resetScheduleForm(); + await loadScheduledJobs(); + } catch (error) { + toast.error(error.message); + } +} + +function resetScheduleForm() { + editingScheduleJobUuid = null; + if (!elements.scheduleForm) return; + elements.scheduleForm.reset(); + elements.scheduleEnabled.value = 'true'; + elements.scheduleTriggerType.value = 'interval'; + elements.scheduleIntervalMinutes.value = '60'; + elements.scheduleEveryNDays.value = '1'; + elements.scheduleTimeOfDay.value = '09:00'; + elements.scheduleStartDate.value = new Date().toISOString().slice(0, 10); + elements.saveScheduleBtn.textContent = '保存计划任务'; + elements.cancelScheduleEditBtn.style.display = 'none'; + updateScheduleTriggerFields(); +} + +function setRegistrationConfigToForm(config) { + const registrationConfig = config || {}; + const emailServiceType = registrationConfig.email_service_type || 'tempmail'; + const emailServiceId = registrationConfig.email_service_id; + const serviceValue = emailServiceType === 'outlook_batch' + ? 'outlook_batch:all' + : `${emailServiceType}:${emailServiceId ?? 'default'}`; + + elements.emailService.value = serviceValue; + handleServiceChange({ target: elements.emailService }); + + elements.autoUploadCpa.checked = !!registrationConfig.auto_upload_cpa; + elements.cpaServiceSelectGroup.style.display = elements.autoUploadCpa.checked ? 'block' : 'none'; + elements.autoUploadSub2api.checked = !!registrationConfig.auto_upload_sub2api; + elements.sub2apiServiceSelectGroup.style.display = elements.autoUploadSub2api.checked ? 'block' : 'none'; + elements.autoUploadTm.checked = !!registrationConfig.auto_upload_tm; + elements.tmServiceSelectGroup.style.display = elements.autoUploadTm.checked ? 'block' : 'none'; + if (elements.autoUploadNewApi) { + elements.autoUploadNewApi.checked = !!registrationConfig.auto_upload_new_api; + } + if (elements.newApiServiceSelectGroup && elements.autoUploadNewApi) { + elements.newApiServiceSelectGroup.style.display = elements.autoUploadNewApi.checked ? 'block' : 'none'; + } + + setSelectedServiceIds(elements.cpaServiceSelect, registrationConfig.cpa_service_ids || []); + setSelectedServiceIds(elements.sub2apiServiceSelect, registrationConfig.sub2api_service_ids || []); + setSelectedServiceIds(elements.tmServiceSelect, registrationConfig.tm_service_ids || []); + setSelectedServiceIds(elements.newApiServiceSelect, registrationConfig.new_api_service_ids || []); + + if (emailServiceType === 'outlook_batch') { + isOutlookBatchMode = true; + elements.outlookBatchSection.style.display = 'block'; + elements.regModeGroup.style.display = 'none'; + elements.batchCountGroup.style.display = 'none'; + elements.batchOptions.style.display = 'none'; + if (Array.isArray(registrationConfig.service_ids) && registrationConfig.service_ids.length) { + const selectedSet = new Set(registrationConfig.service_ids.map(item => parseInt(item))); + document.querySelectorAll('.outlook-account-checkbox').forEach(cb => { + cb.checked = selectedSet.has(parseInt(cb.value)); + }); + } + elements.outlookSkipRegistered.checked = registrationConfig.skip_registered !== false; + elements.outlookIntervalMin.value = registrationConfig.interval_min || 5; + elements.outlookIntervalMax.value = registrationConfig.interval_max || 30; + elements.outlookConcurrencyCount.value = registrationConfig.concurrency || 3; + elements.outlookConcurrencyMode.value = registrationConfig.mode || 'pipeline'; + handleConcurrencyModeChange(elements.outlookConcurrencyMode, elements.outlookConcurrencyHint, elements.outlookIntervalGroup); + return; + } + + isOutlookBatchMode = false; + elements.outlookBatchSection.style.display = 'none'; + elements.regModeGroup.style.display = 'block'; + elements.regMode.value = registrationConfig.reg_mode === 'batch' ? 'batch' : 'single'; + handleModeChange({ target: elements.regMode }); + elements.batchCount.value = registrationConfig.batch_count || 1; + elements.intervalMin.value = registrationConfig.interval_min || 5; + elements.intervalMax.value = registrationConfig.interval_max || 30; + elements.concurrencyCount.value = registrationConfig.concurrency || 1; + elements.concurrencyMode.value = registrationConfig.mode || 'pipeline'; + handleConcurrencyModeChange(elements.concurrencyMode, elements.concurrencyHint, elements.intervalGroup); +} + +function setSelectedServiceIds(container, selectedIds) { + if (!container) return; + const selectedSet = new Set((selectedIds || []).map(item => parseInt(item))); + container.querySelectorAll('.msd-item input').forEach(cb => { + cb.checked = selectedSet.size === 0 ? true : selectedSet.has(parseInt(cb.value)); + }); + const dropdownId = `${container.id}-dd`; + updateMsdLabel(dropdownId); +} + +function formatScheduleDateTime(value) { + if (!value) return '-'; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString('zh-CN', { hour12: false }); +} + +function getScheduleStatusClass(job) { + if (!job.enabled) return 'cancelled'; + if (job.status === 'failed') return 'failed'; + if (job.is_running || job.status === 'running') return 'running'; + if (job.status === 'scheduled') return 'completed'; + return 'pending'; +} + +async function loadScheduledJobs() { + if (!elements.scheduleJobsTable) return; + try { + const data = await api.get('/registration/schedules?page=1&page_size=100'); + scheduledJobs = data.jobs || []; + if (editingScheduleJobUuid) { + const currentJob = scheduledJobs.find(item => item.job_uuid === editingScheduleJobUuid); + if (currentJob) { + setRegistrationConfigToForm(currentJob.registration_config || {}); + } + } + renderScheduledJobs(); + } catch (error) { + elements.scheduleJobsTable.innerHTML = `
加载失败:${escapeHtml(error.message)}
`; + } +} + +function renderScheduledJobs() { + if (!elements.scheduleJobsTable) return; + if (!scheduledJobs.length) { + elements.scheduleJobsTable.innerHTML = `
🗓️
暂无计划任务
`; + return; + } + + elements.scheduleJobsTable.innerHTML = scheduledJobs.map(job => ` + + +
${escapeHtml(job.name)}
+
${job.enabled ? '已启用' : '已暂停'}
+ + ${escapeHtml(job.schedule_description || '-')} + ${escapeHtml(formatScheduleDateTime(job.next_run_at))} + ${escapeHtml(formatScheduleDateTime(job.last_run_at))} + ${escapeHtml(job.status || 'idle')} + +
+ + + + +
+ + + `).join(''); +} + +async function editScheduledJob(jobUuid) { + try { + const job = await api.get(`/registration/schedules/${jobUuid}`); + editingScheduleJobUuid = jobUuid; + elements.scheduleName.value = job.name || ''; + elements.scheduleEnabled.value = job.enabled ? 'true' : 'false'; + elements.scheduleTriggerType.value = job.schedule_type || 'interval'; + if (job.schedule_type === 'interval') { + elements.scheduleIntervalMinutes.value = job.schedule_config?.interval_minutes || 60; + } else { + elements.scheduleEveryNDays.value = job.schedule_config?.every_n_days || 1; + elements.scheduleTimeOfDay.value = job.schedule_config?.time_of_day || '09:00'; + elements.scheduleStartDate.value = job.schedule_config?.start_date || new Date().toISOString().slice(0, 10); + } + setRegistrationConfigToForm(job.registration_config || {}); + elements.saveScheduleBtn.textContent = '更新计划任务'; + elements.cancelScheduleEditBtn.style.display = 'block'; + updateScheduleTriggerFields(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } catch (error) { + toast.error(error.message); + } +} + +async function toggleScheduledJob(jobUuid, shouldEnable) { + try { + const endpoint = shouldEnable ? `/registration/schedules/${jobUuid}/enable` : `/registration/schedules/${jobUuid}/pause`; + await api.post(endpoint, {}); + toast.success(shouldEnable ? '计划任务已启用' : '计划任务已暂停'); + await loadScheduledJobs(); + } catch (error) { + toast.error(error.message); + } +} + +async function runScheduledJobNow(jobUuid) { + try { + const data = await api.post(`/registration/schedules/${jobUuid}/run`, {}); + toast.success(data.message || '计划任务已触发执行'); + await loadScheduledJobs(); + } catch (error) { + toast.error(error.message); + } +} + +async function deleteScheduledJob(jobUuid) { + try { + await api.delete(`/registration/schedules/${jobUuid}`); + toast.success('计划任务已删除'); + if (editingScheduleJobUuid === jobUuid) { + resetScheduleForm(); + } + await loadScheduledJobs(); + } catch (error) { + toast.error(error.message); + } +} + +window.editScheduledJob = editScheduledJob; +window.toggleScheduledJob = toggleScheduledJob; +window.runScheduledJobNow = runScheduledJobNow; +window.deleteScheduledJob = deleteScheduledJob; + // 开始注册 async function handleStartRegistration(e) { e.preventDefault(); + if (isAutoMode) { + await handleSaveAutoRegistration(); + return; + } + const selectedValue = elements.emailService.value; if (!selectedValue) { toast.error('请选择一个邮箱服务'); @@ -473,8 +1012,6 @@ async function handleStartRegistration(e) { return; } - const [emailServiceType, serviceId] = selectedValue.split(':'); - // 禁用开始按钮 elements.startBtn.disabled = true; elements.cancelBtn.disabled = false; @@ -482,21 +1019,7 @@ async function handleStartRegistration(e) { // 清空日志 elements.consoleLog.innerHTML = ''; - // 构建请求数据(代理从设置中自动获取) - const requestData = { - email_service_type: emailServiceType, - 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, - sub2api_service_ids: elements.autoUploadSub2api && elements.autoUploadSub2api.checked ? getSelectedServiceIds(elements.sub2apiServiceSelect) : [], - auto_upload_tm: elements.autoUploadTm ? elements.autoUploadTm.checked : false, - tm_service_ids: elements.autoUploadTm && elements.autoUploadTm.checked ? getSelectedServiceIds(elements.tmServiceSelect) : [], - }; - - // 如果选择了数据库中的服务,传递 service_id - if (serviceId && serviceId !== 'default') { - requestData.email_service_id = parseInt(serviceId); - } + const requestData = buildCurrentRegistrationConfig(); if (isBatchMode) { await handleBatchRegistration(requestData); @@ -505,6 +1028,142 @@ async function handleStartRegistration(e) { } } + +async function loadAutoRegistrationSettings() { + if (!elements.autoRegistrationEnabled) return; + try { + const data = await api.get('/settings'); + const reg = data.registration || {}; + elements.autoRegistrationEnabled.checked = reg.auto_enabled || false; + elements.autoRegistrationCheckInterval.value = reg.auto_check_interval || 60; + elements.autoRegistrationMinReady.value = reg.auto_min_ready_auth_files || 1; + elements.autoRegistrationEmailServiceType.value = reg.auto_email_service_type || 'tempmail'; + elements.autoRegistrationProxy.value = reg.auto_proxy || ''; + elements.autoRegistrationMode.value = reg.auto_mode || 'pipeline'; + elements.autoRegistrationConcurrency.value = reg.auto_concurrency || 1; + elements.autoRegistrationIntervalMin.value = reg.auto_interval_min || 5; + elements.autoRegistrationIntervalMax.value = reg.auto_interval_max || 30; + handleConcurrencyModeChange( + elements.autoRegistrationMode, + elements.concurrencyHint, + elements.autoRegistrationIntervalGroup + ); + elements.autoRegistrationEmailServiceId.dataset.selectedId = String(reg.auto_email_service_id || 0); + elements.autoRegistrationCpaServiceId.dataset.selectedId = String(reg.auto_cpa_service_id || 0); + populateAutoRegistrationEmailServiceOptions(reg.auto_email_service_id || 0); + } catch (error) { + console.error('加载自动注册设置失败:', error); + } +} + +async function loadAutoRegistrationCpaOptions() { + if (!elements.autoRegistrationCpaServiceId) return; + try { + const services = await api.get('/cpa-services?enabled=true'); + const options = ['']; + services.forEach(service => { + options.push(``); + }); + elements.autoRegistrationCpaServiceId.innerHTML = options.join(''); + elements.autoRegistrationCpaServiceId.value = elements.autoRegistrationCpaServiceId.dataset.selectedId || '0'; + } catch (error) { + console.error('加载 CPA 服务失败:', error); + } +} + +function populateAutoRegistrationEmailServiceOptions(selectedId = 0) { + if (!elements.autoRegistrationEmailServiceId || !elements.autoRegistrationEmailServiceType) return; + const selectedType = elements.autoRegistrationEmailServiceType.value || 'tempmail'; + const options = ['']; + const bucket = availableServices[selectedType]; + if (bucket && Array.isArray(bucket.services)) { + bucket.services.forEach(service => { + options.push(``); + }); + } + elements.autoRegistrationEmailServiceId.innerHTML = options.join(''); + elements.autoRegistrationEmailServiceId.value = String(selectedId || elements.autoRegistrationEmailServiceId.dataset.selectedId || 0); +} + +async function handleSaveAutoRegistration() { + const autoCheckInterval = parseInt(elements.autoRegistrationCheckInterval.value, 10) || 60; + const autoMinReady = parseInt(elements.autoRegistrationMinReady.value, 10) || 1; + const autoEmailServiceId = parseInt(elements.autoRegistrationEmailServiceId.value, 10) || 0; + const autoConcurrency = parseInt(elements.autoRegistrationConcurrency.value, 10) || 1; + const autoIntervalMin = parseInt(elements.autoRegistrationIntervalMin.value, 10) || 0; + const autoIntervalMax = parseInt(elements.autoRegistrationIntervalMax.value, 10) || 0; + const autoCpaServiceId = parseInt(elements.autoRegistrationCpaServiceId.value, 10) || 0; + + if (autoCheckInterval < 5 || autoCheckInterval > 3600) { + toast.error('自动注册检查间隔必须在 5-3600 秒之间'); + return; + } + if (autoMinReady < 1 || autoMinReady > 10000) { + toast.error('自动注册保底数量必须在 1-10000 之间'); + return; + } + if (autoIntervalMin < 0 || autoIntervalMax < autoIntervalMin) { + toast.error('自动注册启动间隔参数无效'); + return; + } + if (autoConcurrency < 1 || autoConcurrency > 100) { + toast.error('自动注册并发数必须在 1-100 之间'); + return; + } + if (elements.autoRegistrationEnabled.checked && autoCpaServiceId <= 0) { + toast.error('启用自动注册前请先选择一个 CPA 服务'); + return; + } + + const data = await api.get('/settings'); + const reg = data.registration || {}; + const payload = { + max_retries: reg.max_retries || 3, + timeout: reg.timeout || 120, + default_password_length: reg.default_password_length || 12, + entry_flow: reg.entry_flow || 'native', + sleep_min: reg.sleep_min || 5, + sleep_max: reg.sleep_max || 30, + auto_enabled: elements.autoRegistrationEnabled.checked, + auto_check_interval: autoCheckInterval, + auto_min_ready_auth_files: autoMinReady, + auto_email_service_type: elements.autoRegistrationEmailServiceType.value, + auto_email_service_id: autoEmailServiceId, + auto_proxy: elements.autoRegistrationProxy.value.trim(), + auto_interval_min: autoIntervalMin, + auto_interval_max: autoIntervalMax, + auto_concurrency: autoConcurrency, + auto_mode: elements.autoRegistrationMode.value, + auto_cpa_service_id: autoCpaServiceId, + }; + + await api.post('/settings/registration', payload); + toast.success('自动注册设置已保存'); + + if (elements.autoRegistrationEnabled.checked) { + sessionStorage.setItem('activeTask', JSON.stringify({ mode: 'auto' })); + autoMonitorLastLogIndex = 0; + displayedLogs.clear(); + elements.consoleLog.innerHTML = ''; + addLog('info', '[系统] 自动注册监控已启动'); + startAutoRegistrationMonitor(); + } else { + stopAutoRegistrationMonitor(); + const saved = sessionStorage.getItem('activeTask'); + if (saved) { + try { + const parsed = JSON.parse(saved); + if (parsed.mode === 'auto') { + sessionStorage.removeItem('activeTask'); + } + } catch { + sessionStorage.removeItem('activeTask'); + } + } + addLog('info', '[系统] 自动注册已禁用'); + } +} + // 单次注册 async function handleSingleRegistration(requestData) { // 重置任务状态 @@ -539,24 +1198,105 @@ 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 { - webSocket = new WebSocket(wsUrl); + const socket = new WebSocket(wsUrl); + webSocket = socket; - webSocket.onopen = () => { + socket.onopen = () => { + if (webSocket !== socket) return; console.log('WebSocket 连接成功'); useWebSocket = true; + clearWebSocketReconnect(); // 停止轮询(如果有) stopLogPolling(); // 开始心跳 startWebSocketHeartbeat(); }; - webSocket.onmessage = (event) => { + socket.onmessage = (event) => { + if (webSocket !== socket) return; const data = JSON.parse(event.data); if (data.type === 'log') { @@ -604,43 +1344,52 @@ function connectWebSocket(taskUuid) { } }; - webSocket.onclose = (event) => { + socket.onclose = (event) => { + const isCurrentSocket = webSocket === socket; + if (isCurrentSocket) { + webSocket = null; + stopWebSocketHeartbeat(); + } + console.log('WebSocket 连接关闭:', event.code); - stopWebSocketHeartbeat(); - // 只有在任务未完成且最终状态不是完成状态时才切换到轮询 - // 使用 taskFinalStatus 而不是 currentTask.status,因为 currentTask 可能已被重置 - const shouldPoll = !taskCompleted && - taskFinalStatus === null; // 如果 taskFinalStatus 有值,说明任务已完成 + const shouldReconnect = isCurrentSocket && + !wsManualClose && + !taskCompleted && + taskFinalStatus === null && + activeTaskUuid === taskUuid; - if (shouldPoll && currentTask) { - console.log('切换到轮询模式'); + if (shouldReconnect) { + console.log('WebSocket 断开,准备自动重连'); useWebSocket = false; - startLogPolling(currentTask.task_uuid); + startLogPolling(taskUuid); + scheduleWebSocketReconnect(taskUuid); } }; - webSocket.onerror = (error) => { + socket.onerror = (error) => { + if (webSocket !== socket) return; 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) { - webSocket.close(); + const socket = webSocket; webSocket = null; + socket.close(); } } @@ -694,7 +1443,7 @@ async function handleBatchRegistration(requestData) { try { const data = await api.post('/registration/batch', requestData); - currentBatch = data; + currentBatch = { ...data, pollingMode: 'batch' }; activeBatchId = data.batch_id; // 保存用于重连 // 持久化到 sessionStorage,跨页面导航后可恢复 sessionStorage.setItem('activeTask', JSON.stringify({ batch_id: data.batch_id, mode: 'batch', total: data.count })); @@ -720,7 +1469,7 @@ async function handleCancelTask() { try { // 批量任务取消(包括普通批量模式和 Outlook 批量模式) - if (currentBatch && (isBatchMode || isOutlookBatchMode)) { + if (currentBatch && (isBatchMode || isOutlookBatchMode || isAutoMode)) { // 优先通过 WebSocket 取消 if (batchWebSocket && batchWebSocket.readyState === WebSocket.OPEN) { batchWebSocket.send(JSON.stringify({ type: 'cancel' })); @@ -735,8 +1484,10 @@ async function handleCancelTask() { await api.post(endpoint); addLog('warning', '[警告] 批量任务取消请求已提交'); toast.info('任务取消请求已提交'); - stopBatchPolling(); - resetButtons(); + if (!isAutoMode) { + stopBatchPolling(); + resetButtons(); + } } } // 单次任务取消 @@ -771,6 +1522,10 @@ async function handleCancelTask() { // 开始轮询日志 function startLogPolling(taskUuid) { + if (logPollingInterval) { + return; + } + let lastLogIndex = 0; logPollingInterval = setInterval(async () => { @@ -834,6 +1589,10 @@ function stopLogPolling() { // 开始轮询批量状态 function startBatchPolling(batchId) { + if (batchPollingInterval) { + return; + } + batchPollingInterval = setInterval(async () => { try { const data = await api.get(`/registration/batch/${batchId}`); @@ -879,6 +1638,12 @@ function showTaskStatus(task) { elements.taskId.textContent = task.task_uuid.substring(0, 8) + '...'; elements.taskEmail.textContent = '-'; elements.taskService.textContent = '-'; + if (elements.taskLastChecked) { + elements.taskLastChecked.textContent = '-'; + } + if (elements.taskInventory) { + elements.taskInventory.textContent = '-'; + } } // 更新任务状态 @@ -888,7 +1653,12 @@ function updateTaskStatus(status) { running: { text: '运行中', class: 'running' }, completed: { text: '已完成', class: 'completed' }, failed: { text: '失败', class: 'failed' }, - cancelled: { text: '已取消', class: 'disabled' } + cancelled: { text: '已取消', class: 'disabled' }, + checking: { text: '检查中', class: 'running' }, + idle: { text: '空闲', class: 'completed' }, + disabled: { text: '已禁用', class: 'disabled' }, + error: { text: '异常', class: 'failed' }, + cancelling: { text: '取消中', class: 'running' }, }; const info = statusInfo[status] || { text: status, class: '' }; @@ -900,8 +1670,8 @@ function updateTaskStatus(status) { // 显示批量状态 function showBatchStatus(batch) { elements.batchProgressSection.style.display = 'block'; - elements.taskStatusRow.style.display = 'none'; - elements.taskStatusBadge.style.display = 'none'; + elements.taskStatusRow.style.display = isAutoMode ? 'grid' : 'none'; + elements.taskStatusBadge.style.display = isAutoMode ? 'inline-flex' : 'none'; elements.batchProgressText.textContent = `0/${batch.count}`; elements.batchProgressPercent.textContent = '0%'; elements.progressBar.style.width = '0%'; @@ -914,6 +1684,113 @@ function showBatchStatus(batch) { elements.batchFailed.dataset.last = '0'; } +function formatAutoMonitorTimestamp(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString('zh-CN', { + hour12: false, + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} + +function updateAutoMonitorHeader(status, lastCheckedAt) { + if (!elements.autoMonitorStatusBadge || !elements.autoMonitorLastChecked) return; + + if (!isAutoMode) { + elements.autoMonitorStatusBadge.style.display = 'none'; + elements.autoMonitorLastChecked.style.display = 'none'; + return; + } + + const statusInfo = { + pending: { text: '自动等待', class: 'pending' }, + checking: { text: '自动检查中', class: 'running' }, + running: { text: '自动补货中', class: 'running' }, + idle: { text: '自动空闲', class: 'completed' }, + disabled: { text: '自动已禁用', class: 'disabled' }, + error: { text: '自动异常', class: 'failed' }, + cancelling: { text: '自动取消中', class: 'running' }, + }; + + const info = statusInfo[status] || { text: `自动${status || '未知'}`, class: 'pending' }; + elements.autoMonitorStatusBadge.style.display = 'inline-flex'; + elements.autoMonitorStatusBadge.textContent = info.text; + elements.autoMonitorStatusBadge.className = `status-badge ${info.class}`; + elements.autoMonitorLastChecked.style.display = 'inline'; + elements.autoMonitorLastChecked.textContent = `最近检查: ${formatAutoMonitorTimestamp(lastCheckedAt)}`; +} + +async function pollAutoRegistrationStatus() { + try { + const data = await api.get('/registration/auto-monitor'); + + elements.taskStatusRow.style.display = 'grid'; + elements.taskId.textContent = data.current_batch_id || 'auto-registration'; + elements.taskStatus.textContent = data.message || data.status || '-'; + if (elements.taskLastChecked) { + elements.taskLastChecked.textContent = formatAutoMonitorTimestamp(data.last_checked_at); + } + if (elements.taskInventory) { + const readyCount = data.current_ready_count ?? '-'; + const targetCount = data.target_ready_count ?? '-'; + elements.taskInventory.textContent = `${readyCount} / ${targetCount}`; + } + const effectiveStatus = data.batch && data.batch.cancelled && !data.batch.finished + ? 'cancelling' + : (data.status || 'pending'); + updateAutoMonitorHeader(effectiveStatus, data.last_checked_at); + updateTaskStatus(effectiveStatus); + + const logs = data.logs || []; + for (let i = autoMonitorLastLogIndex; i < logs.length; i++) { + addLog(getLogType(logs[i]), logs[i]); + } + autoMonitorLastLogIndex = logs.length; + + if (data.batch) { + currentBatch = data.batch; + activeBatchId = data.batch.batch_id; + batchCompleted = !!data.batch.finished; + elements.cancelBtn.disabled = !!data.batch.finished; + showBatchStatus({ count: data.batch.total }); + updateBatchProgress(data.batch); + if ((!batchWebSocket || batchWebSocket.readyState === WebSocket.CLOSED) && !data.batch.finished) { + connectBatchWebSocket(data.batch.batch_id); + } + } else { + currentBatch = null; + activeBatchId = null; + elements.cancelBtn.disabled = true; + elements.batchProgressSection.style.display = 'none'; + } + } catch (error) { + console.error('加载自动注册监控失败:', error); + updateAutoMonitorHeader('error', null); + elements.taskStatus.textContent = '自动注册监控获取失败'; + addLog('warning', '[警告] 自动注册监控获取失败'); + } +} + +function startAutoRegistrationMonitor() { + stopAutoRegistrationMonitor(); + pollAutoRegistrationStatus(); + autoMonitorPollingInterval = setInterval(() => { + pollAutoRegistrationStatus(); + }, 2000); +} + +function stopAutoRegistrationMonitor() { + if (autoMonitorPollingInterval) { + clearInterval(autoMonitorPollingInterval); + autoMonitorPollingInterval = null; + } +} + // 更新批量进度 function updateBatchProgress(data) { const progress = ((data.completed / data.total) * 100).toFixed(0); @@ -1140,7 +2017,14 @@ function getLogType(log) { // 重置按钮状态 function resetButtons() { elements.startBtn.disabled = false; - elements.cancelBtn.disabled = true; + elements.cancelBtn.disabled = isAutoMode; + stopLogPolling(); + stopBatchPolling(); + if (!isAutoMode) { + stopAutoRegistrationMonitor(); + } + clearWebSocketReconnect(); + clearBatchWebSocketReconnect(); currentTask = null; currentBatch = null; isBatchMode = false; @@ -1154,7 +2038,9 @@ function resetButtons() { activeTaskUuid = null; activeBatchId = null; // 清除 sessionStorage 持久化状态 - sessionStorage.removeItem('activeTask'); + if (!isAutoMode) { + sessionStorage.removeItem('activeTask'); + } // 断开 WebSocket disconnectWebSocket(); disconnectBatchWebSocket(); @@ -1274,6 +2160,7 @@ async function handleOutlookBatchRegistration() { const requestData = { service_ids: selectedIds, skip_registered: skipRegistered, + registration_type: elements.registrationType ? elements.registrationType.value : 'child', interval_min: intervalMin, interval_max: intervalMax, concurrency: Math.min(50, Math.max(1, concurrency)), @@ -1284,6 +2171,8 @@ async function handleOutlookBatchRegistration() { sub2api_service_ids: elements.autoUploadSub2api && elements.autoUploadSub2api.checked ? getSelectedServiceIds(elements.sub2apiServiceSelect) : [], auto_upload_tm: elements.autoUploadTm ? elements.autoUploadTm.checked : false, tm_service_ids: elements.autoUploadTm && elements.autoUploadTm.checked ? getSelectedServiceIds(elements.tmServiceSelect) : [], + auto_upload_new_api: elements.autoUploadNewApi ? elements.autoUploadNewApi.checked : false, + new_api_service_ids: elements.autoUploadNewApi && elements.autoUploadNewApi.checked ? getSelectedServiceIds(elements.newApiServiceSelect) : [], }; addLog('info', `[系统] 正在启动 Outlook 批量注册 (${selectedIds.length} 个账户)...`); @@ -1298,7 +2187,7 @@ async function handleOutlookBatchRegistration() { return; } - currentBatch = { batch_id: data.batch_id, ...data }; + currentBatch = { batch_id: data.batch_id, ...data, pollingMode: 'outlook_batch' }; activeBatchId = data.batch_id; // 保存用于重连 // 持久化到 sessionStorage,跨页面导航后可恢复 sessionStorage.setItem('activeTask', JSON.stringify({ batch_id: data.batch_id, mode: isOutlookBatchMode ? 'outlook_batch' : 'batch', total: data.to_register })); @@ -1322,21 +2211,37 @@ async function handleOutlookBatchRegistration() { // 连接批量任务 WebSocket function connectBatchWebSocket(batchId) { + activeBatchId = batchId; + + if (batchWebSocket && [WebSocket.OPEN, WebSocket.CONNECTING].includes(batchWebSocket.readyState)) { + return; + } + + if (batchWsReconnectTimer) { + clearTimeout(batchWsReconnectTimer); + batchWsReconnectTimer = null; + } + batchWsManualClose = false; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/ws/batch/${batchId}`; try { - batchWebSocket = new WebSocket(wsUrl); + const socket = new WebSocket(wsUrl); + batchWebSocket = socket; - batchWebSocket.onopen = () => { + socket.onopen = () => { + if (batchWebSocket !== socket) return; console.log('批量任务 WebSocket 连接成功'); + clearBatchWebSocketReconnect(); // 停止轮询(如果有) stopBatchPolling(); // 开始心跳 startBatchWebSocketHeartbeat(); }; - batchWebSocket.onmessage = (event) => { + socket.onmessage = (event) => { + if (batchWebSocket !== socket) return; const data = JSON.parse(event.data); if (data.type === 'log') { @@ -1389,40 +2294,49 @@ function connectBatchWebSocket(batchId) { } }; - batchWebSocket.onclose = (event) => { + socket.onclose = (event) => { + const isCurrentSocket = batchWebSocket === socket; + if (isCurrentSocket) { + batchWebSocket = null; + stopBatchWebSocketHeartbeat(); + } + console.log('批量任务 WebSocket 连接关闭:', event.code); - stopBatchWebSocketHeartbeat(); - // 只有在任务未完成且最终状态不是完成状态时才切换到轮询 - // 使用 batchFinalStatus 而不是 currentBatch.status,因为 currentBatch 可能已被重置 - const shouldPoll = !batchCompleted && - batchFinalStatus === null; // 如果 batchFinalStatus 有值,说明任务已完成 + const shouldReconnect = isCurrentSocket && + !batchWsManualClose && + !batchCompleted && + batchFinalStatus === null && + activeBatchId === batchId; - if (shouldPoll && currentBatch) { - console.log('切换到轮询模式'); - startOutlookBatchPolling(currentBatch.batch_id); + if (shouldReconnect) { + console.log('批量任务 WebSocket 断开,准备自动重连'); + startCurrentBatchPolling(batchId); + scheduleBatchWebSocketReconnect(batchId); } }; - batchWebSocket.onerror = (error) => { + socket.onerror = (error) => { + if (batchWebSocket !== socket) return; console.error('批量任务 WebSocket 错误:', error); - stopBatchWebSocketHeartbeat(); - // 切换到轮询 - startOutlookBatchPolling(batchId); }; } catch (error) { console.error('批量任务 WebSocket 连接失败:', error); - startOutlookBatchPolling(batchId); + startCurrentBatchPolling(batchId); + scheduleBatchWebSocketReconnect(batchId); } } // 断开批量任务 WebSocket function disconnectBatchWebSocket() { + batchWsManualClose = true; + clearBatchWebSocketReconnect(); stopBatchWebSocketHeartbeat(); if (batchWebSocket) { - batchWebSocket.close(); + const socket = batchWebSocket; batchWebSocket = null; + socket.close(); } } @@ -1453,6 +2367,10 @@ function cancelBatchViaWebSocket() { // 开始轮询 Outlook 批量状态(降级方案) function startOutlookBatchPolling(batchId) { + if (batchPollingInterval) { + return; + } + batchPollingInterval = setInterval(async () => { try { const data = await api.get(`/registration/outlook-batch/${batchId}`); @@ -1524,6 +2442,11 @@ function initVisibilityReconnect() { addLog('info', '[系统] 页面重新激活,正在重连批量任务监控...'); connectBatchWebSocket(activeBatchId); } + + if (isAutoMode && !autoMonitorPollingInterval) { + addLog('info', '[系统] 页面重新激活,正在恢复自动注册监控...'); + startAutoRegistrationMonitor(); + } }); } @@ -1578,7 +2501,7 @@ async function restoreActiveTask() { return; } // 批量任务仍在运行,恢复状态 - currentBatch = { batch_id, ...data }; + currentBatch = { batch_id, ...data, pollingMode: mode }; activeBatchId = batch_id; isOutlookBatchMode = (mode === 'outlook_batch'); batchCompleted = false; @@ -1594,6 +2517,8 @@ async function restoreActiveTask() { } catch { sessionStorage.removeItem('activeTask'); } + } else if (mode === 'auto') { + sessionStorage.removeItem('activeTask'); } } 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 9583b2b5..be426a16 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,6 +41,11 @@ const elements = { tempmailApi: document.getElementById('tempmail-api'), tempmailEnabled: document.getElementById('tempmail-enabled'), testTempmailBtn: document.getElementById('test-tempmail-btn'), + yydsApi: document.getElementById('yyds-api'), + yydsApiKey: document.getElementById('yyds-api-key'), + yydsDefaultDomain: document.getElementById('yyds-default-domain'), + yydsEnabled: document.getElementById('yyds-enabled'), + testYydsBtn: document.getElementById('test-yyds-btn'), // 添加自定义域名模态框 addCustomModal: document.getElementById('add-custom-modal'), @@ -49,8 +54,11 @@ const elements = { cancelAddCustom: document.getElementById('cancel-add-custom'), customSubType: document.getElementById('custom-sub-type'), addMoemailFields: document.getElementById('add-moemail-fields'), + addTempmailBuiltinFields: document.getElementById('add-tempmail-builtin-fields'), + addYydsFields: document.getElementById('add-yyds-fields'), addTempmailFields: document.getElementById('add-tempmail-fields'), addDuckmailFields: document.getElementById('add-duckmail-fields'), + addLuckmailFields: document.getElementById('add-luckmail-fields'), addFreemailFields: document.getElementById('add-freemail-fields'), addImapFields: document.getElementById('add-imap-fields'), @@ -60,8 +68,11 @@ const elements = { closeEditCustomModal: document.getElementById('close-edit-custom-modal'), cancelEditCustom: document.getElementById('cancel-edit-custom'), editMoemailFields: document.getElementById('edit-moemail-fields'), + editTempmailBuiltinFields: document.getElementById('edit-tempmail-builtin-fields'), + editYydsFields: document.getElementById('edit-yyds-fields'), editTempmailFields: document.getElementById('edit-tempmail-fields'), editDuckmailFields: document.getElementById('edit-duckmail-fields'), + editLuckmailFields: document.getElementById('edit-luckmail-fields'), editFreemailFields: document.getElementById('edit-freemail-fields'), editImapFields: document.getElementById('edit-imap-fields'), editCustomTypeBadge: document.getElementById('edit-custom-type-badge'), @@ -76,8 +87,12 @@ const elements = { const CUSTOM_SUBTYPE_LABELS = { moemail: '🔗 MoeMail(自定义域名 API)', + tempmail_builtin: 'Tempmail.lol(官方渠道)', + yyds_mail: 'YYDS Mail(官方渠道)', tempmail: '📮 TempMail(自部署 Cloudflare Worker)', + cloudmail: '☁️ CloudMail(自部署 Cloudflare Worker)', duckmail: '🦆 DuckMail(DuckMail API)', + luckmail: 'LuckMail(接码平台)', freemail: 'Freemail(自部署 Cloudflare Worker)', imap: '📧 IMAP 邮箱(Gmail/QQ/163等)' }; @@ -159,6 +174,7 @@ function initEventListeners() { // 临时邮箱配置 elements.tempmailForm.addEventListener('submit', handleSaveTempmail); elements.testTempmailBtn.addEventListener('click', handleTestTempmail); + elements.testYydsBtn.addEventListener('click', handleTestYyds); // 点击其他地方关闭更多菜单 document.addEventListener('click', () => { @@ -182,8 +198,11 @@ function closeEmailMoreMenu(el) { function switchAddSubType(subType) { elements.customSubType.value = subType; elements.addMoemailFields.style.display = subType === 'moemail' ? '' : 'none'; - elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; + elements.addTempmailBuiltinFields.style.display = subType === 'tempmail_builtin' ? '' : 'none'; + elements.addYydsFields.style.display = subType === 'yyds_mail' ? '' : 'none'; + elements.addTempmailFields.style.display = (subType === 'tempmail' || subType === 'cloudmail') ? '' : 'none'; elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; + elements.addLuckmailFields.style.display = subType === 'luckmail' ? '' : 'none'; elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; elements.addImapFields.style.display = subType === 'imap' ? '' : 'none'; } @@ -192,8 +211,11 @@ function switchAddSubType(subType) { function switchEditSubType(subType) { elements.editCustomSubTypeHidden.value = subType; elements.editMoemailFields.style.display = subType === 'moemail' ? '' : 'none'; - elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; + elements.editTempmailBuiltinFields.style.display = subType === 'tempmail_builtin' ? '' : 'none'; + elements.editYydsFields.style.display = subType === 'yyds_mail' ? '' : 'none'; + elements.editTempmailFields.style.display = (subType === 'tempmail' || subType === 'cloudmail') ? '' : 'none'; elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; + elements.editLuckmailFields.style.display = subType === 'luckmail' ? '' : 'none'; elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; elements.editImapFields.style.display = subType === 'imap' ? '' : 'none'; elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail; @@ -204,7 +226,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.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.tempmail_builtin_count || 0) + (data.yyds_mail_count || 0) + (data.temp_mail_count || 0) + (data.cloudmail_count || 0) + (data.duck_mail_count || 0) + (data.luckmail_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) { @@ -221,7 +243,7 @@ async function loadOutlookServices() { if (outlookServices.length === 0) { elements.outlookTable.innerHTML = ` - +
📭
暂无 Outlook 账户
@@ -240,9 +262,7 @@ async function loadOutlookServices() { ${getOutlookAuthBadge(service)} - - ${getOutlookRegistrationBadge(service)} - + ${getOutlookRegistrationBadge(service)} ${service.enabled ? '✅' : '⭕'} ${service.priority} ${format.date(service.last_used)} @@ -261,6 +281,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); @@ -272,7 +293,7 @@ async function loadOutlookServices() { } catch (error) { console.error('加载 Outlook 服务失败:', error); - elements.outlookTable.innerHTML = `
加载失败
`; + elements.outlookTable.innerHTML = `
加载失败
`; } } @@ -298,12 +319,24 @@ function getCustomServiceTypeBadge(subType) { if (subType === 'moemail') { return 'MoeMail'; } + if (subType === 'tempmail_builtin') { + return 'Tempmail.lol'; + } + if (subType === 'yyds_mail') { + return 'YYDS Mail'; + } if (subType === 'tempmail') { return 'TempMail'; } + if (subType === 'cloudmail') { + return 'CloudMail'; + } if (subType === 'duckmail') { return 'DuckMail'; } + if (subType === 'luckmail') { + return 'LuckMail'; + } if (subType === 'freemail') { return 'Freemail'; } @@ -316,6 +349,28 @@ function getCustomServiceAddress(service) { const emailAddr = service.config?.email || ''; return `${escapeHtml(host)}
${escapeHtml(emailAddr)}
`; } + if (service._subType === 'tempmail_builtin') { + const baseUrl = service.config?.base_url || '-'; + const timeout = service.config?.timeout || 30; + return `${escapeHtml(baseUrl)}
超时:${escapeHtml(String(timeout))} 秒
`; + } + if (service._subType === 'yyds_mail') { + const baseUrl = service.config?.base_url || '-'; + const domain = service.config?.default_domain || ''; + if (!domain) { + return escapeHtml(baseUrl); + } + return `${escapeHtml(baseUrl)}
默认域名:@${escapeHtml(domain)}
`; + } + if (service._subType === 'luckmail') { + const baseUrl = service.config?.base_url || '-'; + const projectCode = service.config?.project_code || 'openai'; + const preferredDomain = service.config?.preferred_domain || ''; + const detail = preferredDomain + ? `项目:${escapeHtml(projectCode)} / 优先域名:${escapeHtml(preferredDomain)}` + : `项目:${escapeHtml(projectCode)}`; + return `${escapeHtml(baseUrl)}
${detail}
`; + } const baseUrl = service.config?.base_url || '-'; const domain = service.config?.default_domain || service.config?.domain; if (!domain) { @@ -324,22 +379,30 @@ 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] = await Promise.all([ + const [r1, r2, r3, r4, r5, r6, r7, r8, r9] = await Promise.all([ api.get('/email-services?service_type=moe_mail'), + api.get('/email-services?service_type=tempmail'), + 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=luckmail'), 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: 'tempmail' })), - ...(r3.services || []).map(s => ({ ...s, _subType: 'duckmail' })), - ...(r4.services || []).map(s => ({ ...s, _subType: 'freemail' })), - ...(r5.services || []).map(s => ({ ...s, _subType: 'imap' })) + ...(r2.services || []).map(s => ({ ...s, _subType: 'tempmail_builtin' })), + ...(r3.services || []).map(s => ({ ...s, _subType: 'yyds_mail' })), + ...(r4.services || []).map(s => ({ ...s, _subType: 'tempmail' })), + ...(r5.services || []).map(s => ({ ...s, _subType: 'cloudmail' })), + ...(r6.services || []).map(s => ({ ...s, _subType: 'duckmail' })), + ...(r7.services || []).map(s => ({ ...s, _subType: 'luckmail' })), + ...(r8.services || []).map(s => ({ ...s, _subType: 'freemail' })), + ...(r9.services || []).map(s => ({ ...s, _subType: 'imap' })) ]; if (customServices.length === 0) { @@ -364,6 +427,7 @@ async function loadCustomServices() { ${escapeHtml(service.name)} ${getCustomServiceTypeBadge(service._subType)} ${getCustomServiceAddress(service)} + -- ${service.enabled ? '✅' : '⭕'} ${service.priority} ${format.date(service.last_used)} @@ -404,6 +468,13 @@ async function loadTempmailConfig() { elements.tempmailApi.value = settings.tempmail.api_url || ''; elements.tempmailEnabled.checked = settings.tempmail.enabled !== false; } + if (settings.yyds_mail) { + elements.yydsApi.value = settings.yyds_mail.api_url || ''; + elements.yydsDefaultDomain.value = settings.yyds_mail.default_domain || ''; + elements.yydsEnabled.checked = settings.yyds_mail.enabled === true; + elements.yydsApiKey.value = ''; + elements.yydsApiKey.placeholder = settings.yyds_mail.has_api_key ? '已设置,留空保持不变' : '请输入 YYDS Mail API Key'; + } } catch (error) { // 忽略错误 } @@ -461,8 +532,24 @@ async function handleAddCustom(e) { api_key: formData.get('api_key'), default_domain: formData.get('domain') }; - } else if (subType === 'tempmail') { - serviceType = 'temp_mail'; + } else if (subType === 'tempmail_builtin') { + serviceType = 'tempmail'; + config = { + base_url: formData.get('tpo_base_url'), + timeout: parseInt(formData.get('tpo_timeout'), 10) || 30, + max_retries: parseInt(formData.get('tpo_max_retries'), 10) || 3 + }; + } else if (subType === 'yyds_mail') { + serviceType = 'yyds_mail'; + config = { + base_url: formData.get('yyds_base_url'), + api_key: formData.get('yyds_api_key'), + default_domain: formData.get('yyds_default_domain'), + timeout: parseInt(formData.get('yyds_timeout'), 10) || 30, + max_retries: parseInt(formData.get('yyds_max_retries'), 10) || 3 + }; + } 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'), @@ -477,6 +564,15 @@ async function handleAddCustom(e) { default_domain: formData.get('dm_domain'), password_length: parseInt(formData.get('dm_password_length'), 10) || 12 }; + } else if (subType === 'luckmail') { + serviceType = 'luckmail'; + config = { + base_url: formData.get('lm_base_url'), + api_key: formData.get('lm_api_key'), + project_code: formData.get('lm_project_code') || 'openai', + email_type: formData.get('lm_email_type') || 'ms_graph', + preferred_domain: formData.get('lm_preferred_domain') + }; } else if (subType === 'freemail') { serviceType = 'freemail'; config = { @@ -579,10 +675,18 @@ async function handleBatchDeleteOutlook() { async function handleSaveTempmail(e) { e.preventDefault(); try { - await api.post('/settings/tempmail', { + const payload = { api_url: elements.tempmailApi.value, - enabled: elements.tempmailEnabled.checked - }); + enabled: elements.tempmailEnabled.checked, + yyds_api_url: elements.yydsApi.value, + yyds_default_domain: elements.yydsDefaultDomain.value, + yyds_enabled: elements.yydsEnabled.checked + }; + const yydsApiKey = elements.yydsApiKey.value.trim(); + if (yydsApiKey) { + payload.yyds_api_key = yydsApiKey; + } + await api.post('/settings/tempmail', payload); toast.success('配置已保存'); } catch (error) { toast.error('保存失败: ' + error.message); @@ -592,7 +696,7 @@ async function handleSaveTempmail(e) { // 测试临时邮箱 async function handleTestTempmail() { elements.testTempmailBtn.disabled = true; - elements.testTempmailBtn.textContent = '测试中...'; + elements.testTempmailBtn.textContent = '测试 Tempmail.lol 中...'; try { const result = await api.post('/email-services/test-tempmail', { api_url: elements.tempmailApi.value @@ -603,7 +707,30 @@ async function handleTestTempmail() { toast.error('测试失败: ' + error.message); } finally { elements.testTempmailBtn.disabled = false; - elements.testTempmailBtn.textContent = '🔌 测试连接'; + elements.testTempmailBtn.textContent = '🔌 测试 Tempmail.lol'; + } +} + +async function handleTestYyds() { + elements.testYydsBtn.disabled = true; + elements.testYydsBtn.textContent = '测试 YYDS Mail 中...'; + try { + const payload = { + provider: 'yyds_mail', + api_url: elements.yydsApi.value + }; + const apiKey = elements.yydsApiKey.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 || result.message || '未知错误')); + } catch (error) { + toast.error('测试失败: ' + error.message); + } finally { + elements.testYydsBtn.disabled = false; + elements.testYydsBtn.textContent = '🔌 测试 YYDS Mail'; } } @@ -629,10 +756,18 @@ async function editCustomService(id, subType) { try { const service = await api.get(`/email-services/${id}/full`); const resolvedSubType = subType || ( - service.service_type === 'temp_mail' + service.service_type === 'tempmail' + ? 'tempmail_builtin' + : service.service_type === 'yyds_mail' + ? 'yyds_mail' + : service.service_type === 'temp_mail' ? 'tempmail' + : service.service_type === 'cloudmail' + ? 'cloudmail' : service.service_type === 'duck_mail' ? 'duckmail' + : service.service_type === 'luckmail' + ? 'luckmail' : service.service_type === 'freemail' ? 'freemail' : service.service_type === 'imap_mail' @@ -652,7 +787,18 @@ 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 === 'tempmail') { + } else if (resolvedSubType === 'tempmail_builtin') { + document.getElementById('edit-tpo-base-url').value = service.config?.base_url || ''; + document.getElementById('edit-tpo-timeout').value = service.config?.timeout || 30; + document.getElementById('edit-tpo-max-retries').value = service.config?.max_retries || 3; + } else if (resolvedSubType === 'yyds_mail') { + 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 ? '已设置,留空保持不变' : '请输入 API Key'; + document.getElementById('edit-yyds-default-domain').value = service.config?.default_domain || ''; + document.getElementById('edit-yyds-timeout').value = service.config?.timeout || 30; + document.getElementById('edit-yyds-max-retries').value = service.config?.max_retries || 3; + } 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 密码'; @@ -663,6 +809,13 @@ async function editCustomService(id, subType) { document.getElementById('edit-dm-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : '请输入 API Key(可选)'; document.getElementById('edit-dm-domain').value = service.config?.default_domain || ''; document.getElementById('edit-dm-password-length').value = service.config?.password_length || 12; + } else if (resolvedSubType === 'luckmail') { + document.getElementById('edit-lm-base-url').value = service.config?.base_url || ''; + document.getElementById('edit-lm-api-key').value = ''; + document.getElementById('edit-lm-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : '请输入 API Key'; + document.getElementById('edit-lm-project-code').value = service.config?.project_code || 'openai'; + document.getElementById('edit-lm-email-type').value = service.config?.email_type || 'ms_graph'; + document.getElementById('edit-lm-preferred-domain').value = service.config?.preferred_domain || ''; } else if (resolvedSubType === 'freemail') { document.getElementById('edit-fm-base-url').value = service.config?.base_url || ''; document.getElementById('edit-fm-admin-token').value = ''; @@ -698,7 +851,22 @@ async function handleEditCustom(e) { }; const apiKey = formData.get('api_key'); if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); - } else if (subType === 'tempmail') { + } else if (subType === 'tempmail_builtin') { + config = { + base_url: formData.get('tpo_base_url'), + timeout: parseInt(formData.get('tpo_timeout'), 10) || 30, + max_retries: parseInt(formData.get('tpo_max_retries'), 10) || 3 + }; + } else if (subType === 'yyds_mail') { + config = { + base_url: formData.get('yyds_base_url'), + default_domain: formData.get('yyds_default_domain'), + timeout: parseInt(formData.get('yyds_timeout'), 10) || 30, + max_retries: parseInt(formData.get('yyds_max_retries'), 10) || 3 + }; + const apiKey = formData.get('yyds_api_key'); + if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); + } else if (subType === 'tempmail' || subType === 'cloudmail') { config = { base_url: formData.get('tm_base_url'), domain: formData.get('tm_domain'), @@ -714,6 +882,15 @@ async function handleEditCustom(e) { }; const apiKey = formData.get('dm_api_key'); if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); + } else if (subType === 'luckmail') { + config = { + base_url: formData.get('lm_base_url'), + project_code: formData.get('lm_project_code') || 'openai', + email_type: formData.get('lm_email_type') || 'ms_graph', + preferred_domain: formData.get('lm_preferred_domain') + }; + const apiKey = formData.get('lm_api_key'); + if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); } else if (subType === 'freemail') { config = { base_url: formData.get('fm_base_url'), 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..95a0e508 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'), @@ -68,12 +75,20 @@ const elements = { tmServiceForm: document.getElementById('tm-service-form'), tmServiceModalTitle: document.getElementById('tm-service-modal-title'), testTmServiceBtn: document.getElementById('test-tm-service-btn'), + addNewApiServiceBtn: document.getElementById('add-new-api-service-btn'), + newApiServicesTable: document.getElementById('new-api-services-table'), + newApiServiceEditModal: document.getElementById('new-api-service-edit-modal'), + closeNewApiServiceModal: document.getElementById('close-new-api-service-modal'), + cancelNewApiServiceBtn: document.getElementById('cancel-new-api-service-btn'), + newApiServiceForm: document.getElementById('new-api-service-form'), + newApiServiceModalTitle: document.getElementById('new-api-service-modal-title'), + testNewApiServiceBtn: document.getElementById('test-new-api-service-btn'), // 验证码设置 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 @@ -89,6 +104,7 @@ document.addEventListener('DOMContentLoaded', () => { loadCpaServices(); loadSub2ApiServices(); loadTmServices(); + loadNewApiServices(); initEventListeners(); }); @@ -211,6 +227,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 +254,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 +289,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) { @@ -278,6 +313,26 @@ function initEventListeners() { if (elements.testTmServiceBtn) { elements.testTmServiceBtn.addEventListener('click', handleTestTmService); } + if (elements.addNewApiServiceBtn) { + elements.addNewApiServiceBtn.addEventListener('click', () => openNewApiServiceModal()); + } + if (elements.closeNewApiServiceModal) { + elements.closeNewApiServiceModal.addEventListener('click', closeNewApiServiceModal); + } + if (elements.cancelNewApiServiceBtn) { + elements.cancelNewApiServiceBtn.addEventListener('click', closeNewApiServiceModal); + } + if (elements.newApiServiceEditModal) { + elements.newApiServiceEditModal.addEventListener('click', (e) => { + if (e.target === elements.newApiServiceEditModal) closeNewApiServiceModal(); + }); + } + if (elements.newApiServiceForm) { + elements.newApiServiceForm.addEventListener('submit', handleSaveNewApiService); + } + if (elements.testNewApiServiceBtn) { + elements.testNewApiServiceBtn.addEventListener('click', handleTestNewApiService); + } // CPA 服务管理 if (elements.addCpaServiceBtn) { @@ -354,12 +409,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 +431,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 +896,7 @@ async function loadProxies() { console.error('加载代理列表失败:', error); elements.proxiesTable.innerHTML = ` - +
加载失败
@@ -843,7 +912,7 @@ function renderProxies(proxies) { if (!proxies || proxies.length === 0) { elements.proxiesTable.innerHTML = ` - +
🌐
暂无代理
@@ -935,6 +1004,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 +1426,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 +1456,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 +1482,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 +1496,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 +1590,7 @@ async function loadSub2ApiServices() { renderSub2ApiServices(services); } catch (e) { if (elements.sub2ApiServicesTable) { - elements.sub2ApiServicesTable.innerHTML = '加载失败'; + elements.sub2ApiServicesTable.innerHTML = '加载失败'; } } } @@ -1449,13 +1598,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 +1627,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 +1669,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, }; @@ -1590,6 +1744,162 @@ async function handleTestSub2ApiService() { } } +async function loadNewApiServices() { + if (!elements.newApiServicesTable) return; + try { + const services = await api.get('/new-api-services'); + renderNewApiServicesTable(services); + } catch (e) { + elements.newApiServicesTable.innerHTML = `${e.message}`; + } +} + +function renderNewApiServicesTable(services) { + if (!services || services.length === 0) { + elements.newApiServicesTable.innerHTML = '暂无 new-api 服务,点击「添加服务」新增'; + return; + } + elements.newApiServicesTable.innerHTML = services.map(s => ` + + ${escapeHtml(s.name)} + ${escapeHtml(s.api_url)}
    ${escapeHtml(s.username || '')} + ${s.enabled ? '✅' : '⭕'} + ${s.priority} + + + + + + + `).join(''); +} + +function openNewApiServiceModal(service = null) { + document.getElementById('new-api-service-id').value = service ? service.id : ''; + document.getElementById('new-api-service-name').value = service ? service.name : ''; + document.getElementById('new-api-service-url').value = service ? service.api_url : ''; + document.getElementById('new-api-service-username').value = service ? (service.username || '') : ''; + document.getElementById('new-api-service-password').value = ''; + document.getElementById('new-api-service-priority').value = service ? service.priority : 0; + document.getElementById('new-api-service-enabled').checked = service ? service.enabled : true; + document.getElementById('new-api-service-password').placeholder = service && service.has_password ? '已配置,留空保持不变' : '请输入管理员密码'; + elements.newApiServiceModalTitle.textContent = service ? '编辑 new-api 服务' : '添加 new-api 服务'; + elements.newApiServiceEditModal.classList.add('active'); +} + +function closeNewApiServiceModal() { + elements.newApiServiceEditModal.classList.remove('active'); +} + +async function editNewApiService(id) { + try { + const service = await api.get(`/new-api-services/${id}`); + openNewApiServiceModal(service); + } catch (e) { + toast.error('获取服务信息失败: ' + e.message); + } +} + +async function handleSaveNewApiService(e) { + e.preventDefault(); + const id = document.getElementById('new-api-service-id').value; + const name = document.getElementById('new-api-service-name').value.trim(); + const apiUrl = document.getElementById('new-api-service-url').value.trim(); + const username = document.getElementById('new-api-service-username').value.trim(); + const password = document.getElementById('new-api-service-password').value.trim(); + const priority = parseInt(document.getElementById('new-api-service-priority').value) || 0; + const enabled = document.getElementById('new-api-service-enabled').checked; + + if (!name || !apiUrl || !username) { + toast.error('名称、API URL 和管理员用户名不能为空'); + return; + } + if (!id && !password) { + toast.error('新增服务时管理员密码不能为空'); + return; + } + + try { + const payload = { name, api_url: apiUrl, username, priority, enabled }; + if (password) payload.password = password; + + if (id) { + await api.patch(`/new-api-services/${id}`, payload); + toast.success('服务已更新'); + } else { + await api.post('/new-api-services', payload); + toast.success('服务已添加'); + } + closeNewApiServiceModal(); + loadNewApiServices(); + } catch (e) { + toast.error('保存失败: ' + e.message); + } +} + +async function deleteNewApiService(id, name) { + const confirmed = await confirm(`确定要删除 new-api 服务「${name}」吗?`); + if (!confirmed) return; + try { + await api.delete(`/new-api-services/${id}`); + toast.success('已删除'); + loadNewApiServices(); + } catch (e) { + toast.error('删除失败: ' + e.message); + } +} + +async function testNewApiServiceById(id) { + try { + const result = await api.post(`/new-api-services/${id}/test`); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (e) { + toast.error('测试失败: ' + e.message); + } +} + +async function handleTestNewApiService() { + const apiUrl = document.getElementById('new-api-service-url').value.trim(); + const username = document.getElementById('new-api-service-username').value.trim(); + const password = document.getElementById('new-api-service-password').value.trim(); + const id = document.getElementById('new-api-service-id').value; + + if (!apiUrl || !username) { + toast.error('请先填写 API URL 和管理员用户名'); + return; + } + if (!id && !password) { + toast.error('请先填写管理员密码'); + return; + } + + elements.testNewApiServiceBtn.disabled = true; + elements.testNewApiServiceBtn.textContent = '测试中...'; + + try { + let result; + if (id && !password) { + result = await api.post(`/new-api-services/${id}/test`); + } else { + result = await api.post('/new-api-services/test-connection', { api_url: apiUrl, username, password }); + } + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (e) { + toast.error('测试失败: ' + e.message); + } finally { + elements.testNewApiServiceBtn.disabled = false; + elements.testNewApiServiceBtn.textContent = '🔌 测试连接'; + } +} + function escapeHtml(text) { if (!text) return ''; const d = document.createElement('div'); diff --git a/static/js/utils.js b/static/js/utils.js index b7b5dab0..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; + // ============================================ // 事件委托助手 // ============================================ diff --git a/tags/v1.1.2.json b/tags/v1.1.2.json new file mode 100644 index 00000000..0f133601 --- /dev/null +++ b/tags/v1.1.2.json @@ -0,0 +1 @@ +{"tag_name": "v1.1.2", "commit": "3ab48fc79cbac31fdb90af12840fd6e97d63b480","date": "2026-03-26 21:31:26"} \ No newline at end of file diff --git a/templates/accounts.html b/templates/accounts.html index fa28f782..0ea5dff5 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -7,6 +7,32 @@ @@ -115,18 +256,18 @@

    OpenAI 注册系统

    - + @@ -154,6 +295,14 @@

    账号管理

    0
    失败账号
    +
    +
    0
    +
    母号账号
    +
    +
    +
    0
    +
    子号账号
    +
    @@ -175,13 +324,17 @@

    账号管理

    + +
    - @@ -191,6 +344,12 @@

    账号管理

    + +
    + @@ -340,7 +504,67 @@

    🔗 选择 Sub2API 服务

    + + + + {% include "partials/site_footer.html" %} + + + + diff --git a/templates/accounts_overview.html b/templates/accounts_overview.html index b0c603f6..51452f1b 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; } @@ -501,7 +514,7 @@ {% include "partials/site_notice.html" %} -
    +
    @@ -600,6 +616,7 @@

    Codex 账号管理

    + @@ -619,6 +636,10 @@

    Codex 账号管理

    +
    @@ -737,5 +758,11 @@

    一键导入账号

    + {% include "partials/site_footer.html" %} + + + + + diff --git a/templates/auto_team.html b/templates/auto_team.html index c15d2c4b..a422a080 100644 --- a/templates/auto_team.html +++ b/templates/auto_team.html @@ -3,22 +3,832 @@ - 自动进team - OpenAI 注册系统 + team - OpenAI 注册系统 @@ -32,27 +842,341 @@

    OpenAI 注册系统

    +
    -
    功能未开发!
    + +
    + + +
    + +
    +
    +
    +
    +

    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。
    +
    +
    +
    + +
    +
    + + + + {% include "partials/site_footer.html" %} + + + + diff --git a/templates/card_pool.html b/templates/card_pool.html index 90eb878b..a23c9d7b 100644 --- a/templates/card_pool.html +++ b/templates/card_pool.html @@ -6,19 +6,219 @@ 卡池 - OpenAI 注册系统 @@ -32,27 +232,213 @@

    OpenAI 注册系统

    +
    -
    功能未开发!
    + +
    + + +
    + +
    +
    +
    +
    0
    +
    兑换码总数
    +
    +
    +
    0
    +
    未使用
    +
    +
    +
    0
    +
    已使用
    +
    +
    +
    0
    +
    已过期
    +
    +
    + +
    +
    +
    +
    + + + + +
    + + +
    + + +
    +
    +
    + + + +
    +
    +
    + +
    +
    +
    + + + + + + + + + + + + + + + + + + + +
    兑换码供应商状态创建时间过期时间使用者邮箱使用时间操作
    暂无兑换码,请点击「导入」添加。
    +
    + +
    +
    +
    + +
    +
    + 信用卡分类已预留,暂不启用功能。 +
    +
    + + + + + + + + {% include "partials/site_footer.html" %} + + + + + diff --git a/templates/email_services.html b/templates/email_services.html index ad2480ef..6a9d7d6f 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -18,18 +18,18 @@

    OpenAI 注册系统

    - + @@ -98,7 +98,7 @@

    📥 Outlook 批量导入

    - + @@ -215,8 +238,12 @@

    ➕ 添加自定义邮箱服务

    @@ -236,6 +263,48 @@

    ➕ 添加自定义邮箱服务

    + + + + + + + + + + + + + + +
    +
    + 空行和以 # / // 开头的行会自动忽略 +
    +
    +
    + + +
    +
    + +
    +
    + +
    +
    + +
    + + +
    + +
    +
    + +
    @@ -228,13 +285,14 @@

    ☁️ CPA 服务

    名称 API URL + 代理地址 状态 优先级 操作 - 加载中... + 加载中...
    @@ -254,13 +312,14 @@

    🔗 Sub2API 服务

    名称 API URL + 目标类型 状态 优先级 操作 - 加载中... + 加载中... @@ -292,6 +351,31 @@

    🚀 Team Manager 服务

    + +
    +
    +

    🆕 new-api 服务

    + +
    +
    +
    + + + + + + + + + + + + + +
    名称API URL / 用户名状态优先级操作
    加载中...
    +
    +
    +
    @@ -339,6 +423,54 @@

    添加 Team Manager 服务

    + +