diff --git a/README.md b/README.md index 86c7c6fa..0bcb7dc0 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,52 @@ 基于 [cnlimiter/codex-manager](https://github.com/cnlimiter/codex-manager) 持续修复和维护的增强版本。 -这个版本的目标很直接: 把近期 OpenAI 注册链路里那些“昨天还能跑,今天突然翻车”的坑补上,让注册、登录、拿 token、打包运行都更稳一点。 +这个版本的目标很直接:把近期 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/tag/v1.1.2) + +## 项目地址 + +- GitHub Repo: [https://github.com/dou-jiang/codex-console](https://github.com/dou-jiang/codex-console) +- Blog: [https://blog.cysq8.cn/](https://blog.cysq8.cn/) ## QQ群 -- 交流群: https://qm.qq.com/q/ZTCKxawxeo +- 交流群: [291638849(点击加群)](https://qm.qq.com/q/4TETC3mWco) +- Telegram 频道: [codex_console](https://t.me/codex_console) + +## Blog 说明 + +我会在 Blog 持续更新这些内容: + +- 部署教程和环境配置说明 +- 每个版本的更新日志和 Release 说明 +- 常见报错、排查思路和修复记录 +- 邮箱服务、上传服务、任务调度、自检功能等使用说明 +- 上游变化后的兼容性调整说明 + +访问地址: + +- [https://blog.cysq8.cn/](https://blog.cysq8.cn/) + +## 赞助支持 + +如果这个项目对你有帮助,欢迎赞助支持项目继续维护与更新。 + + + + + + +
+ 微信赞助
+ 微信赞助二维码 +
+ 支付宝赞助
+ 支付宝赞助二维码 +
## 致谢 @@ -17,34 +55,136 @@ 本仓库是在原项目思路和结构之上进行兼容性修复、流程调整和体验优化,适合作为一个“当前可用的修复维护版”继续使用。 -## 这个分支修了什么 +## 版本更新 -为适配当前注册链路,这个分支重点补了下面几个问题: +### v1.0 -1. 新增 Sentinel POW 求解逻辑 +1. 新增 Sentinel POW 求解逻辑。 OpenAI 现在会强制校验 Sentinel POW,原先直接传空值已经不行了,这里补上了实际求解流程。 -2. 注册和登录拆成两段 +2. 注册和登录拆成两段。 现在注册完成后通常不会直接返回可用 token,而是跳转到绑定手机或后续页面。 本分支改成“先注册成功,再单独走一次登录流程拿 token”,避免卡死在旧逻辑里。 -3. 去掉重复发送验证码 +3. 去掉重复发送验证码。 登录流程里服务端本身会自动发送验证码邮件,旧逻辑再手动发一次,容易让新旧验证码打架。 现在改成直接等待系统自动发来的那封验证码邮件。 -4. 修复重新登录流程的页面判断问题 +4. 修复重新登录流程的页面判断问题。 针对重新登录时页面流转变化,调整了登录入口和密码提交逻辑,减少卡在错误页面的情况。 -5. 优化终端和 Web UI 提示文案 +5. 优化终端和 Web UI 提示文案。 保留可读性的前提下,把一些提示改得更友好一点,出错时至少不至于像在挨骂。 +### v1.1 + +1. 修复注册流程中的问题,解决 Outlook 和临时邮箱收不到邮件导致注册卡住、无法完成注册的问题。 + +2. 修复无法检查订阅状态的问题,提升订阅识别和状态检查的可用性。 + +3. 新增绑卡半自动模式,支持自动随机地址;3DS 无法跳过,需按实际流程完成验证。 + +4. 新增已订阅账号管理功能,支持查看和管理账号额度。 + +5. 新增后台日志功能,并补充数据导出与导入能力,方便排查问题和迁移数据。 + +6. 优化部分 UI 细节与交互体验,减少页面操作时的割裂感。 + +7. 补充细节稳定性处理,尽量减少注册、订阅检测和账号管理过程中出现卡住或误判的情况。 + +### v1.1.1 + +1. 新增 `CloudMail` 邮箱服务实现,并完成服务注册、配置接入、邮件轮询、验证码提取和基础收件处理能力。 + +2. 新增上传目标 `newApi` 支持,可根据配置选择不同导入目标类型。 + +3. 新增 `Codex` 账号导出格式,支持后续登录、迁移和导入使用。 + +4. 新增 `CPA` 认证文件 `proxy_url` 支持,现可在 CPA 服务配置中保存和使用代理地址。 + +5. 优化 OAuth token 刷新兼容逻辑,完善异常返回与一次性令牌场景处理,降低刷新报错概率。 + +6. 优化批量验证流程,改为受控并发执行,减少长时间阻塞和卡死问题。 + +7. 修复模板渲染兼容问题,提升不同 Starlette 版本下页面渲染稳定性。 + +8. 修复六位数字误判为 OTP 的问题,避免邮箱域名或无关文本中的六位数字被错误识别为验证码。 + +9. 新增 Outlook 账户“注册状态”识别与展示功能,可直接看到“已注册 / 未注册”,并支持显示关联账号编号。 + +10. 修复 Outlook 邮箱匹配大小写问题,避免 Outlook.com 因大小写差异被误判为未注册。 + +11. 修复 Outlook 列表列错位、乱码和占位文案问题,恢复中文显示并优化列表信息布局。 + +12. 优化 WebUI 端口冲突处理,默认端口占用时自动切换可用端口。 + +13. 增加启动时轻量字段迁移逻辑,自动补齐新增字段,提升旧数据升级兼容性。 + +14. 批量注册上限由 `100` 提升至 `1000`,前后端同步。 + +15. 公告区改为固定文案与固定链接,强化“永久免费开源、禁止倒卖、付费请退款”提示,并新增爱发电支持入口。 + +### v1.1.2 + +1. 新增统一鉴权与安全基线。 + 增加 `src/web/auth.py`、首次改密页面 `setup_password.html`,统一 API 与 WebSocket 鉴权口径,并补齐默认密码改密流程。 + +2. 新增系统自检能力与修复中心。 + 增加 `system_selfcheck.py`、`selfcheck.py`、`selfcheck.html`、`selfcheck_scheduler.py`,支持自检记录、调度与修复动作。 + +3. 新增统一任务中心。 + 增加 `tasks.py`,统一管理注册、支付、自检、Auto Team 等任务的状态、配额、暂停、继续、取消和重试。 + +4. 新增 Auto Team 模块。 + 增加后端 `auto_team.py` 与前端 `auto_team.js`、`auto_team_manage.js`。 + +5. 新增数据库迁移体系。 + 引入 Alembic,补齐 `alembic.ini`、`alembic/` 目录与迁移说明。 + +6. 新增母子标签系统和卡池功能。 + 补齐账号标签、池状态、自动绑卡与上游对接能力。 + +7. 扩展邮箱服务。 + 保留原有邮箱链路,同时补齐 `CloudMail`、`LuckMail`、`YYDS Mail` 等服务接入。 + +8. 新增周期任务调度。 + 支持计划任务创建、编辑、启停、立即执行以及前端轮询管理。 + +9. 新增 New-API 服务上传。 + 支持独立服务配置、测试、单账号上传、批量上传和注册成功后自动上传。 + +10. 增强自动注册与自动补货链路。 + 增加自动注册核心模块、库存监控、补货计划生成、取消感知、批次统计修复,以及 PR60 anyauto V2 回退流程。 + +11. 优化前端请求与轮询稳定性。 + 对 `app.js`、`accounts.js`、`payment.js`、`utils.js` 等进行了去重、降噪、超时、并发与降级路径增强。 + +12. 增强注册链路容错。 + `src/core/register.py` 增加登录续跑、continue URL 缓存、workspace 缓存、OTP 重试等处理,对 native / abcard / outlook 等入口做了细分。 + +13. 增强账号模型与业务语义。 + 增加 `role_tag`、`biz_tag`、`pool_state`、`priority`、`last_used_at` 等字段,支持团队池、候选池、阻断池等状态表达。 + +14. 增强支付与历史数据保留。 + `BindCardTask` 支持账号删除后保留 `account_email` 快照;新增审计日志模型 `OperationAuditLog` 与 `SelfCheckRun` 记录。 + +15. 扩展配置能力。 + `settings.py` 增加自检、熔断、自动刷新、自动注册等多项配置,支持更多运行时调参。 + +16. 补充测试与 CI。 + 新增多组测试:任务、安全、自注册、新 API、自动刷新、邮箱服务等,并新增 `.github/workflows/tests.yml`。 + +17. 修复自动一键刷新、公告样式与页面外链。 + 修复定时自动一键刷新不可用、公告按钮样式问题、Footer 外链和 Blog 跳转问题。 + ## 核心能力 -- Web UI 管理注册任务和账号数据 -- 支持批量注册、日志实时查看、基础任务管理 -- 支持多种邮箱服务接码 +- Web UI 管理注册任务、账号、支付、自检、邮箱服务、卡池、Auto Team 和日志数据 +- 支持单任务、批量任务、自动补货、计划任务、任务暂停 / 继续 / 取消 / 重试 +- 支持多种邮箱服务接码和自部署邮箱接入 +- 支持 CPA、Sub2API、Team Manager、New-API 等上传链路 - 支持 SQLite 和远程 PostgreSQL -- 支持打包为 Windows/Linux/macOS 可执行文件 +- 支持打包为 Windows / Linux / macOS 可执行文件 - 更适配当前 OpenAI 注册与登录链路 ## 环境要求 @@ -64,13 +204,13 @@ pip install -r requirements.txt ## 环境变量配置 -可选。复制 `.env.example` 为 `.env` 后按需修改: +可选。复制 `.env.example` 为 `.env` 后按需修改: ```bash cp .env.example .env ``` -常用变量如下: +常用变量如下: | 变量 | 说明 | 默认值 | | --- | --- | --- | @@ -79,7 +219,7 @@ cp .env.example .env | `APP_ACCESS_PASSWORD` | Web UI 访问密钥 | `admin123` | | `APP_DATABASE_URL` | 数据库连接字符串 | `data/database.db` | -优先级: +优先级: `命令行参数 > 环境变量(.env) > 数据库设置 > 默认值` @@ -102,19 +242,19 @@ python webui.py --access-password mypassword python webui.py --host 0.0.0.0 --port 8080 --access-password mypassword ``` -说明: +说明: - `--access-password` 的优先级高于数据库中的密钥设置 - 该参数只对本次启动生效 - 打包后的 exe 也支持这个参数 -例如: +例如: ```bash codex-console.exe --access-password mypassword ``` -启动后访问: +启动后访问: [http://127.0.0.1:8000](http://127.0.0.1:8000) @@ -149,7 +289,7 @@ docker run -d \ ghcr.io//codex-console:latest ``` -说明: +说明: - `WEBUI_HOST`: 监听主机,默认 `0.0.0.0` - `WEBUI_PORT`: 监听端口,默认 `1455` @@ -157,7 +297,7 @@ docker run -d \ - `DEBUG`: 设为 `1` 或 `true` 可开启调试模式 - `LOG_LEVEL`: 日志级别,例如 `info`、`debug` -注意: +注意: `-v $(pwd)/data:/app/data` 很重要,这会把数据库和账号数据持久化到宿主机。否则容器一重启,数据也可能跟着表演消失术。 @@ -180,13 +320,13 @@ build.bat bash build.sh ``` -Windows 打包完成后,默认会在 `dist/` 目录生成类似下面的文件: +Windows 打包完成后,默认会在 `dist/` 目录生成类似下面的文件: ```text dist/codex-console-windows-X64.exe ``` -如果打包失败,优先检查: +如果打包失败,优先检查: - Python 是否已加入 PATH - 依赖是否安装完整 @@ -195,13 +335,13 @@ dist/codex-console-windows-X64.exe ## 项目定位 -这个仓库更适合作为: +这个仓库更适合作为: - 原项目的修复增强版 - 当前注册链路的兼容维护版 - 自己二次开发的基础版本 -如果你准备公开发布,建议在仓库描述里明确写上: +如果你准备公开发布,建议在仓库描述里明确写上: `Forked and fixed from cnlimiter/codex-manager` @@ -209,15 +349,15 @@ dist/codex-console-windows-X64.exe ## 仓库命名 -当前仓库名: +当前仓库名: `codex-console` ## 安全基线说明(新增) -- `/api/*` 与 `/api/ws/*` 已统一接入登录鉴权。 -- 首次启动检测到默认口令或默认密钥时,会强制跳转到 `/setup-password` 完成改密。 -- 支付相关 API Key 不再使用代码内硬编码默认值,需通过环境变量或配置显式提供。 +- `/api/*` 与 `/api/ws/*` 已统一接入登录鉴权 +- 首次启动检测到默认口令或默认密钥时,会强制跳转到 `/setup-password` 完成改密 +- 支付相关 API Key 不再使用代码内硬编码默认值,需通过环境变量或配置显式提供 ## 数据库迁移(Alembic) diff --git a/docs/assets/alipay-pay.png b/docs/assets/alipay-pay.png new file mode 100644 index 00000000..0e23be51 Binary files /dev/null and b/docs/assets/alipay-pay.png differ diff --git a/docs/assets/wechat-pay.png b/docs/assets/wechat-pay.png new file mode 100644 index 00000000..2a4efd58 Binary files /dev/null and b/docs/assets/wechat-pay.png differ diff --git a/pyproject.toml b/pyproject.toml index 2bd705e6..0fc0b11b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-console" -version = "1.0.4" +version = "1.1.2" description = "OpenAI account management console" requires-python = ">=3.10" dependencies = [ diff --git a/release/v1.1.2.md b/release/v1.1.2.md new file mode 100644 index 00000000..6339a176 --- /dev/null +++ b/release/v1.1.2.md @@ -0,0 +1,41 @@ +# v1.1.2 + +发布日期:2026-03-31 + +## 本次新增 + +- 新增统一鉴权与安全基线,补齐 `src/web/auth.py`、首次改密页面、API 与 WebSocket 鉴权统一入口。 +- 新增系统自检能力、自检页面、自检调度器和修复中心。 +- 新增统一任务中心,统一管理注册、支付、自检、Auto Team 等任务状态。 +- 新增周期任务调度,支持计划任务创建、编辑、启停、立即执行与轮询管理。 +- 新增 New-API 服务配置与上传能力,支持单账号、批量与注册成功后自动上传。 +- 新增 Auto Team 后端接口与前端管理页面。 +- 新增卡池功能、自动绑卡能力与相关上游对接能力。 +- 新增 Alembic 数据库迁移体系。 +- 新增多组测试与 CI 工作流。 + +## 本次优化 + +- 优化前端请求、轮询、去重、超时与并发控制。 +- 优化自动注册补货与库存监控链路,增强任务取消感知、批次统计与监控刷新。 +- 优化注册链路容错,补齐 continue URL、workspace、OTP 重试和多入口处理。 +- 优化账号模型、支付历史快照、审计记录和配置能力。 +- 优化公告、Footer、外链跳转、页面样式与部分交互细节。 + +## 本次修复 + +- 修复自动补货首次执行后不再按间隔继续执行的问题。 +- 修复自动补货批次取消时子任务未正确同步取消的问题。 +- 修复并行模式与流水线模式下取消响应不及时、统计不准确的问题。 +- 修复监控页最近检查时间与库存未及时刷新的问题。 +- 修复注册页刷新后自动恢复监控的异常行为。 +- 修复定时自动一键刷新链路不可用的问题。 +- 修复公告按钮样式、Footer 外链与 Blog 跳转异常。 +- 修复多邮箱服务接入、CloudMail / LuckMail / YYDS Mail 等兼容问题。 + +## 文档更新 + +- README 已更新为完整的 `v1.1.2` 说明。 +- README 已补回历史版本更新记录:`v1.0`、`v1.1`、`v1.1.1`、`v1.1.2`。 +- README 已增加 Blog 地址与说明:[https://blog.cysq8.cn/](https://blog.cysq8.cn/) +- README 已增加赞助说明与微信、支付宝二维码。 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 22a9f0e5..d2dfb489 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -53,10 +53,12 @@ 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" @@ -117,7 +119,7 @@ def account_label_to_role_tag(account_label: str) -> str: # ============================================================================ APP_NAME = "OpenAI/Codex CLI 自动注册系统" -APP_VERSION = "2.0.0" +APP_VERSION = "1.1.2" APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统" # ============================================================================ @@ -248,7 +250,8 @@ def account_label_to_role_tag(account_label: str) -> str: ] # 密码生成 -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 3babe832..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="2.0.0", + default_value="1.1.2", category=SettingCategory.GENERAL, description="应用版本" ), @@ -316,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="邮箱服务优先级" ), @@ -330,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, @@ -342,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( @@ -477,9 +586,24 @@ class SettingDefinition: "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, @@ -662,7 +786,7 @@ class Settings(BaseModel): # 应用信息 app_name: str = "OpenAI/Codex CLI 自动注册系统" - app_version: str = "2.0.0" + app_version: str = "1.1.2" debug: bool = False # 数据库配置 @@ -751,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 index 4109590c..8a25d669 100644 --- a/src/core/circuit_breaker.py +++ b/src/core/circuit_breaker.py @@ -10,6 +10,7 @@ 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 @@ -22,7 +23,7 @@ def _utc_now() -> datetime: - return datetime.utcnow() + return utcnow_naive() def _now_iso() -> str: 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 90f60cce..05875e22 100644 --- a/src/core/openai/overview.py +++ b/src/core/openai/overview.py @@ -19,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), 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 b9582550..51c9abed 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, OAUTH_CLIENT_ID from ...database.session import get_db +from ..timezone_utils import utcnow_naive from ...database import crud from ...database.models import Account @@ -261,7 +262,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 @@ -393,7 +394,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: @@ -472,4 +473,4 @@ def validate_account_token( 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 08ed3783..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,6 +2815,165 @@ def run(self) -> RegistrationResult: result.error_message = str(e) return result + 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, diff --git a/src/core/system_selfcheck.py b/src/core/system_selfcheck.py index cbc5d219..3db35961 100644 --- a/src/core/system_selfcheck.py +++ b/src/core/system_selfcheck.py @@ -20,7 +20,7 @@ from ..config.constants import AccountStatus from ..config.settings import get_settings from ..core.dynamic_proxy import get_proxy_url_for_task -from ..core.timezone_utils import to_shanghai_iso +from ..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 @@ -75,7 +75,7 @@ def _utc_now() -> datetime: - return datetime.utcnow() + return utcnow_naive() def _now_iso() -> str: 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 63383aaf..56db2d0f 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -2,7 +2,16 @@ 数据库模块 """ -from .models import Base, Account, EmailService, RegistrationTask, Setting, SelfCheckRun +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 @@ -13,6 +22,8 @@ '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 f37b1429..8b712b93 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from sqlalchemy import and_, or_, desc, asc, func +from ..core.timezone_utils import utcnow_naive from ..config.constants import ( PoolState, account_label_to_role_tag, @@ -23,6 +24,9 @@ Proxy, CpaService, Sub2ApiService, + TeamManagerService, + NewApiService, + ScheduledRegistrationJob, BindCardTask, TeamInviteRecord, OperationAuditLog, @@ -95,7 +99,7 @@ def create_account( pool_state_manual=normalized_pool_state_manual, priority=int(priority) if priority is not None else 50, last_used_at=last_used_at, - registered_at=datetime.utcnow() + registered_at=utcnow_naive() ) db.add(db_account) db.commit() @@ -488,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, @@ -689,7 +693,7 @@ 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 @@ -875,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 d3b1aa7b..0fb42814 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -6,11 +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() @@ -47,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' @@ -66,8 +67,8 @@ class Account(Base): 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") @@ -115,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): @@ -131,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) @@ -164,8 +165,8 @@ 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") @@ -183,11 +184,11 @@ class TeamInviteRecord(Base): state = Column(String(20), default="pending", index=True) # pending / invited / joined / expired / failed invite_attempts = Column(Integer, default=1) last_error = Column(Text) - invited_at = Column(DateTime, default=datetime.utcnow) + invited_at = Column(DateTime, default=utcnow_naive) accepted_at = Column(DateTime) expires_at = Column(DateTime) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + 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") @@ -204,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 { @@ -231,7 +232,7 @@ class OperationAuditLog(Base): target_id = Column(String(120), index=True) # 作用对象 ID(字符串兼容多类型) target_email = Column(String(255), index=True) # 账号邮箱快照 payload = Column(JSONEncodedDict) # 变更前后与附加信息 - created_at = Column(DateTime, default=datetime.utcnow, index=True) + created_at = Column(DateTime, default=utcnow_naive, index=True) def to_dict(self) -> Dict[str, Any]: return { @@ -264,10 +265,10 @@ class SelfCheckRun(Base): summary = Column(String(500)) error_message = Column(Text) result_data = Column(JSONEncodedDict) # 结构化明细(检查项/修复项/统计) - created_at = Column(DateTime, default=datetime.utcnow, index=True) + created_at = Column(DateTime, default=utcnow_naive, index=True) started_at = Column(DateTime) finished_at = Column(DateTime) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive) def to_dict(self) -> Dict[str, Any]: return { @@ -300,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): @@ -314,8 +315,8 @@ class CpaService(Base): 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): @@ -329,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): @@ -343,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): @@ -362,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/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/web/app.py b/src/web/app.py index 23bff0ff..40bbef8e 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -1,20 +1,19 @@ -""" -FastAPI 应用主文件 -轻量级 Web UI,支持注册、账号管理、设置 -""" +"""FastAPI app entrypoint.""" import logging -import sys import secrets -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, Depends -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.project_notice import PROJECT_NOTICE from ..config.settings import get_settings, update_settings from .auth import ( build_auth_token, @@ -26,24 +25,22 @@ ) 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("*"): @@ -53,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=["*"], @@ -73,30 +160,21 @@ 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", dependencies=[Depends(require_api_auth)]) - - # 注册 WebSocket 路由 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 def _render_template( request: Request, @@ -104,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) @@ -121,11 +194,7 @@ def _render_template( status_code=status_code, ) except TypeError: - return templates.TemplateResponse( - name, - template_context, - status_code=status_code, - ) + return templates.TemplateResponse(name, template_context, status_code=status_code) def _guard_page_request(request: Request) -> Optional[RedirectResponse]: if is_default_security_config_active(): @@ -136,7 +205,6 @@ def _guard_page_request(request: Request) -> Optional[RedirectResponse]: @app.get("/login", response_class=HTMLResponse) 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( @@ -147,7 +215,6 @@ async def login_page(request: Request, next: Optional[str] = "/", notice: Option @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() @@ -156,7 +223,7 @@ async def login_submit(request: Request, password: str = Form(...), next: Option return _render_template( request, "login.html", - {"error": "密码错误", "next": next or "/", "notice": ""}, + {"error": "Invalid password", "next": next or "/", "notice": ""}, status_code=401, ) @@ -170,7 +237,6 @@ async def login_submit(request: Request, password: str = Form(...), next: Option @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( @@ -186,7 +252,6 @@ async def setup_password_submit( new_password: str = Form(...), confirm_password: str = Form(...), ): - """首次启动设置访问密码。""" if not is_default_security_config_active(): return RedirectResponse(url="/login", status_code=302) @@ -195,7 +260,7 @@ async def setup_password_submit( return _render_template( request, "setup_password.html", - {"error": "当前密码不正确", "message": ""}, + {"error": "Current password is incorrect", "message": ""}, status_code=400, ) @@ -205,31 +270,30 @@ async def setup_password_submit( return _render_template( request, "setup_password.html", - {"error": "新密码至少 8 位", "message": ""}, + {"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": "两次输入的新密码不一致", "message": ""}, + {"error": "Passwords do not match", "message": ""}, status_code=400, ) if new_value == "admin123": return _render_template( request, "setup_password.html", - {"error": "新密码不能继续使用默认口令", "message": ""}, + {"error": "Default password is not allowed", "message": ""}, status_code=400, ) - # 首次改密时同时轮换 secret,避免默认 secret 继续生效。 update_settings( webui_access_password=new_value, webui_secret_key=secrets.token_urlsafe(48), ) response = RedirectResponse( - url="/login?notice=访问密码已更新,请使用新密码登录", + url="/login?notice=Password updated, please sign in again", status_code=302, ) response.delete_cookie("webui_auth") @@ -237,14 +301,12 @@ async def setup_password_submit( @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): - """首页 - 注册页面""" redirect_response = _guard_page_request(request) if redirect_response: return redirect_response @@ -252,7 +314,6 @@ async def index(request: Request): @app.get("/accounts", response_class=HTMLResponse) async def accounts_page(request: Request): - """账号管理页面""" redirect_response = _guard_page_request(request) if redirect_response: return redirect_response @@ -260,7 +321,6 @@ async def accounts_page(request: Request): @app.get("/accounts-overview", response_class=HTMLResponse) async def accounts_overview_page(request: Request): - """账号总览页面""" redirect_response = _guard_page_request(request) if redirect_response: return redirect_response @@ -268,7 +328,6 @@ async def accounts_overview_page(request: Request): @app.get("/email-services", response_class=HTMLResponse) async def email_services_page(request: Request): - """邮箱服务管理页面""" redirect_response = _guard_page_request(request) if redirect_response: return redirect_response @@ -276,7 +335,6 @@ async def email_services_page(request: Request): @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): - """设置页面""" redirect_response = _guard_page_request(request) if redirect_response: return redirect_response @@ -284,7 +342,6 @@ async def settings_page(request: Request): @app.get("/payment", response_class=HTMLResponse) async def payment_page(request: Request): - """支付页面""" redirect_response = _guard_page_request(request) if redirect_response: return redirect_response @@ -292,7 +349,6 @@ async def payment_page(request: Request): @app.get("/card-pool", response_class=HTMLResponse) async def card_pool_page(request: Request): - """卡池页面(占位)""" redirect_response = _guard_page_request(request) if redirect_response: return redirect_response @@ -300,7 +356,6 @@ async def card_pool_page(request: Request): @app.get("/auto-team", response_class=HTMLResponse) async def auto_team_page(request: Request): - """team 页面(占位)""" redirect_response = _guard_page_request(request) if redirect_response: return redirect_response @@ -308,7 +363,6 @@ async def auto_team_page(request: Request): @app.get("/logs", response_class=HTMLResponse) async def logs_page(request: Request): - """后台日志页面""" redirect_response = _guard_page_request(request) if redirect_response: return redirect_response @@ -316,83 +370,12 @@ async def logs_page(request: Request): @app.get("/selfcheck", response_class=HTMLResponse) async def selfcheck_page(request: Request): - """系统自检页面""" redirect_response = _guard_page_request(request) if redirect_response: return redirect_response return _render_template(request, "selfcheck.html") - @app.on_event("startup") - async def startup_event(): - """应用启动事件""" - import asyncio - from ..database.init_db import initialize_database - from ..core.db_logs import cleanup_database_logs - from .auto_quick_refresh_scheduler import auto_quick_refresh_scheduler - from .selfcheck_scheduler import selfcheck_scheduler - - # 确保数据库已初始化(reload 模式下子进程也需要初始化) - try: - 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()) - app.state.auto_quick_refresh_task = asyncio.create_task(auto_quick_refresh_scheduler.run_loop()) - app.state.selfcheck_scheduler_task = asyncio.create_task(selfcheck_scheduler.run_loop()) - - logger.info("=" * 50) - logger.info(f"{settings.app_name} v{settings.app_version} 启动中,程序正在伸懒腰...") - logger.info(f"调试模式: {settings.debug}") - logger.info(f"数据库连接已接好线: {settings.database_url}") - if is_default_security_config_active(): - logger.warning("检测到默认安全配置,已强制进入首次改密流程:请访问 /setup-password") - logger.info("=" * 50) - - @app.on_event("shutdown") - async def shutdown_event(): - """应用关闭事件""" - cleanup_task = getattr(app.state, "log_cleanup_task", None) - if cleanup_task: - cleanup_task.cancel() - auto_quick_refresh_task = getattr(app.state, "auto_quick_refresh_task", None) - if auto_quick_refresh_task: - auto_quick_refresh_task.cancel() - selfcheck_scheduler_task = getattr(app.state, "selfcheck_scheduler_task", None) - if selfcheck_scheduler_task: - selfcheck_scheduler_task.cancel() - logger.info("应用关闭,今天先收摊啦") - return app -# 创建全局应用实例 app = create_app() - diff --git a/src/web/routes/__init__.py b/src/web/routes/__init__.py index 65d9c3e5..58c80bbd 100644 --- a/src/web/routes/__init__.py +++ b/src/web/routes/__init__.py @@ -12,6 +12,7 @@ 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 @@ -28,6 +29,7 @@ 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"]) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 7ab156b8..104e27a3 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -2,49 +2,38 @@ 账号管理 API 路由 """ import io +import asyncio import json import logging import re +import threading import zipfile import base64 -import time -import threading -import uuid -from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional +from typing import Any, Dict, List, Optional -from fastapi import APIRouter, HTTPException, Query, Body, Request +from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body from fastapi.responses import StreamingResponse -from pydantic import BaseModel -from sqlalchemy import and_, func, or_ - -from ...config.constants import ( - AccountLabel, - AccountStatus, - RoleTag, - account_label_to_role_tag, - normalize_account_label, - normalize_pool_state, - normalize_role_tag, - role_tag_to_account_label, -) +from 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 -from ..services.accounts_service import get_role_tag_counts as _service_get_role_tag_counts -from ..services.accounts_service import stream_accounts as _service_stream_accounts logger = logging.getLogger(__name__) router = APIRouter() @@ -60,27 +49,29 @@ AccountStatus.BANNED.value, ) -ACCOUNT_ASYNC_TASK_MAX_KEEP = 300 -ACCOUNT_ASYNC_EXECUTOR_MAX_WORKERS = 6 -ACCOUNT_BATCH_REFRESH_ASYNC_MAX_WORKERS = 8 -ACCOUNT_BATCH_REFRESH_RETRY_ATTEMPTS = 2 -ACCOUNT_BATCH_REFRESH_RETRY_BASE_DELAY_SECONDS = 1.0 -ACCOUNT_BATCH_VALIDATE_ASYNC_MAX_WORKERS = 8 -ACCOUNT_BATCH_VALIDATE_SYNC_MAX_WORKERS = 12 -ACCOUNT_BATCH_VALIDATE_RETRY_ATTEMPTS = 2 -ACCOUNT_BATCH_VALIDATE_RETRY_BASE_DELAY_SECONDS = 0.8 -ACCOUNT_BATCH_VALIDATE_HTTP_TIMEOUT_SECONDS = 18 -ACCOUNT_OVERVIEW_REFRESH_MAX_WORKERS = 8 -ACCOUNT_OVERVIEW_REFRESH_RETRY_ATTEMPTS = 2 -ACCOUNT_OVERVIEW_REFRESH_RETRY_BASE_DELAY_SECONDS = 1.0 -QUICK_REFRESH_TASK_WAIT_TIMEOUT_SECONDS = 40 * 60 -QUICK_REFRESH_TASK_POLL_INTERVAL_SECONDS = 1.2 -_account_async_tasks: Dict[str, Dict[str, Any]] = {} -_account_async_tasks_lock = threading.Lock() -_account_async_executor = ThreadPoolExecutor( - max_workers=ACCOUNT_ASYNC_EXECUTOR_MAX_WORKERS, - thread_name_prefix="account_async", -) +_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]: @@ -111,262 +102,32 @@ def _apply_status_filter(query, status: Optional[str]): return query.filter(Account.status == normalized) -def _utc_now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - -def _resolve_actor(request: Optional[Request]) -> str: - if request is None: - return "system" - header_keys = ("x-operator", "x-user", "x-username") - for key in header_keys: - value = str(request.headers.get(key) or "").strip() - if value: - return value[:120] - client_host = "" - try: - client_host = str(getattr(getattr(request, "client", None), "host", "") or "").strip() - except Exception: - client_host = "" - return f"api@{client_host}" if client_host else "api" - - -def _audit_account_action( - db, - *, - actor: str, - action: str, - account: Optional[Account] = None, - target_id: Optional[int] = None, - target_email: Optional[str] = None, - payload: Optional[Dict[str, Any]] = None, -) -> None: - try: - crud.create_operation_audit_log( - db, - actor=actor, - action=action, - target_type="account", - target_id=target_id if target_id is not None else getattr(account, "id", None), - target_email=target_email if target_email is not None else getattr(account, "email", None), - payload=payload or {}, +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()) ) - except Exception: - logger.warning("写入账号操作审计日志失败: action=%s", action, exc_info=True) - - -def _iter_query_in_batches(query, batch_size: int = 200) -> Iterator[Account]: - """ - 分批迭代查询结果,避免一次性 all() 把全量记录加载进内存。 - """ - yield from _service_stream_accounts(query, batch_size=batch_size) - - -def _cleanup_account_async_tasks_locked(): - """限制内存中的异步任务数量,优先清理已结束的旧任务。""" - total = len(_account_async_tasks) - if total <= ACCOUNT_ASYNC_TASK_MAX_KEEP: - return - - overflow = total - ACCOUNT_ASYNC_TASK_MAX_KEEP - finished_keys = [ - (task_id, _account_async_tasks[task_id].get("_created_ts", 0)) - for task_id in _account_async_tasks - if _account_async_tasks[task_id].get("status") in {"completed", "failed", "cancelled"} - ] - finished_keys.sort(key=lambda item: item[1]) - - removed = 0 - for task_id, _ in finished_keys: - if removed >= overflow: - break - _account_async_tasks.pop(task_id, None) - removed += 1 - - # 如果当前大多是运行中任务,不强制裁剪,避免前端轮询中的任务被提前清理。 - - -def _create_account_async_task(task_type: str, total: int = 0, payload: Optional[dict] = None) -> str: - task_id = str(uuid.uuid4()) - task = { - "id": task_id, - "task_type": task_type, - "status": "pending", - "message": "任务已创建,等待执行", - "created_at": _utc_now_iso(), - "started_at": None, - "finished_at": None, - "cancel_requested": False, - "pause_requested": False, - "paused": False, - "progress": { - "total": max(0, int(total or 0)), - "completed": 0, - "success": 0, - "failed": 0, - }, - "result": None, - "error": None, - "payload": payload or {}, - "details": [], - "_created_ts": time.time(), - } - with _account_async_tasks_lock: - _account_async_tasks[task_id] = task - _cleanup_account_async_tasks_locked() - task_manager.register_domain_task( - domain="accounts", - task_id=task_id, - task_type=task_type, - payload=payload or {}, - progress=task["progress"], - ) - return task_id - - -def _get_account_async_task(task_id: str) -> Optional[Dict[str, Any]]: - with _account_async_tasks_lock: - return _account_async_tasks.get(task_id) - - -def _get_account_async_task_or_404(task_id: str) -> Dict[str, Any]: - task = _get_account_async_task(task_id) - if not task: - raise HTTPException(status_code=404, detail="任务不存在") - return task + return [int(row[0]) for row in query.all()] -def _build_account_async_task_snapshot(task: Dict[str, Any]) -> Dict[str, Any]: - data = { - "id": task.get("id"), - "task_type": task.get("task_type"), - "status": task.get("status"), - "message": task.get("message"), - "created_at": task.get("created_at"), - "started_at": task.get("started_at"), - "finished_at": task.get("finished_at"), - "cancel_requested": bool(task.get("cancel_requested")), - "pause_requested": bool(task.get("pause_requested")), - "paused": bool(task.get("paused")), - "progress": task.get("progress") or {}, - "payload": task.get("payload") or {}, - "result": task.get("result"), - "error": task.get("error"), - "details": task.get("details") or [], - } - return data - +def has_active_batch_operations() -> bool: + if _QUICK_REFRESH_WORKFLOW_LOCK.locked(): + return True -def _update_account_async_task(task_id: str, **fields): - with _account_async_tasks_lock: - task = _account_async_tasks.get(task_id) - if not task: - return - task.update(fields) - task_manager.update_domain_task("accounts", task_id, **fields) - - -def _append_account_async_task_detail(task_id: str, detail: dict, max_items: int = 500): - with _account_async_tasks_lock: - task = _account_async_tasks.get(task_id) - if not task: - return - details = task.setdefault("details", []) - details.append(detail) - if len(details) > max_items: - task["details"] = details[-max_items:] - task_manager.append_domain_task_detail("accounts", task_id, detail, max_items=max_items) - - -def _set_account_async_task_progress(task_id: str, *, completed: int, success: int, failed: int, total: Optional[int] = None): - with _account_async_tasks_lock: - task = _account_async_tasks.get(task_id) - if not task: - return - progress = task.setdefault("progress", {}) - if total is not None: - progress["total"] = max(0, int(total)) - progress["completed"] = max(0, int(completed)) - progress["success"] = max(0, int(success)) - progress["failed"] = max(0, int(failed)) - payload = { - "completed": max(0, int(completed)), - "success": max(0, int(success)), - "failed": max(0, int(failed)), - } - if total is not None: - payload["total"] = max(0, int(total)) - task_manager.set_domain_task_progress("accounts", task_id, **payload) - - -def _is_account_async_task_cancel_requested(task_id: str) -> bool: - local_requested = False - with _account_async_tasks_lock: - task = _account_async_tasks.get(task_id) - local_requested = bool(task and task.get("cancel_requested")) - return local_requested or task_manager.is_domain_task_cancel_requested("accounts", task_id) - - -def _is_account_async_task_pause_requested(task_id: str) -> bool: - local_requested = False - with _account_async_tasks_lock: - task = _account_async_tasks.get(task_id) - local_requested = bool(task and task.get("pause_requested")) - return local_requested or task_manager.is_domain_task_pause_requested("accounts", task_id) - - -def _wait_if_account_async_task_paused(task_id: str, running_message: str) -> bool: - paused_once = False - while True: - if _is_account_async_task_cancel_requested(task_id): - return False - if not _is_account_async_task_pause_requested(task_id): - if paused_once: - _update_account_async_task( - task_id, - status="running", - paused=False, - message=running_message, - ) - return True - if not paused_once: - _update_account_async_task( - task_id, - status="paused", - paused=True, - message="任务已暂停,等待继续", - ) - paused_once = True - time.sleep(0.35) - - -def _run_account_async_task_guard(task_id: str, task_type: str, worker, *args): - acquired, running, quota = task_manager.try_acquire_domain_slot("accounts", task_id) - if not acquired: - reason = f"并发配额已满(running={running}, quota={quota})" - _update_account_async_task( - task_id, - status="failed", - finished_at=_utc_now_iso(), - message=reason, - error=reason, - paused=False, - ) - return - try: - worker(task_id, *args) - except Exception as exc: - logger.exception("异步任务执行失败: task_id=%s type=%s error=%s", task_id, task_type, exc) - _update_account_async_task( - task_id, - status="failed", - finished_at=_utc_now_iso(), - message=f"任务异常: {exc}", - error=str(exc), - paused=False, - ) - finally: - task_manager.release_domain_slot("accounts", task_id) + 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 ============== @@ -388,22 +149,13 @@ class AccountResponse(BaseModel): proxy_used: Optional[str] = None cpa_uploaded: bool = False cpa_uploaded_at: Optional[str] = None - account_label: str = AccountLabel.NONE.value - role_tag: str = RoleTag.NONE.value - biz_tag: Optional[str] = None - pool_state: str = "candidate_pool" - pool_state_manual: Optional[str] = None - last_pool_sync_at: Optional[str] = None - priority: int = 50 - last_used_at: Optional[str] = None subscription_type: Optional[str] = None subscription_at: Optional[str] = None cookies: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class AccountListResponse(BaseModel): @@ -418,10 +170,6 @@ class AccountUpdateRequest(BaseModel): metadata: Optional[dict] = None cookies: Optional[str] = None # 完整 cookie 字符串,用于支付请求 session_token: Optional[str] = None - role_tag: Optional[str] = None - biz_tag: Optional[str] = None - pool_state_manual: Optional[str] = None - priority: Optional[int] = None class ManualAccountCreateRequest(BaseModel): @@ -440,10 +188,6 @@ class ManualAccountCreateRequest(BaseModel): cookies: Optional[str] = None proxy_used: Optional[str] = None source: Optional[str] = "manual" - account_label: Optional[str] = AccountLabel.NONE.value - role_tag: Optional[str] = None - biz_tag: Optional[str] = None - priority: Optional[int] = 50 subscription_type: Optional[str] = None metadata: Optional[dict] = None @@ -464,12 +208,6 @@ class AccountImportItem(BaseModel): cookies: Optional[str] = None proxy_used: Optional[str] = None source: Optional[str] = "import" - account_label: Optional[str] = AccountLabel.NONE.value - role_tag: Optional[str] = None - biz_tag: Optional[str] = None - pool_state: Optional[str] = None - pool_state_manual: Optional[str] = None - priority: Optional[int] = 50 subscription_type: Optional[str] = None plan_type: Optional[str] = None auth_mode: Optional[str] = None @@ -552,26 +290,6 @@ def resolve_account_ids( return [row[0] for row in query.all()] -def _resolve_account_role_tag(account: Account) -> str: - role_value = str(getattr(account, "role_tag", "") or "").strip() - if role_value: - normalized_role = normalize_role_tag(role_value) - if normalized_role != RoleTag.NONE.value: - return normalized_role - return account_label_to_role_tag(getattr(account, "account_label", None)) - - -def _resolve_account_pool_state(account: Account) -> str: - return normalize_pool_state(getattr(account, "pool_state", None)) - - -def _set_account_role_tag(account: Account, role_tag: Optional[str]) -> str: - normalized_role = normalize_role_tag(role_tag) - account.role_tag = normalized_role - account.account_label = role_tag_to_account_label(normalized_role) - return normalized_role - - def account_to_response(account: Account) -> AccountResponse: """转换 Account 模型为响应模型""" return AccountResponse( @@ -590,14 +308,6 @@ def account_to_response(account: Account) -> AccountResponse: proxy_used=account.proxy_used, cpa_uploaded=account.cpa_uploaded or False, cpa_uploaded_at=account.cpa_uploaded_at.isoformat() if account.cpa_uploaded_at else None, - account_label=normalize_account_label(getattr(account, "account_label", None)), - role_tag=_resolve_account_role_tag(account), - biz_tag=(str(getattr(account, "biz_tag", "") or "").strip() or None), - pool_state=_resolve_account_pool_state(account), - pool_state_manual=(str(getattr(account, "pool_state_manual", "") or "").strip() or None), - last_pool_sync_at=account.last_pool_sync_at.isoformat() if getattr(account, "last_pool_sync_at", None) else None, - priority=int(getattr(account, "priority", 50) or 50), - last_used_at=account.last_used_at.isoformat() if getattr(account, "last_used_at", None) else None, subscription_type=account.subscription_type, subscription_at=account.subscription_at.isoformat() if account.subscription_at else None, cookies=account.cookies, @@ -833,17 +543,6 @@ def _is_paid_subscription(value: Optional[str]) -> bool: return normalized in PAID_SUBSCRIPTION_TYPES -def _promote_child_label_if_paid(account: Account, subscription_type: Optional[str], *, reason: str) -> bool: - """ - 历史兼容函数:关闭“付费后自动子号升母号”。 - 账号标签仅允许手动修改或专用业务入口修改,避免 Team 加入后误升母号。 - """ - _ = account - _ = subscription_type - _ = reason - return False - - def _pick_first_text(*values: Any) -> Optional[str]: for value in values: if value is None: @@ -943,8 +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 - _promote_child_label_if_paid(account, detected_sub, reason="overview_detected_paid") + 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( @@ -960,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: @@ -982,10 +691,6 @@ async def create_manual_account(request: ManualAccountCreateRequest): email_service = (request.email_service or "manual").strip() or "manual" status = request.status or AccountStatus.ACTIVE.value source = (request.source or "manual").strip() or "manual" - role_tag = normalize_role_tag( - request.role_tag if request.role_tag is not None else request.account_label - ) - account_label = role_tag_to_account_label(role_tag) subscription_type = _normalize_subscription_input(request.subscription_type) if not email or "@" not in email: @@ -1018,15 +723,10 @@ async def create_manual_account(request: ManualAccountCreateRequest): cookies=request.cookies, proxy_used=request.proxy_used, extra_data=request.metadata or {}, - account_label=account_label, - role_tag=role_tag, - biz_tag=request.biz_tag, - priority=request.priority if request.priority is not None else 50, ) if subscription_type: account.subscription_type = subscription_type - account.subscription_at = datetime.utcnow() - _promote_child_label_if_paid(account, subscription_type, reason="manual_create_paid") + account.subscription_at = utcnow_naive() db.commit() db.refresh(account) except Exception as exc: @@ -1136,24 +836,6 @@ def _safe_text(value: Optional[str]) -> Optional[str]: email_service = str(item.email_service or "manual").strip() or "manual" source = str(item.source or "import").strip() or "import" - raw_role_tag = _pick_first_text( - item.role_tag, - raw_item.get("role_tag"), - raw_item.get("registration_type"), - item.account_label, - raw_item.get("account_label"), - ) - role_tag = normalize_role_tag(raw_role_tag) if raw_role_tag is not None else RoleTag.NONE.value - account_label = role_tag_to_account_label(role_tag) - biz_tag = _pick_first_text(item.biz_tag, raw_item.get("biz_tag")) - raw_pool_state = _pick_first_text(item.pool_state, raw_item.get("pool_state")) - pool_state = normalize_pool_state(raw_pool_state) if raw_pool_state is not None else None - raw_pool_state_manual = _pick_first_text(item.pool_state_manual, raw_item.get("pool_state_manual")) - pool_state_manual = normalize_pool_state(raw_pool_state_manual) if raw_pool_state_manual is not None else None - try: - priority_value = int(item.priority) if item.priority is not None else 50 - except Exception: - priority_value = 50 subscription_type = ( _normalize_subscription_input(item.subscription_type) or _normalize_subscription_input(item.plan_type) @@ -1204,22 +886,15 @@ def _safe_text(value: Optional[str]) -> Optional[str]: "cookies": item.cookies if item.cookies is not None else None, "proxy_used": _safe_text(item.proxy_used), "source": source, - "account_label": account_label, - "role_tag": role_tag, - "biz_tag": biz_tag, - "pool_state": pool_state, - "pool_state_manual": pool_state_manual, - "priority": priority_value, "extra_data": metadata, - "last_refresh": datetime.utcnow(), + "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 - _promote_child_label_if_paid(account, subscription_type, reason="import_overwrite_paid") + account.subscription_at = utcnow_naive() if subscription_type else None db.commit() result["updated"] += 1 continue @@ -1241,17 +916,10 @@ def _safe_text(value: Optional[str]) -> Optional[str]: extra_data=metadata, status=status, source=source, - account_label=account_label, - role_tag=role_tag, - biz_tag=biz_tag, - pool_state=pool_state, - pool_state_manual=pool_state_manual, - priority=priority_value, ) if subscription_type: account.subscription_type = subscription_type - account.subscription_at = datetime.utcnow() - _promote_child_label_if_paid(account, subscription_type, reason="import_create_paid") + account.subscription_at = utcnow_naive() db.commit() result["created"] += 1 except Exception as exc: @@ -1267,9 +935,6 @@ async def list_accounts( page_size: int = Query(20, ge=1, le=100, description="每页数量"), status: Optional[str] = Query(None, description="状态筛选"), email_service: Optional[str] = Query(None, description="邮箱服务筛选"), - role_tag: Optional[str] = Query(None, description="角色标签筛选:parent/child/none"), - pool_state: Optional[str] = Query(None, description="池状态筛选:team_pool/candidate_pool/blocked"), - biz_tag: Optional[str] = Query(None, description="业务标签筛选"), search: Optional[str] = Query(None, description="搜索关键词"), ): """ @@ -1289,28 +954,6 @@ async def list_accounts( if email_service: query = query.filter(Account.email_service == email_service) - # 角色标签筛选 - if role_tag: - normalized_role = normalize_role_tag(role_tag) - fallback_label = role_tag_to_account_label(normalized_role) - query = query.filter( - or_( - func.lower(func.coalesce(Account.role_tag, "")) == normalized_role, - and_( - func.trim(func.coalesce(Account.role_tag, "")) == "", - func.lower(func.coalesce(Account.account_label, "")) == fallback_label, - ), - ) - ) - - # 池状态筛选 - if pool_state: - query = query.filter(Account.pool_state == normalize_pool_state(pool_state)) - - # 业务标签筛选 - if biz_tag: - query = query.filter(Account.biz_tag == str(biz_tag).strip()) - # 搜索 if search: search_pattern = f"%{search}%" @@ -1355,12 +998,11 @@ async def list_accounts_overview_cards( if email_service: query = query.filter(Account.email_service == email_service) - ordered_query = query.order_by(Account.created_at.desc()) - accounts = [] - for account in _iter_query_in_batches(ordered_query, batch_size=200): - if _is_overview_card_removed(account): - continue - accounts.append(account) + accounts = [ + account + for account in query.order_by(Account.created_at.desc()).all() + if not _is_overview_card_removed(account) + ] current_account_id = _get_current_account_id(db) global_proxy = _get_proxy(proxy) # 卡片列表接口默认“缓存优先”,避免首次进入或新增卡片后触发全量远端请求造成页面卡死。 @@ -1450,9 +1092,9 @@ async def list_accounts_overview_addable( if email_service: query = query.filter(Account.email_service == email_service) - ordered_query = query.order_by(Account.created_at.desc()) + accounts = query.order_by(Account.created_at.desc()).all() rows = [] - for account in _iter_query_in_batches(ordered_query, batch_size=200): + for account in accounts: if not _is_overview_card_removed(account): continue if not _is_paid_subscription(account.subscription_type): @@ -1492,9 +1134,9 @@ async def list_accounts_overview_selectable( if email_service: query = query.filter(Account.email_service == email_service) - ordered_query = query.order_by(Account.created_at.desc()) + accounts = query.order_by(Account.created_at.desc()).all() rows = [] - for account in _iter_query_in_batches(ordered_query, batch_size=200): + for account in accounts: # 仅返回当前未在卡片中的账号(即已从卡片移除) if not _is_overview_card_removed(account): continue @@ -1591,12 +1233,10 @@ async def attach_accounts_overview_card(account_id: int): @router.post("/overview/refresh") -def refresh_accounts_overview(request: OverviewRefreshRequest): +async def refresh_accounts_overview(request: OverviewRefreshRequest): """ 批量刷新账号总览数据。 - 使用线程池并发执行,避免长时间刷新阻塞其他接口。 """ - started_at = time.monotonic() proxy = _get_proxy(request.proxy) result = {"success_count": 0, "failed_count": 0, "details": []} @@ -1616,68 +1256,87 @@ def refresh_accounts_overview(request: OverviewRefreshRequest): ).order_by(Account.created_at.desc()).all() ids = [acc.id for acc in candidates if not _is_overview_card_removed(acc)] - logger.info( - "账号总览刷新开始: target_count=%s force=%s select_all=%s proxy=%s", - len(ids), - bool(request.force), - bool(request.select_all), - proxy or "-", - ) + logger.info( + "账号总览刷新开始: target_count=%s force=%s select_all=%s proxy=%s", + len(ids), + bool(request.force), + bool(request.select_all), + proxy or "-", + ) - if not ids: - return result + for account_id in ids: + account = crud.get_account_by_id(db, account_id) + if not account: + result["failed_count"] += 1 + result["details"].append({"id": account_id, "success": False, "error": "账号不存在"}) + logger.warning("账号总览刷新失败: account_id=%s error=账号不存在", account_id) + continue + if (not _is_paid_subscription(account.subscription_type)) or _is_overview_card_removed(account): + result["details"].append( + { + "id": account.id, + "email": account.email, + "success": False, + "error": "账号不在 Codex 卡片范围内,已跳过", + } + ) + continue - worker_count = min(ACCOUNT_OVERVIEW_REFRESH_MAX_WORKERS, max(1, len(ids))) - with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="overview_refresh") as pool: - future_map = { - pool.submit(_refresh_overview_account_with_retry, account_id, bool(request.force), proxy): account_id - for account_id in ids - } - for future in as_completed(future_map): - account_id = future_map[future] - try: - detail = future.result() - except Exception as exc: - detail = {"id": account_id, "success": False, "error": str(exc)} + account_proxy = (account.proxy_used or "").strip() or proxy + overview, updated = _get_account_overview_data( + db, + account, + force_refresh=request.force, + proxy=account_proxy, + allow_network=True, + ) + if updated: + db.commit() - result["details"].append(detail) - if detail.get("success") is True: - result["success_count"] += 1 - elif not detail.get("skipped"): + if overview.get("hourly_quota", {}).get("status") == "unknown" and overview.get("weekly_quota", {}).get("status") == "unknown": result["failed_count"] += 1 - - if detail.get("success") is True: - logger.info( - "账号总览刷新成功: account_id=%s email=%s plan=%s", - detail.get("id"), - detail.get("email"), - detail.get("plan_type") or "-", - ) - elif detail.get("skipped"): - logger.info( - "账号总览刷新跳过: account_id=%s email=%s reason=%s", - detail.get("id"), - detail.get("email"), - detail.get("error"), + result["details"].append( + { + "id": account.id, + "email": account.email, + "success": False, + "error": overview.get("error") or "未获取到配额数据", + } ) - else: logger.warning( "账号总览刷新失败: account_id=%s email=%s error=%s", - detail.get("id"), - detail.get("email"), - detail.get("error"), + account.id, + account.email, + overview.get("error") or "未获取到配额数据", + ) + else: + result["success_count"] += 1 + result["details"].append( + { + "id": account.id, + "email": account.email, + "success": True, + "plan_type": overview.get("plan_type"), + } + ) + logger.info( + "账号总览刷新成功: account_id=%s email=%s plan=%s hourly=%s weekly=%s code_review=%s hourly_source=%s weekly_source=%s", + account.id, + account.email, + overview.get("plan_type") or "-", + overview.get("hourly_quota", {}).get("percentage"), + overview.get("weekly_quota", {}).get("percentage"), + overview.get("code_review_quota", {}).get("percentage"), + overview.get("hourly_quota", {}).get("source"), + overview.get("weekly_quota", {}).get("source"), ) - result["details"].sort(key=lambda item: int(item.get("id") or 0)) - duration = round(time.monotonic() - started_at, 2) - logger.info( - "账号总览刷新完成: success=%s failed=%s total=%s workers=%s duration=%.2fs", - result["success_count"], - result["failed_count"], - len(ids), - worker_count, - duration, - ) + logger.info( + "账号总览刷新完成: success=%s failed=%s", + result["success_count"], + result["failed_count"], + ) + return result @@ -1748,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) @@ -1766,22 +1425,12 @@ async def get_account_tokens(account_id: int): @router.patch("/{account_id}", response_model=AccountResponse) -async def update_account(account_id: int, request: AccountUpdateRequest, http_request: Request): +async def update_account(account_id: int, request: AccountUpdateRequest): """更新账号状态""" with get_db() as db: account = crud.get_account_by_id(db, account_id) if not account: raise HTTPException(status_code=404, detail="账号不存在") - actor = _resolve_actor(http_request) - before_snapshot = { - "status": account.status, - "role_tag": account.role_tag, - "account_label": account.account_label, - "biz_tag": account.biz_tag, - "pool_state_manual": account.pool_state_manual, - "priority": account.priority, - "subscription_type": account.subscription_type, - } update_data = {} if request.status: @@ -1790,9 +1439,9 @@ async def update_account(account_id: int, request: AccountUpdateRequest, http_re update_data["status"] = request.status if request.metadata: - current_metadata = account.extra_data if isinstance(account.extra_data, dict) else {} + current_metadata = account.metadata or {} current_metadata.update(request.metadata) - update_data["extra_data"] = current_metadata + update_data["metadata"] = current_metadata if request.cookies is not None: # 留空则清空,非空则更新 @@ -1801,48 +1450,9 @@ async def update_account(account_id: int, request: AccountUpdateRequest, http_re if request.session_token is not None: # 留空则清空,非空则更新 update_data["session_token"] = request.session_token or None - update_data["last_refresh"] = datetime.utcnow() - - if request.role_tag is not None: - normalized_role = normalize_role_tag(request.role_tag) - update_data["role_tag"] = normalized_role - update_data["account_label"] = role_tag_to_account_label(normalized_role) - - if request.biz_tag is not None: - update_data["biz_tag"] = str(request.biz_tag).strip() or None - - if request.pool_state_manual is not None: - text = str(request.pool_state_manual or "").strip() - update_data["pool_state_manual"] = normalize_pool_state(text) if text else None - - if request.priority is not None: - try: - update_data["priority"] = max(0, int(request.priority)) - except Exception: - raise HTTPException(status_code=400, detail="priority 必须为整数") + update_data["last_refresh"] = utcnow_naive() account = crud.update_account(db, account_id, **update_data) - if update_data: - after_snapshot = { - "status": account.status, - "role_tag": account.role_tag, - "account_label": account.account_label, - "biz_tag": account.biz_tag, - "pool_state_manual": account.pool_state_manual, - "priority": account.priority, - "subscription_type": account.subscription_type, - } - _audit_account_action( - db, - actor=actor, - action="account.update", - account=account, - payload={ - "fields": sorted(list(update_data.keys())), - "before": before_snapshot, - "after": after_snapshot, - }, - ) return account_to_response(account) @@ -1864,25 +1474,20 @@ async def delete_account(account_id: int): if not account: raise HTTPException(status_code=404, detail="账号不存在") - try: - crud.delete_account(db, account_id) - except Exception as e: - raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}") + crud.delete_account(db, account_id) return {"success": True, "message": f"账号 {account.email} 已删除"} @router.post("/batch-delete") -async def batch_delete_accounts(request: BatchDeleteRequest, http_request: Request): +async def batch_delete_accounts(request: BatchDeleteRequest): """批量删除账号""" with get_db() as db: - actor = _resolve_actor(http_request) ids = resolve_account_ids( db, request.ids, request.select_all, request.status_filter, request.email_service_filter, request.search_filter ) deleted_count = 0 errors = [] - deleted_ids: List[int] = [] for account_id in ids: try: @@ -1890,25 +1495,9 @@ async def batch_delete_accounts(request: BatchDeleteRequest, http_request: Reque if account: crud.delete_account(db, account_id) deleted_count += 1 - deleted_ids.append(int(account_id)) except Exception as e: errors.append(f"ID {account_id}: {str(e)}") - _audit_account_action( - db, - actor=actor, - action="account.batch_delete", - target_id=0, - target_email=None, - payload={ - "requested_ids": [int(item) for item in ids], - "deleted_ids": deleted_ids, - "deleted_count": deleted_count, - "error_count": len(errors), - "errors": errors[:50], - }, - ) - return { "success": True, "deleted_count": deleted_count, @@ -1917,16 +1506,14 @@ async def batch_delete_accounts(request: BatchDeleteRequest, http_request: Reque @router.post("/batch-update") -async def batch_update_accounts(request: BatchUpdateRequest, http_request: Request): +async def batch_update_accounts(request: BatchUpdateRequest): """批量更新账号状态""" if request.status not in [e.value for e in AccountStatus]: raise HTTPException(status_code=400, detail="无效的状态值") with get_db() as db: - actor = _resolve_actor(http_request) updated_count = 0 errors = [] - updated_ids: List[int] = [] for account_id in request.ids: try: @@ -1934,26 +1521,9 @@ async def batch_update_accounts(request: BatchUpdateRequest, http_request: Reque if account: crud.update_account(db, account_id, status=request.status) updated_count += 1 - updated_ids.append(int(account_id)) except Exception as e: errors.append(f"ID {account_id}: {str(e)}") - _audit_account_action( - db, - actor=actor, - action="account.batch_update_status", - target_id=0, - target_email=None, - payload={ - "status": request.status, - "requested_ids": [int(item) for item in request.ids], - "updated_ids": updated_ids, - "updated_count": updated_count, - "error_count": len(errors), - "errors": errors[:50], - }, - ) - return { "success": True, "updated_count": updated_count, @@ -1993,11 +1563,6 @@ async def export_accounts_json(request: BatchExportRequest): "id_token": acc.id_token, "session_token": acc.session_token, "email_service": acc.email_service, - "account_label": normalize_account_label(getattr(acc, "account_label", None)), - "role_tag": _resolve_account_role_tag(acc), - "biz_tag": str(getattr(acc, "biz_tag", "") or "").strip() or None, - "pool_state": _resolve_account_pool_state(acc), - "priority": int(getattr(acc, "priority", 50) or 50), "registered_at": acc.registered_at.isoformat() if acc.registered_at else None, "last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else None, "expires_at": acc.expires_at.isoformat() if acc.expires_at else None, @@ -2040,8 +1605,7 @@ async def export_accounts_csv(request: BatchExportRequest): "ID", "Email", "Password", "Client ID", "Account ID", "Workspace ID", "Access Token", "Refresh Token", "ID Token", "Session Token", - "Email Service", "Account Label", "Role Tag", "Biz Tag", "Pool State", "Priority", - "Status", "Registered At", "Last Refresh", "Expires At" + "Email Service", "Status", "Registered At", "Last Refresh", "Expires At" ]) # 写入数据 @@ -2058,11 +1622,6 @@ async def export_accounts_csv(request: BatchExportRequest): acc.id_token or "", acc.session_token or "", acc.email_service, - normalize_account_label(getattr(acc, "account_label", None)), - _resolve_account_role_tag(acc), - str(getattr(acc, "biz_tag", "") or "").strip(), - _resolve_account_pool_state(acc), - int(getattr(acc, "priority", 50) or 50), acc.status, acc.registered_at.isoformat() if acc.registered_at else "", acc.last_refresh.isoformat() if acc.last_refresh else "", @@ -2146,7 +1705,7 @@ def make_account_entry(acc) -> dict: @router.post("/export/codex") async def export_accounts_codex(request: BatchExportRequest): - """导出账号为 Codex JSONL 格式(便于迁移/导入)。""" + """????? Codex ???????""" with get_db() as db: ids = resolve_account_ids( db, request.ids, request.select_all, @@ -2242,19 +1801,10 @@ async def get_accounts_stats(): func.count(Account.id) ).group_by(Account.email_service).all() - # 按角色标签统计(Service + Repository 聚合) - role_counts = _service_get_role_tag_counts(db) - return { "total": total, "by_status": {status: count for status, count in status_stats}, - "by_email_service": {service: count for service, count in service_stats}, - "by_role_tag": role_counts, - "tagged_role_counts": { - "parent": role_counts["parent"], - "child": role_counts["child"], - "total_labeled": role_counts["parent"] + role_counts["child"], - }, + "by_email_service": {service: count for service, count in service_stats} } @@ -2334,21 +1884,6 @@ async def get_accounts_overview(): } -@router.get("/audit-logs") -async def list_account_audit_logs(limit: int = Query(100, ge=1, le=500), action: Optional[str] = Query(None)): - with get_db() as db: - rows = crud.list_operation_audit_logs( - db, - limit=limit, - action=action, - target_type="account", - ) - return { - "success": True, - "items": [row.to_dict() for row in rows], - } - - # ============== Token 刷新相关 ============== class TokenRefreshRequest(BaseModel): @@ -2381,1273 +1916,183 @@ class BatchValidateRequest(BaseModel): search_filter: Optional[str] = None -def _wait_account_async_task_finished( - task_id: str, - timeout_seconds: int = QUICK_REFRESH_TASK_WAIT_TIMEOUT_SECONDS, - poll_interval: float = QUICK_REFRESH_TASK_POLL_INTERVAL_SECONDS, -) -> Dict[str, Any]: - started_at = time.monotonic() - while time.monotonic() - started_at < timeout_seconds: - task = _get_account_async_task(task_id) - if task: - snapshot = _build_account_async_task_snapshot(task) - status = str(snapshot.get("status") or "").lower() - if status in {"completed", "failed", "cancelled"}: - return snapshot - time.sleep(max(0.2, float(poll_interval))) - raise TimeoutError(f"等待账号任务超时: {task_id}") - - -def _wait_payment_op_task_finished( - op_task_id: str, - timeout_seconds: int = QUICK_REFRESH_TASK_WAIT_TIMEOUT_SECONDS, - poll_interval: float = QUICK_REFRESH_TASK_POLL_INTERVAL_SECONDS, -) -> Dict[str, Any]: - from . import payment as payment_routes - - started_at = time.monotonic() - while time.monotonic() - started_at < timeout_seconds: - task = payment_routes._get_payment_op_task(op_task_id) - if task: - snapshot = payment_routes._build_payment_op_task_snapshot(task) - status = str(snapshot.get("status") or "").lower() - if status in {"completed", "failed", "cancelled"}: - return snapshot - time.sleep(max(0.2, float(poll_interval))) - raise TimeoutError(f"等待支付任务超时: {op_task_id}") - - -def _task_terminal_error(task_snapshot: Dict[str, Any], default_message: str) -> str: - status = str(task_snapshot.get("status") or "").lower() - if status == "completed": - return "" - if status == "cancelled": - return str(task_snapshot.get("message") or "任务已取消") - return str(task_snapshot.get("error") or task_snapshot.get("message") or default_message) - - -def has_active_batch_operations() -> bool: - active_status = {"pending", "running"} - account_task_types = {"batch_refresh", "batch_validate", "overview_refresh", "quick_refresh"} - with _account_async_tasks_lock: - for task in _account_async_tasks.values(): - status = str(task.get("status") or "").lower() - task_type = str(task.get("task_type") or "").strip().lower() - if status in active_status and task_type in account_task_types: - return True +@router.post("/batch-refresh") +async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: BackgroundTasks): + """批量刷新账号 Token""" + proxy = _get_proxy(request.proxy) - try: - from . import payment as payment_routes - - with payment_routes._PAYMENT_OP_TASK_LOCK: - for task in payment_routes._PAYMENT_OP_TASKS.values(): - status = str(task.get("status") or "").lower() - task_type = str(task.get("task_type") or "").strip().lower() - if status in active_status and task_type in {"batch_check_subscription", "quick_refresh"}: - return True - except Exception: - pass + results = { + "success_count": 0, + "failed_count": 0, + "errors": [] + } - return False + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + for account_id in ids: + try: + result = do_refresh(account_id, proxy) + if result.success: + results["success_count"] += 1 + else: + results["failed_count"] += 1 + results["errors"].append({"id": account_id, "error": result.error_message}) + except Exception as e: + results["failed_count"] += 1 + results["errors"].append({"id": account_id, "error": str(e)}) -def _compact_refresh_result(result: Dict[str, Any]) -> Dict[str, int]: - return { - "success_count": int(result.get("success_count") or 0), - "failed_count": int(result.get("failed_count") or 0), - "total": int(result.get("total") or 0), - } + return results -def _compact_validate_result(result: Dict[str, Any]) -> Dict[str, int]: - return { - "valid_count": int(result.get("valid_count") or 0), - "invalid_count": int(result.get("invalid_count") or 0), - "total": int(result.get("total") or 0), - } +@router.post("/{account_id}/refresh") +async def refresh_account_token(account_id: int, request: Optional[TokenRefreshRequest] = Body(default=None)): + """刷新单个账号的 Token""" + proxy = _get_proxy(request.proxy if request else None) + result = do_refresh(account_id, proxy) + if result.success: + return { + "success": True, + "message": "Token 刷新成功", + "expires_at": result.expires_at.isoformat() if result.expires_at else None + } + else: + return { + "success": False, + "error": result.error_message + } -def run_quick_refresh_workflow( - *, - source: str = "manual", - proxy: Optional[str] = None, - select_all: bool = True, - status_filter: Optional[str] = None, - email_service_filter: Optional[str] = None, - search_filter: Optional[str] = None, -) -> Dict[str, Any]: - payload = { - "ids": [], - "proxy": proxy, - "select_all": bool(select_all), - "status_filter": status_filter, - "email_service_filter": email_service_filter, - "search_filter": search_filter, - } - # 1) 批量验证 - validate_task = start_batch_validate_async(BatchValidateRequest(**payload)) - validate_task_id = str(validate_task.get("id") or "") - if not validate_task_id: - raise RuntimeError("创建批量验证任务失败:缺少 task_id") - validate_final = _wait_account_async_task_finished(validate_task_id) - validate_error = _task_terminal_error(validate_final, "批量验证任务失败") - if validate_error: - raise RuntimeError(f"批量验证失败: {validate_error}") - validate_result = _compact_validate_result(validate_final.get("result") or {}) - - # 2) 批量检测订阅 - from . import payment as payment_routes - - subscription_task = payment_routes.start_batch_check_subscription_async( - payment_routes.BatchCheckSubscriptionRequest(**payload) - ) - subscription_task_id = str(subscription_task.get("id") or "") - if not subscription_task_id: - raise RuntimeError("创建批量订阅检测任务失败:缺少 op_task_id") - subscription_final = _wait_payment_op_task_finished(subscription_task_id) - subscription_error = _task_terminal_error(subscription_final, "批量订阅检测任务失败") - if subscription_error: - raise RuntimeError(f"批量检测订阅失败: {subscription_error}") - subscription_raw = subscription_final.get("result") or {} - subscription_result = { - "success_count": int(subscription_raw.get("success_count") or 0), - "failed_count": int(subscription_raw.get("failed_count") or 0), - "total": int(subscription_raw.get("total") or 0), - } +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) - return { - "source": source, - "finished_at": _utc_now_iso(), - "validate": validate_result, - "subscription": subscription_result, + results = { + "valid_count": 0, + "invalid_count": 0, + "details": [] } + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) -def _is_retryable_refresh_error(error_message: Optional[str]) -> bool: - text = str(error_message or "").strip().lower() - if not text: - return False - retry_markers = ( - "network_error", - "network", - "timeout", - "timed out", - "connection", - "temporarily", - "too many requests", - "http 429", - "http 500", - "http 502", - "http 503", - "http 504", - ) - return any(marker in text for marker in retry_markers) - - -def _refresh_one_account_with_retry( - account_id: int, - proxy: Optional[str], - max_attempts: int = ACCOUNT_BATCH_REFRESH_RETRY_ATTEMPTS, -) -> Dict[str, Any]: - attempts = max(1, int(max_attempts or 1)) - last_error = "" - for attempt in range(1, attempts + 1): + for account_id in ids: try: - refresh_result = do_refresh(account_id, proxy) - except Exception as exc: - refresh_result = None - last_error = str(exc) - if attempt < attempts: - time.sleep(ACCOUNT_BATCH_REFRESH_RETRY_BASE_DELAY_SECONDS * attempt) - continue - return { + is_valid, error = do_validate(account_id, proxy) + results["details"].append({ "id": account_id, - "success": False, - "error": last_error, - "attempts": attempt, - } - - if refresh_result and refresh_result.success: - return { - "id": account_id, - "success": True, - "attempts": attempt, - } - - last_error = str(getattr(refresh_result, "error_message", "") or "刷新失败") - can_retry = attempt < attempts and _is_retryable_refresh_error(last_error) - if can_retry: - time.sleep(ACCOUNT_BATCH_REFRESH_RETRY_BASE_DELAY_SECONDS * attempt) - continue - return { - "id": account_id, - "success": False, - "error": last_error, - "attempts": attempt, - } - - return { - "id": account_id, - "success": False, - "error": last_error or "刷新失败", - "attempts": attempts, - } - - -def _run_batch_refresh_task(task_id: str, ids: List[int], proxy: Optional[str]): - total = len(ids) - success_count = 0 - failed_count = 0 - completed_count = 0 - - _update_account_async_task( - task_id, - status="running", - started_at=_utc_now_iso(), - message=f"开始刷新 Token,共 {total} 个账号", - paused=False, - ) - - if total <= 0: - _update_account_async_task( - task_id, - status="completed", - finished_at=_utc_now_iso(), - message="没有可刷新的账号", - paused=False, - result={ - "success_count": 0, - "failed_count": 0, - "total": 0, - "cancelled": False, - }, - ) - return - - worker_count = min(ACCOUNT_BATCH_REFRESH_ASYNC_MAX_WORKERS, max(1, total)) - _update_account_async_task(task_id, message=f"处理中 0/{total}(并发 {worker_count})") - - next_index = 0 - running: Dict[Any, int] = {} - cancelled = False - pool = ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="batch_refresh_async") - try: - while completed_count < total: - if not _wait_if_account_async_task_paused( - task_id, - f"处理中 {completed_count}/{total}(并发 {worker_count})", - ): - cancelled = True - break - if _is_account_async_task_cancel_requested(task_id): - cancelled = True - break - - while next_index < total and len(running) < worker_count: - if not _wait_if_account_async_task_paused( - task_id, - f"处理中 {completed_count}/{total}(并发 {worker_count})", - ): - cancelled = True - break - account_id = int(ids[next_index]) - next_index += 1 - future = pool.submit(_refresh_one_account_with_retry, account_id, proxy) - running[future] = account_id - - if cancelled: - break - if not running: - continue - - done, _ = wait(tuple(running.keys()), timeout=0.6, return_when=FIRST_COMPLETED) - if not done: - continue - - for future in done: - account_id = int(running.pop(future, 0) or 0) - if account_id <= 0: - continue - try: - detail = future.result() - except Exception as exc: - detail = { - "id": account_id, - "success": False, - "error": str(exc), - "attempts": 1, - } - - completed_count += 1 - if detail.get("success"): - success_count += 1 - else: - failed_count += 1 - - _append_account_async_task_detail(task_id, detail) - _set_account_async_task_progress( - task_id, - total=total, - completed=completed_count, - success=success_count, - failed=failed_count, - ) - _update_account_async_task( - task_id, - message=f"处理中 {completed_count}/{total}(并发 {worker_count})", - ) - - if cancelled: - for future in list(running.keys()): - future.cancel() - _update_account_async_task( - task_id, - status="cancelled", - finished_at=_utc_now_iso(), - message=f"任务已取消,进度 {completed_count}/{total}", - paused=False, - result={ - "success_count": success_count, - "failed_count": failed_count, - "total": total, - "cancelled": True, - }, - ) - return - finally: - pool.shutdown(wait=False, cancel_futures=True) - - _update_account_async_task( - task_id, - status="completed", - finished_at=_utc_now_iso(), - message=f"刷新完成:成功 {success_count},失败 {failed_count}", - paused=False, - result={ - "success_count": success_count, - "failed_count": failed_count, - "total": total, - "cancelled": False, - }, - ) - - -def _is_retryable_validate_error(error_message: Optional[str]) -> bool: - text = str(error_message or "").strip().lower() - if not text: - return False - retry_markers = ( - "network_error", - "network", - "timeout", - "timed out", - "connection", - "temporarily", - "too many requests", - "http 429", - "http 500", - "http 502", - "http 503", - "http 504", - "rate limit", - ) - return any(marker in text for marker in retry_markers) - - -def _calculate_validate_worker_count(total: int, *, async_mode: bool) -> int: - """计算批量验证并发数:避免过低并发导致慢,也避免线程过多造成抖动。""" - safe_total = max(0, int(total or 0)) - if safe_total <= 0: - return 1 - max_workers = ACCOUNT_BATCH_VALIDATE_ASYNC_MAX_WORKERS if async_mode else ACCOUNT_BATCH_VALIDATE_SYNC_MAX_WORKERS - if safe_total <= 3: - return safe_total - if safe_total <= 10: - return min(max_workers, max(4, safe_total)) - if safe_total <= 40: - return min(max_workers, max(6, safe_total // 2)) - return min(max_workers, max(8, safe_total // 3)) - - -def _derive_account_status_from_validate_result(is_valid: bool, error: Optional[str]) -> str: - """ - 将 Token 验证结果映射为账号状态。 - 这里与 token_refresh.validate_account_token 的回写规则保持一致, - 便于异步任务在进度明细里即时返回最终状态。 - """ - if bool(is_valid): - return AccountStatus.ACTIVE.value - - error_text = str(error or "").lower() - if ( - "402" in error_text - or "payment required" in error_text - or "订阅受限" in error_text - ): - return AccountStatus.EXPIRED.value - if ( - "401" in error_text - or "invalid" in error_text - or "unauthorized" in error_text - or "过期" in error_text - or "expired" in error_text - ): - return AccountStatus.FAILED.value - if ( - "封禁" in error_text - or "banned" in error_text - or "forbidden" in error_text - ): - return AccountStatus.BANNED.value - return AccountStatus.FAILED.value - - -def _validate_one_account_with_retry( - account_id: int, - proxy: Optional[str], - max_attempts: int = ACCOUNT_BATCH_VALIDATE_RETRY_ATTEMPTS, - timeout_seconds: int = ACCOUNT_BATCH_VALIDATE_HTTP_TIMEOUT_SECONDS, -) -> Dict[str, Any]: - attempts = max(1, int(max_attempts or 1)) - timeout_seconds = max(5, int(timeout_seconds or ACCOUNT_BATCH_VALIDATE_HTTP_TIMEOUT_SECONDS)) - last_error = "" - for attempt in range(1, attempts + 1): - try: - is_valid, error = do_validate(account_id, proxy, timeout_seconds=timeout_seconds) + "valid": is_valid, + "error": error + }) if is_valid: - return { - "id": account_id, - "valid": True, - "status": _derive_account_status_from_validate_result(True, error), - "error": None, - "attempts": attempt, - } - last_error = str(error or "token_invalid") - can_retry = attempt < attempts and _is_retryable_validate_error(last_error) - if can_retry: - time.sleep(ACCOUNT_BATCH_VALIDATE_RETRY_BASE_DELAY_SECONDS * attempt) - continue - return { - "id": account_id, - "valid": False, - "status": _derive_account_status_from_validate_result(False, last_error), - "error": last_error, - "attempts": attempt, - } - except Exception as exc: - last_error = str(exc) - can_retry = attempt < attempts and _is_retryable_validate_error(last_error) - if can_retry: - time.sleep(ACCOUNT_BATCH_VALIDATE_RETRY_BASE_DELAY_SECONDS * attempt) - continue - return { + results["valid_count"] += 1 + else: + results["invalid_count"] += 1 + except Exception as e: + # 异常账号兜底打标 failed,保证前端“失败”筛选可见。 + try: + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if account and account.status != AccountStatus.FAILED.value: + crud.update_account(db, account_id, status=AccountStatus.FAILED.value) + except Exception: + pass + results["invalid_count"] += 1 + results["details"].append({ "id": account_id, "valid": False, - "status": _derive_account_status_from_validate_result(False, last_error), - "error": last_error, - "attempts": attempt, - } + "error": str(e) + }) - return { - "id": account_id, - "valid": False, - "status": _derive_account_status_from_validate_result(False, last_error or "validation_failed"), - "error": last_error or "validation_failed", - "attempts": attempts, - } + return results -def _run_batch_validate_task(task_id: str, ids: List[int], proxy: Optional[str]): - started_perf = time.perf_counter() - total = len(ids) - valid_count = 0 - invalid_count = 0 - completed_count = 0 - retry_count = 0 - - _update_account_async_task( - task_id, - status="running", - started_at=_utc_now_iso(), - message=f"开始验证 Token,共 {total} 个账号", - paused=False, - ) +@router.post("/batch-validate") +async def batch_validate_tokens(request: BatchValidateRequest): + """批量验证账号 Token 有效性""" + return _run_batch_validate_tokens(request) - if total <= 0: - _update_account_async_task( - task_id, - status="completed", - finished_at=_utc_now_iso(), - message="没有可验证的账号", - paused=False, - result={ - "valid_count": 0, - "invalid_count": 0, - "total": 0, - "cancelled": False, - }, - ) - return - worker_count = _calculate_validate_worker_count(total, async_mode=True) - _update_account_async_task(task_id, message=f"处理中 0/{total}(并发 {worker_count})") +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") - next_index = 0 - running: Dict[Any, int] = {} - cancelled = False - pool = ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="batch_validate_async") + started_at = utcnow_naive() try: - while completed_count < total: - if not _wait_if_account_async_task_paused( - task_id, - f"处理中 {completed_count}/{total}(并发 {worker_count})", - ): - cancelled = True - break - if _is_account_async_task_cancel_requested(task_id): - cancelled = True - break - - while next_index < total and len(running) < worker_count: - if not _wait_if_account_async_task_paused( - task_id, - f"处理中 {completed_count}/{total}(并发 {worker_count})", - ): - cancelled = True - break - account_id = int(ids[next_index]) - next_index += 1 - future = pool.submit(_validate_one_account_with_retry, account_id, proxy) - running[future] = account_id - - if cancelled: - break - if not running: - continue - - done, _ = wait(tuple(running.keys()), timeout=0.6, return_when=FIRST_COMPLETED) - if not done: - continue - - for future in done: - account_id = int(running.pop(future, 0) or 0) - if account_id <= 0: - continue - try: - detail = future.result() - except Exception as exc: - detail = { - "id": account_id, - "valid": False, - "error": str(exc), - "attempts": 1, - } - - completed_count += 1 - if detail.get("valid"): - valid_count += 1 - else: - invalid_count += 1 - attempts = int(detail.get("attempts") or 1) - retry_count += max(0, attempts - 1) - - _append_account_async_task_detail(task_id, detail) - _set_account_async_task_progress( - task_id, - total=total, - completed=completed_count, - success=valid_count, - failed=invalid_count, - ) - _update_account_async_task( - task_id, - message=f"处理中 {completed_count}/{total}(并发 {worker_count})", - ) - - if detail.get("valid"): - logger.info( - "批量验证结果: task_id=%s account_id=%s valid=true attempts=%s", - task_id, - account_id, - detail.get("attempts"), - ) - else: - logger.warning( - "批量验证结果: task_id=%s account_id=%s valid=false attempts=%s error=%s", - task_id, - account_id, - detail.get("attempts"), - str(detail.get("error") or "")[:220] or "-", - ) - - if cancelled: - for future in list(running.keys()): - future.cancel() - _update_account_async_task( - task_id, - status="cancelled", - finished_at=_utc_now_iso(), - message=f"任务已取消,进度 {completed_count}/{total}", - paused=False, - result={ - "valid_count": valid_count, - "invalid_count": invalid_count, - "total": total, - "cancelled": True, - "worker_count": worker_count, - "retry_count": retry_count, - "duration_ms": int((time.perf_counter() - started_perf) * 1000), - }, - ) - return - finally: - pool.shutdown(wait=False, cancel_futures=True) - - _update_account_async_task( - task_id, - status="completed", - finished_at=_utc_now_iso(), - message=f"验证完成:有效 {valid_count},无效 {invalid_count}", - paused=False, - result={ - "valid_count": valid_count, - "invalid_count": invalid_count, - "total": total, - "cancelled": False, - "worker_count": worker_count, - "retry_count": retry_count, - "duration_ms": int((time.perf_counter() - started_perf) * 1000), - }, - ) - logger.info( - "批量验证完成: task_id=%s total=%s valid=%s invalid=%s proxy=%s", - task_id, - total, - valid_count, - invalid_count, - proxy or "-", - ) - - -def _is_retryable_overview_refresh_error(error_message: Optional[str]) -> bool: - text = str(error_message or "").strip().lower() - if not text: - return False - retry_markers = ( - "network_error", - "network", - "timeout", - "timed out", - "connection", - "temporarily", - "too many requests", - "http 429", - "http 500", - "http 502", - "http 503", - "http 504", - "rate limit", - ) - return any(marker in text for marker in retry_markers) - - -def _refresh_overview_account_once(account_id: int, force_refresh: bool, proxy: Optional[str]) -> Dict[str, Any]: - with get_db() as db: - account = crud.get_account_by_id(db, account_id) - if not account: - return {"id": account_id, "success": False, "error": "账号不存在"} - - if (not _is_paid_subscription(account.subscription_type)) or _is_overview_card_removed(account): - return { - "id": account.id, - "email": account.email, - "success": False, - "skipped": True, - "error": "账号不在 Codex 卡片范围内,已跳过", - } - - account_proxy = (account.proxy_used or "").strip() or proxy - overview, updated = _get_account_overview_data( - db, - account, - force_refresh=force_refresh, - proxy=account_proxy, - allow_network=True, - ) - if updated: - db.commit() - - hourly_unknown = overview.get("hourly_quota", {}).get("status") == "unknown" - weekly_unknown = overview.get("weekly_quota", {}).get("status") == "unknown" - if hourly_unknown and weekly_unknown: - return { - "id": account.id, - "email": account.email, - "success": False, - "error": overview.get("error") or "未获取到配额数据", - } - - return { - "id": account.id, - "email": account.email, - "success": True, - "plan_type": overview.get("plan_type"), + 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": [], } - -def _refresh_overview_account_with_retry( - account_id: int, - force_refresh: bool, - proxy: Optional[str], - max_attempts: int = ACCOUNT_OVERVIEW_REFRESH_RETRY_ATTEMPTS, -) -> Dict[str, Any]: - attempts = max(1, int(max_attempts or 1)) - last_detail: Dict[str, Any] = {"id": int(account_id), "success": False, "error": "刷新失败"} - - for attempt in range(1, attempts + 1): - try: - detail = _refresh_overview_account_once(account_id, force_refresh, proxy) - except Exception as exc: - detail = {"id": int(account_id), "success": False, "error": str(exc)} - - detail["attempts"] = attempt - if detail.get("success") or detail.get("skipped"): - return detail - - last_detail = detail - error_text = str(detail.get("error") or "") - can_retry = attempt < attempts and _is_retryable_overview_refresh_error(error_text) - if can_retry: - time.sleep(ACCOUNT_OVERVIEW_REFRESH_RETRY_BASE_DELAY_SECONDS * attempt) - continue - return detail - - return last_detail - - -def _run_overview_refresh_async_task(task_id: str, ids: List[int], force_refresh: bool, proxy: Optional[str]): - total = len(ids) - success_count = 0 - failed_count = 0 - completed_count = 0 - - _update_account_async_task( - task_id, - status="running", - started_at=_utc_now_iso(), - message=f"开始刷新账号总览,共 {total} 个账号", - paused=False, - ) - - if total <= 0: - _update_account_async_task( - task_id, - status="completed", - finished_at=_utc_now_iso(), - message="没有可刷新的总览账号", - paused=False, - result={ - "success_count": 0, - "failed_count": 0, - "total": 0, - "cancelled": False, - }, - ) - return - - worker_count = min(ACCOUNT_OVERVIEW_REFRESH_MAX_WORKERS, max(1, total)) - _update_account_async_task(task_id, message=f"处理中 0/{total}(并发 {worker_count})") - - next_index = 0 - running: Dict[Any, int] = {} - cancelled = False - pool = ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="overview_refresh_async") - try: - while completed_count < total: - if not _wait_if_account_async_task_paused( - task_id, - f"处理中 {completed_count}/{total}(并发 {worker_count})", - ): - cancelled = True - break - if _is_account_async_task_cancel_requested(task_id): - cancelled = True - break - - while next_index < total and len(running) < worker_count: - if not _wait_if_account_async_task_paused( - task_id, - f"处理中 {completed_count}/{total}(并发 {worker_count})", - ): - cancelled = True - break - account_id = int(ids[next_index]) - next_index += 1 - future = pool.submit(_refresh_overview_account_with_retry, account_id, force_refresh, proxy) - running[future] = account_id - - if cancelled: - break - if not running: - continue - - done, _ = wait(tuple(running.keys()), timeout=0.6, return_when=FIRST_COMPLETED) - if not done: - continue - - for future in done: - account_id = int(running.pop(future, 0) or 0) - if account_id <= 0: - continue - try: - detail = future.result() - except Exception as exc: - detail = {"id": account_id, "success": False, "error": str(exc), "attempts": 1} - - completed_count += 1 - if detail.get("success") is True: - success_count += 1 - elif not detail.get("skipped"): - failed_count += 1 - - _append_account_async_task_detail(task_id, detail) - _set_account_async_task_progress( - task_id, - total=total, - completed=completed_count, - success=success_count, - failed=failed_count, - ) - _update_account_async_task( - task_id, - message=f"处理中 {completed_count}/{total}(并发 {worker_count})", - ) - - if cancelled: - for future in list(running.keys()): - future.cancel() - _update_account_async_task( - task_id, - status="cancelled", - finished_at=_utc_now_iso(), - message=f"任务已取消,进度 {completed_count}/{total}", - paused=False, - result={ - "success_count": success_count, - "failed_count": failed_count, - "total": total, - "cancelled": True, - }, - ) - return - finally: - pool.shutdown(wait=False, cancel_futures=True) - - _update_account_async_task( - task_id, - status="completed", - finished_at=_utc_now_iso(), - message=f"刷新完成:成功 {success_count},失败 {failed_count}", - paused=False, - result={ - "success_count": success_count, - "failed_count": failed_count, - "total": total, - "cancelled": False, - }, - ) - - -@router.get("/tasks/{task_id}") -def get_account_async_task_status(task_id: str): - task = _get_account_async_task_or_404(task_id) - return _build_account_async_task_snapshot(task) - - -@router.post("/tasks/{task_id}/cancel") -def cancel_account_async_task(task_id: str): - with _account_async_tasks_lock: - task = _account_async_tasks.get(task_id) - if not task: - raise HTTPException(status_code=404, detail="任务不存在") - if task.get("status") in {"completed", "failed", "cancelled"}: - return { - "success": True, - "task_id": task_id, - "status": task.get("status"), - "message": "任务已结束,无需取消", - } - task["cancel_requested"] = True - task["pause_requested"] = False - task["paused"] = False - task["message"] = "已提交取消请求,等待任务停止" - task_manager.request_domain_task_cancel("accounts", task_id) - return { - "success": True, - "task_id": task_id, - "status": "cancelling", - } - - -@router.post("/tasks/{task_id}/pause") -def pause_account_async_task(task_id: str): - with _account_async_tasks_lock: - task = _account_async_tasks.get(task_id) - if not task: - raise HTTPException(status_code=404, detail="任务不存在") - status = str(task.get("status") or "").strip().lower() - if status in {"completed", "failed", "cancelled"}: - return { - "success": True, - "task_id": task_id, - "status": status, - "message": "任务已结束,无法暂停", - } - task["pause_requested"] = True - task["paused"] = True - task["status"] = "paused" - task["message"] = "任务已暂停,等待继续" - task_manager.request_domain_task_pause("accounts", task_id) - return { - "success": True, - "task_id": task_id, - "status": "paused", - "message": "任务已暂停", - } - - -@router.post("/tasks/{task_id}/resume") -def resume_account_async_task(task_id: str): - with _account_async_tasks_lock: - task = _account_async_tasks.get(task_id) - if not task: - raise HTTPException(status_code=404, detail="任务不存在") - status = str(task.get("status") or "").strip().lower() - if status in {"completed", "failed", "cancelled"}: - return { - "success": True, - "task_id": task_id, - "status": status, - "message": "任务已结束,无需继续", - } - task["pause_requested"] = False - task["paused"] = False - if status == "paused": - task["status"] = "running" - task["message"] = "任务已继续执行" - task_manager.request_domain_task_resume("accounts", task_id) - return { - "success": True, - "task_id": task_id, - "status": "running", - "message": "任务已继续执行", - } - - -def retry_account_async_task(task_id: str) -> Dict[str, Any]: - task = _get_account_async_task_or_404(task_id) - task_type = str(task.get("task_type") or "").strip().lower() - payload = dict(task.get("payload") or {}) - - ids = payload.get("ids") - if not isinstance(ids, list): - ids = [] - safe_ids = [int(item) for item in ids if str(item).strip().isdigit()] - - common_kwargs = { - "ids": safe_ids, - "proxy": payload.get("proxy"), - "select_all": bool(payload.get("select_all", False)), - "status_filter": payload.get("status_filter"), - "email_service_filter": payload.get("email_service_filter"), - "search_filter": payload.get("search_filter"), - } - - if task_type == "batch_refresh": - return start_batch_refresh_async(BatchRefreshRequest(**common_kwargs)) - if task_type == "batch_validate": - return start_batch_validate_async(BatchValidateRequest(**common_kwargs)) - if task_type == "overview_refresh": - return start_overview_refresh_async( - OverviewRefreshRequest( - **common_kwargs, - force=bool(payload.get("force", False)), + if candidate_ids: + validate_result = _run_batch_validate_tokens( + BatchValidateRequest(ids=candidate_ids, proxy=proxy, select_all=False) ) - ) - - raise HTTPException(status_code=400, detail=f"不支持重试的任务类型: {task_type or '-'}") - - -@router.post("/batch-refresh/async") -def start_batch_refresh_async(request: BatchRefreshRequest): - proxy = _get_proxy(request.proxy) - with get_db() as db: - ids = resolve_account_ids( - db, request.ids, request.select_all, - request.status_filter, request.email_service_filter, request.search_filter - ) - - task_payload = { - "ids": [int(item) for item in ids], - "proxy": proxy, - "select_all": bool(request.select_all), - "status_filter": request.status_filter, - "email_service_filter": request.email_service_filter, - "search_filter": request.search_filter, - } - task_id = _create_account_async_task("batch_refresh", total=len(ids), payload=task_payload) - if not ids: - _update_account_async_task( - task_id, - status="completed", - started_at=_utc_now_iso(), - finished_at=_utc_now_iso(), - message="没有可刷新的账号", - result={"success_count": 0, "failed_count": 0, "total": 0, "cancelled": False}, - ) - else: - _account_async_executor.submit( - _run_account_async_task_guard, - task_id, - "batch_refresh", - _run_batch_refresh_task, - ids, - proxy, - ) - - task = _get_account_async_task_or_404(task_id) - return _build_account_async_task_snapshot(task) - - -@router.post("/batch-validate/async") -def start_batch_validate_async(request: BatchValidateRequest): - proxy = _get_proxy(request.proxy) - with get_db() as db: - ids = resolve_account_ids( - db, request.ids, request.select_all, - request.status_filter, request.email_service_filter, request.search_filter - ) - - task_payload = { - "ids": [int(item) for item in ids], - "proxy": proxy, - "select_all": bool(request.select_all), - "status_filter": request.status_filter, - "email_service_filter": request.email_service_filter, - "search_filter": request.search_filter, - } - task_id = _create_account_async_task("batch_validate", total=len(ids), payload=task_payload) - if not ids: - _update_account_async_task( - task_id, - status="completed", - started_at=_utc_now_iso(), - finished_at=_utc_now_iso(), - message="没有可验证的账号", - result={"valid_count": 0, "invalid_count": 0, "total": 0, "cancelled": False}, - ) - else: - _account_async_executor.submit( - _run_account_async_task_guard, - task_id, - "batch_validate", - _run_batch_validate_task, - ids, - proxy, - ) - - task = _get_account_async_task_or_404(task_id) - return _build_account_async_task_snapshot(task) - - -@router.post("/overview/refresh/async") -def start_overview_refresh_async(request: OverviewRefreshRequest): - proxy = _get_proxy(request.proxy) - with get_db() as db: - ids = resolve_account_ids( - db, - request.ids, - request.select_all, - request.status_filter, - request.email_service_filter, - request.search_filter, - ) - if not ids: - candidates = db.query(Account).filter( - func.lower(Account.subscription_type).in_(PAID_SUBSCRIPTION_TYPES) - ).order_by(Account.created_at.desc()).all() - ids = [acc.id for acc in candidates if not _is_overview_card_removed(acc)] - - task_payload = { - "ids": [int(item) for item in ids], - "proxy": proxy, - "force": bool(request.force), - "select_all": bool(request.select_all), - "status_filter": request.status_filter, - "email_service_filter": request.email_service_filter, - "search_filter": request.search_filter, - } - task_id = _create_account_async_task("overview_refresh", total=len(ids), payload=task_payload) - if not ids: - _update_account_async_task( - task_id, - status="completed", - started_at=_utc_now_iso(), - finished_at=_utc_now_iso(), - message="没有可刷新的总览账号", - result={"success_count": 0, "failed_count": 0, "total": 0, "cancelled": False}, - ) - else: - _account_async_executor.submit( - _run_account_async_task_guard, - task_id, - "overview_refresh", - _run_overview_refresh_async_task, - ids, - bool(request.force), - proxy, - ) - - task = _get_account_async_task_or_404(task_id) - return _build_account_async_task_snapshot(task) - - -@router.post("/batch-refresh") -def batch_refresh_tokens(request: BatchRefreshRequest): - """批量刷新账号 Token(并发执行,避免长轮询阻塞)。""" - proxy = _get_proxy(request.proxy) - - results = { - "success_count": 0, - "failed_count": 0, - "errors": [] - } - - with get_db() as db: - ids = resolve_account_ids( - db, request.ids, request.select_all, - request.status_filter, request.email_service_filter, request.search_filter - ) - - if not ids: - return results - - def _refresh_one(account_id: int) -> dict: - try: - result = do_refresh(account_id, proxy) - if result.success: - return {"id": account_id, "success": True} - return {"id": account_id, "success": False, "error": result.error_message} - except Exception as exc: - return {"id": account_id, "success": False, "error": str(exc)} - - worker_count = min(6, max(1, len(ids))) - with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="batch_refresh") as pool: - future_map = {pool.submit(_refresh_one, account_id): account_id for account_id in ids} - for future in as_completed(future_map): - item = future.result() - if item.get("success"): - results["success_count"] += 1 - else: - results["failed_count"] += 1 - results["errors"].append({"id": item.get("id"), "error": item.get("error")}) - - return results - - -@router.post("/{account_id}/refresh") -def refresh_account_token(account_id: int, request: Optional[TokenRefreshRequest] = Body(default=None)): - """刷新单个账号的 Token""" - proxy = _get_proxy(request.proxy if request else None) - result = do_refresh(account_id, proxy) + 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) - if result.success: - return { - "success": True, - "message": "Token 刷新成功", - "expires_at": result.expires_at.isoformat() if result.expires_at else None - } - else: + finished_at = utcnow_naive() + duration_ms = max(0, int((finished_at - started_at).total_seconds() * 1000)) return { - "success": False, - "error": result.error_message - } - - -@router.post("/batch-validate") -def batch_validate_tokens(request: BatchValidateRequest): - """批量验证账号 Token 有效性(并发执行)。""" - started = time.perf_counter() - proxy = _get_proxy(request.proxy) - - results = { - "valid_count": 0, - "invalid_count": 0, - "details": [], - "worker_count": 0, - "retry_count": 0, - "duration_ms": 0, - } - - with get_db() as db: - ids = resolve_account_ids( - db, request.ids, request.select_all, - request.status_filter, request.email_service_filter, request.search_filter - ) - - if not ids: - results["duration_ms"] = int((time.perf_counter() - started) * 1000) - return results - - worker_count = _calculate_validate_worker_count(len(ids), async_mode=False) - results["worker_count"] = worker_count - with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="batch_validate") as pool: - future_map = { - pool.submit( - _validate_one_account_with_retry, - account_id, - proxy, - ACCOUNT_BATCH_VALIDATE_RETRY_ATTEMPTS, - ACCOUNT_BATCH_VALIDATE_HTTP_TIMEOUT_SECONDS, - ): account_id - for account_id in ids + "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, } - for future in as_completed(future_map): - detail = future.result() - results["details"].append(detail) - results["retry_count"] += max(0, int(detail.get("attempts") or 1) - 1) - if detail.get("valid"): - results["valid_count"] += 1 - else: - results["invalid_count"] += 1 - - results["details"].sort(key=lambda item: int(item.get("id") or 0)) - results["duration_ms"] = int((time.perf_counter() - started) * 1000) - logger.info( - "批量验证(同步)完成: total=%s valid=%s invalid=%s workers=%s retries=%s duration_ms=%s proxy=%s", - len(ids), - results["valid_count"], - results["invalid_count"], - results["worker_count"], - results["retry_count"], - results["duration_ms"], - proxy or "-", - ) - return results + finally: + _QUICK_REFRESH_WORKFLOW_LOCK.release() @router.post("/{account_id}/validate") -def validate_account_token(account_id: int, request: Optional[TokenValidateRequest] = Body(default=None)): +async def validate_account_token(account_id: int, request: Optional[TokenValidateRequest] = Body(default=None)): """验证单个账号的 Token 有效性""" proxy = _get_proxy(request.proxy if request else None) is_valid, error = do_validate(account_id, proxy) - next_status = _derive_account_status_from_validate_result(is_valid, error) return { "id": account_id, "valid": is_valid, - "status": next_status, "error": error } @@ -3687,8 +2132,6 @@ async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): raise HTTPException(status_code=404, detail="指定的 CPA 服务不存在") cpa_api_url = svc.api_url cpa_api_token = svc.api_token - if not request.proxy: - proxy = getattr(svc, "proxy_url", None) or proxy with get_db() as db: ids = resolve_account_ids( @@ -3717,8 +2160,6 @@ async def upload_account_to_cpa(account_id: int, request: Optional[CPAUploadRequ raise HTTPException(status_code=404, detail="指定的 CPA 服务不存在") cpa_api_url = svc.api_url cpa_api_token = svc.api_token - if not (request and request.proxy): - proxy = getattr(svc, "proxy_url", None) or proxy with get_db() as db: account = crud.get_account_by_id(db, account_id) @@ -3739,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: @@ -3772,7 +2213,6 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): # 解析指定的 Sub2API 服务 api_url = None api_key = None - target_type = "sub2api" if request.service_id: with get_db() as db: svc = crud.get_sub2api_service_by_id(db, request.service_id) @@ -3780,14 +2220,12 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): raise HTTPException(status_code=404, detail="指定的 Sub2API 服务不存在") api_url = svc.api_url api_key = svc.api_key - target_type = getattr(svc, "target_type", "sub2api") else: with get_db() as db: svcs = crud.get_sub2api_services(db, enabled=True) if svcs: api_url = svcs[0].api_url api_key = svcs[0].api_key - target_type = getattr(svcs[0], "target_type", "sub2api") if not api_url or not api_key: raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") @@ -3802,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=target_type, ) return results @@ -3817,7 +2254,6 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp api_url = None api_key = None - target_type = "sub2api" if service_id: with get_db() as db: svc = crud.get_sub2api_service_by_id(db, service_id) @@ -3825,14 +2261,12 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp raise HTTPException(status_code=404, detail="指定的 Sub2API 服务不存在") api_url = svc.api_url api_key = svc.api_key - target_type = getattr(svc, "target_type", "sub2api") else: with get_db() as db: svcs = crud.get_sub2api_services(db, enabled=True) if svcs: api_url = svcs[0].api_url api_key = svcs[0].api_key - target_type = getattr(svcs[0], "target_type", "sub2api") if not api_url or not api_key: raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") @@ -3847,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=target_type + target_type="sub2api" ) if success: return {"success": True, "message": message} @@ -3855,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): @@ -3938,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 "" @@ -3967,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 index 3a51afa9..10a62eb3 100644 --- a/src/web/routes/auto_team.py +++ b/src/web/routes/auto_team.py @@ -3514,4 +3514,3 @@ async def execute_auto_team_invite(request: AutoTeamInviteRequest): 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 840579ca..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__) @@ -39,21 +40,20 @@ class EmailServiceUpdate(BaseModel): class EmailServiceResponse(BaseModel): - """邮箱服务响应""" + """??????""" id: int service_type: str name: str enabled: bool priority: int - config: Optional[Dict[str, Any]] = None # 过滤敏感信息后的配置 + config: Optional[Dict[str, Any]] = None # ?????????? registration_status: Optional[str] = None registered_account_id: Optional[int] = None last_used: Optional[str] = None 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: @@ -118,23 +132,13 @@ def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]: return filtered -def _normalize_outlook_email_config(service_type: str, config: Optional[Dict[str, Any]]) -> Dict[str, Any]: - """统一 Outlook 配置中的邮箱大小写,避免和账号库比较不一致。""" - normalized = dict(config or {}) - if str(service_type or "").strip().lower() != "outlook": - return normalized - - if "email" in normalized: - normalized["email"] = str(normalized.get("email") or "").strip().lower() - return normalized - - def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: - """转换服务模型为响应""" + """?????????""" + 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: @@ -155,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, @@ -170,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, @@ -183,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 } @@ -200,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': @@ -210,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 @@ -222,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", @@ -261,18 +292,6 @@ async def get_service_types(): {"name": "enable_prefix", "label": "启用前缀", "required": False, "default": True}, ] }, - { - "value": "cloudmail", - "label": "CloudMail(自部署)", - "description": "CloudMail 自部署邮箱服务(配置与 Temp-Mail 兼容)", - "config_fields": [ - {"name": "base_url", "label": "Worker 地址", "required": True, "placeholder": "https://mail.example.com"}, - {"name": "admin_password", "label": "Admin 密码", "required": True, "secret": True}, - {"name": "custom_auth", "label": "Custom Auth(可选)", "required": False, "secret": True}, - {"name": "domain", "label": "邮箱域名", "required": True, "placeholder": "example.com"}, - {"name": "enable_prefix", "label": "启用前缀", "required": False, "default": True}, - ] - }, { "value": "duck_mail", "label": "DuckMail", @@ -294,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 邮箱", @@ -305,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}, + ] } ] } @@ -357,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, @@ -373,31 +417,16 @@ async def create_email_service(request: EmailServiceCreate): except ValueError: raise HTTPException(status_code=400, detail=f"无效的服务类型: {request.service_type}") - normalized_service_type = str(request.service_type or "").strip().lower() - normalized_config = _normalize_outlook_email_config(normalized_service_type, request.config) - normalized_name = str(request.name or "").strip() - if normalized_service_type == "outlook": - normalized_email = str(normalized_config.get("email") or normalized_name).strip().lower() - if normalized_email: - normalized_name = normalized_email - normalized_config["email"] = normalized_email - with get_db() as db: # 检查名称是否重复 - if normalized_service_type == "outlook": - existing = db.query(EmailServiceModel).filter( - EmailServiceModel.service_type == "outlook", - func.lower(EmailServiceModel.name) == normalized_name - ).first() - else: - existing = db.query(EmailServiceModel).filter(EmailServiceModel.name == normalized_name).first() + existing = db.query(EmailServiceModel).filter(EmailServiceModel.name == request.name).first() if existing: raise HTTPException(status_code=400, detail="服务名称已存在") service = EmailServiceModel( - service_type=normalized_service_type, - name=normalized_name, - config=normalized_config, + service_type=request.service_type, + name=request.name, + config=normalize_email_service_config(request.service_type, request.config), enabled=request.enabled, priority=request.priority ) @@ -421,19 +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} - merged_config = _normalize_outlook_email_config(service.service_type, merged_config) - if ( - str(service.service_type or "").strip().lower() == "outlook" - and request.name is None - ): - normalized_email = str(merged_config.get("email") or "").strip().lower() - if normalized_email: - update_data["name"] = normalized_email - update_data["config"] = merged_config + 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: @@ -472,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() @@ -572,7 +597,7 @@ async def batch_import_outlook(request: OutlookBatchImportRequest): errors.append(f"行 {i+1}: 格式错误,至少需要邮箱和密码") continue - email = parts[0].strip().lower() + email = parts[0].strip() password = parts[1].strip() # 验证邮箱格式 @@ -584,7 +609,7 @@ async def batch_import_outlook(request: OutlookBatchImportRequest): # 检查是否已存在 existing = db.query(EmailServiceModel).filter( EmailServiceModel.service_type == "outlook", - func.lower(EmailServiceModel.name) == email + EmailServiceModel.name == email ).first() if existing: @@ -663,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() + + 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} - tempmail = EmailServiceFactory.create(EmailServiceType.TEMPMAIL, config) + 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 0e0d15ee..c1dbbcd1 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -6,27 +6,21 @@ import os import re import uuid -import threading -from contextlib import contextmanager -from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait -from typing import Optional, List, Dict, Any, Tuple, Callable +from typing import Optional, List from datetime import datetime import time -from urllib.parse import urlparse, urlunparse, quote +from urllib.parse import urlparse, urlunparse -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field -from sqlalchemy import func, or_ +from sqlalchemy import or_ from sqlalchemy.orm import joinedload from curl_cffi import requests as cffi_requests from ...database.session import get_db -from ...database import crud from ...database.models import Account, BindCardTask, EmailService as EmailServiceModel from ...config.settings import get_settings -from ...config.constants import ( - OPENAI_PAGE_TYPES, -) +from ...config.constants import OPENAI_PAGE_TYPES from ...services import EmailServiceFactory, EmailServiceType from ...core.register import RegistrationEngine from .accounts import resolve_account_ids @@ -37,14 +31,11 @@ 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 from ...core.dynamic_proxy import get_proxy_url_for_task -from ...core.circuit_breaker import allow_request as breaker_allow_request -from ...core.circuit_breaker import record_failure as breaker_record_failure -from ...core.circuit_breaker import record_success as breaker_record_success -from ..task_manager import task_manager logger = logging.getLogger(__name__) router = APIRouter() @@ -73,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", @@ -91,51 +105,6 @@ "ES": "EUR", "EU": "EUR", } -VENDOR_REDEEM_CODE_REGEX = re.compile(r"^UK(?:-[A-Z0-9]{5}){5}$") -VENDOR_EFUN_FLOW_VERSION = "vendor_efun_flow_20260327d" -VENDOR_BINDCARD_API_URL_ENV = "VENDOR_BINDCARD_API_URL" -VENDOR_BINDCARD_API_KEY_ENV = "VENDOR_BINDCARD_API_KEY" -VENDOR_BINDCARD_API_URL_DEFAULT = "https://card.aimizy.com/api/v1/bindcard" -VENDOR_BINDCARD_API_KEY_DEFAULT = "" -EFUNCARD_BASE_URL_ENV = "EFUNCARD_BASE_URL" -EFUNCARD_API_KEY_ENV = "EFUNCARD_API_KEY" -EFUNCARD_BASE_URL_DEFAULT = "https://card.efuncard.com" -EFUNCARD_API_KEY_DEFAULT = "" -EFUNCARD_CODE_REGEX = re.compile(r"^UK(?:-[A-Z0-9]{5}){5}$") -EFUNCARD_EXPIRY_REGEX = re.compile(r"^\s*(\d{1,2})\s*/\s*(\d{2,4})\s*$") -VENDOR_NODE_INSTRUCTION_SPLIT_REGEX = re.compile(r"\s*,\s*") -_VENDOR_PROGRESS_LOCK = threading.Lock() -_VENDOR_PROGRESS_STATE: Dict[int, Dict[str, Any]] = {} -_VENDOR_PROGRESS_LOG_MAX = 400 -_VENDOR_FORCE_STOP_AFTER_SECONDS = 120 -_PAYMENT_OP_TASK_LOCK = threading.Lock() -_PAYMENT_OP_TASKS: Dict[str, Dict[str, Any]] = {} -_PAYMENT_OP_TASK_MAX_KEEP = 300 -_PAYMENT_OP_TASK_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="payment_op") -TERMINAL_BIND_TASK_STATUSES = ("completed", "failed", "cancelled") -_BIND_TASK_CREATE_LOCK_GUARD = threading.Lock() -_BIND_TASK_CREATE_LOCKS: Dict[int, threading.Lock] = {} -BIND_TASK_CREATE_LOCK_TIMEOUT_SECONDS = 8.0 -PAYMENT_BATCH_SUBSCRIPTION_CHECK_MAX_WORKERS = 8 -PAYMENT_BATCH_SUBSCRIPTION_CHECK_RETRY_ATTEMPTS = 2 -PAYMENT_BATCH_SUBSCRIPTION_CHECK_RETRY_BASE_DELAY_SECONDS = 0.8 -DISABLED_BIND_MODES = ("third_party", "vendor_auto") -ALLOWED_BIND_MODES = ("semi_auto", "local_auto", "vendor_efun") - - -def _resolve_actor(request: Optional[Request]) -> str: - if request is None: - return "system" - for key in ("x-operator", "x-user", "x-username"): - value = str(request.headers.get(key) or "").strip() - if value: - return value[:120] - client_host = "" - try: - client_host = str(getattr(getattr(request, "client", None), "host", "") or "").strip() - except Exception: - client_host = "" - return f"api@{client_host}" if client_host else "api" def _is_official_checkout_link(link: Optional[str]) -> bool: @@ -226,446 +195,8 @@ def _resolve_runtime_proxy(explicit_proxy: Optional[str], account: Optional[Acco return None -def _get_bind_task_create_lock(account_id: int) -> threading.Lock: - key = int(account_id) - with _BIND_TASK_CREATE_LOCK_GUARD: - lock = _BIND_TASK_CREATE_LOCKS.get(key) - if lock is None: - lock = threading.Lock() - _BIND_TASK_CREATE_LOCKS[key] = lock - return lock - - -@contextmanager -def _acquire_bind_task_create_lock(account_id: int): - lock = _get_bind_task_create_lock(account_id) - acquired = lock.acquire(timeout=BIND_TASK_CREATE_LOCK_TIMEOUT_SECONDS) - if not acquired: - raise HTTPException(status_code=429, detail="该账号正在创建绑卡任务,请稍后重试") - try: - yield - finally: - lock.release() - - -def _find_active_bind_task_for_account(db, account_id: int) -> Optional[BindCardTask]: - return ( - db.query(BindCardTask) - .filter(BindCardTask.account_id == int(account_id)) - .filter( - or_( - BindCardTask.status.is_(None), - ~BindCardTask.status.in_(TERMINAL_BIND_TASK_STATUSES), - ) - ) - .order_by(BindCardTask.created_at.desc(), BindCardTask.id.desc()) - .first() - ) - - -def _resolve_efuncard_base_url(request_base_url: Optional[str]) -> str: - raw = ( - str(request_base_url or "").strip() - or str(os.getenv(EFUNCARD_BASE_URL_ENV) or "").strip() - or EFUNCARD_BASE_URL_DEFAULT - ) - if "://" not in raw: - raw = f"https://{raw}" - parsed = urlparse(raw) - if not parsed.scheme or not parsed.netloc: - raise HTTPException(status_code=400, detail="EfunCard base_url 无效") - return urlunparse(parsed._replace(path="", params="", query="", fragment="")).rstrip("/") - - -def _resolve_efuncard_api_key(request_api_key: Optional[str]) -> str: - token = ( - str(request_api_key or "").strip() - or str(os.getenv(EFUNCARD_API_KEY_ENV) or "").strip() - or EFUNCARD_API_KEY_DEFAULT - ) - if not token: - raise HTTPException( - status_code=400, - detail=f"缺少 EfunCard API Key(可设置环境变量 {EFUNCARD_API_KEY_ENV})", - ) - return token - - -def _normalize_efuncard_code(code: Optional[str]) -> str: - text = str(code or "").strip().upper() - if not text: - raise HTTPException(status_code=400, detail="code 不能为空") - compact = re.sub(r"[^A-Z0-9]", "", text) - if not compact.startswith("UK"): - raise HTTPException(status_code=400, detail="code 格式无效") - body = compact[2:] - if len(body) != 25: - raise HTTPException(status_code=400, detail="code 格式无效") - normalized = f"UK-{body[0:5]}-{body[5:10]}-{body[10:15]}-{body[15:20]}-{body[20:25]}" - if not EFUNCARD_CODE_REGEX.fullmatch(normalized): - raise HTTPException(status_code=400, detail="code 格式无效") - return normalized - - -def _parse_efuncard_expiry(expiry_date: Optional[str]) -> Tuple[str, str]: - text = str(expiry_date or "").strip() - if not text: - return "", "" - match = EFUNCARD_EXPIRY_REGEX.match(text) - if not match: - return "", "" - month_raw = str(match.group(1) or "").strip() - year_raw = str(match.group(2) or "").strip() - try: - month_int = int(month_raw) - except Exception: - return "", "" - if month_int < 1 or month_int > 12: - return "", "" - month = f"{month_int:02d}" - if len(year_raw) == 2: - year = f"20{year_raw}" - else: - year = year_raw[:4] - if not year.isdigit(): - return "", "" - return month, year - - -def _efuncard_request( - *, - method: str, - path: str, - api_key: str, - base_url: str, - proxy: Optional[str], - payload: Optional[Dict[str, Any]] = None, - timeout_seconds: int = 35, -) -> Dict[str, Any]: - url = f"{base_url}{path}" - headers = { - "Authorization": f"Bearer {api_key}", - "Accept": "application/json", - "Content-Type": "application/json", - } - session_kwargs: Dict[str, Any] = { - "impersonate": "chrome120", - "timeout": max(5, int(timeout_seconds)), - } - if proxy: - session_kwargs["proxy"] = proxy - session = cffi_requests.Session(**session_kwargs) - try: - # 避免继承宿主机 HTTP(S)_PROXY,EFun 仅使用显式配置的代理 - session.trust_env = False - except Exception: - pass - method_up = str(method or "GET").strip().upper() - if method_up == "POST": - response = session.post(url, headers=headers, json=payload or {}) - elif method_up == "GET": - response = session.get(url, headers=headers) - else: - raise HTTPException(status_code=400, detail=f"不支持的 method: {method_up}") - - raw = "" - try: - body = response.json() - if not isinstance(body, dict): - body = {"success": False, "raw": body} - except Exception: - try: - raw = str(response.text or "").strip() - except Exception: - raw = "" - body = {"success": False, "message": raw or "provider_non_json_response"} - - if response.status_code >= 400: - message = str( - body.get("message") - or body.get("error") - or body.get("msg") - or raw - or f"http_{response.status_code}" - ).strip() - raise HTTPException(status_code=response.status_code, detail=f"EfunCard 请求失败: {message}") - - if body.get("success") is False: - message = str(body.get("message") or body.get("error") or body.get("msg") or "provider_failed").strip() - raise HTTPException(status_code=400, detail=f"EfunCard 返回失败: {message}") - - return body - - -def _resolve_vendor_bindcard_api_url(request_url: Optional[str]) -> str: - raw = ( - str(request_url or "").strip() - or str(os.getenv(VENDOR_BINDCARD_API_URL_ENV) or "").strip() - or VENDOR_BINDCARD_API_URL_DEFAULT - ) - if "://" not in raw: - raw = f"https://{raw}" - parsed = urlparse(raw) - if not parsed.scheme or not parsed.netloc: - raise HTTPException(status_code=400, detail="卡商 bindcard API 地址无效") - path = str(parsed.path or "").strip() - if not path or path == "/": - path = "/api/v1/bindcard" - normalized = parsed._replace(path="/" + path.lstrip("/"), params="", query="", fragment="") - return urlunparse(normalized) - - -def _resolve_vendor_bindcard_api_key(request_api_key: Optional[str]) -> str: - token = ( - str(request_api_key or "").strip() - or str(os.getenv(VENDOR_BINDCARD_API_KEY_ENV) or "").strip() - or VENDOR_BINDCARD_API_KEY_DEFAULT - ) - if not token: - raise HTTPException( - status_code=400, - detail=f"缺少卡商 bindcard API Key(请求传 api_key 或设置环境变量 {VENDOR_BINDCARD_API_KEY_ENV})", - ) - return token - - -def _build_vendor_bindcard_api_candidates(api_url: str) -> List[str]: - text = str(api_url or "").strip() - if not text: - return [] - if "://" not in text: - text = f"https://{text}" - parsed = urlparse(text) - if not parsed.scheme or not parsed.netloc: - return [] - base = f"{parsed.scheme}://{parsed.netloc}" - path = str(parsed.path or "").strip() - if not path or path == "/": - path = "/api/v1/bindcard" - path = "/" + path.lstrip("/") - lower = path.lower() - candidates: List[str] = [] - - def _append(value: str): - item = str(value or "").strip() - if item and item not in candidates: - candidates.append(item) - - _append(base + path) - if lower.endswith("/bindcard"): - _append(base + path[: -len("/bindcard")] + "/bind-card") - elif lower.endswith("/bind-card"): - _append(base + path[: -len("/bind-card")] + "/bindcard") - else: - _append(base + "/api/v1/bindcard") - _append(base + "/api/v1/bind-card") - return candidates - - -def _normalize_vendor_card_payload(redeem_data: Dict[str, Any]) -> Dict[str, str]: - data = redeem_data if isinstance(redeem_data, dict) else {} - card_number = str(data.get("cardNumber") or data.get("card_number") or "").strip() - cvc = str(data.get("cvv") or data.get("cvc") or "").strip() - exp_month = str(data.get("exp_month") or data.get("expiryMonth") or "").strip() - exp_year = str(data.get("exp_year") or data.get("expiryYear") or "").strip() - - if not exp_month or not exp_year: - exp_date = str(data.get("expiryDate") or "").strip() - month_fallback, year_fallback = _parse_efuncard_expiry(exp_date) - exp_month = exp_month or month_fallback - exp_year = exp_year or year_fallback - - try: - exp_month_num = int(str(exp_month or "").strip()) - exp_month = f"{exp_month_num:02d}" - except Exception: - exp_month = "" - - exp_year_text = re.sub(r"[^0-9]", "", str(exp_year or "").strip()) - if len(exp_year_text) == 2: - exp_year_text = f"20{exp_year_text}" - if len(exp_year_text) > 4: - exp_year_text = exp_year_text[:4] - - return { - "number": card_number, - "exp_month": exp_month, - "exp_year": exp_year_text, - "cvc": cvc, - } - - -def _vendor_country_code_from_text(text: Optional[str], fallback: str = "US") -> str: - raw = str(text or "").strip().upper() - if not raw: - return _normalize_checkout_country(fallback) - if len(raw) == 2 and raw.isalpha(): - return _normalize_checkout_country(raw) - - mapping = { - "UNITED KINGDOM": "GB", - "GREAT BRITAIN": "GB", - "BRITAIN": "GB", - "ENGLAND": "GB", - "UK": "GB", - "U.K.": "GB", - "UNITED STATES": "US", - "USA": "US", - "U.S.": "US", - "U.S.A.": "US", - "HONG KONG": "HK", - "JAPAN": "JP", - "SINGAPORE": "SG", - "CANADA": "CA", - "AUSTRALIA": "AU", - "GERMANY": "DE", - "FRANCE": "FR", - "SPAIN": "ES", - "ITALY": "IT", - } - for key, code in mapping.items(): - if key in raw: - return _normalize_checkout_country(code) - return _normalize_checkout_country(fallback) - - -def _vendor_proxy_country_label(country_code: str) -> str: - code = _normalize_checkout_country(country_code) - mapping = { - "US": "美国", - "GB": "英国", - "CA": "加拿大", - "AU": "澳大利亚", - "SG": "新加坡", - "HK": "香港", - "JP": "日本", - "TR": "土耳其", - "IN": "印度", - "BR": "巴西", - "MX": "墨西哥", - "DE": "德国", - "FR": "法国", - "IT": "意大利", - "ES": "西班牙", - } - return mapping.get(code, code) - - -def _parse_vendor_node_instructions(node_text: Optional[str], fallback_country: str) -> Dict[str, str]: - cleaned = re.sub(r"\s+", " ", str(node_text or "")).strip(" ,") - if not cleaned: - return {} - parts = [p.strip() for p in VENDOR_NODE_INSTRUCTION_SPLIT_REGEX.split(cleaned) if p and p.strip()] - if not parts: - return {} - - country = _vendor_country_code_from_text(parts[-1], fallback=fallback_country) - postal = parts[-2] if len(parts) >= 2 else "" - city = parts[-3] if len(parts) >= 3 else "" - line1 = ", ".join(parts[:-3]) if len(parts) >= 4 else (parts[0] if parts else "") - if not line1 and len(parts) >= 2: - line1 = parts[0] - - return { - "country": country, - "line1": str(line1 or "").strip(), - "city": str(city or "").strip(), - "state": "", - "postal_code": str(postal or "").strip(), - "raw": cleaned, - } - - -def _build_vendor_billing_payload( - *, - account: Account, - redeem_data: Dict[str, Any], - country_hint: str, -) -> Tuple[Dict[str, str], str]: - fallback_country = _normalize_checkout_country(country_hint) - node_text = str( - (redeem_data or {}).get("nodeInstructions") - or (redeem_data or {}).get("groupInstructions") - or "" - ).strip() - parsed = _parse_vendor_node_instructions(node_text, fallback_country) - source = "efun.nodeInstructions" if parsed else "random_billing" - - try: - random_profile = generate_random_billing_profile(country=fallback_country, proxy=None) - except Exception: - random_profile = {} - - random_name = str((random_profile or {}).get("billing_name") or "").strip() - random_line1 = str((random_profile or {}).get("address_line1") or "").strip() - random_city = str((random_profile or {}).get("address_city") or "").strip() - random_state = str((random_profile or {}).get("address_state") or "").strip() - random_postal = str((random_profile or {}).get("postal_code") or "").strip() - random_country = _normalize_checkout_country((random_profile or {}).get("country_code") or fallback_country) - - email = str(getattr(account, "email", "") or "").strip() - email_name = str(email.split("@")[0] if "@" in email else email).replace(".", " ").replace("_", " ").strip() - fallback_name = re.sub(r"\s+", " ", email_name).title() if email_name else "Card Holder" - - billing = { - "name": random_name or fallback_name, - "email": email, - "country": _normalize_checkout_country(parsed.get("country") or random_country or fallback_country), - "state": str(parsed.get("state") or random_state or "").strip(), - "city": str(parsed.get("city") or random_city or "").strip(), - "line1": str(parsed.get("line1") or random_line1 or "").strip(), - "postal_code": str(parsed.get("postal_code") or random_postal or "").strip(), - } - return billing, source - - -def _invoke_vendor_bindcard_api( - *, - api_url: str, - api_key: str, - payload: Dict[str, Any], - timeout_seconds: int = 120, -) -> Tuple[Dict[str, Any], str]: - headers = { - "Accept": "*/*", - "Content-Type": "application/json", - "User-Agent": "codex-console2/vendor-efun-bindcard", - "Authorization": f"Bearer {api_key}", - } - candidates = _build_vendor_bindcard_api_candidates(api_url) - if not candidates: - raise RuntimeError("卡商 bindcard API 地址无效") - errors: List[str] = [] - for endpoint in candidates: - try: - session = cffi_requests.Session( - impersonate="chrome120", - timeout=max(20, int(timeout_seconds or 120)), - ) - try: - session.trust_env = False - except Exception: - pass - resp = session.post(endpoint, headers=headers, json=payload) - parsed = _parse_third_party_response(resp) - if resp.status_code >= 400: - body = str((parsed or {}).get("message") or (parsed or {}).get("error") or (resp.text or ""))[:400] - errors.append(f"{endpoint} http={resp.status_code} body={body}") - continue - if not isinstance(parsed, dict): - parsed = {"raw": str(parsed)[:1000]} - parsed["_meta_endpoint"] = endpoint - return parsed, endpoint - except Exception as exc: - errors.append(f"{endpoint} error={exc}") - continue - summary = " | ".join(errors[-4:]) if errors else "unknown_error" - raise RuntimeError(f"卡商 bindcard API 调用失败: {summary}") - - def _serialize_bind_card_task(task: BindCardTask) -> dict: - # 账号删除后仍展示邮箱快照(纯文本),便于历史任务识别 - account_email = task.account.email if task.account else (str(getattr(task, "account_email", "") or "").strip() or None) + account_email = task.account.email if task.account else None return { "id": task.id, "account_id": task.account_id, @@ -1230,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): @@ -1283,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}),无法自动获取登录验证码" @@ -1423,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 "" @@ -1432,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) @@ -1688,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: @@ -1762,2183 +1311,81 @@ 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 task.last_error = reason -def _promote_child_account_to_mother(account: Account, *, reason: str) -> bool: - """ - 历史兼容函数:关闭“订阅命中 plus/team 后自动子号升母号”。 - 防止子号加入 Team 后被错误改为母号。 - """ - _ = account - _ = reason - return False - - -def _apply_subscription_result( - account: Account, - *, - status: str, - checked_at: datetime, - confidence: Optional[str] = None, - promote_reason: str = "subscription_upgrade", -) -> bool: - normalized_status = str(status or "free").strip().lower() - normalized_confidence = str(confidence or "").strip().lower() - - if normalized_status in ("plus", "team"): - account.subscription_type = normalized_status - account.subscription_at = checked_at - return _promote_child_account_to_mother(account, reason=promote_reason) - - if normalized_status == "free" and normalized_confidence == "high": - account.subscription_type = None - account.subscription_at = None - - return False +def _resolve_third_party_bind_api_url(request_url: Optional[str]) -> Optional[str]: + raw = ( + str(request_url or "").strip() + or str(os.getenv(THIRD_PARTY_BIND_API_URL_ENV) or "").strip() + or THIRD_PARTY_BIND_API_DEFAULT + ) + normalized = _normalize_third_party_bind_api_url(raw) + return normalized or None -def _vendor_progress_init(task_id: int) -> None: - now = datetime.now().strftime("%H:%M:%S") - started_at_epoch = int(time.time()) - with _VENDOR_PROGRESS_LOCK: - _VENDOR_PROGRESS_STATE[task_id] = { - "status": "running", - "progress": 0, - "updated_at": now, - "started_at_epoch": started_at_epoch, - "force_stop_after_seconds": _VENDOR_FORCE_STOP_AFTER_SECONDS, - "next_index": 0, - "logs": [], - "stop_requested": False, - } +def _resolve_third_party_bind_api_key(request_key: Optional[str]) -> Optional[str]: + token = str(request_key or "").strip() or str(os.getenv(THIRD_PARTY_BIND_API_KEY_ENV) or "").strip() + return token or None -def _vendor_progress_log( - task_id: int, - message: str, - *, - progress: Optional[int] = None, - status: Optional[str] = None, -) -> None: - text = str(message or "").strip() +def _normalize_third_party_bind_api_url(raw_url: Optional[str]) -> Optional[str]: + text = str(raw_url or "").strip() if not text: - return - now = datetime.now().strftime("%H:%M:%S") - with _VENDOR_PROGRESS_LOCK: - state = _VENDOR_PROGRESS_STATE.setdefault( - task_id, - { - "status": "running", - "progress": 0, - "updated_at": now, - "started_at_epoch": int(time.time()), - "force_stop_after_seconds": _VENDOR_FORCE_STOP_AFTER_SECONDS, - "next_index": 0, - "logs": [], - "stop_requested": False, - }, - ) - if progress is not None: - try: - state["progress"] = max(0, min(100, int(progress))) - except Exception: - pass - if status: - state["status"] = status - state["updated_at"] = now - idx = int(state.get("next_index") or 0) - logs = list(state.get("logs") or []) - logs.append({"index": idx, "time": now, "message": text}) - if len(logs) > _VENDOR_PROGRESS_LOG_MAX: - logs = logs[-_VENDOR_PROGRESS_LOG_MAX:] - state["logs"] = logs - state["next_index"] = idx + 1 - _VENDOR_PROGRESS_STATE[task_id] = state - - -def _vendor_progress_snapshot(task_id: int, cursor: int = 0) -> Dict[str, Any]: - with _VENDOR_PROGRESS_LOCK: - state = dict(_VENDOR_PROGRESS_STATE.get(task_id) or {}) - logs = list(state.get("logs") or []) - safe_cursor = max(0, int(cursor or 0)) - new_logs = [item for item in logs if int(item.get("index") or 0) >= safe_cursor] - next_cursor = safe_cursor - if logs: - next_cursor = int(logs[-1].get("index") or 0) + 1 - started_at_epoch = int(state.get("started_at_epoch") or 0) - now_epoch = int(time.time()) - elapsed_seconds = max(0, now_epoch - started_at_epoch) if started_at_epoch > 0 else 0 - force_stop_after_seconds = max(1, int(state.get("force_stop_after_seconds") or _VENDOR_FORCE_STOP_AFTER_SECONDS)) - return { - "status": str(state.get("status") or "idle"), - "progress": int(state.get("progress") or 0), - "updated_at": state.get("updated_at"), - "started_at_epoch": started_at_epoch if started_at_epoch > 0 else None, - "elapsed_seconds": elapsed_seconds, - "force_stop_after_seconds": force_stop_after_seconds, - "can_force_stop": elapsed_seconds >= force_stop_after_seconds, - "logs": new_logs, - "next_cursor": next_cursor, - "stop_requested": bool(state.get("stop_requested")), - } - - -def _vendor_progress_exists(task_id: int) -> bool: - with _VENDOR_PROGRESS_LOCK: - return int(task_id) in _VENDOR_PROGRESS_STATE - - -def _vendor_request_stop(task_id: int) -> bool: - now = datetime.now().strftime("%H:%M:%S") - with _VENDOR_PROGRESS_LOCK: - state = _VENDOR_PROGRESS_STATE.setdefault( - task_id, - { - "status": "running", - "progress": 0, - "updated_at": now, - "started_at_epoch": int(time.time()), - "force_stop_after_seconds": _VENDOR_FORCE_STOP_AFTER_SECONDS, - "next_index": 0, - "logs": [], - "stop_requested": False, - }, - ) - already = bool(state.get("stop_requested")) - state["stop_requested"] = True - state["updated_at"] = now - _VENDOR_PROGRESS_STATE[task_id] = state - return not already - - -def _vendor_should_stop(task_id: int) -> bool: - with _VENDOR_PROGRESS_LOCK: - state = _VENDOR_PROGRESS_STATE.get(task_id) or {} - return bool(state.get("stop_requested")) - - -def _vendor_get_latest_active_task_id() -> Optional[int]: - with _VENDOR_PROGRESS_LOCK: - items = [] - for raw_task_id, raw_state in (_VENDOR_PROGRESS_STATE or {}).items(): - try: - task_id = int(raw_task_id) - except Exception: - continue - state = dict(raw_state or {}) - status = str(state.get("status") or "running").strip().lower() - if status in ("completed", "failed", "cancelled"): - continue - updated_at = str(state.get("updated_at") or "") - items.append((task_id, updated_at)) - if not items: return None - items.sort(key=lambda item: (item[1], item[0]), reverse=True) - return int(items[0][0]) - + if "://" not in text: + text = "https://" + text + try: + parsed = urlparse(text) + except Exception: + return None + if not parsed.scheme or not parsed.netloc: + return None + path = parsed.path or "" + if not path or path == "/": + path = THIRD_PARTY_BIND_PATH_DEFAULT + path = "/" + path.lstrip("/") + normalized = parsed._replace(path=path, params="", fragment="") + return urlunparse(normalized) -def _payment_now_iso() -> str: - return datetime.utcnow().isoformat() +def _build_third_party_bind_api_candidates(api_url: str) -> List[str]: + """ + 对第三方地址做容错: + - 支持只给根域名(自动补 /api/v1/bind-card) + - 支持给到 /api/v1(自动补 /bind-card) + - 保留原始路径作为首选 + """ + normalized = _normalize_third_party_bind_api_url(api_url) + if not normalized: + return [] -def _cleanup_payment_op_tasks_locked() -> None: - total = len(_PAYMENT_OP_TASKS) - if total <= _PAYMENT_OP_TASK_MAX_KEEP: - return + candidates: List[str] = [] - overflow = total - _PAYMENT_OP_TASK_MAX_KEEP - finished = [ - (task_id, _PAYMENT_OP_TASKS[task_id].get("_created_ts", 0)) - for task_id in _PAYMENT_OP_TASKS - if _PAYMENT_OP_TASKS[task_id].get("status") in {"completed", "failed", "cancelled"} - ] - finished.sort(key=lambda item: item[1]) - removed = 0 - for task_id, _ in finished: - if removed >= overflow: - break - _PAYMENT_OP_TASKS.pop(task_id, None) - removed += 1 - - -def _create_payment_op_task(task_type: str, *, bind_task_id: Optional[int] = None, progress: Optional[dict] = None) -> str: - task_id = str(uuid.uuid4()) - task = { - "id": task_id, - "task_type": str(task_type or "unknown"), - "bind_task_id": int(bind_task_id) if bind_task_id else None, - "status": "pending", - "message": "任务已创建,等待执行", - "created_at": _payment_now_iso(), - "started_at": None, - "finished_at": None, - "cancel_requested": False, - "pause_requested": False, - "paused": False, - "progress": progress or {}, - "payload": {"bind_task_id": int(bind_task_id) if bind_task_id else None}, - "result": None, - "error": None, - "details": [], - "_created_ts": time.time(), - } - with _PAYMENT_OP_TASK_LOCK: - _PAYMENT_OP_TASKS[task_id] = task - _cleanup_payment_op_tasks_locked() - task_manager.register_domain_task( - domain="payment", - task_id=task_id, - task_type=task_type, - payload={"bind_task_id": int(bind_task_id) if bind_task_id else None}, - progress=task["progress"], - ) - return task_id + def _append(url: Optional[str]): + value = str(url or "").strip() + if value and value not in candidates: + candidates.append(value) + _append(normalized) + parsed = urlparse(normalized) + base = f"{parsed.scheme}://{parsed.netloc}" + path = (parsed.path or "").rstrip("/") + lower = path.lower() -def _get_payment_op_task(task_id: str) -> Optional[Dict[str, Any]]: - with _PAYMENT_OP_TASK_LOCK: - return _PAYMENT_OP_TASKS.get(task_id) + if lower in ("", "/"): + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + elif lower.endswith("/api/v1"): + _append(base + path + "/bind-card") + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + elif not lower.endswith("/bind-card"): + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) - -def _get_payment_op_task_or_404(task_id: str) -> Dict[str, Any]: - task = _get_payment_op_task(task_id) - if not task: - raise HTTPException(status_code=404, detail="支付任务不存在") - return task - - -def _update_payment_op_task(task_id: str, **fields) -> None: - with _PAYMENT_OP_TASK_LOCK: - task = _PAYMENT_OP_TASKS.get(task_id) - if not task: - return - task.update(fields) - task_manager.update_domain_task("payment", task_id, **fields) - - -def _set_payment_op_task_progress(task_id: str, **progress_fields) -> None: - with _PAYMENT_OP_TASK_LOCK: - task = _PAYMENT_OP_TASKS.get(task_id) - if not task: - return - progress = task.setdefault("progress", {}) - progress.update(progress_fields) - task_manager.set_domain_task_progress("payment", task_id, **(progress_fields or {})) - - -def _append_payment_op_task_detail(task_id: str, detail: dict, max_items: int = 400) -> None: - with _PAYMENT_OP_TASK_LOCK: - task = _PAYMENT_OP_TASKS.get(task_id) - if not task: - return - details = task.setdefault("details", []) - details.append(detail) - if len(details) > max_items: - task["details"] = details[-max_items:] - task_manager.append_domain_task_detail("payment", task_id, detail, max_items=max_items) - - -def _is_payment_op_task_cancel_requested(task_id: str) -> bool: - local_requested = False - with _PAYMENT_OP_TASK_LOCK: - task = _PAYMENT_OP_TASKS.get(task_id) - local_requested = bool(task and task.get("cancel_requested")) - return local_requested or task_manager.is_domain_task_cancel_requested("payment", task_id) - - -def _is_payment_op_task_pause_requested(task_id: str) -> bool: - local_requested = False - with _PAYMENT_OP_TASK_LOCK: - task = _PAYMENT_OP_TASKS.get(task_id) - local_requested = bool(task and task.get("pause_requested")) - return local_requested or task_manager.is_domain_task_pause_requested("payment", task_id) - - -def _wait_if_payment_op_task_paused(task_id: str, running_message: str) -> bool: - paused_once = False - while True: - if _is_payment_op_task_cancel_requested(task_id): - return False - if not _is_payment_op_task_pause_requested(task_id): - if paused_once: - _update_payment_op_task( - task_id, - status="running", - paused=False, - message=running_message, - ) - return True - if not paused_once: - _update_payment_op_task( - task_id, - status="paused", - paused=True, - message="任务已暂停,等待继续", - ) - paused_once = True - time.sleep(0.35) - - -def _build_payment_op_task_snapshot(task: Dict[str, Any]) -> Dict[str, Any]: - return { - "id": task.get("id"), - "task_type": task.get("task_type"), - "bind_task_id": task.get("bind_task_id"), - "status": task.get("status"), - "message": task.get("message"), - "created_at": task.get("created_at"), - "started_at": task.get("started_at"), - "finished_at": task.get("finished_at"), - "cancel_requested": bool(task.get("cancel_requested")), - "pause_requested": bool(task.get("pause_requested")), - "paused": bool(task.get("paused")), - "progress": task.get("progress") or {}, - "payload": task.get("payload") or {}, - "result": task.get("result"), - "error": task.get("error"), - "details": task.get("details") or [], - } - - -def _run_payment_op_task_guard(task_id: str, task_type: str, worker, *args) -> None: - acquired, running, quota = task_manager.try_acquire_domain_slot("payment", task_id) - if not acquired: - reason = f"并发配额已满(running={running}, quota={quota})" - _update_payment_op_task( - task_id, - status="failed", - finished_at=_payment_now_iso(), - message=reason, - error=reason, - paused=False, - ) - return - try: - worker(task_id, *args) - except Exception as exc: - logger.exception("支付异步任务异常: task_id=%s type=%s error=%s", task_id, task_type, exc) - _update_payment_op_task( - task_id, - status="failed", - finished_at=_payment_now_iso(), - message=f"任务异常: {exc}", - error=str(exc), - paused=False, - ) - finally: - task_manager.release_domain_slot("payment", task_id) - - -def _vendor_fill_input_by_hints(target, hints: List[str], value: str) -> bool: - try: - return bool( - target.evaluate( - """ - ({ hints, value }) => { - const nodes = Array.from( - document.querySelectorAll("input, textarea, [contenteditable='true']") - ); - const loweredHints = (hints || []).map(h => String(h || "").toLowerCase()); - const loweredValue = String(value || "").toLowerCase(); - const isUrlValue = loweredValue.includes("http") || loweredValue.includes("chatgpt.com/checkout") || loweredValue.includes("cs_live"); - const isRedeemValue = /[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/i.test(loweredValue); - - const isVisible = (el) => { - if (!el) return false; - const style = window.getComputedStyle(el); - if (!style) return false; - if (style.display === "none" || style.visibility === "hidden") return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - - const collectLabelText = (el) => { - const parts = []; - const id = el.getAttribute("id"); - if (id) { - document.querySelectorAll(`label[for="${CSS.escape(id)}"]`).forEach((node) => { - const t = (node.innerText || node.textContent || "").trim(); - if (t) parts.push(t); - }); - } - const parentLabel = el.closest("label"); - if (parentLabel) { - const t = (parentLabel.innerText || parentLabel.textContent || "").trim(); - if (t) parts.push(t); - } - const labelledBy = el.getAttribute("aria-labelledby"); - if (labelledBy) { - labelledBy.split(/\s+/).forEach((idRef) => { - const node = document.getElementById(idRef); - const t = (node?.innerText || node?.textContent || "").trim(); - if (t) parts.push(t); - }); - } - const block = el.closest("div, section, form, article"); - if (block) { - const t = (block.innerText || block.textContent || "").trim(); - if (t) parts.push(t.slice(0, 220)); - } - return parts.join(" "); - }; - - const setNativeValue = (el, val) => { - if (el && el.isContentEditable) { - el.focus(); - el.textContent = val; - } else { - const proto = Object.getPrototypeOf(el); - const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); - if (descriptor && descriptor.set) { - descriptor.set.call(el, val); - } else { - el.value = val; - } - if ("selectionStart" in el && typeof el.value === "string") { - const len = el.value.length; - try { - el.setSelectionRange(len, len); - } catch (_) {} - } - } - el.dispatchEvent(new Event("input", { bubbles: true })); - el.dispatchEvent(new Event("change", { bubbles: true })); - }; - - const candidates = []; - for (const el of nodes) { - if (!isVisible(el)) continue; - const attrs = [ - el.getAttribute("name"), - el.getAttribute("id"), - el.getAttribute("placeholder"), - el.getAttribute("aria-label"), - el.getAttribute("data-testid"), - el.type, - el.className, - ].filter(Boolean).join(" ").toLowerCase(); - const labels = collectLabelText(el).toLowerCase(); - const haystack = `${attrs} ${labels}`.trim(); - const scoreBase = loweredHints.reduce((acc, h) => acc + (h && haystack.includes(h) ? 2 : 0), 0); - let score = scoreBase; - if (isUrlValue) { - if (/(checkout|url|link|cs_live|支付链接)/i.test(haystack)) score += 4; - if (String(el.getAttribute("type") || "").toLowerCase() === "url") score += 3; - } - if (isRedeemValue) { - if (/(redeem|coupon|code|card|卡密|兑换|卡片|序列号)/i.test(haystack)) score += 4; - const maxLen = parseInt(el.getAttribute("maxlength") || "0", 10); - if (maxLen >= 16 && maxLen <= 32) score += 2; - } - candidates.push({ el, haystack, score }); - } - - candidates.sort((a, b) => b.score - a.score); - for (const item of candidates) { - if (item.score <= 0) continue; - try { - item.el.focus(); - setNativeValue(item.el, value); - return true; - } catch (_) {} - } - - // 关键词不命中时的兜底: - // checkout: 优先 url/link 风格输入框;redeem: 优先长度较短、常见 code 框。 - const fallback = candidates.filter((x) => x.el && !x.el.disabled && !x.el.readOnly); - const rankFallback = (list) => { - return list.sort((a, b) => { - const aa = (a.el.getAttribute("placeholder") || "").toLowerCase(); - const bb = (b.el.getAttribute("placeholder") || "").toLowerCase(); - let as = 0; - let bs = 0; - if (isUrlValue) { - if (/(http|url|link|checkout|cs_)/i.test(aa)) as += 3; - if (/(http|url|link|checkout|cs_)/i.test(bb)) bs += 3; - } else if (isRedeemValue) { - if (/(code|redeem|coupon|card|兑换|卡片)/i.test(aa)) as += 3; - if (/(code|redeem|coupon|card|兑换|卡片)/i.test(bb)) bs += 3; - const am = parseInt(a.el.getAttribute("maxlength") || "0", 10); - const bm = parseInt(b.el.getAttribute("maxlength") || "0", 10); - if (am >= 16 && am <= 32) as += 2; - if (bm >= 16 && bm <= 32) bs += 2; - } - return bs - as; - }); - }; - const ranked = rankFallback(fallback); - for (const item of ranked) { - try { - item.el.focus(); - setNativeValue(item.el, value); - return true; - } catch (_) {} - } - return false; - } - """, - {"hints": hints, "value": value}, - ) - ) - except Exception: - return False - - -def _vendor_fill_input_by_locator(target, hints: List[str], value: str) -> bool: - lowered_hints = [str(h or "").strip().lower() for h in (hints or []) if str(h or "").strip()] - lowered_value = str(value or "").strip().lower() - is_url_value = ( - "http" in lowered_value - or "chatgpt.com/checkout" in lowered_value - or "cs_live" in lowered_value - or "cs_test" in lowered_value - ) - is_redeem_value = bool(re.search(r"^UK(?:-[A-Z0-9]{5}){5}$", str(value or "").strip(), re.IGNORECASE)) - - def _try_fill(item) -> bool: - try: - if not item.is_visible(): - return False - except Exception: - return False - try: - item.scroll_into_view_if_needed(timeout=1200) - except Exception: - pass - try: - item.click(timeout=1200, force=True) - except Exception: - pass - try: - item.fill(str(value or ""), timeout=1500) - return True - except Exception: - pass - try: - item.press("Control+A", timeout=800) - item.type(str(value or ""), delay=0, timeout=1500) - return True - except Exception: - return False - - # 1) 先按关键词定位 placeholder / label - for hint in lowered_hints: - try: - regex = re.compile(re.escape(hint), re.IGNORECASE) - except Exception: - continue - for getter in ("placeholder", "label"): - try: - locator = target.get_by_placeholder(regex) if getter == "placeholder" else target.get_by_label(regex) - total = min(locator.count(), 6) - except Exception: - continue - for idx in range(total): - try: - if _try_fill(locator.nth(idx)): - return True - except Exception: - continue - - # 2) 再遍历输入框做打分 - try: - locator = target.locator("input, textarea") - total = min(locator.count(), 80) - except Exception: - total = 0 - locator = None - - scored = [] - for idx in range(total): - try: - item = locator.nth(idx) - if not item.is_visible(): - continue - meta = item.evaluate( - """ - (el) => { - const low = (v) => String(v || "").toLowerCase(); - const attrs = [ - el.getAttribute("name"), - el.getAttribute("id"), - el.getAttribute("placeholder"), - el.getAttribute("aria-label"), - el.getAttribute("data-testid"), - el.getAttribute("class"), - el.getAttribute("type"), - ].map(low).join(" "); - let blockText = ""; - try { - const b = el.closest("div, section, form, article"); - if (b) blockText = low((b.innerText || b.textContent || "").slice(0, 180)); - } catch (_) {} - const maxlength = parseInt(el.getAttribute("maxlength") || "0", 10) || 0; - const disabled = !!el.disabled; - const readonly = !!el.readOnly; - return { attrs, blockText, maxlength, disabled, readonly }; - } - """ - ) - if not isinstance(meta, dict): - continue - if bool(meta.get("disabled")): - continue - haystack = f"{meta.get('attrs', '')} {meta.get('blockText', '')}".strip() - score = 0 - for hint in lowered_hints: - if hint and hint in haystack: - score += 2 - if is_url_value: - if re.search(r"(checkout|session|token|link|url|cs_live|cs_test|支付链接|结账链接)", haystack, re.IGNORECASE): - score += 4 - if is_redeem_value: - if re.search(r"(redeem|cdk|code|coupon|激活|兑换|卡密|卡片|序列号)", haystack, re.IGNORECASE): - score += 4 - maxlength = int(meta.get("maxlength") or 0) - if 16 <= maxlength <= 40: - score += 2 - scored.append((score, idx)) - except Exception: - continue - - scored.sort(key=lambda x: x[0], reverse=True) - for score, idx in scored: - if score <= 0: - continue - try: - if _try_fill(locator.nth(idx)): - return True - except Exception: - continue - - # 3) 最后兜底:可见输入框依次尝试 - for idx in range(total): - try: - if _try_fill(locator.nth(idx)): - return True - except Exception: - continue - - # 4) contenteditable 兜底 - try: - c_locator = target.locator("[contenteditable='true']") - c_total = min(c_locator.count(), 20) - except Exception: - c_total = 0 - c_locator = None - for idx in range(c_total): - try: - item = c_locator.nth(idx) - if not item.is_visible(): - continue - item.scroll_into_view_if_needed(timeout=1000) - item.click(timeout=1000, force=True) - ok = bool( - item.evaluate( - "(el, val) => { el.textContent = String(val || ''); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; }", - str(value or ""), - ) - ) - if ok: - return True - except Exception: - continue - - return False - - -def _vendor_fill_locator_item(item, value: str) -> bool: - try: - if not item.is_visible(): - return False - except Exception: - return False - try: - item.scroll_into_view_if_needed(timeout=1200) - except Exception: - pass - try: - item.click(timeout=1200, force=True) - except Exception: - pass - try: - item.fill(str(value or ""), timeout=1500) - return True - except Exception: - pass - try: - item.press("Control+A", timeout=800) - item.type(str(value or ""), delay=0, timeout=1500) - return True - except Exception: - return False - - -def _vendor_fill_redeem_input(target, redeem_code: str) -> bool: - selectors = ( - "input[placeholder*='卡密']", - "textarea[placeholder*='卡密']", - "input[placeholder*='激活码']", - "textarea[placeholder*='激活码']", - "input[placeholder*='CODE-']", - "textarea[placeholder*='CODE-']", - "input[placeholder*='US-']", - "textarea[placeholder*='US-']", - "input[name*='code' i]", - "textarea[name*='code' i]", - "input[id*='code' i]", - "textarea[id*='code' i]", - "input[name*='redeem' i]", - "input[id*='redeem' i]", - "input[name*='cdk' i]", - "input[id*='cdk' i]", - ) - for selector in selectors: - try: - locator = target.locator(selector) - total = min(locator.count(), 8) - except Exception: - continue - for idx in range(total): - try: - if _vendor_fill_locator_item(locator.nth(idx), redeem_code): - return True - except Exception: - continue - - # 标签附近输入框兜底(覆盖“卡密 / 激活码”这类文本与输入框分离的结构) - try: - ok = bool( - target.evaluate( - """ - ({ code }) => { - const isVisible = (el) => { - if (!el) return false; - try { - const st = window.getComputedStyle(el); - if (!st) return false; - if (st.display === "none" || st.visibility === "hidden" || Number(st.opacity || 1) === 0) return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - } catch (_) { return false; } - }; - const all = Array.from(document.querySelectorAll("input, textarea")); - const hit = all.find((el) => { - if (!isVisible(el)) return false; - const attrs = [ - el.getAttribute("placeholder"), - el.getAttribute("aria-label"), - el.getAttribute("name"), - el.getAttribute("id"), - ].join(" ").toLowerCase(); - if (/(卡密|激活码|redeem|cdk|coupon|code)/i.test(attrs)) return true; - const block = el.closest("div, section, form, article"); - const text = String(block?.innerText || "").toLowerCase(); - return /(卡密\\s*\\/\\s*激活码|请输入您的卡密兑换码|验证兑换码)/i.test(text); - }); - if (!hit) return false; - hit.focus(); - try { - const proto = Object.getPrototypeOf(hit); - const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); - if (descriptor && descriptor.set) descriptor.set.call(hit, String(code || "")); - else hit.value = String(code || ""); - } catch (_) { - hit.value = String(code || ""); - } - hit.dispatchEvent(new Event("input", { bubbles: true })); - hit.dispatchEvent(new Event("change", { bubbles: true })); - return true; - } - """, - {"code": str(redeem_code or "")}, - ) - ) - if ok: - return True - except Exception: - pass - return False - - -def _vendor_force_fill_first_text_input(target, value: str) -> bool: - try: - ok = bool( - target.evaluate( - """ - ({ value }) => { - const val = String(value || ""); - const isVisible = (el) => { - if (!el) return false; - try { - const st = window.getComputedStyle(el); - if (!st) return false; - if (st.display === "none" || st.visibility === "hidden" || Number(st.opacity || 1) === 0) return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - } catch (_) { return false; } - }; - const setVal = (el) => { - try { - if (el.isContentEditable || String(el.getAttribute("contenteditable") || "").toLowerCase() === "true") { - el.focus(); - el.textContent = val; - } else { - const proto = Object.getPrototypeOf(el); - const descriptor = proto ? Object.getOwnPropertyDescriptor(proto, "value") : null; - if (descriptor && descriptor.set) descriptor.set.call(el, val); - else el.value = val; - } - el.dispatchEvent(new Event("input", { bubbles: true })); - el.dispatchEvent(new Event("change", { bubbles: true })); - return true; - } catch (_) { - return false; - } - }; - - // 1) 优先“卡密/激活码/兑换码”文本锚点附近 - const anchors = Array.from(document.querySelectorAll("label, div, span, p, h1, h2, h3, h4, h5, h6")) - .filter((el) => { - if (!isVisible(el)) return false; - const t = String(el.innerText || el.textContent || "").toLowerCase(); - return /(卡密\\s*\\/\\s*激活码|验证兑换码|请输入您的卡密兑换码|激活码|兑换码|cdk|redeem|coupon code)/i.test(t); - }); - - for (const anchor of anchors.slice(0, 12)) { - const box = anchor.closest("div, section, form, article") || anchor.parentElement || document.body; - if (!box) continue; - const fields = Array.from(box.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']")); - for (const f of fields) { - if (!isVisible(f)) continue; - if (setVal(f)) return true; - } - } - - // 2) 再尝试页面第一个可见文本输入 - const fields = Array.from(document.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']")); - for (const f of fields) { - if (!isVisible(f)) continue; - const type = String(f.getAttribute("type") || "").toLowerCase(); - if (["checkbox", "radio", "button", "submit", "hidden"].includes(type)) continue; - if (setVal(f)) return true; - } - return false; - } - """, - {"value": str(value or "")}, - ) - ) - return ok - except Exception: - return False - - -def _vendor_force_fill_first_text_input_any_frame(page, value: str) -> bool: - try: - if _vendor_force_fill_first_text_input(page, value): - return True - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - if _vendor_force_fill_first_text_input(frame, value): - return True - except Exception: - continue - except Exception: - pass - return False - - -def _vendor_fill_redeem_input_any_frame(page, redeem_code: str) -> bool: - try: - if _vendor_fill_redeem_input(page, redeem_code): - return True - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - if _vendor_fill_redeem_input(frame, redeem_code): - return True - except Exception: - continue - except Exception: - pass - return False - - -def _vendor_has_redeem_input_any_frame(page) -> bool: - selectors = ( - "input[placeholder*='卡密']", - "textarea[placeholder*='卡密']", - "input[placeholder*='激活码']", - "textarea[placeholder*='激活码']", - "input[name*='code' i], textarea[name*='code' i], input[id*='code' i], textarea[id*='code' i]", - "input[name*='redeem' i], input[id*='redeem' i], input[name*='cdk' i], input[id*='cdk' i]", - ) - targets = [page] - try: - targets.extend(list(page.frames or [])) - except Exception: - pass - for target in targets: - for selector in selectors: - try: - locator = target.locator(selector) - total = min(locator.count(), 6) - except Exception: - continue - for idx in range(total): - try: - if locator.nth(idx).is_visible(): - return True - except Exception: - continue - return False - - -def _vendor_fill_input_any_frame(page, hints: List[str], value: str) -> bool: - try: - if _vendor_fill_input_by_locator(page, hints, value): - return True - except Exception: - pass - try: - if _vendor_fill_input_by_hints(page, hints, value): - return True - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - if _vendor_fill_input_by_locator(frame, hints, value): - return True - except Exception: - continue - try: - if _vendor_fill_input_by_hints(frame, hints, value): - return True - except Exception: - continue - except Exception: - pass - return False - - -def _vendor_collect_input_descriptors(target, limit: int = 14) -> List[str]: - # 先走 Playwright 定位器(更稳,兼容动态节点) - try: - out: List[str] = [] - locator = target.locator("input, textarea, [contenteditable='true']") - total = min(locator.count(), max(4, int(limit or 14)) * 3) - for idx in range(total): - try: - item = locator.nth(idx) - if not item.is_visible(): - continue - info = item.evaluate( - """ - (el) => { - const low = (v) => String(v || "").replace(/\\s+/g, " ").trim(); - return [ - String(el.tagName || "").toLowerCase(), - low(el.getAttribute("placeholder")), - low(el.getAttribute("aria-label")), - low(el.getAttribute("name")), - low(el.getAttribute("id")), - low(el.getAttribute("type")), - ].join(":"); - } - """ - ) - text = str(info or "").strip() - if text and text not in out: - out.append(text[:120]) - if len(out) >= max(4, int(limit or 14)): - return out - except Exception: - continue - if out: - return out - except Exception: - pass - - try: - rows = target.evaluate( - """ - ({ limit }) => { - const out = []; - const isVisible = (el) => { - if (!el) return false; - try { - const st = window.getComputedStyle(el); - if (!st) return false; - if (st.display === "none" || st.visibility === "hidden" || Number(st.opacity || 1) === 0) return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - } catch (_) { return false; } - }; - const nodes = Array.from(document.querySelectorAll("input, textarea, [contenteditable='true']")); - for (const el of nodes) { - if (!isVisible(el)) continue; - const tag = String(el.tagName || "").toLowerCase(); - const desc = [ - el.getAttribute("placeholder") || "", - el.getAttribute("aria-label") || "", - el.getAttribute("name") || "", - el.getAttribute("id") || "", - el.getAttribute("type") || "", - ].join(" ").replace(/\\s+/g, " ").trim(); - const text = `${tag}:${desc || "(no-attrs)"}`.slice(0, 90); - if (text && !out.includes(text)) out.push(text); - if (out.length >= Number(limit || 14)) break; - } - return out; - } - """, - {"limit": max(4, int(limit or 14))}, - ) - if isinstance(rows, list): - return [str(x).strip() for x in rows if str(x or "").strip()] - except Exception: - pass - return [] - - -def _vendor_collect_input_descriptors_any_frame(page, limit: int = 18) -> List[str]: - merged: List[str] = [] - try: - for text in _vendor_collect_input_descriptors(page, limit=limit): - if text not in merged: - merged.append(text) - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - for text in _vendor_collect_input_descriptors(frame, limit=limit): - if text not in merged: - merged.append(text) - if len(merged) >= limit: - return merged[:limit] - except Exception: - continue - except Exception: - pass - return merged[:limit] - - -def _normalize_vendor_redeem_code(code: Optional[str]) -> str: - text = str(code or "").strip().upper() - compact = re.sub(r"[^A-Z0-9]", "", text) - if compact.startswith("UK"): - body = compact[2:] - if len(body) == 25: - return f"UK-{body[0:5]}-{body[5:10]}-{body[10:15]}-{body[15:20]}-{body[20:25]}" - text = re.sub(r"\s+", "", text) - text = re.sub(r"-{2,}", "-", text).strip("-") - return text - - -def _vendor_seed_checkout_context(page, checkout_url: str) -> None: - try: - page.evaluate( - """ - ({ checkoutUrl }) => { - const val = String(checkoutUrl || ""); - if (!val) return false; - const keys = [ - "checkout", - "checkout_url", - "checkoutUrl", - "payment_link", - "paymentLink", - "url", - "link", - ]; - for (const key of keys) { - try { localStorage.setItem(key, val); } catch (_) {} - try { sessionStorage.setItem(key, val); } catch (_) {} - } - - const selectors = [ - "input[name='checkout']", - "input[name='checkout_url']", - "input[id*='checkout']", - "input[placeholder*='checkout' i]", - "textarea[name='checkout']", - "textarea[name='checkout_url']", - "textarea[id*='checkout']", - "textarea[placeholder*='checkout' i]", - ]; - const setNativeValue = (el, v) => { - const proto = Object.getPrototypeOf(el); - const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); - if (descriptor && descriptor.set) { - descriptor.set.call(el, v); - } else { - el.value = v; - } - el.dispatchEvent(new Event("input", { bubbles: true })); - el.dispatchEvent(new Event("change", { bubbles: true })); - }; - for (const selector of selectors) { - const el = document.querySelector(selector); - if (!el) continue; - try { setNativeValue(el, val); } catch (_) {} - } - - window.__VENDOR_CHECKOUT_URL__ = val; - try { - document.dispatchEvent(new CustomEvent("checkout:prefill", { detail: { checkoutUrl: val } })); - } catch (_) {} - return true; - } - """, - {"checkoutUrl": checkout_url}, - ) - except Exception: - pass - - -def _regenerate_vendor_checkout_for_task( - db, - *, - task: BindCardTask, - account: Account, - proxy: Optional[str], -) -> Optional[str]: - req = CheckoutRequestBase( - account_id=account.id, - plan_type=str(task.plan_type or "plus"), - workspace_name=str(task.workspace_name or "MyTeam"), - price_interval=str(task.price_interval or "month"), - seat_quantity=int(task.seat_quantity or 5), - proxy=proxy, - country=_normalize_checkout_country(task.country), - currency=_normalize_checkout_currency(_normalize_checkout_country(task.country), task.currency), - ) - link, source, _fallback_reason, checkout_session_id, publishable_key, client_secret = _generate_checkout_link_for_account( - account=account, - request=req, - proxy=proxy, - ) - link = str(link or "").strip() - if not link: - return None - - task.checkout_url = link - task.checkout_source = source - task.checkout_session_id = checkout_session_id - task.publishable_key = publishable_key - task.client_secret = client_secret - task.last_checked_at = datetime.utcnow() - db.commit() - db.refresh(task) - return link - - -def _regenerate_vendor_checkout_for_task_with_retry( - db, - *, - task: BindCardTask, - account: Account, - explicit_proxy: Optional[str] = None, -) -> Tuple[str, Optional[str]]: - last_error: Optional[Exception] = None - for proxy_item in _build_proxy_candidates(explicit_proxy, account, include_direct=True): - try: - link = _regenerate_vendor_checkout_for_task( - db, - task=task, - account=account, - proxy=proxy_item, - ) - if link: - return link, proxy_item - except Exception as exc: - last_error = exc - continue - if last_error: - raise last_error - raise RuntimeError("未生成到有效 checkout 链接") - - -def _vendor_click_button_by_hints(target, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: - try: - return bool( - target.evaluate( - """ - ({ hints, excludeHints }) => { - const loweredHints = (hints || []).map(h => String(h || "").toLowerCase()); - const loweredExclude = (excludeHints || []).map(h => String(h || "").toLowerCase()); - const nodes = Array.from( - document.querySelectorAll( - "button, input[type='button'], input[type='submit'], a[role='button'], [role='button'], .btn, .button, .ant-btn, .el-button, [onclick]" - ) - ); - const isDisabled = (el) => { - if (!el) return true; - try { - if (el.disabled) return true; - const ariaDisabled = String(el.getAttribute("aria-disabled") || "").toLowerCase(); - if (ariaDisabled === "true") return true; - const cls = String(el.className || "").toLowerCase(); - if (/(disabled|is-disabled|btn-disabled|ant-btn-disabled)/i.test(cls)) return true; - } catch (_) {} - return false; - }; - const clickNode = (el) => { - if (!el || isDisabled(el)) return false; - try { - el.scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); - } catch (_) {} - try { el.click(); } catch (_) {} - try { el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } catch (_) {} - return true; - }; - for (const el of nodes) { - const text = (el.innerText || el.textContent || el.value || "").trim().toLowerCase(); - if (!text) continue; - if (loweredExclude.some(h => h && text.includes(h))) continue; - if (!loweredHints.some(h => text.includes(h))) continue; - if (clickNode(el)) return true; - } - - // 兜底:部分站点把可点击控件做成 div/span - const loose = Array.from(document.querySelectorAll("div, span, a")); - for (const el of loose) { - const text = (el.innerText || el.textContent || "").trim().toLowerCase(); - if (!text || text.length > 24) continue; - if (loweredExclude.some(h => h && text.includes(h))) continue; - if (!loweredHints.some(h => text.includes(h))) continue; - const cls = String(el.className || "").toLowerCase(); - const role = String(el.getAttribute("role") || "").toLowerCase(); - const styleCursor = (() => { try { return String(window.getComputedStyle(el).cursor || "").toLowerCase(); } catch (_) { return ""; } })(); - if (!(role === "button" || /(btn|button|click|action|submit)/i.test(cls) || styleCursor === "pointer")) { - continue; - } - if (clickNode(el)) return true; - } - return false; - } - """, - {"hints": hints, "excludeHints": exclude_hints or []}, - ) - ) - except Exception: - return False - - -def _vendor_click_button_any_frame(page, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: - if _vendor_click_button_by_locator_any_frame(page, hints, exclude_hints): - return True - try: - if _vendor_click_button_by_hints(page, hints, exclude_hints): - return True - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - if _vendor_click_button_by_hints(frame, hints, exclude_hints): - return True - except Exception: - continue - except Exception: - pass - return False - - -def _vendor_click_button_by_locator(target, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: - lowered_hints = [str(h or "").strip().lower() for h in (hints or []) if str(h or "").strip()] - lowered_excludes = [str(h or "").strip().lower() for h in (exclude_hints or []) if str(h or "").strip()] - if not lowered_hints: - return False - - selectors = ( - "button", - "[role='button']", - "a", - ".btn", - ".button", - ".ant-btn", - ".el-button", - "[onclick]", - "div", - "span", - ) - for hint in lowered_hints: - try: - regex = re.compile(re.escape(hint), re.IGNORECASE) - except Exception: - continue - for selector in selectors: - try: - locator = target.locator(selector, has_text=regex) - total = min(locator.count(), 5) - except Exception: - continue - for idx in range(total): - try: - item = locator.nth(idx) - if not item.is_visible(): - continue - text = str(item.inner_text() or "").strip().lower() - if not text: - continue - if any(ex and ex in text for ex in lowered_excludes): - continue - item.scroll_into_view_if_needed(timeout=1000) - item.click(timeout=1200, force=True) - return True - except Exception: - continue - return False - - -def _vendor_click_button_by_locator_any_frame(page, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: - try: - if _vendor_click_button_by_locator(page, hints, exclude_hints): - return True - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - if _vendor_click_button_by_locator(frame, hints, exclude_hints): - return True - except Exception: - continue - except Exception: - pass - return False - - -def _vendor_click_button_near_input_value( - target, - *, - input_value: str, - hints: List[str], - exclude_hints: Optional[List[str]] = None, -) -> bool: - try: - return bool( - target.evaluate( - """ - ({ inputValue, hints, excludeHints }) => { - const normalized = String(inputValue || "").toLowerCase().replace(/[^a-z0-9]/g, ""); - if (!normalized) return false; - const loweredHints = (hints || []).map(h => String(h || "").toLowerCase()).filter(Boolean); - const loweredExclude = (excludeHints || []).map(h => String(h || "").toLowerCase()).filter(Boolean); - const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); - const txt = (el) => String(el?.innerText || el?.textContent || el?.value || "").trim().toLowerCase(); - const isDisabled = (el) => { - if (!el) return true; - try { - if (el.disabled) return true; - const ad = String(el.getAttribute("aria-disabled") || "").toLowerCase(); - if (ad === "true") return true; - const cls = String(el.className || "").toLowerCase(); - if (/(disabled|is-disabled|btn-disabled|ant-btn-disabled)/i.test(cls)) return true; - } catch (_) {} - return false; - }; - const clickNode = (el) => { - if (!el || isDisabled(el)) return false; - try { el.scrollIntoView({ behavior: "instant", block: "center", inline: "center" }); } catch (_) {} - try { el.click(); } catch (_) {} - try { el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } catch (_) {} - return true; - }; - const matchButtonText = (text) => { - const low = String(text || "").toLowerCase(); - if (!low) return false; - if (loweredExclude.some(h => h && low.includes(h))) return false; - return loweredHints.some(h => h && low.includes(h)); - }; - - const allInputs = Array.from(document.querySelectorAll("input, textarea")); - const nearInputs = allInputs.filter((el) => { - try { - const v = norm(el.value); - return !!v && (v.includes(normalized) || normalized.includes(v)); - } catch (_) { - return false; - } - }); - if (!nearInputs.length) return false; - - for (const inputEl of nearInputs) { - const containers = []; - let node = inputEl; - for (let i = 0; i < 5 && node; i += 1) { - node = node.parentElement; - if (!node) break; - containers.push(node); - if (node.matches && (node.matches("form, section, article") || node.getAttribute("role") === "dialog")) break; - } - for (const box of containers) { - const btns = Array.from( - box.querySelectorAll("button, input[type='button'], input[type='submit'], [role='button'], a, .btn, .button, .ant-btn, .el-button, [onclick]") - ); - for (const btn of btns) { - if (!matchButtonText(txt(btn))) continue; - if (clickNode(btn)) return true; - } - } - } - return false; - } - """, - { - "inputValue": str(input_value or ""), - "hints": hints, - "excludeHints": exclude_hints or [], - }, - ) - ) - except Exception: - return False - - -def _vendor_click_button_near_input_value_any_frame( - page, - *, - input_value: str, - hints: List[str], - exclude_hints: Optional[List[str]] = None, -) -> bool: - try: - if _vendor_click_button_near_input_value( - page, - input_value=input_value, - hints=hints, - exclude_hints=exclude_hints, - ): - return True - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - if _vendor_click_button_near_input_value( - frame, - input_value=input_value, - hints=hints, - exclude_hints=exclude_hints, - ): - return True - except Exception: - continue - except Exception: - pass - return False - - -def _vendor_has_input_by_hints(target, hints: List[str]) -> bool: - try: - return bool( - target.evaluate( - """ - ({ hints }) => { - const loweredHints = (hints || []).map(h => String(h || "").toLowerCase()).filter(Boolean); - const nodes = Array.from(document.querySelectorAll("input, textarea")); - const norm = (v) => String(v || "").toLowerCase(); - for (const el of nodes) { - const txt = [ - el.getAttribute("name"), - el.getAttribute("id"), - el.getAttribute("placeholder"), - el.getAttribute("aria-label"), - el.getAttribute("data-placeholder"), - ].map(norm).join(" "); - if (!txt) continue; - if (loweredHints.some(h => txt.includes(h))) return true; - } - return false; - } - """, - {"hints": hints}, - ) - ) - except Exception: - return False - - -def _vendor_has_input_any_frame(page, hints: List[str]) -> bool: - try: - if _vendor_has_input_by_hints(page, hints): - return True - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - if _vendor_has_input_by_hints(frame, hints): - return True - except Exception: - continue - except Exception: - pass - return False - - -def _vendor_has_button_by_hints(target, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: - try: - return bool( - target.evaluate( - """ - ({ hints, excludeHints }) => { - const loweredHints = (hints || []).map(h => String(h || "").toLowerCase()).filter(Boolean); - const loweredExclude = (excludeHints || []).map(h => String(h || "").toLowerCase()).filter(Boolean); - const nodes = Array.from( - document.querySelectorAll("button, input[type='button'], input[type='submit'], [role='button'], a, .btn, .button, .ant-btn, .el-button, [onclick]") - ); - for (const el of nodes) { - const text = String(el.innerText || el.textContent || el.value || "").trim().toLowerCase(); - if (!text) continue; - if (loweredExclude.some(h => h && text.includes(h))) continue; - if (loweredHints.some(h => text.includes(h))) return true; - } - return false; - } - """, - {"hints": hints, "excludeHints": exclude_hints or []}, - ) - ) - except Exception: - return False - - -def _vendor_has_button_any_frame(page, hints: List[str], exclude_hints: Optional[List[str]] = None) -> bool: - try: - if _vendor_has_button_by_hints(page, hints, exclude_hints): - return True - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - if _vendor_has_button_by_hints(frame, hints, exclude_hints): - return True - except Exception: - continue - except Exception: - pass - return False - - -def _vendor_has_text_by_locator(target, hints: List[str]) -> bool: - lowered_hints = [str(h or "").strip().lower() for h in (hints or []) if str(h or "").strip()] - if not lowered_hints: - return False - for hint in lowered_hints: - try: - regex = re.compile(re.escape(hint), re.IGNORECASE) - locator = target.get_by_text(regex) - total = min(locator.count(), 5) - for idx in range(total): - try: - if locator.nth(idx).is_visible(): - return True - except Exception: - continue - except Exception: - continue - return False - - -def _vendor_has_text_any_frame_by_locator(page, hints: List[str]) -> bool: - try: - if _vendor_has_text_by_locator(page, hints): - return True - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - if _vendor_has_text_by_locator(frame, hints): - return True - except Exception: - continue - except Exception: - pass - return False - - -def _vendor_collect_button_texts(target, limit: int = 16) -> List[str]: - try: - rows = target.evaluate( - """ - ({ limit }) => { - const isVisible = (el) => { - if (!el) return false; - try { - const st = window.getComputedStyle(el); - if (!st) return false; - if (st.display === "none" || st.visibility === "hidden" || Number(st.opacity || 1) === 0) return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - } catch (_) { - return false; - } - }; - const nodes = Array.from( - document.querySelectorAll("button, input[type='button'], input[type='submit'], [role='button'], a, .btn, .button, .ant-btn, .el-button, [onclick]") - ); - const out = []; - for (const el of nodes) { - if (!isVisible(el)) continue; - const t = String(el.innerText || el.textContent || el.value || "").replace(/\\s+/g, " ").trim(); - if (!t || t.length > 26) continue; - if (!out.includes(t)) out.push(t); - if (out.length >= Number(limit || 16)) break; - } - return out; - } - """, - {"limit": max(4, int(limit or 16))}, - ) - if isinstance(rows, list): - return [str(x).strip() for x in rows if str(x or "").strip()] - except Exception: - pass - return [] - - -def _vendor_collect_button_texts_any_frame(page, limit: int = 20) -> List[str]: - merged: List[str] = [] - try: - for text in _vendor_collect_button_texts(page, limit=limit): - if text not in merged: - merged.append(text) - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - for text in _vendor_collect_button_texts(frame, limit=limit): - if text not in merged: - merged.append(text) - if len(merged) >= limit: - return merged[:limit] - except Exception: - continue - except Exception: - pass - return merged[:limit] - - -def _vendor_is_stage2_ready_any_frame(page) -> bool: - if _vendor_has_text_any_frame_by_locator( - page, - ["填写测试信息", "获取 checkout session", "直接输入 token", "开始测试", "下一步开始测试"], - ): - return True - - has_checkout_input = _vendor_has_input_any_frame( - page, - ["checkout", "checkout session", "checkout_url", "cs_live", "cs_id", "token", "链接", "支付链接", "结账链接"], - ) - has_stage2_controls = _vendor_has_button_any_frame( - page, - ["直接输入token", "直接输入 token", "access token 生成", "获取token", "获取 token", "获取链接", "开始测试", "下一步开始测试"], - exclude_hints=["验证兑换码", "订单查询", "历史查询"], - ) - return bool(has_checkout_input and has_stage2_controls) - - -def _vendor_detect_stage_by_dom(target) -> int: - try: - value = target.evaluate( - """ - () => { - const textOf = (el) => String((el && (el.innerText || el.textContent)) || "").replace(/\\s+/g, " ").trim().toLowerCase(); - const isVisible = (el) => { - if (!el) return false; - try { - const st = window.getComputedStyle(el); - if (!st) return false; - if (st.display === "none" || st.visibility === "hidden" || Number(st.opacity || 1) === 0) return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - } catch (_) { - return false; - } - }; - const pickByActiveClass = () => { - const selectors = [ - ".active", ".is-active", ".current", ".is-current", - ".ant-steps-item-process", ".step.active", ".steps .active" - ]; - for (const selector of selectors) { - const nodes = Array.from(document.querySelectorAll(selector)); - for (const node of nodes) { - if (!isVisible(node)) continue; - const t = textOf(node); - if (!t) continue; - if (/(验证兑换码|兑换码|cdk|redeem|step\\s*1|\\b1\\b)/i.test(t)) return 1; - if (/(填写信息|填写测试信息|checkout|token|step\\s*2|\\b2\\b)/i.test(t)) return 2; - if (/(测试处理|处理中|testing|step\\s*3|\\b3\\b)/i.test(t)) return 3; - if (/(完成|success|done|step\\s*4|\\b4\\b)/i.test(t)) return 4; - } - } - return 0; - }; - - const activeStage = pickByActiveClass(); - if (activeStage > 0) return activeStage; - - const body = textOf(document.body); - if (!body) return 0; - if (/(填写测试信息|获取 checkout session|直接输入 token|开始测试)/i.test(body)) return 2; - if (/(测试处理|处理中|执行中|请稍候|正在测试)/i.test(body)) return 3; - if (/(验证兑换码|激活cdk|兑换码验证)/i.test(body)) return 1; - return 0; - } - """ - ) - return int(value or 0) - except Exception: - return 0 - - -def _vendor_detect_stage_by_locator(target) -> int: - try: - if _vendor_has_text_by_locator(target, ["测试处理", "处理中", "执行中", "正在测试"]): - return 3 - if _vendor_has_text_by_locator(target, ["填写测试信息", "获取 checkout session", "直接输入 token", "开始测试"]): - return 2 - if _vendor_has_text_by_locator(target, ["验证兑换码", "请输入您的卡密兑换码", "激活码"]): - return 1 - if _vendor_has_text_by_locator(target, ["完成", "测试完成", "订阅成功"]): - return 4 - except Exception: - pass - return 0 - - -def _vendor_detect_stage_any_frame(page) -> int: - detected = 0 - try: - detected = max(detected, _vendor_detect_stage_by_dom(page)) - except Exception: - pass - try: - detected = max(detected, _vendor_detect_stage_by_locator(page)) - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - detected = max(detected, _vendor_detect_stage_by_dom(frame)) - except Exception: - continue - except Exception: - pass - try: - for frame in list(page.frames or []): - try: - detected = max(detected, _vendor_detect_stage_by_locator(frame)) - except Exception: - continue - except Exception: - pass - return int(detected or 0) - - -def _vendor_wait_stage_any_frame( - page, - expected_stage: int, - *, - timeout_ms: int = 12000, - poll_ms: int = 500, -) -> bool: - deadline = time.monotonic() + max(1.0, float(timeout_ms) / 1000.0) - while time.monotonic() < deadline: - stage = _vendor_detect_stage_any_frame(page) - if int(stage or 0) == int(expected_stage): - return True - try: - page.wait_for_timeout(max(100, int(poll_ms))) - except Exception: - time.sleep(max(0.1, float(poll_ms) / 1000.0)) - return False - - -def _run_vendor_auto_bind_task( - task_id: int, - *, - redeem_code: str, - checkout_override: Optional[str], - api_url: Optional[str], - api_key: Optional[str], - timeout_seconds: int, -) -> None: - _vendor_progress_log(task_id, "卡商模式开始执行", progress=3, status="running") - _vendor_progress_log(task_id, f"流程版本: {VENDOR_EFUN_FLOW_VERSION}", progress=3, status="running") - try: - with get_db() as db: - task = ( - db.query(BindCardTask) - .options(joinedload(BindCardTask.account)) - .filter(BindCardTask.id == task_id) - .first() - ) - if not task: - _vendor_progress_log(task_id, "任务不存在", progress=100, status="failed") - return - account = task.account - if not account: - task.status = "failed" - task.last_error = "任务关联账号不存在" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, "任务关联账号不存在", progress=100, status="failed") - return - - if _vendor_should_stop(task_id): - task.status = "failed" - task.last_error = "用户手动停止卡商订阅任务" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, "任务已停止", progress=100, status="cancelled") - return - - checkout_url = str(checkout_override or "").strip() - checkout_session_id = str(getattr(task, "checkout_session_id", "") or "").strip() - checkout_proxy = _resolve_runtime_proxy(None, account) - if not checkout_proxy: - task.status = "failed" - task.last_error = "未配置可用代理,vendor_efun 模式要求生成 Checkout 阶段走代理" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log( - task_id, - "未配置可用代理,无法生成 Checkout(vendor_efun 要求 checkout 阶段走代理)", - progress=100, - status="failed", - ) - return - - _vendor_progress_log(task_id, "卡商模式:强制按账号生成最新 Checkout", progress=10) - try: - refreshed_link = _regenerate_vendor_checkout_for_task( - db, - task=task, - account=account, - proxy=checkout_proxy, - ) - checkout_url = str(refreshed_link or "").strip() - checkout_session_id = str(getattr(task, "checkout_session_id", "") or "").strip() or _extract_checkout_session_id_from_url(checkout_url) or "" - _vendor_progress_log( - task_id, - "最新 Checkout 已生成并回填(proxy=on,后续步骤走直连)", - progress=16, - ) - except Exception as regen_exc: - task.status = "failed" - task.last_error = f"强制生成 checkout 失败: {regen_exc}" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, f"强制生成 checkout 失败: {regen_exc}", progress=100, status="failed") - return - - if not checkout_url: - task.status = "failed" - task.last_error = "强制生成 checkout 为空" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, "强制生成 checkout 为空", progress=100, status="failed") - return - - if _vendor_should_stop(task_id): - task.status = "failed" - task.last_error = "用户手动停止卡商订阅任务" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, "任务已停止", progress=100, status="cancelled") - return - - access_token = str(getattr(account, "access_token", "") or "").strip() - if not access_token: - task.status = "failed" - task.last_error = "账号缺少 access_token,无法调用卡商 bindcard 接口" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, "账号缺少 access_token,无法执行卡商接口提交", progress=100, status="failed") - return - - _vendor_progress_log(task_id, "调用 EFun 接口开卡", progress=22) - try: - redeem_body = _efuncard_request( - method="POST", - path="/api/external/redeem", - api_key=_resolve_efuncard_api_key(None), - base_url=_resolve_efuncard_base_url(None), - proxy=None, - payload={"code": redeem_code}, - ) - redeem_data = redeem_body.get("data") if isinstance(redeem_body, dict) else {} - if not isinstance(redeem_data, dict): - redeem_data = {} - except Exception as redeem_exc: - task.status = "failed" - task.last_error = f"EFun 开卡失败: {redeem_exc}" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, f"EFun 开卡失败: {redeem_exc}", progress=100, status="failed") - return - - card_payload = _normalize_vendor_card_payload(redeem_data) - if not card_payload.get("number") or not card_payload.get("exp_month") or not card_payload.get("exp_year") or not card_payload.get("cvc"): - task.status = "failed" - task.last_error = "EFun 开卡返回缺少卡号/有效期/CVC" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, "EFun 开卡返回缺少卡号/有效期/CVC", progress=100, status="failed") - return - - masked_card = _mask_card_number(card_payload.get("number")) - _vendor_progress_log(task_id, f"EFun 开卡成功,卡片: {masked_card}", progress=30) - - billing_payload, billing_source = _build_vendor_billing_payload( - account=account, - redeem_data=redeem_data, - country_hint=str(task.country or "US"), - ) - _vendor_progress_log(task_id, f"账单信息已生成(source={billing_source})", progress=36) - - proxy_country = _vendor_proxy_country_label(billing_payload.get("country")) - bind_payload: Dict[str, Any] = { - "acc_token": access_token, - "plan_type": str(task.plan_type or "plus").strip().lower() or "plus", - "card": { - "number": card_payload["number"], - "exp_month": card_payload["exp_month"], - "exp_year": card_payload["exp_year"], - "cvc": card_payload["cvc"], - }, - "billing": { - "name": str(billing_payload.get("name") or "").strip(), - "email": str(billing_payload.get("email") or account.email or "").strip(), - "country": _normalize_checkout_country(billing_payload.get("country")), - "state": str(billing_payload.get("state") or "").strip(), - "city": str(billing_payload.get("city") or "").strip(), - "line1": str(billing_payload.get("line1") or "").strip(), - "postal_code": str(billing_payload.get("postal_code") or "").strip(), - }, - "proxy_mode": "system", - "proxy_country": proxy_country, - } - if checkout_url: - bind_payload["checkout_url"] = checkout_url - if checkout_session_id: - bind_payload["session_id"] = checkout_session_id - - _vendor_progress_log(task_id, "提交卡商 bindcard 接口", progress=43) - try: - resolved_api_url = _resolve_vendor_bindcard_api_url(api_url) - resolved_api_key = _resolve_vendor_bindcard_api_key(api_key) - vendor_response, used_endpoint = _invoke_vendor_bindcard_api( - api_url=resolved_api_url, - api_key=resolved_api_key, - payload=bind_payload, - timeout_seconds=min(max(int(timeout_seconds or 180), 60), 300), - ) - except Exception as submit_exc: - task.status = "failed" - task.last_error = f"卡商接口提交失败: {submit_exc}" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, f"卡商接口提交失败: {submit_exc}", progress=100, status="failed") - return - - assessment = _assess_third_party_submission_result(vendor_response if isinstance(vendor_response, dict) else {}) - assess_state = str(assessment.get("state") or "pending").strip().lower() - assess_reason = str(assessment.get("reason") or "").strip() - snapshot = assessment.get("snapshot") if isinstance(assessment.get("snapshot"), dict) else {} - payment_status = str(snapshot.get("payment_status") or "").strip().lower() - checkout_status = str(snapshot.get("checkout_status") or "").strip().lower() - logger.info( - "卡商EFun接口提交结果: task_id=%s account_id=%s endpoint=%s state=%s payment_status=%s checkout_status=%s reason=%s", - task.id, - account.id, - used_endpoint, - assess_state, - payment_status or "-", - checkout_status or "-", - assess_reason or "-", - ) - if assess_state == "failed": - task.status = "failed" - task.last_error = f"卡商接口返回失败: {assess_reason or 'unknown'}" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, f"卡商接口返回失败: {assess_reason or 'unknown'}", progress=100, status="failed") - return - - _mark_task_paid_pending_sync( - task, - ( - f"卡商接口已受理(state={assess_state}, payment_status={payment_status or '-'}, reason={assess_reason or '-'}),开始同步订阅。" - ), - ) - db.commit() - _vendor_progress_log(task_id, "卡商接口提交成功,开始同步订阅", progress=70) - - if _vendor_should_stop(task_id): - task.status = "failed" - task.last_error = "用户手动停止卡商订阅任务" - task.last_checked_at = datetime.utcnow() - db.commit() - _vendor_progress_log(task_id, "任务已停止", progress=100, status="cancelled") - return - - detail, refreshed = _check_subscription_detail_with_retry( - db=db, - account=account, - proxy=None, - allow_token_refresh=True, - ) - sub_status = str(detail.get("status") or "free").lower() - source = str(detail.get("source") or "unknown") - confidence = str(detail.get("confidence") or "low") - now = datetime.utcnow() - task.last_checked_at = now - - if sub_status in ("plus", "team"): - _apply_subscription_result( - account, - status=sub_status, - checked_at=now, - confidence=confidence, - promote_reason="vendor_sync_paid", - ) - task.status = "completed" - task.completed_at = now - task.last_error = None - db.commit() - _vendor_progress_log( - task_id, - f"订阅同步完成: {sub_status.upper()} (source={source}, confidence={confidence}, token_refreshed={refreshed})", - progress=100, - status="completed", - ) - return - - if assess_state == "pending" and _is_third_party_challenge_pending(assessment): - task.status = "waiting_user_action" - task.last_error = ( - f"卡商接口返回挑战态(reason={assess_reason or 'requires_action'}),请稍后点击“同步订阅”确认。" - ) - task.last_checked_at = now - db.commit() - _vendor_progress_log(task_id, "卡商接口进入挑战态,等待人工完成后同步订阅", progress=100, status="pending") - return - - _mark_task_paid_pending_sync( - task, - ( - f"卡商接口已提交,但当前订阅={sub_status}(source={source}, confidence={confidence})。请稍后点“同步订阅”重试。" - ), - ) - db.commit() - _vendor_progress_log( - task_id, - f"订阅同步未命中付费状态({sub_status}),任务保留在已支付待同步", - progress=100, - status="pending", - ) - return - except Exception as exc: - logger.exception("卡商模式执行异常: task_id=%s error=%s", task_id, exc) - _vendor_progress_log(task_id, f"执行异常: {exc}", progress=100, status="failed") - with get_db() as db: - task = db.query(BindCardTask).filter(BindCardTask.id == task_id).first() - if task: - task.status = "failed" - task.last_error = f"卡商模式执行异常: {exc}" - task.last_checked_at = datetime.utcnow() - db.commit() - -def _resolve_third_party_bind_api_url(request_url: Optional[str]) -> Optional[str]: - raw = ( - str(request_url or "").strip() - or str(os.getenv(THIRD_PARTY_BIND_API_URL_ENV) or "").strip() - or THIRD_PARTY_BIND_API_DEFAULT - ) - normalized = _normalize_third_party_bind_api_url(raw) - return normalized or None - - -def _resolve_third_party_bind_api_key(request_key: Optional[str]) -> Optional[str]: - token = str(request_key or "").strip() or str(os.getenv(THIRD_PARTY_BIND_API_KEY_ENV) or "").strip() - return token or None - - -def _normalize_third_party_bind_api_url(raw_url: Optional[str]) -> Optional[str]: - text = str(raw_url or "").strip() - if not text: - return None - if "://" not in text: - text = "https://" + text - try: - parsed = urlparse(text) - except Exception: - return None - if not parsed.scheme or not parsed.netloc: - return None - path = parsed.path or "" - if not path or path == "/": - path = THIRD_PARTY_BIND_PATH_DEFAULT - path = "/" + path.lstrip("/") - normalized = parsed._replace(path=path, params="", fragment="") - return urlunparse(normalized) - - -def _build_third_party_bind_api_candidates(api_url: str) -> List[str]: - """ - 对第三方地址做容错: - - 支持只给根域名(自动补 /api/v1/bind-card) - - 支持给到 /api/v1(自动补 /bind-card) - - 保留原始路径作为首选 - """ - normalized = _normalize_third_party_bind_api_url(api_url) - if not normalized: - return [] - - candidates: List[str] = [] - - def _append(url: Optional[str]): - value = str(url or "").strip() - if value and value not in candidates: - candidates.append(value) - - _append(normalized) - parsed = urlparse(normalized) - base = f"{parsed.scheme}://{parsed.netloc}" - path = (parsed.path or "").rstrip("/") - lower = path.lower() - - if lower in ("", "/"): - _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) - elif lower.endswith("/api/v1"): - _append(base + path + "/bind-card") - _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) - elif not lower.endswith("/bind-card"): - _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) - - return candidates + return candidates def _parse_third_party_response(resp) -> dict: @@ -4371,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 @@ -4461,322 +1908,6 @@ def _check_subscription_detail_with_retry( return detail, refreshed -def _is_retryable_subscription_check_error(error_message: Optional[str]) -> bool: - text = str(error_message or "").strip().lower() - if not text: - return False - retry_markers = ( - "network_error", - "network", - "timeout", - "timed out", - "connection", - "temporarily", - "too many requests", - "http 429", - "http 500", - "http 502", - "http 503", - "http 504", - "rate limit", - ) - return any(marker in text for marker in retry_markers) - - -def _batch_check_subscription_one( - account_id: int, - explicit_proxy: Optional[str], - max_attempts: int = PAYMENT_BATCH_SUBSCRIPTION_CHECK_RETRY_ATTEMPTS, -) -> Dict[str, Any]: - subscription_allowed, subscription_breaker = breaker_allow_request("subscription_check") - if not subscription_allowed: - return { - "id": account_id, - "email": None, - "success": False, - "error": f"subscription_check 熔断中,稍后重试: {subscription_breaker}", - "attempts": 0, - "breaker": subscription_breaker, - } - - attempts = max(1, int(max_attempts or 1)) - last_error = "" - with get_db() as db: - account = db.query(Account).filter(Account.id == account_id).first() - if not account: - return { - "id": account_id, - "email": None, - "success": False, - "error": "账号不存在", - "attempts": 1, - } - - for attempt in range(1, attempts + 1): - runtime_proxy: Optional[str] = None - try: - runtime_proxy = _resolve_runtime_proxy(explicit_proxy, account) - requested_proxy = bool(str(runtime_proxy or "").strip()) - if requested_proxy: - proxy_allowed, _proxy_breaker = breaker_allow_request("proxy_runtime") - if not proxy_allowed: - runtime_proxy = None - using_proxy = bool(str(runtime_proxy or "").strip()) - - detail, refreshed = _check_subscription_detail_with_retry( - db=db, - account=account, - proxy=runtime_proxy, - allow_token_refresh=True, - ) - status = str(detail.get("status") or "free").lower() - confidence = str(detail.get("confidence") or "low").lower() - before_subscription = str(account.subscription_type or "free") - - _apply_subscription_result( - account, - status=status, - checked_at=datetime.utcnow(), - confidence=confidence, - promote_reason="batch_check_subscription", - ) - - db.commit() - if before_subscription != status: - try: - crud.create_operation_audit_log( - db, - actor="system", - action="account.subscription_auto_detect", - target_type="account", - target_id=account.id, - target_email=account.email, - payload={ - "before": before_subscription, - "after": status, - "confidence": confidence, - "source": detail.get("source"), - "task": "batch_check_subscription", - }, - ) - except Exception: - logger.debug("记录订阅自动检测审计日志失败: account_id=%s", account.id, exc_info=True) - breaker_record_success("subscription_check") - if using_proxy: - breaker_record_success("proxy_runtime") - return { - "id": account_id, - "email": account.email, - "success": True, - "subscription_type": status, - "confidence": confidence, - "source": detail.get("source"), - "token_refreshed": refreshed, - "attempts": attempt, - } - except Exception as exc: - db.rollback() - last_error = str(exc) - breaker_record_failure("subscription_check", last_error) - if bool(str(runtime_proxy or explicit_proxy or getattr(account, "proxy_used", "") or "").strip()): - breaker_record_failure("proxy_runtime", last_error) - can_retry = attempt < attempts and _is_retryable_subscription_check_error(last_error) - if can_retry: - time.sleep(PAYMENT_BATCH_SUBSCRIPTION_CHECK_RETRY_BASE_DELAY_SECONDS * attempt) - continue - return { - "id": account_id, - "email": account.email, - "success": False, - "error": last_error, - "attempts": attempt, - } - - return { - "id": account_id, - "email": None, - "success": False, - "error": last_error or "subscription_check_failed", - "attempts": attempts, - } - - -def _run_batch_check_subscription_async_task( - op_task_id: str, - ids: List[int], - explicit_proxy: Optional[str], -) -> None: - total = len(ids) - success_count = 0 - failed_count = 0 - completed_count = 0 - - _update_payment_op_task( - op_task_id, - status="running", - started_at=_payment_now_iso(), - message=f"开始检测订阅,共 {total} 个账号", - paused=False, - ) - _set_payment_op_task_progress( - op_task_id, - total=total, - completed=0, - success=0, - failed=0, - ) - - if total <= 0: - _update_payment_op_task( - op_task_id, - status="completed", - finished_at=_payment_now_iso(), - message="没有可检测的账号", - paused=False, - result={"success_count": 0, "failed_count": 0, "total": 0, "cancelled": False, "details": []}, - ) - return - - worker_count = min(PAYMENT_BATCH_SUBSCRIPTION_CHECK_MAX_WORKERS, max(1, total)) - _update_payment_op_task(op_task_id, message=f"处理中 0/{total}(并发 {worker_count})") - - next_index = 0 - running: Dict[Any, int] = {} - details: List[Dict[str, Any]] = [] - cancelled = False - pool = ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="payment_sub_check_async") - try: - while completed_count < total: - if not _wait_if_payment_op_task_paused( - op_task_id, - f"处理中 {completed_count}/{total}(并发 {worker_count})", - ): - cancelled = True - break - if _is_payment_op_task_cancel_requested(op_task_id): - cancelled = True - break - - while next_index < total and len(running) < worker_count: - if not _wait_if_payment_op_task_paused( - op_task_id, - f"处理中 {completed_count}/{total}(并发 {worker_count})", - ): - cancelled = True - break - account_id = int(ids[next_index]) - next_index += 1 - future = pool.submit(_batch_check_subscription_one, account_id, explicit_proxy) - running[future] = account_id - - if cancelled: - break - if not running: - continue - - done, _ = wait(tuple(running.keys()), timeout=0.6, return_when=FIRST_COMPLETED) - if not done: - continue - - for future in done: - account_id = int(running.pop(future, 0) or 0) - if account_id <= 0: - continue - try: - detail = future.result() - except Exception as exc: - detail = { - "id": account_id, - "email": None, - "success": False, - "error": str(exc), - "attempts": 1, - } - - completed_count += 1 - if detail.get("success"): - success_count += 1 - else: - failed_count += 1 - details.append(detail) - _append_payment_op_task_detail(op_task_id, detail) - _set_payment_op_task_progress( - op_task_id, - total=total, - completed=completed_count, - success=success_count, - failed=failed_count, - ) - _update_payment_op_task( - op_task_id, - message=f"处理中 {completed_count}/{total}(并发 {worker_count})", - ) - - if cancelled: - for future in list(running.keys()): - future.cancel() - _update_payment_op_task( - op_task_id, - status="cancelled", - finished_at=_payment_now_iso(), - message=f"任务已取消,进度 {completed_count}/{total}", - paused=False, - result={ - "success_count": success_count, - "failed_count": failed_count, - "total": total, - "cancelled": True, - "details": details, - }, - ) - with get_db() as db: - crud.create_operation_audit_log( - db, - actor="system", - action="payment.batch_check_subscription.cancelled", - target_type="batch_operation", - target_id=op_task_id, - payload={ - "total": total, - "completed": completed_count, - "success_count": success_count, - "failed_count": failed_count, - }, - ) - return - finally: - pool.shutdown(wait=False, cancel_futures=True) - - _update_payment_op_task( - op_task_id, - status="completed", - finished_at=_payment_now_iso(), - message=f"检测完成:成功 {success_count},失败 {failed_count}", - paused=False, - result={ - "success_count": success_count, - "failed_count": failed_count, - "total": total, - "cancelled": False, - "details": details, - }, - ) - with get_db() as db: - crud.create_operation_audit_log( - db, - actor="system", - action="payment.batch_check_subscription.completed", - target_type="batch_operation", - target_id=op_task_id, - payload={ - "total": total, - "completed": completed_count, - "success_count": success_count, - "failed_count": failed_count, - }, - ) - - def _generate_checkout_link_for_account( account: Account, request: "CheckoutRequestBase", @@ -4865,8 +1996,7 @@ class GenerateLinkRequest(CheckoutRequestBase): class CreateBindCardTaskRequest(CheckoutRequestBase): auto_open: bool = False - bind_mode: str = "semi_auto" # semi_auto / local_auto / vendor_efun - custom_checkout_url: Optional[str] = None + bind_mode: str = "semi_auto" # semi_auto / third_party / local_auto class OpenIncognitoRequest(BaseModel): @@ -4924,25 +2054,6 @@ class LocalAutoBindRequest(BaseModel): profile: ThirdPartyProfileRequest -class VendorAutoBindRequest(BaseModel): - redeem_code: str - checkout_url: Optional[str] = None - api_url: Optional[str] = None - api_key: Optional[str] = None - timeout_seconds: int = Field(default=180, ge=60, le=900) - - -class EfunRequestBase(BaseModel): - code: str - api_key: Optional[str] = None - base_url: Optional[str] = None - proxy: Optional[str] = None - - -class EfunThreeDSVerifyRequest(EfunRequestBase): - minutes: int = Field(default=30, ge=1, le=1440) - - class MarkSubscriptionRequest(BaseModel): subscription_type: str # 'free' / 'plus' / 'team' @@ -4956,10 +2067,6 @@ class BatchCheckSubscriptionRequest(BaseModel): search_filter: Optional[str] = None -class BindTaskFailStatsRequest(BaseModel): - account_ids: List[int] = [] - - class SaveSessionTokenRequest(BaseModel): session_token: str merge_cookie: bool = True @@ -4990,138 +2097,6 @@ def get_random_billing_profile( raise HTTPException(status_code=500, detail=f"随机账单资料生成失败: {exc}") -# ============== EFun 卡商接口 ============== - -@router.post("/efun/redeem") -def efuncard_redeem(request: EfunRequestBase): - base_url = _resolve_efuncard_base_url(request.base_url) - api_key = _resolve_efuncard_api_key(request.api_key) - code = _normalize_efuncard_code(request.code) - proxy = _normalize_proxy_value(request.proxy) or None - - body = _efuncard_request( - method="POST", - path="/api/external/redeem", - api_key=api_key, - base_url=base_url, - proxy=proxy, - payload={"code": code}, - ) - data = body.get("data") if isinstance(body, dict) else {} - if not isinstance(data, dict): - data = {} - - expiry = str(data.get("expiryDate") or "").strip() - exp_month, exp_year = _parse_efuncard_expiry(expiry) - card_number = str(data.get("cardNumber") or "").strip() - cvv = str(data.get("cvv") or "").strip() - masked = _mask_card_number(card_number) if card_number else "-" - - logger.info("EfunCard 开卡成功: code=%s card=%s status=%s", code, masked, data.get("status")) - return { - "success": True, - "provider": "efuncard", - "card": { - "card_number": card_number, - "exp_month": exp_month, - "exp_year": exp_year, - "cvc": cvv, - "expiry_raw": expiry, - "masked": masked, - "code": str(data.get("code") or code).strip(), - "card_id": data.get("cardId"), - "status": str(data.get("status") or "").strip(), - "created_at": data.get("createdAt"), - }, - "raw": data, - } - - -@router.post("/efun/cancel") -def efuncard_cancel(request: EfunRequestBase): - base_url = _resolve_efuncard_base_url(request.base_url) - api_key = _resolve_efuncard_api_key(request.api_key) - code = _normalize_efuncard_code(request.code) - proxy = _normalize_proxy_value(request.proxy) or None - - body = _efuncard_request( - method="POST", - path="/api/external/cards/cancel", - api_key=api_key, - base_url=base_url, - proxy=proxy, - payload={"code": code}, - ) - data = body.get("data") if isinstance(body, dict) else {} - if not isinstance(data, dict): - data = {} - logger.info("EfunCard 销卡完成: code=%s status=%s refund=%s", code, data.get("status"), data.get("refundAmount")) - return {"success": True, "provider": "efuncard", "data": data} - - -@router.post("/efun/query") -def efuncard_query(request: EfunRequestBase): - base_url = _resolve_efuncard_base_url(request.base_url) - api_key = _resolve_efuncard_api_key(request.api_key) - code = _normalize_efuncard_code(request.code) - proxy = _normalize_proxy_value(request.proxy) or None - - encoded_code = quote(code, safe="") - body = _efuncard_request( - method="GET", - path=f"/api/external/cards/query/{encoded_code}", - api_key=api_key, - base_url=base_url, - proxy=proxy, - ) - data = body.get("data") if isinstance(body, dict) else {} - if not isinstance(data, dict): - data = {} - return {"success": True, "provider": "efuncard", "data": data} - - -@router.post("/efun/billing") -def efuncard_billing(request: EfunRequestBase): - base_url = _resolve_efuncard_base_url(request.base_url) - api_key = _resolve_efuncard_api_key(request.api_key) - code = _normalize_efuncard_code(request.code) - proxy = _normalize_proxy_value(request.proxy) or None - - encoded_code = quote(code, safe="") - body = _efuncard_request( - method="GET", - path=f"/api/external/billing/{encoded_code}", - api_key=api_key, - base_url=base_url, - proxy=proxy, - ) - data = body.get("data") if isinstance(body, dict) else {} - if not isinstance(data, dict): - data = {} - return {"success": True, "provider": "efuncard", "data": data} - - -@router.post("/efun/3ds/verify") -def efuncard_verify_3ds(request: EfunThreeDSVerifyRequest): - base_url = _resolve_efuncard_base_url(request.base_url) - api_key = _resolve_efuncard_api_key(request.api_key) - code = _normalize_efuncard_code(request.code) - proxy = _normalize_proxy_value(request.proxy) or None - - body = _efuncard_request( - method="POST", - path="/api/external/3ds/verify", - api_key=api_key, - base_url=base_url, - proxy=proxy, - payload={"code": code, "minutes": int(request.minutes)}, - ) - data = body.get("data") if isinstance(body, dict) else {} - if not isinstance(data, dict): - data = {} - return {"success": True, "provider": "efuncard", "data": data} - - @router.get("/accounts/{account_id}/session-diagnostic") def get_account_session_diagnostic( account_id: int, @@ -5219,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(), }, } @@ -5279,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) @@ -5370,113 +2345,83 @@ def open_browser_incognito(request: OpenIncognitoRequest): def create_bind_card_task(request: CreateBindCardTaskRequest): """创建绑卡任务(从账号管理中选择账号)""" bind_mode = str(request.bind_mode or "semi_auto").strip().lower() - if bind_mode in DISABLED_BIND_MODES: - raise HTTPException(status_code=403, detail=f"bind_mode={bind_mode} 已被禁用") - if bind_mode not in ALLOWED_BIND_MODES: - raise HTTPException(status_code=400, detail="bind_mode 必须为 semi_auto / local_auto / vendor_efun") + if bind_mode not in ("semi_auto", "third_party", "local_auto"): + raise HTTPException(status_code=400, detail="bind_mode 必须为 semi_auto / third_party / local_auto") - with _acquire_bind_task_create_lock(request.account_id): - with get_db() as db: - account = db.query(Account).filter(Account.id == request.account_id).first() - if not account: - raise HTTPException(status_code=404, detail="账号不存在") + with get_db() as db: + account = db.query(Account).filter(Account.id == request.account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") - active_task = _find_active_bind_task_for_account(db, account.id) - if active_task: - logger.info( - "拒绝重复创建绑卡任务: account_id=%s active_task_id=%s active_status=%s", - account.id, - active_task.id, - active_task.status, - ) - raise HTTPException( - status_code=409, - detail=f"账号已有进行中的绑卡任务(task_id={active_task.id}, status={active_task.status or 'pending'}),请先完成或取消后再创建", - ) + logger.info( + "创建绑卡任务: account_id=%s email=%s plan=%s country=%s mode=%s auto_open=%s", + account.id, account.email, request.plan_type, request.country, bind_mode, request.auto_open + ) - logger.info( - "创建绑卡任务: account_id=%s email=%s plan=%s country=%s mode=%s auto_open=%s", - account.id, account.email, request.plan_type, request.country, bind_mode, request.auto_open + proxy = _resolve_runtime_proxy(request.proxy, account) + try: + link, source, fallback_reason, checkout_session_id, publishable_key, client_secret = _generate_checkout_link_for_account( + account=account, + request=request, + proxy=proxy, ) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"创建绑卡任务失败: {e}") + raise HTTPException(status_code=500, detail=f"创建绑卡任务失败: {str(e)}") - proxy = _resolve_runtime_proxy(request.proxy, account) - checkout_request = request - custom_checkout = str(request.custom_checkout_url or "").strip() - if bind_mode == "vendor_auto" and custom_checkout: - parsed_custom = urlparse(custom_checkout) - if parsed_custom.scheme not in ("http", "https") or not parsed_custom.netloc: - raise HTTPException(status_code=400, detail="自定义 Checkout 链接格式无效") - link = custom_checkout - source = "vendor_custom" - fallback_reason = None - checkout_session_id = _extract_checkout_session_id_from_url(link) - publishable_key = None - client_secret = None - else: - try: - link, source, fallback_reason, checkout_session_id, publishable_key, client_secret = _generate_checkout_link_for_account( - account=account, - request=checkout_request, - proxy=proxy, - ) - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"创建绑卡任务失败: {e}") - raise HTTPException(status_code=500, detail=f"创建绑卡任务失败: {str(e)}") - - task = BindCardTask( - account_id=account.id, - account_email=account.email, - plan_type=request.plan_type, - workspace_name=request.workspace_name if request.plan_type == "team" else None, - price_interval=request.price_interval if request.plan_type == "team" else None, - seat_quantity=request.seat_quantity if request.plan_type == "team" else None, - country=_normalize_checkout_country(request.country), - currency=_normalize_checkout_currency(_normalize_checkout_country(request.country), request.currency), - checkout_url=link, - checkout_session_id=checkout_session_id, - publishable_key=publishable_key, - client_secret=client_secret, - checkout_source=source, - bind_mode=bind_mode, - status="link_ready", - ) - db.add(task) - db.commit() - db.refresh(task) + task = BindCardTask( + account_id=account.id, + plan_type=request.plan_type, + workspace_name=request.workspace_name if request.plan_type == "team" else None, + price_interval=request.price_interval if request.plan_type == "team" else None, + seat_quantity=request.seat_quantity if request.plan_type == "team" else None, + country=_normalize_checkout_country(request.country), + currency=_normalize_checkout_currency(_normalize_checkout_country(request.country), request.currency), + checkout_url=link, + checkout_session_id=checkout_session_id, + publishable_key=publishable_key, + client_secret=client_secret, + checkout_source=source, + bind_mode=bind_mode, + status="link_ready", + ) + db.add(task) + db.commit() + db.refresh(task) - logger.info( - "绑卡任务已创建: task_id=%s account_id=%s plan=%s source=%s status=%s", - task.id, task.account_id, task.plan_type, source, task.status - ) + logger.info( + "绑卡任务已创建: task_id=%s account_id=%s plan=%s source=%s status=%s", + task.id, task.account_id, task.plan_type, source, task.status + ) - opened = False - if request.auto_open and bind_mode in ("semi_auto", "vendor_efun") and link: - opened = open_url_incognito(link, account.cookies if account else None) - if opened: - task.status = "opened" - task.opened_at = datetime.utcnow() - db.commit() - db.refresh(task) - logger.info("绑卡任务自动打开成功: task_id=%s mode=%s", task.id, bind_mode) - else: - logger.warning("绑卡任务自动打开失败: task_id=%s mode=%s", task.id, bind_mode) + opened = False + if request.auto_open and bind_mode == "semi_auto" and link: + opened = open_url_incognito(link, account.cookies if account else None) + if opened: + task.status = "opened" + task.opened_at = utcnow_naive() + db.commit() + db.refresh(task) + logger.info("绑卡任务自动打开成功: task_id=%s mode=%s", task.id, bind_mode) + else: + logger.warning("绑卡任务自动打开失败: task_id=%s mode=%s", task.id, bind_mode) - return { - "success": True, - "task": _serialize_bind_card_task(task), - "link": link, - "is_official_checkout": _is_official_checkout_link(link), - "source": source, - "fallback_reason": fallback_reason, - "auto_opened": opened, - "checkout_session_id": checkout_session_id, - "publishable_key": publishable_key, - "has_client_secret": bool(client_secret), - } + return { + "success": True, + "task": _serialize_bind_card_task(task), + "link": link, + "is_official_checkout": _is_official_checkout_link(link), + "source": source, + "fallback_reason": fallback_reason, + "auto_opened": opened, + "checkout_session_id": checkout_session_id, + "publishable_key": publishable_key, + "has_client_secret": bool(client_secret), + } @router.get("/bind-card/tasks") @@ -5493,12 +2438,8 @@ def list_bind_card_tasks( query = query.filter(BindCardTask.status == status) if search: pattern = f"%{search}%" - query = query.outerjoin(Account, BindCardTask.account_id == Account.id).filter( - or_( - Account.email.ilike(pattern), - Account.account_id.ilike(pattern), - BindCardTask.account_email.ilike(pattern), - ) + query = query.join(Account, BindCardTask.account_id == Account.id).filter( + or_(Account.email.ilike(pattern), Account.account_id.ilike(pattern)) ) total = query.count() @@ -5506,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: @@ -5530,48 +2471,6 @@ def list_bind_card_tasks( } -@router.post("/bind-card/tasks/fail-stats") -def get_bind_card_task_fail_stats(request: BindTaskFailStatsRequest): - account_ids = [] - for item in (request.account_ids or []): - try: - account_id = int(item) - except Exception: - continue - if account_id > 0: - account_ids.append(account_id) - - with get_db() as db: - query = ( - db.query( - BindCardTask.account_id.label("account_id"), - func.count(BindCardTask.id).label("fail_count"), - ) - .filter(BindCardTask.account_id.isnot(None)) - .filter(BindCardTask.status.in_(("failed", "cancelled"))) - ) - if account_ids: - query = query.filter(BindCardTask.account_id.in_(account_ids)) - - rows = query.group_by(BindCardTask.account_id).all() - - stats = [] - for row in rows: - try: - aid = int(getattr(row, "account_id", 0) or 0) - except Exception: - aid = 0 - try: - fail_count = int(getattr(row, "fail_count", 0) or 0) - except Exception: - fail_count = 0 - if aid <= 0: - continue - stats.append({"account_id": aid, "fail_count": max(0, fail_count)}) - - return {"success": True, "stats": stats} - - @router.post("/bind-card/tasks/{task_id}/open") def open_bind_card_task(task_id: int): """打开绑卡任务对应的 checkout 链接""" @@ -5588,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) @@ -5608,7 +2507,6 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi A: 三态判定(success/pending/failed) B: 尝试轮询第三方状态接口,能确认 paid 就标记 paid_pending_sync(等待订阅同步) """ - raise HTTPException(status_code=403, detail="第三方自动绑卡模式已禁用") third_party_response_safe: dict = {} api_url_for_log = "" third_party_assessment: dict = {} @@ -5676,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: @@ -5709,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", @@ -5729,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 = ( @@ -5789,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 = ( @@ -5864,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)) @@ -5902,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( @@ -5923,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)后重试全自动。" @@ -5944,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。" "请先在支付页执行“会话诊断/自动补会话”," @@ -5984,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}") @@ -5999,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" @@ -6034,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) @@ -6060,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( @@ -6100,167 +2998,6 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): } -@router.post("/bind-card/tasks/{task_id}/auto-bind-vendor") -def auto_bind_bind_card_task_vendor(task_id: int, request: VendorAutoBindRequest): - """ - 卡商全自动模式(接口版): - - 先强制按账号生成最新 checkout(生成阶段走代理) - - 再调用 EFun 开卡接口获取卡信息 - - 提交 card.aimizy.com /api/v1/bindcard - - 最后同步订阅状态 - """ - redeem_code = _normalize_vendor_redeem_code(request.redeem_code) - if not redeem_code: - raise HTTPException(status_code=400, detail="兑换码不能为空") - if not VENDOR_REDEEM_CODE_REGEX.fullmatch(redeem_code): - raise HTTPException(status_code=400, detail="兑换码格式错误") - - with get_db() as db: - task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() - if not task: - raise HTTPException(status_code=404, detail="绑卡任务不存在") - account = task.account - if not account: - raise HTTPException(status_code=404, detail="任务关联账号不存在") - - checkout_override = str(request.checkout_url or "").strip() - if checkout_override: - parsed = urlparse(checkout_override) - if parsed.scheme not in ("http", "https") or not parsed.netloc: - raise HTTPException(status_code=400, detail="自定义 Checkout 链接格式无效") - task.checkout_url = checkout_override - task.checkout_session_id = _extract_checkout_session_id_from_url(checkout_override) - - task.bind_mode = "vendor_efun" - task.status = "verifying" - task.last_error = None - task.last_checked_at = datetime.utcnow() - db.commit() - db.refresh(task) - - _vendor_progress_init(task_id) - _vendor_progress_log( - task_id, - "已开始卡商接口订阅流程(EFun 开卡 + bindcard 提交 + 订阅同步)", - progress=8, - status="running", - ) - - thread = threading.Thread( - target=_run_vendor_auto_bind_task, - kwargs={ - "task_id": task_id, - "redeem_code": redeem_code, - "checkout_override": str(request.checkout_url or "").strip() or None, - "api_url": str(request.api_url or "").strip() or None, - "api_key": str(request.api_key or "").strip() or None, - "timeout_seconds": int(request.timeout_seconds), - }, - daemon=True, - ) - thread.start() - - with get_db() as db: - task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() - return { - "success": True, - "pending": True, - "task": _serialize_bind_card_task(task) if task else {"id": task_id}, - "progress": _vendor_progress_snapshot(task_id, 0), - } - - -@router.get("/bind-card/tasks/{task_id}/vendor-progress") -def get_bind_card_task_vendor_progress(task_id: int, cursor: int = Query(0, ge=0)): - snapshot = _vendor_progress_snapshot(task_id, cursor) - with get_db() as db: - task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() - if not task: - if _vendor_progress_exists(task_id): - # 兼容多实例/多数据库文件场景:进度仍在,但任务查询暂未命中时继续返回进度 - return { - "success": True, - "task": { - "id": task_id, - "status": str(snapshot.get("status") or "running"), - "bind_mode": "vendor_efun", - "account_email": None, - }, - "progress": snapshot, - "task_missing": True, - } - raise HTTPException(status_code=404, detail="绑卡任务不存在") - return { - "success": True, - "task": _serialize_bind_card_task(task), - "progress": snapshot, - } - - -@router.post("/bind-card/vendor-stop-active") -def stop_active_bind_card_task_vendor(): - task_id = _vendor_get_latest_active_task_id() - if not task_id: - raise HTTPException(status_code=404, detail="当前没有进行中的卡商任务") - return stop_bind_card_task_vendor(task_id) - - -@router.post("/bind-card/tasks/{task_id}/vendor-stop") -@router.post("/bind-card/tasks/{task_id}/stop-vendor") -@router.post("/bind-card/tasks/{task_id}/stop") -@router.post("/bind-card/tasks/{task_id}/cancel") -def stop_bind_card_task_vendor(task_id: int): - with get_db() as db: - task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() - if not task: - if _vendor_progress_exists(task_id): - requested_now = _vendor_request_stop(task_id) - if requested_now: - _vendor_progress_log(task_id, "已收到停止指令,正在停止任务...", progress=99, status="cancelled") - else: - _vendor_progress_log(task_id, "停止指令已存在,等待任务退出", progress=99, status="cancelled") - return { - "success": True, - "stopped": True, - "task_missing": True, - "task": {"id": task_id, "bind_mode": "vendor_efun", "status": "cancelled"}, - "progress": _vendor_progress_snapshot(task_id, 0), - } - raise HTTPException(status_code=404, detail="绑卡任务不存在") - if str(task.bind_mode or "").strip().lower() not in ("vendor_auto", "vendor_efun"): - raise HTTPException(status_code=400, detail="仅卡商模式任务支持停止") - - task_status = str(task.status or "").lower() - if task_status in ("completed", "failed"): - return { - "success": True, - "stopped": False, - "message": "任务已结束,无需停止", - "task": _serialize_bind_card_task(task), - "progress": _vendor_progress_snapshot(task_id, 0), - } - - requested_now = _vendor_request_stop(task_id) - now = datetime.utcnow() - task.status = "failed" - task.last_error = "用户手动停止卡商订阅任务" - task.last_checked_at = now - db.commit() - db.refresh(task) - - if requested_now: - _vendor_progress_log(task_id, "已收到停止指令,正在停止任务...", progress=99, status="cancelled") - else: - _vendor_progress_log(task_id, "停止指令已存在,等待任务退出", progress=99, status="cancelled") - - return { - "success": True, - "stopped": True, - "task": _serialize_bind_card_task(task), - "progress": _vendor_progress_snapshot(task_id, 0), - } - - @router.post("/bind-card/tasks/{task_id}/sync-subscription") def sync_bind_card_task_subscription(task_id: int, request: SyncBindCardTaskRequest): """同步任务账号订阅状态,并回写到账号管理""" @@ -6273,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, @@ -6297,13 +3034,13 @@ def sync_bind_card_task_subscription(task_id: int, request: SyncBindCardTaskRequ raise HTTPException(status_code=500, detail=f"订阅检测失败: {exc}") # 仅在高置信度 free 时清空;低置信度 free 不覆盖已有订阅 - _apply_subscription_result( - account, - status=status, - checked_at=now, - confidence=detail.get("confidence"), - promote_reason="bind_task_sync_subscription", - ) + if status in ("plus", "team"): + account.subscription_type = status + account.subscription_at = now + elif status == "free": + if str(detail.get("confidence") or "").lower() == "high": + account.subscription_type = None + account.subscription_at = None task.last_checked_at = now if status in ("plus", "team"): @@ -6359,12 +3096,13 @@ def sync_bind_card_task_subscription(task_id: int, request: SyncBindCardTaskRequ } -def _execute_mark_user_action( - task_id: int, - request: MarkUserActionRequest, - cancel_checker: Optional[Callable[[], bool]] = None, - progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None, -) -> Dict[str, Any]: +@router.post("/bind-card/tasks/{task_id}/mark-user-action") +def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest): + """ + 用户确认“已完成支付”后,自动轮询订阅状态一段时间: + - 命中 plus/team -> completed + - 超时未命中 -> paid_pending_sync 或 waiting_user_action + """ with get_db() as db: task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() if not task: @@ -6382,7 +3120,7 @@ def _execute_mark_user_action( ) previous_status = str(task.status or "") - now = datetime.utcnow() + now = utcnow_naive() task.status = "verifying" task.last_error = None task.last_checked_at = now @@ -6391,31 +3129,9 @@ def _execute_mark_user_action( deadline = time.monotonic() + timeout_seconds checks = 0 last_status = "free" - last_detail: Dict[str, Any] = {} token_refresh_used = False while time.monotonic() < deadline: - if callable(cancel_checker) and cancel_checker(): - fallback_status = previous_status if previous_status in ("paid_pending_sync", "waiting_user_action", "link_ready") else "waiting_user_action" - task.status = fallback_status - task.last_error = "用户取消了验证任务" - task.last_checked_at = datetime.utcnow() - task.completed_at = None - db.commit() - db.refresh(task) - return { - "success": True, - "verified": False, - "cancelled": True, - "checks": checks, - "subscription_type": last_status, - "detail": last_detail or None, - "token_refresh_used": token_refresh_used, - "task": _serialize_bind_card_task(task), - "account_id": account.id, - "account_email": account.email, - } - checks += 1 try: detail, refreshed = _check_subscription_detail_with_retry( @@ -6428,13 +3144,12 @@ def _execute_mark_user_action( token_refresh_used = True status = str(detail.get("status") or "free").lower() last_status = status - last_detail = detail if isinstance(detail, dict) else {} logger.info( "绑卡任务验证轮询: task_id=%s attempt=%s status=%s source=%s confidence=%s token_refreshed=%s", task.id, checks, status, detail.get("source"), detail.get("confidence"), bool(detail.get("token_refreshed")) ) 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 @@ -6442,31 +3157,17 @@ def _execute_mark_user_action( 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() - _apply_subscription_result( - account, - status=status, - checked_at=checked_at, - confidence=detail.get("confidence"), - promote_reason="bind_task_verify_polling", - ) + checked_at = utcnow_naive() + if status in ("plus", "team"): + account.subscription_type = status + account.subscription_at = checked_at + elif status == "free": + # 低置信度 free 不覆盖已有订阅,避免误判清空 + if str(detail.get("confidence") or "").lower() == "high": + account.subscription_type = None + account.subscription_at = None task.last_checked_at = checked_at - if callable(progress_callback): - try: - progress_callback( - { - "checks": checks, - "timeout_seconds": timeout_seconds, - "interval_seconds": interval_seconds, - "status": status, - "source": detail.get("source"), - "confidence": detail.get("confidence"), - } - ) - except Exception: - pass - if status in ("plus", "team"): task.status = "completed" task.completed_at = checked_at @@ -6480,7 +3181,6 @@ def _execute_mark_user_action( return { "success": True, "verified": True, - "cancelled": False, "checks": checks, "subscription_type": status, "detail": detail, @@ -6497,18 +3197,18 @@ def _execute_mark_user_action( timeout_msg = ( f"在 {timeout_seconds} 秒内未检测到订阅变更(当前: {last_status}," - f"source={last_detail.get('source') or 'unknown'}," - f"confidence={last_detail.get('confidence') or 'unknown'}," + f"source={detail.get('source') if 'detail' in locals() else 'unknown'}," + f"confidence={detail.get('confidence') if 'detail' in locals() else 'unknown'}," f"token_refreshed={token_refresh_used}" + ( - f",note={str(last_detail.get('note') or '').strip()}" - if last_detail and last_detail.get("note") + f",note={str(detail.get('note') or '').strip()}" + if "detail" in locals() and detail and detail.get("note") else "" ) + ")。" ) - timeout_confidence = str(last_detail.get("confidence") or "unknown").lower() - timeout_source = str(last_detail.get("source") or "unknown") + timeout_confidence = str((detail if "detail" in locals() else {}).get("confidence") or "unknown").lower() + timeout_source = str((detail if "detail" in locals() else {}).get("source") or "unknown") should_keep_paid_pending = ( previous_status == "paid_pending_sync" or (last_status == "free" and timeout_confidence != "high") @@ -6524,7 +3224,7 @@ def _execute_mark_user_action( 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) @@ -6536,10 +3236,9 @@ def _execute_mark_user_action( return { "success": True, "verified": False, - "cancelled": False, "checks": checks, "subscription_type": last_status, - "detail": last_detail or None, + "detail": detail if "detail" in locals() else None, "token_refresh_used": token_refresh_used, "task": _serialize_bind_card_task(task), "account_id": account.id, @@ -6547,209 +3246,6 @@ def _execute_mark_user_action( } -def _run_mark_user_action_async_task(op_task_id: str, bind_task_id: int, payload: Dict[str, Any]) -> None: - timeout_seconds = int(payload.get("timeout_seconds") or 180) - interval_seconds = int(payload.get("interval_seconds") or 10) - _update_payment_op_task( - op_task_id, - status="running", - started_at=_payment_now_iso(), - message=f"开始验证订阅(最长 {timeout_seconds} 秒)", - ) - _set_payment_op_task_progress( - op_task_id, - checks=0, - timeout_seconds=timeout_seconds, - interval_seconds=interval_seconds, - ) - - request = MarkUserActionRequest( - proxy=payload.get("proxy"), - timeout_seconds=timeout_seconds, - interval_seconds=interval_seconds, - ) - - result = _execute_mark_user_action( - bind_task_id, - request, - cancel_checker=lambda: _is_payment_op_task_cancel_requested(op_task_id), - progress_callback=lambda progress: _set_payment_op_task_progress(op_task_id, **(progress or {})), - ) - _append_payment_op_task_detail( - op_task_id, - { - "checked_at": _payment_now_iso(), - "subscription_type": result.get("subscription_type"), - "verified": bool(result.get("verified")), - "cancelled": bool(result.get("cancelled")), - "checks": int(result.get("checks") or 0), - }, - ) - final_status = "cancelled" if result.get("cancelled") else "completed" - _update_payment_op_task( - op_task_id, - status=final_status, - finished_at=_payment_now_iso(), - message=( - "任务已取消" - if final_status == "cancelled" - else ("验证成功" if result.get("verified") else "验证完成,未命中订阅") - ), - result=result, - ) - - -@router.post("/bind-card/tasks/{task_id}/mark-user-action/async") -def start_mark_bind_card_task_user_action_async(task_id: int, request: MarkUserActionRequest): - payload = { - "proxy": request.proxy, - "timeout_seconds": int(request.timeout_seconds), - "interval_seconds": int(request.interval_seconds), - } - op_task_id = _create_payment_op_task( - "mark_user_action", - bind_task_id=task_id, - progress={ - "checks": 0, - "timeout_seconds": int(request.timeout_seconds), - "interval_seconds": int(request.interval_seconds), - }, - ) - task_manager.update_domain_task( - "payment", - op_task_id, - payload={ - "bind_task_id": int(task_id), - "proxy": request.proxy, - "timeout_seconds": int(request.timeout_seconds), - "interval_seconds": int(request.interval_seconds), - }, - ) - _PAYMENT_OP_TASK_EXECUTOR.submit( - _run_payment_op_task_guard, - op_task_id, - "mark_user_action", - _run_mark_user_action_async_task, - task_id, - payload, - ) - return _build_payment_op_task_snapshot(_get_payment_op_task_or_404(op_task_id)) - - -@router.get("/ops/tasks/{op_task_id}") -def get_payment_op_task(op_task_id: str): - return _build_payment_op_task_snapshot(_get_payment_op_task_or_404(op_task_id)) - - -@router.post("/ops/tasks/{op_task_id}/cancel") -def cancel_payment_op_task(op_task_id: str): - with _PAYMENT_OP_TASK_LOCK: - task = _PAYMENT_OP_TASKS.get(op_task_id) - if not task: - raise HTTPException(status_code=404, detail="支付任务不存在") - if task.get("status") in {"completed", "failed", "cancelled"}: - return { - "success": True, - "task_id": op_task_id, - "status": task.get("status"), - "message": "任务已结束,无需取消", - } - task["cancel_requested"] = True - task["pause_requested"] = False - task["paused"] = False - task["message"] = "已提交取消请求,等待任务结束" - task_manager.request_domain_task_cancel("payment", op_task_id) - return {"success": True, "task_id": op_task_id, "status": "cancelling"} - - -@router.post("/ops/tasks/{op_task_id}/pause") -def pause_payment_op_task(op_task_id: str): - with _PAYMENT_OP_TASK_LOCK: - task = _PAYMENT_OP_TASKS.get(op_task_id) - if not task: - raise HTTPException(status_code=404, detail="支付任务不存在") - status = str(task.get("status") or "").strip().lower() - if status in {"completed", "failed", "cancelled"}: - return { - "success": True, - "task_id": op_task_id, - "status": status, - "message": "任务已结束,无法暂停", - } - task["pause_requested"] = True - task["paused"] = True - task["status"] = "paused" - task["message"] = "任务已暂停,等待继续" - task_manager.request_domain_task_pause("payment", op_task_id) - return {"success": True, "task_id": op_task_id, "status": "paused", "message": "任务已暂停"} - - -@router.post("/ops/tasks/{op_task_id}/resume") -def resume_payment_op_task(op_task_id: str): - with _PAYMENT_OP_TASK_LOCK: - task = _PAYMENT_OP_TASKS.get(op_task_id) - if not task: - raise HTTPException(status_code=404, detail="支付任务不存在") - status = str(task.get("status") or "").strip().lower() - if status in {"completed", "failed", "cancelled"}: - return { - "success": True, - "task_id": op_task_id, - "status": status, - "message": "任务已结束,无需继续", - } - task["pause_requested"] = False - task["paused"] = False - if status == "paused": - task["status"] = "running" - task["message"] = "任务已继续执行" - task_manager.request_domain_task_resume("payment", op_task_id) - return {"success": True, "task_id": op_task_id, "status": "running", "message": "任务已继续执行"} - - -def retry_payment_op_task(op_task_id: str) -> Dict[str, Any]: - task = _get_payment_op_task_or_404(op_task_id) - task_type = str(task.get("task_type") or "").strip().lower() - payload = dict(task.get("payload") or {}) - - if task_type == "batch_check_subscription": - return start_batch_check_subscription_async( - BatchCheckSubscriptionRequest( - ids=[int(item) for item in (payload.get("ids") or []) if str(item).strip().isdigit()], - proxy=payload.get("proxy"), - select_all=bool(payload.get("select_all", False)), - status_filter=payload.get("status_filter"), - email_service_filter=payload.get("email_service_filter"), - search_filter=payload.get("search_filter"), - ) - ) - - if task_type == "mark_user_action": - bind_task_id = int(payload.get("bind_task_id") or task.get("bind_task_id") or 0) - if bind_task_id <= 0: - raise HTTPException(status_code=400, detail="mark_user_action 缺少 bind_task_id,无法重试") - return start_mark_bind_card_task_user_action_async( - bind_task_id, - MarkUserActionRequest( - proxy=payload.get("proxy"), - timeout_seconds=int(payload.get("timeout_seconds") or 180), - interval_seconds=int(payload.get("interval_seconds") or 10), - ), - ) - - raise HTTPException(status_code=400, detail=f"不支持重试的支付任务类型: {task_type or '-'}") - - -@router.post("/bind-card/tasks/{task_id}/mark-user-action") -def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest): - """ - 用户确认“已完成支付”后,自动轮询订阅状态一段时间: - - 命中 plus/team -> completed - - 超时未命中 -> paid_pending_sync 或 waiting_user_action - """ - return _execute_mark_user_action(task_id, request) - - @router.delete("/bind-card/tasks/{task_id}") def delete_bind_card_task(task_id: int): """删除绑卡任务""" @@ -6769,90 +3265,65 @@ def delete_bind_card_task(task_id: int): def batch_check_subscription(request: BatchCheckSubscriptionRequest): """批量检测账号订阅状态""" explicit_proxy = _normalize_proxy_value(request.proxy) + + results = {"success_count": 0, "failed_count": 0, "details": []} + with get_db() as db: ids = resolve_account_ids( db, request.ids, request.select_all, request.status_filter, request.email_service_filter, request.search_filter ) - results = {"success_count": 0, "failed_count": 0, "details": []} - if not ids: - return results - - worker_count = min(PAYMENT_BATCH_SUBSCRIPTION_CHECK_MAX_WORKERS, max(1, len(ids))) - with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="payment_sub_check") as pool: - future_map = { - pool.submit(_batch_check_subscription_one, int(account_id), explicit_proxy): int(account_id) - for account_id in ids - } - for future in as_completed(future_map): - try: - detail = future.result() - except Exception as exc: - detail = { - "id": future_map.get(future), - "email": None, - "success": False, - "error": str(exc), - "attempts": 1, - } - if detail.get("success"): - results["success_count"] += 1 - else: + for account_id in ids: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: results["failed_count"] += 1 - results["details"].append(detail) - return results + results["details"].append( + {"id": account_id, "email": None, "success": False, "error": "账号不存在"} + ) + continue + try: + runtime_proxy = _resolve_runtime_proxy(explicit_proxy, account) + detail, refreshed = _check_subscription_detail_with_retry( + db=db, + account=account, + proxy=runtime_proxy, + allow_token_refresh=True, + ) + status = str(detail.get("status") or "free").lower() + confidence = str(detail.get("confidence") or "low").lower() -@router.post("/accounts/batch-check-subscription/async") -def start_batch_check_subscription_async(request: BatchCheckSubscriptionRequest): - """异步批量检测账号订阅状态(并发执行)。""" - explicit_proxy = _normalize_proxy_value(request.proxy) - with get_db() as db: - ids = resolve_account_ids( - db, request.ids, request.select_all, - request.status_filter, request.email_service_filter, request.search_filter - ) + if status in ("plus", "team"): + account.subscription_type = status + account.subscription_at = utcnow_naive() + elif status == "free" and confidence == "high": + account.subscription_type = None + account.subscription_at = None - op_task_id = _create_payment_op_task( - "batch_check_subscription", - progress={"total": len(ids), "completed": 0, "success": 0, "failed": 0}, - ) - task_manager.update_domain_task( - "payment", - op_task_id, - payload={ - "ids": [int(item) for item in ids], - "proxy": explicit_proxy, - "select_all": bool(request.select_all), - "status_filter": request.status_filter, - "email_service_filter": request.email_service_filter, - "search_filter": request.search_filter, - }, - ) + db.commit() + results["success_count"] += 1 + results["details"].append( + { + "id": account_id, + "email": account.email, + "success": True, + "subscription_type": status, + "confidence": confidence, + "source": detail.get("source"), + "token_refreshed": refreshed, + } + ) + except Exception as e: + results["failed_count"] += 1 + results["details"].append( + {"id": account_id, "email": account.email, "success": False, "error": str(e)} + ) - if not ids: - _update_payment_op_task( - op_task_id, - status="completed", - started_at=_payment_now_iso(), - finished_at=_payment_now_iso(), - message="没有可检测的账号", - result={"success_count": 0, "failed_count": 0, "total": 0, "cancelled": False, "details": []}, - ) - else: - _PAYMENT_OP_TASK_EXECUTOR.submit( - _run_payment_op_task_guard, - op_task_id, - "batch_check_subscription", - _run_batch_check_subscription_async_task, - ids, - explicit_proxy, - ) - return _build_payment_op_task_snapshot(_get_payment_op_task_or_404(op_task_id)) + return results @router.post("/accounts/{account_id}/mark-subscription") -def mark_subscription(account_id: int, request: MarkSubscriptionRequest, http_request: Request): +def mark_subscription(account_id: int, request: MarkSubscriptionRequest): """手动标记账号订阅类型""" allowed = ("free", "plus", "team") if request.subscription_type not in allowed: @@ -6862,37 +3333,9 @@ def mark_subscription(account_id: int, request: MarkSubscriptionRequest, http_re account = db.query(Account).filter(Account.id == account_id).first() if not account: raise HTTPException(status_code=404, detail="账号不存在") - actor = _resolve_actor(http_request) - before = { - "subscription_type": account.subscription_type, - "subscription_at": account.subscription_at.isoformat() if account.subscription_at else None, - } - now = datetime.utcnow() - _apply_subscription_result( - account, - status=request.subscription_type, - checked_at=now, - confidence="high", - promote_reason="manual_mark_subscription", - ) + account.subscription_type = None if request.subscription_type == "free" else request.subscription_type + account.subscription_at = utcnow_naive() if request.subscription_type != "free" else None db.commit() - crud.create_operation_audit_log( - db, - actor=actor, - action="account.subscription_manual_mark", - target_type="account", - target_id=account.id, - target_email=account.email, - payload={ - "before": before, - "after": { - "subscription_type": account.subscription_type, - "subscription_at": account.subscription_at.isoformat() if account.subscription_at else None, - }, - }, - ) return {"success": True, "subscription_type": request.subscription_type} - - diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index c6966562..0a82c773 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -1,4 +1,4 @@ -""" +""" 注册任务 API 路由 """ @@ -6,12 +6,11 @@ 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 sqlalchemy import func +from pydantic import BaseModel, ConfigDict, Field from ...config.constants import ( RoleTag, @@ -20,11 +19,20 @@ ) 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() @@ -35,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]]: @@ -78,12 +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 列表 - registration_type: str = RoleTag.CHILD.value # none / parent / child + 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): @@ -103,7 +130,9 @@ class BatchRegistrationRequest(BaseModel): sub2api_service_ids: List[int] = [] auto_upload_tm: bool = False tm_service_ids: List[int] = [] - registration_type: str = RoleTag.CHILD.value # none / parent / child + auto_upload_new_api: bool = False + new_api_service_ids: List[int] = [] + registration_type: str = RoleTag.CHILD.value class RegistrationTaskResponse(BaseModel): @@ -120,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): @@ -172,7 +200,9 @@ class OutlookBatchRegistrationRequest(BaseModel): sub2api_service_ids: List[int] = [] auto_upload_tm: bool = False tm_service_ids: List[int] = [] - registration_type: str = RoleTag.CHILD.value # none / parent / child + auto_upload_new_api: bool = False + new_api_service_ids: List[int] = [] + registration_type: str = RoleTag.CHILD.value class OutlookBatchRegistrationResponse(BaseModel): @@ -184,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: @@ -203,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], @@ -217,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 @@ -230,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, registration_type: str = RoleTag.CHILD.value): +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): """ 在线程池中执行的同步注册任务 @@ -247,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: @@ -294,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 @@ -338,11 +461,8 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: email = svc.config.get("email") if svc.config else None if not email: continue - normalized_email = str(email).strip().lower() # 检查是否已在 accounts 表中注册 - existing = db.query(Account).filter( - func.lower(Account.email) == normalized_email - ).first() + existing = db.query(Account).filter(Account.email == email).first() if not existing: selected_service = svc logger.info(f"选择未注册的 Outlook 账户: {email}") @@ -356,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 @@ -398,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 {} @@ -417,6 +587,16 @@ 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: # 更新代理使用时间 @@ -431,6 +611,16 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: # 保存到数据库 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: try: @@ -454,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: @@ -514,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() ) @@ -527,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 ) @@ -548,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) ) @@ -558,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, registration_type: str = RoleTag.CHILD.value): +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): """ 异步执行注册任务 @@ -591,6 +821,8 @@ 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: @@ -644,6 +876,8 @@ 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, ): """ @@ -664,6 +898,7 @@ async def _run_one(idx: int, uuid: str): auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids or [], auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [], auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids or [], + 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: @@ -712,6 +947,8 @@ 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, ): """ @@ -732,6 +969,7 @@ async def _run_and_release(idx: int, uuid: str, pfx: str): auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids or [], auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [], auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids or [], + 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: @@ -804,6 +1042,8 @@ 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 分发到并行或流水线执行""" @@ -814,6 +1054,7 @@ async def run_batch_registration( auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids, auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids, auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids, + auto_upload_new_api=auto_upload_new_api, new_api_service_ids=new_api_service_ids, registration_type=registration_type, ) else: @@ -824,45 +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 + + +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)) - # 创建任务 - task_uuid = str(uuid.uuid4()) +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, @@ -877,37 +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="间隔时间参数无效") @@ -918,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, @@ -955,16 +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): """获取批量任务状态""" @@ -985,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): """取消批量任务""" @@ -997,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": "批量任务取消请求已提交,正在让它们有序收工"} @@ -1099,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) @@ -1107,11 +1679,14 @@ async def get_registration_stats(): func.date(RegistrationTask.created_at) == today ).group_by(RegistrationTask.status).all() + today_count = db.query(func.count(RegistrationTask.id)).filter( + func.date(RegistrationTask.created_at) == today + ).scalar() + today_by_status = {status: count for status, count in today_status_stats} today_success = int(today_by_status.get("completed", 0)) today_failed = int(today_by_status.get("failed", 0)) - # 今日“注册”口径:仅统计成功 + 失败(不包含 cancelled/running 等) - today_total = today_success + today_failed + today_total = int(today_count or 0) today_success_rate = round((today_success / today_total) * 100, 1) if today_total > 0 else 0.0 return { @@ -1132,6 +1707,7 @@ async def get_available_email_services(): 返回所有已启用的邮箱服务,包括: - tempmail: 临时邮箱(无需配置) + - yyds_mail: YYDS Mail 临时邮箱(需 API Key) - outlook: 已导入的 Outlook 账户 - moe_mail: 已配置的自定义域名服务 """ @@ -1141,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, @@ -1165,6 +1746,11 @@ async def get_available_email_services(): "count": 0, "services": [] }, + "cloudmail": { + "available": False, + "count": 0, + "services": [] + }, "duck_mail": { "available": False, "count": 0, @@ -1179,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", @@ -1252,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 @@ -1307,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 @@ -1336,11 +1995,10 @@ async def get_outlook_accounts_for_registration(): for service in outlook_services: config = service.config or {} email = config.get("email") or service.name - normalized_email = str(email or "").strip().lower() # 检查是否已注册(查询 accounts 表) existing_account = db.query(Account).filter( - func.lower(Account.email) == normalized_email + Account.email == email ).first() is_registered = existing_account is not None @@ -1381,7 +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, - registration_type: str = RoleTag.CHILD.value, + auto_upload_new_api: bool = False, + new_api_service_ids: List[int] = None, ): """ 异步执行 Outlook 批量注册任务,复用通用并发逻辑 @@ -1425,7 +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, - registration_type=registration_type, + auto_upload_new_api=auto_upload_new_api, + new_api_service_ids=new_api_service_ids, ) @@ -1443,104 +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 - normalized_email = str(email or "").strip().lower() - - # 检查是否已注册 - existing_account = db.query(Account).filter( - func.lower(Account.email) == normalized_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, - 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 - ) + return await _start_outlook_batch_registration_internal(request, background_tasks) @router.get("/outlook-batch/{batch_id}") @@ -1581,3 +2144,180 @@ async def cancel_outlook_batch(batch_id: str): 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 index 24216cd7..04735f50 100644 --- a/src/web/routes/selfcheck.py +++ b/src/web/routes/selfcheck.py @@ -437,4 +437,4 @@ def api_selfcheck_run_now(): @router.get("/runtime") def api_selfcheck_runtime(): - return selfcheck_scheduler.snapshot() + 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 c2d6037d..2f34acf6 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -4,16 +4,19 @@ import logging import os -from typing import Optional, Any, Dict, List, Tuple, Set -from urllib.parse import urlparse +from typing import Optional from fastapi import APIRouter, HTTPException, UploadFile, File 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.models import Proxy from ...database.session import get_db +from ...services import EmailServiceType logger = logging.getLogger(__name__) router = APIRouter() @@ -52,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): @@ -62,21 +76,6 @@ class WebUISettings(BaseModel): access_password: Optional[str] = None -class AutoQuickRefreshSettings(BaseModel): - """账号管理自动一键刷新设置""" - enabled: bool = False - interval_minutes: int = 30 - retry_limit: int = 2 - run_now: bool = False - - -class CircuitBreakerSettings(BaseModel): - enabled: bool = True - failure_threshold: int = 5 - cooldown_seconds: int = 180 - probe_interval_seconds: int = 30 - - class AllSettings(BaseModel): """所有设置""" proxy: ProxySettings @@ -84,35 +83,11 @@ class AllSettings(BaseModel): webui: WebUISettings -def _verify_auto_quick_refresh_settings_persisted( - *, - enabled: bool, - interval_minutes: int, - retry_limit: int, -) -> Tuple[bool, List[str]]: - """保存后回读数据库,确保设置真正落库。""" - expected = { - "webui.auto_quick_refresh.enabled": "true" if enabled else "false", - "webui.auto_quick_refresh.interval_minutes": str(interval_minutes), - "webui.auto_quick_refresh.retry_limit": str(retry_limit), - } - mismatches: List[str] = [] - - try: - with get_db() as db: - for key, expected_value in expected.items(): - row = crud.get_setting(db, key) - actual_value = "" - if row and row.value is not None: - actual_value = str(row.value).strip() - if actual_value != expected_value: - mismatches.append( - f"{key}: expected={expected_value}, actual={actual_value or ''}" - ) - except Exception as e: - mismatches.append(f"db_error: {e}") - - return len(mismatches) == 0, mismatches +class AutoQuickRefreshSettingsRequest(BaseModel): + enabled: bool = False + interval_minutes: int = 30 + retry_limit: int = 2 + run_now: bool = False # ============== API Endpoints ============== @@ -146,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, @@ -153,22 +139,22 @@ async def get_all_settings(): "debug": settings.debug, "has_access_password": bool(settings.webui_access_password and settings.webui_access_password.get_secret_value()), }, - "auto_quick_refresh": { - "enabled": bool(getattr(settings, "auto_quick_refresh_enabled", False)), - "interval_minutes": int(getattr(settings, "auto_quick_refresh_interval_minutes", 30) or 30), - "retry_limit": int(getattr(settings, "auto_quick_refresh_retry_limit", 2) or 2), - }, - "circuit_breaker": { - "enabled": bool(getattr(settings, "circuit_breaker_enabled", True)), - "failure_threshold": int(getattr(settings, "circuit_breaker_failure_threshold", 5) or 5), - "cooldown_seconds": int(getattr(settings, "circuit_breaker_cooldown_seconds", 180) or 180), - "probe_interval_seconds": int(getattr(settings, "circuit_breaker_probe_interval_seconds", 30) or 30), - }, "tempmail": { + "enabled": settings.tempmail_enabled, + "api_url": settings.tempmail_base_url, "base_url": settings.tempmail_base_url, "timeout": settings.tempmail_timeout, "max_retries": settings.tempmail_max_retries, }, + "yyds_mail": { + "enabled": settings.yyds_mail_enabled, + "api_url": settings.yyds_mail_base_url, + "base_url": settings.yyds_mail_base_url, + "default_domain": settings.yyds_mail_default_domain, + "timeout": settings.yyds_mail_timeout, + "max_retries": settings.yyds_mail_max_retries, + "has_api_key": bool(settings.yyds_mail_api_key and settings.yyds_mail_api_key.get_secret_value()), + }, "email_code": { "timeout": settings.email_code_timeout, "poll_interval": settings.email_code_poll_interval, @@ -178,81 +164,60 @@ 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 - settings = get_settings() + runtime = auto_quick_refresh_scheduler.snapshot() return { - "enabled": bool(getattr(settings, "auto_quick_refresh_enabled", False)), - "interval_minutes": int(getattr(settings, "auto_quick_refresh_interval_minutes", 30) or 30), - "retry_limit": int(getattr(settings, "auto_quick_refresh_retry_limit", 2) or 2), - "runtime": auto_quick_refresh_scheduler.snapshot(), + "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: AutoQuickRefreshSettings): - """更新账号管理自动一键刷新设置""" - from ..auto_quick_refresh_scheduler import auto_quick_refresh_scheduler +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) - interval_minutes = max(5, min(24 * 60, int(request.interval_minutes or 30))) - retry_limit = max(0, min(5, int(request.retry_limit or 0))) + 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, ) - persisted_ok, mismatches = _verify_auto_quick_refresh_settings_persisted( - enabled=bool(request.enabled), - interval_minutes=interval_minutes, - retry_limit=retry_limit, - ) - if not persisted_ok: - logger.error("自动一键刷新设置落库校验失败: %s", "; ".join(mismatches)) - raise HTTPException( - status_code=500, - detail="自动一键刷新设置保存失败(数据库写入未生效),请重试", - ) - auto_quick_refresh_scheduler.notify_schedule_updated() - if request.run_now and request.enabled: - auto_quick_refresh_scheduler.request_run_now("manual") + 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, - "message": "自动一键刷新设置已更新", - "schedule": { - "enabled": bool(request.enabled), - "interval_minutes": interval_minutes, - "retry_limit": retry_limit, - }, - "runtime": auto_quick_refresh_scheduler.snapshot(), + "enabled": bool(request.enabled), + "interval_minutes": interval_minutes, + "retry_limit": retry_limit, + "runtime": runtime, } -@router.get("/runtime/circuit-breaker") -async def get_circuit_breaker_settings(): - settings = get_settings() - return { - "enabled": bool(getattr(settings, "circuit_breaker_enabled", True)), - "failure_threshold": int(getattr(settings, "circuit_breaker_failure_threshold", 5) or 5), - "cooldown_seconds": int(getattr(settings, "circuit_breaker_cooldown_seconds", 180) or 180), - "probe_interval_seconds": int(getattr(settings, "circuit_breaker_probe_interval_seconds", 30) or 30), - } - - -@router.post("/runtime/circuit-breaker") -async def update_circuit_breaker_settings(request: CircuitBreakerSettings): - update_settings( - circuit_breaker_enabled=bool(request.enabled), - circuit_breaker_failure_threshold=max(1, min(20, int(request.failure_threshold or 1))), - circuit_breaker_cooldown_seconds=max(10, min(3600, int(request.cooldown_seconds or 10))), - circuit_breaker_probe_interval_seconds=max(3, min(600, int(request.probe_interval_seconds or 3))), - ) - return {"success": True, "message": "熔断器设置已更新"} - - @router.get("/proxy/dynamic") async def get_dynamic_proxy_settings(): """获取动态代理设置""" @@ -353,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, @@ -372,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": "注册设置已更新"} @@ -557,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 @@ -622,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): @@ -637,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()), + }, } @@ -651,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) @@ -712,106 +794,6 @@ class ProxyUpdateRequest(BaseModel): priority: Optional[int] = None -class ProxyBatchImportRequest(BaseModel): - """批量导入代理请求""" - content: str - default_type: str = "http" - enabled: bool = True - overwrite_existing: bool = False - - -def _normalize_proxy_type(proxy_type: Optional[str]) -> str: - value = str(proxy_type or "http").strip().lower() - if value in {"http", "https"}: - return "http" - if value in {"socks", "socks5", "socks5h"}: - return "socks5" - return "http" - - -def _build_proxy_key(proxy_type: str, host: str, port: int, username: Optional[str]) -> Tuple[str, str, int, str]: - return ( - _normalize_proxy_type(proxy_type), - str(host or "").strip().lower(), - int(port or 0), - str(username or "").strip(), - ) - - -def _parse_proxy_import_line(line: str, default_type: str) -> Optional[Dict[str, Any]]: - raw = str(line or "").strip() - if not raw: - return None - if raw.startswith("#") or raw.startswith("//"): - return None - - # 支持 CSV: name,type,host,port[,username[,password]] - parts = [item.strip() for item in raw.split(",")] - if len(parts) >= 4 and _normalize_proxy_type(parts[1]) in {"http", "socks5"}: - name = parts[0] or "" - proxy_type = _normalize_proxy_type(parts[1]) - host = parts[2] - try: - port = int(parts[3]) - except Exception as exc: - raise ValueError("CSV 端口无效") from exc - if port <= 0 or port > 65535: - raise ValueError("端口必须在 1-65535 之间") - username = parts[4] if len(parts) >= 5 and parts[4] else None - password = parts[5] if len(parts) >= 6 and parts[5] else None - if not host: - raise ValueError("主机不能为空") - return { - "name": (name or f"{host}:{port}")[:100], - "type": proxy_type, - "host": host[:255], - "port": int(port), - "username": (username[:100] if username else None), - "password": (password[:255] if password else None), - "auth_provided": bool(username or password), - } - - # 支持 name,proxy_line 形式 - custom_name = "" - payload = raw - if "," in raw: - first, rest = raw.split(",", 1) - if rest.strip(): - custom_name = first.strip() - payload = rest.strip() - - source = payload if "://" in payload else f"{default_type}://{payload}" - try: - parsed = urlparse(source) - except Exception as exc: - raise ValueError("代理格式解析失败") from exc - - host = str(parsed.hostname or "").strip() - if not host: - raise ValueError("主机不能为空") - try: - port = int(parsed.port) if parsed.port else 0 - except Exception as exc: - raise ValueError("端口无效") from exc - if port <= 0 or port > 65535: - raise ValueError("端口必须在 1-65535 之间") - - proxy_type = _normalize_proxy_type(parsed.scheme or default_type) - username = str(parsed.username or "").strip() or None - password = str(parsed.password or "").strip() or None - name = custom_name or f"{host}:{port}" - - return { - "name": name[:100], - "type": proxy_type, - "host": host[:255], - "port": int(port), - "username": (username[:100] if username else None), - "password": (password[:255] if password else None), - "auth_provided": bool(parsed.username is not None or parsed.password is not None), - } - - @router.get("/proxies") async def get_proxies_list(enabled: Optional[bool] = None): """获取代理列表""" @@ -823,100 +805,6 @@ async def get_proxies_list(enabled: Optional[bool] = None): } -@router.post("/proxies/batch-import") -async def batch_import_proxies(request: ProxyBatchImportRequest): - """批量导入代理""" - default_type = _normalize_proxy_type(request.default_type) - lines = str(request.content or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") - if len(lines) > 5000: - raise HTTPException(status_code=400, detail="导入行数过多,最多支持 5000 行") - - parsed_rows: List[Tuple[int, Dict[str, Any]]] = [] - errors: List[Dict[str, Any]] = [] - for idx, line in enumerate(lines, start=1): - try: - parsed = _parse_proxy_import_line(line, default_type) - if parsed: - parsed_rows.append((idx, parsed)) - except Exception as exc: - errors.append({ - "line": idx, - "raw": str(line or "")[:200], - "error": str(exc), - }) - - if not parsed_rows and not errors: - raise HTTPException(status_code=400, detail="没有可导入的代理,请检查输入格式") - - created = 0 - updated = 0 - skipped = 0 - - with get_db() as db: - existing = crud.get_proxies(db, limit=100000) - existing_map: Dict[Tuple[str, str, int, str], Proxy] = {} - for p in existing: - key = _build_proxy_key(p.type, p.host, p.port, p.username) - existing_map[key] = p - - seen_keys: Set[Tuple[str, str, int, str]] = set() - for line_no, item in parsed_rows: - key = _build_proxy_key(item["type"], item["host"], item["port"], item.get("username")) - if key in seen_keys: - skipped += 1 - continue - seen_keys.add(key) - - proxy = existing_map.get(key) - try: - if proxy: - if request.overwrite_existing: - proxy.name = item["name"] - proxy.enabled = bool(request.enabled) - if item.get("auth_provided"): - proxy.username = item.get("username") - proxy.password = item.get("password") - updated += 1 - else: - skipped += 1 - continue - - db.add( - Proxy( - name=item["name"], - type=item["type"], - host=item["host"], - port=item["port"], - username=item.get("username"), - password=item.get("password"), - enabled=bool(request.enabled), - priority=0, - ) - ) - created += 1 - except Exception as exc: - errors.append({ - "line": line_no, - "raw": str(lines[line_no - 1] if line_no - 1 < len(lines) else "")[:200], - "error": str(exc), - }) - - if created or updated: - db.commit() - crud._ensure_single_default_proxy(db) - - return { - "success": True, - "total_lines": len(lines), - "valid_rows": len(parsed_rows), - "created": created, - "updated": updated, - "skipped": skipped, - "failed": len(errors), - "errors": errors[:100], - } - - @router.post("/proxies") async def create_proxy_item(request: ProxyCreateRequest): """创建代理""" diff --git a/src/web/routes/tasks.py b/src/web/routes/tasks.py index cf0e773d..dc41e6b1 100644 --- a/src/web/routes/tasks.py +++ b/src/web/routes/tasks.py @@ -190,4 +190,4 @@ def retry_unified_task(domain: str, task_id: str): "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/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/task_manager.py b/src/web/task_manager.py index 7acbfb0a..82049f37 100644 --- a/src/web/task_manager.py +++ b/src/web/task_manager.py @@ -11,6 +11,8 @@ from collections import defaultdict from datetime import datetime +from ..core.timezone_utils import utcnow_naive + logger = logging.getLogger(__name__) # 全局线程池(支持最多 50 个并发注册任务) @@ -127,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: @@ -146,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 } @@ -276,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: @@ -316,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: @@ -430,7 +432,7 @@ def _ensure_domain_task_locked( "task_type": str(task_type or "unknown"), "status": "pending", "message": "任务已创建,等待执行", - "created_at": datetime.utcnow().isoformat(), + "created_at": utcnow_naive().isoformat(), "started_at": None, "finished_at": None, "cancel_requested": False, @@ -443,7 +445,7 @@ def _ensure_domain_task_locked( "result": None, "error": None, "details": [], - "_created_ts": datetime.utcnow().timestamp(), + "_created_ts": utcnow_naive().timestamp(), } tasks[task_key] = task else: @@ -635,7 +637,7 @@ def try_acquire_domain_slot(self, domain: str, task_id: str) -> Tuple[bool, int, running_set.add(task_key) task = self._ensure_domain_task_locked(domain=domain_key, task_id=task_key) task["status"] = "running" - task["started_at"] = task.get("started_at") or datetime.utcnow().isoformat() + task["started_at"] = task.get("started_at") or utcnow_naive().isoformat() task["message"] = task.get("message") or "任务执行中" return True, len(running_set), quota diff --git a/static/css/style.css b/static/css/style.css index 4cf71aae..9fc6ad43 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -137,6 +137,261 @@ body { padding: 0 var(--spacing-lg); } +.site-notice-wrapper { + padding-top: var(--spacing-md); +} + +.site-notice { + 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-text { + margin: 0 0 10px; + color: #0f172a; + font-size: 1rem; + line-height: 1.65; +} + +.site-notice-text:last-of-type { + margin-bottom: 0; +} + +.site-notice-lead strong { + font-weight: 800; +} + +.site-notice-links { + display: flex; + flex-wrap: wrap; + 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-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; + 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; + 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-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; +} + +.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); +} + /* ============================================ 导航栏 ============================================ */ @@ -1289,7 +1544,84 @@ 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: 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-footer { + grid-template-columns: 1fr; + padding: 16px; + } + + .site-footer-actions { + width: 100%; + grid-template-columns: 1fr; + } + .container { padding: 0 var(--spacing-md); } diff --git a/static/js/app.js b/static/js/app.js index 4cb3f204..d4d84193 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,4 +1,4 @@ -/** +/** * 注册页面 JavaScript * 使用 utils.js 中的工具库 */ @@ -8,17 +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 logPollingInFlight = false; -let batchPollingInFlight = false; -let outlookBatchPollingInFlight = false; -let isRecentAccountsLoading = false; -let isTodayStatsLoading = false; 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; // 保存任务的最终状态 @@ -27,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 相关变量 @@ -42,11 +45,22 @@ 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'), @@ -67,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'), @@ -108,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(); @@ -122,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), ]); } @@ -212,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); @@ -235,11 +301,10 @@ function initEventListeners() { elements.outlookConcurrencyMode.addEventListener('change', () => { handleConcurrencyModeChange(elements.outlookConcurrencyMode, elements.outlookConcurrencyHint, elements.outlookIntervalGroup); }); -} -function isIgnorableBackgroundError(error) { - const name = String(error?.name || ''); - return name === 'NetworkOfflineError' || name === 'AbortError'; + if (elements.refreshSchedulesBtn) { + elements.refreshSchedulesBtn.addEventListener('click', () => loadScheduledJobs()); + } } // 加载可用的邮箱服务 @@ -250,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) { @@ -263,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); } @@ -361,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'); @@ -378,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'); @@ -394,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); + } } // 处理邮箱服务切换 @@ -424,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) { @@ -434,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; + } } // 并发模式切换(批量) @@ -468,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('请选择一个邮箱服务'); @@ -484,8 +1012,6 @@ async function handleStartRegistration(e) { return; } - const [emailServiceType, serviceId] = selectedValue.split(':'); - // 禁用开始按钮 elements.startBtn.disabled = true; elements.cancelBtn.disabled = false; @@ -493,22 +1019,7 @@ async function handleStartRegistration(e) { // 清空日志 elements.consoleLog.innerHTML = ''; - // 构建请求数据(代理从设置中自动获取) - const requestData = { - email_service_type: emailServiceType, - registration_type: elements.registrationType ? elements.registrationType.value : 'none', - auto_upload_cpa: elements.autoUploadCpa ? elements.autoUploadCpa.checked : false, - cpa_service_ids: elements.autoUploadCpa && elements.autoUploadCpa.checked ? getSelectedServiceIds(elements.cpaServiceSelect) : [], - auto_upload_sub2api: elements.autoUploadSub2api ? elements.autoUploadSub2api.checked : false, - 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); @@ -517,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) { // 重置任务状态 @@ -551,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') { @@ -616,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(); } } @@ -706,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 })); @@ -715,7 +1452,7 @@ async function handleBatchRegistration(requestData) { showBatchStatus(data); // 优先使用 WebSocket - connectBatchWebSocket(data.batch_id, 'batch'); + connectBatchWebSocket(data.batch_id); } catch (error) { addLog('error', `[错误] 启动失败: ${error.message}`); @@ -732,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' })); @@ -747,8 +1484,10 @@ async function handleCancelTask() { await api.post(endpoint); addLog('warning', '[警告] 批量任务取消请求已提交'); toast.info('任务取消请求已提交'); - stopBatchPolling(); - resetButtons(); + if (!isAutoMode) { + stopBatchPolling(); + resetButtons(); + } } } // 单次任务取消 @@ -783,23 +1522,15 @@ async function handleCancelTask() { // 开始轮询日志 function startLogPolling(taskUuid) { - stopLogPolling(); + if (logPollingInterval) { + return; + } + let lastLogIndex = 0; - logPollingInFlight = false; logPollingInterval = setInterval(async () => { - if (logPollingInFlight) return; - logPollingInFlight = true; try { - const data = await api.get(`/registration/tasks/${taskUuid}/logs`, { - timeoutMs: 15000, - retry: 0, - requestKey: `app:task-logs:${taskUuid}`, - cancelPrevious: true, - silentNetworkError: true, - silentTimeoutError: true, - priority: 'low', - }); + const data = await api.get(`/registration/tasks/${taskUuid}/logs`); // 更新任务状态 updateTaskStatus(data.status); @@ -843,11 +1574,7 @@ function startLogPolling(taskUuid) { } } } catch (error) { - if (!isIgnorableBackgroundError(error)) { - console.error('轮询日志失败:', error); - } - } finally { - logPollingInFlight = false; + console.error('轮询日志失败:', error); } }, 1000); } @@ -858,26 +1585,17 @@ function stopLogPolling() { clearInterval(logPollingInterval); logPollingInterval = null; } - logPollingInFlight = false; } // 开始轮询批量状态 function startBatchPolling(batchId) { - stopBatchPolling(); - batchPollingInFlight = false; + if (batchPollingInterval) { + return; + } + batchPollingInterval = setInterval(async () => { - if (batchPollingInFlight) return; - batchPollingInFlight = true; try { - const data = await api.get(`/registration/batch/${batchId}`, { - timeoutMs: 15000, - retry: 0, - requestKey: `app:batch-status:${batchId}`, - cancelPrevious: true, - silentNetworkError: true, - silentTimeoutError: true, - priority: 'low', - }); + const data = await api.get(`/registration/batch/${batchId}`); updateBatchProgress(data); // 检查是否完成 @@ -899,11 +1617,7 @@ function startBatchPolling(batchId) { } } } catch (error) { - if (!isIgnorableBackgroundError(error)) { - console.error('轮询批量状态失败:', error); - } - } finally { - batchPollingInFlight = false; + console.error('轮询批量状态失败:', error); } }, 2000); } @@ -914,8 +1628,6 @@ function stopBatchPolling() { clearInterval(batchPollingInterval); batchPollingInterval = null; } - batchPollingInFlight = false; - outlookBatchPollingInFlight = false; } // 显示任务状态 @@ -926,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 = '-'; + } } // 更新任务状态 @@ -935,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: '' }; @@ -947,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%'; @@ -961,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); @@ -988,74 +1818,10 @@ function updateBatchProgress(data) { } } -function normalizeRecentRoleTag(roleTag, accountLabel = '') { - const role = String(roleTag || '').trim().toLowerCase(); - if (role === 'parent') return 'parent'; - if (role === 'child') return 'child'; - const label = String(accountLabel || '').trim().toLowerCase(); - if (label === 'mother' || label === 'parent') return 'parent'; - if (label === 'child') return 'child'; - return 'none'; -} - -function getRecentRoleTagText(roleTag) { - if (roleTag === 'parent') return '母号'; - if (roleTag === 'child') return '子号'; - return '普通'; -} - -function getRecentRoleBadgeClass(roleTag) { - if (roleTag === 'parent') return 'parent'; - if (roleTag === 'child') return 'child'; - return 'none'; -} - -function buildRecentRoleSelectOptions(currentRoleTag) { - const role = normalizeRecentRoleTag(currentRoleTag); - const options = [ - { value: 'none', text: '普通' }, - { value: 'parent', text: '母号' }, - { value: 'child', text: '子号' }, - ]; - return options.map(item => ``).join(''); -} - -async function handleRecentAccountRoleTagChange(selectEl) { - const accountId = Number(selectEl?.dataset?.accountRoleId || 0); - if (!accountId) return; - - const previousRole = normalizeRecentRoleTag(selectEl.dataset.prevRole || 'none'); - const nextRole = normalizeRecentRoleTag(selectEl.value || 'none'); - if (nextRole === previousRole) return; - - selectEl.disabled = true; - try { - await api.patch(`/accounts/${accountId}`, { role_tag: nextRole }); - selectEl.dataset.prevRole = nextRole; - toast.success(`账号 #${accountId} 标签已更新为${getRecentRoleTagText(nextRole)}`); - await loadRecentAccounts(true); - } catch (error) { - selectEl.value = previousRole; - toast.error(`更新标签失败: ${error.message}`); - } finally { - selectEl.disabled = false; - } -} - // 加载最近注册的账号 -async function loadRecentAccounts(silent = false) { - if (isRecentAccountsLoading) return; - isRecentAccountsLoading = true; +async function loadRecentAccounts() { try { - const data = await api.get('/accounts?page=1&page_size=10', { - timeoutMs: 30000, - retry: silent ? 0 : 1, - requestKey: 'app:recent-accounts', - cancelPrevious: true, - silentNetworkError: silent, - silentTimeoutError: silent, - priority: silent ? 'low' : 'normal', - }); + const data = await api.get('/accounts?page=1&page_size=10'); if (data.accounts.length === 0) { elements.recentAccountsTable.innerHTML = ` @@ -1088,13 +1854,6 @@ async function loadRecentAccounts(silent = false) { ` : '-'} - - - - ${getRecentRoleTagText(normalizeRecentRoleTag(account.role_tag, account.account_label))} - - - ${getStatusIcon(account.status)} @@ -1108,26 +1867,17 @@ async function loadRecentAccounts(silent = false) { elements.recentAccountsTable.querySelectorAll('.copy-pwd-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); copyToClipboard(btn.dataset.pwd); }); }); + } catch (error) { - if (!silent || !isIgnorableBackgroundError(error)) { - console.error('加载账号列表失败:', error); - } - if (!silent) { - toast.warning('加载账号列表失败,请稍后重试'); - } - } finally { - isRecentAccountsLoading = false; + console.error('加载账号列表失败:', error); } } // 开始账号列表轮询 function startAccountsPolling() { - if (accountsPollingInterval) { - clearInterval(accountsPollingInterval); - } // 每30秒刷新一次账号列表 accountsPollingInterval = setInterval(() => { - loadRecentAccounts(true); + loadRecentAccounts(); }, 30000); } @@ -1163,35 +1913,20 @@ function renderTodayStats(total, success, failed, rate) { } async function loadTodayStats(silent = true) { - if (isTodayStatsLoading) return; - isTodayStatsLoading = true; try { - const data = await api.get('/registration/stats', { - timeoutMs: 25000, - retry: silent ? 0 : 1, - requestKey: 'app:today-stats', - cancelPrevious: true, - silentNetworkError: silent, - silentTimeoutError: silent, - priority: silent ? 'low' : 'normal', - }); + const data = await api.get('/registration/stats'); const byStatus = data?.by_status || {}; const total = Number(data?.today_total ?? data?.today_count ?? 0); const success = Number(data?.today_success ?? byStatus.completed ?? 0); const failed = Number(data?.today_failed ?? byStatus.failed ?? 0); - const finished = success + failed; - const fallbackRate = finished > 0 ? (success / finished) * 100 : 0; + const fallbackRate = total > 0 ? (success / total) * 100 : 0; const rate = Number(data?.today_success_rate ?? fallbackRate); renderTodayStats(total, success, failed, Number.isFinite(rate) ? rate : 0); } catch (error) { - if (!silent || !isIgnorableBackgroundError(error)) { - console.error('加载今日统计失败:', error); - } + console.error('加载今日统计失败:', error); if (!silent) { toast.error('加载今日统计失败'); } - } finally { - isTodayStatsLoading = false; } } @@ -1282,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; @@ -1296,7 +2038,9 @@ function resetButtons() { activeTaskUuid = null; activeBatchId = null; // 清除 sessionStorage 持久化状态 - sessionStorage.removeItem('activeTask'); + if (!isAutoMode) { + sessionStorage.removeItem('activeTask'); + } // 断开 WebSocket disconnectWebSocket(); disconnectBatchWebSocket(); @@ -1347,7 +2091,7 @@ function renderOutlookAccountsList() {
${escapeHtml(account.email)}
${account.is_registered - ? `✓ 已注册${account.registered_account_id ? ` #${account.registered_account_id}` : ''}` + ? `✓ 已注册` : '未注册' } ${account.has_oauth ? ' | OAuth' : ''} @@ -1416,7 +2160,7 @@ async function handleOutlookBatchRegistration() { const requestData = { service_ids: selectedIds, skip_registered: skipRegistered, - registration_type: elements.registrationType ? elements.registrationType.value : 'none', + registration_type: elements.registrationType ? elements.registrationType.value : 'child', interval_min: intervalMin, interval_max: intervalMax, concurrency: Math.min(50, Math.max(1, concurrency)), @@ -1427,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} 个账户)...`); @@ -1441,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 })); @@ -1452,7 +2198,7 @@ async function handleOutlookBatchRegistration() { showBatchStatus({ count: data.to_register }); // 优先使用 WebSocket - connectBatchWebSocket(data.batch_id, 'outlook_batch'); + connectBatchWebSocket(data.batch_id); } catch (error) { addLog('error', `[错误] 启动失败: ${error.message}`); @@ -1463,41 +2209,39 @@ async function handleOutlookBatchRegistration() { // ============== 批量任务 WebSocket 功能 ============== -function normalizeBatchMode(mode) { - const text = String(mode || '').trim().toLowerCase(); - if (text === 'outlook_batch') return 'outlook_batch'; - if (text === 'batch') return 'batch'; - return isOutlookBatchMode ? 'outlook_batch' : 'batch'; -} +// 连接批量任务 WebSocket +function connectBatchWebSocket(batchId) { + activeBatchId = batchId; -function startCurrentBatchPolling(batchId, mode = null) { - const normalizedMode = normalizeBatchMode(mode); - if (normalizedMode === 'outlook_batch') { - startOutlookBatchPolling(batchId); + if (batchWebSocket && [WebSocket.OPEN, WebSocket.CONNECTING].includes(batchWebSocket.readyState)) { return; } - startBatchPolling(batchId); -} -// 连接批量任务 WebSocket -function connectBatchWebSocket(batchId, mode = null) { - const batchMode = normalizeBatchMode(mode); - const batchLabel = batchMode === 'outlook_batch' ? 'Outlook 批量任务' : '批量任务'; + 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') { @@ -1530,12 +2274,12 @@ function connectBatchWebSocket(batchId, mode = null) { if (!toastShown) { toastShown = true; if (data.status === 'completed') { - addLog('success', `[完成] ${batchLabel}完成!成功: ${data.success}, 失败: ${data.failed}, 跳过: ${data.skipped || 0}`); + addLog('success', `[完成] Outlook 批量任务完成!成功: ${data.success}, 失败: ${data.failed}, 跳过: ${data.skipped || 0}`); if (data.success > 0) { - toast.success(`${batchLabel}完成,成功 ${data.success} 个`); + toast.success(`Outlook 批量注册完成,成功 ${data.success} 个`); loadRecentAccounts(); } else { - toast.warning(`${batchLabel}完成,但没有成功注册任何账号`); + toast.warning('Outlook 批量注册完成,但没有成功注册任何账号'); } } else if (data.status === 'failed') { addLog('error', '[错误] 批量任务执行失败'); @@ -1550,40 +2294,49 @@ function connectBatchWebSocket(batchId, mode = null) { } }; - 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('切换到轮询模式'); - startCurrentBatchPolling(currentBatch.batch_id, batchMode); + if (shouldReconnect) { + console.log('批量任务 WebSocket 断开,准备自动重连'); + startCurrentBatchPolling(batchId); + scheduleBatchWebSocketReconnect(batchId); } }; - batchWebSocket.onerror = (error) => { + socket.onerror = (error) => { + if (batchWebSocket !== socket) return; console.error('批量任务 WebSocket 错误:', error); - stopBatchWebSocketHeartbeat(); - // 切换到轮询 - startCurrentBatchPolling(batchId, batchMode); }; } catch (error) { console.error('批量任务 WebSocket 连接失败:', error); - startCurrentBatchPolling(batchId, batchMode); + startCurrentBatchPolling(batchId); + scheduleBatchWebSocketReconnect(batchId); } } // 断开批量任务 WebSocket function disconnectBatchWebSocket() { + batchWsManualClose = true; + clearBatchWebSocketReconnect(); stopBatchWebSocketHeartbeat(); if (batchWebSocket) { - batchWebSocket.close(); + const socket = batchWebSocket; batchWebSocket = null; + socket.close(); } } @@ -1614,22 +2367,13 @@ function cancelBatchViaWebSocket() { // 开始轮询 Outlook 批量状态(降级方案) function startOutlookBatchPolling(batchId) { - stopBatchPolling(); - let lastLogIndex = 0; - outlookBatchPollingInFlight = false; + if (batchPollingInterval) { + return; + } + batchPollingInterval = setInterval(async () => { - if (outlookBatchPollingInFlight) return; - outlookBatchPollingInFlight = true; try { - const data = await api.get(`/registration/outlook-batch/${batchId}`, { - timeoutMs: 15000, - retry: 0, - requestKey: `app:outlook-batch-status:${batchId}`, - cancelPrevious: true, - silentNetworkError: true, - silentTimeoutError: true, - priority: 'low', - }); + const data = await api.get(`/registration/outlook-batch/${batchId}`); // 更新进度 updateBatchProgress({ @@ -1641,12 +2385,13 @@ function startOutlookBatchPolling(batchId) { // 输出日志 if (data.logs && data.logs.length > 0) { + const lastLogIndex = batchPollingInterval.lastLogIndex || 0; for (let i = lastLogIndex; i < data.logs.length; i++) { const log = data.logs[i]; const logType = getLogType(log); addLog(logType, log); } - lastLogIndex = data.logs.length; + batchPollingInterval.lastLogIndex = data.logs.length; } // 检查是否完成 @@ -1667,13 +2412,11 @@ function startOutlookBatchPolling(batchId) { } } } catch (error) { - if (!isIgnorableBackgroundError(error)) { - console.error('轮询 Outlook 批量状态失败:', error); - } - } finally { - outlookBatchPollingInFlight = false; + console.error('轮询 Outlook 批量状态失败:', error); } }, 2000); + + batchPollingInterval.lastLogIndex = 0; } // ============== 页面可见性重连机制 ============== @@ -1697,7 +2440,12 @@ function initVisibilityReconnect() { if (activeBatchId && !batchCompleted && batchWsDisconnected) { console.log('[重连] 页面重新可见,重连批量任务 WebSocket:', activeBatchId); addLog('info', '[系统] 页面重新激活,正在重连批量任务监控...'); - connectBatchWebSocket(activeBatchId, isOutlookBatchMode ? 'outlook_batch' : 'batch'); + connectBatchWebSocket(activeBatchId); + } + + if (isAutoMode && !autoMonitorPollingInterval) { + addLog('info', '[系统] 页面重新激活,正在恢复自动注册监控...'); + startAutoRegistrationMonitor(); } }); } @@ -1753,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; @@ -1765,10 +2513,26 @@ async function restoreActiveTask() { showBatchStatus({ count: total || data.total }); updateBatchProgress(data); addLog('info', `[系统] 检测到进行中的批量任务,正在重连监控... (${batch_id.substring(0, 8)})`); - connectBatchWebSocket(batch_id, mode); + connectBatchWebSocket(batch_id); } catch { sessionStorage.removeItem('activeTask'); } + } else if (mode === 'auto') { + sessionStorage.removeItem('activeTask'); } } + +async function refreshOutlookRegistrationStatus() { + try { + const ids = outlookAccounts.map(item => item.id).filter(Boolean); + const data = await api.post('/registration/outlook/check-accounts', { service_ids: ids }); + outlookAccounts = data.accounts || []; + renderOutlookAccounts(outlookAccounts); + addLog('info', `[??] Outlook ??????? (???: ${data.registered_count}, ???: ${data.unregistered_count})`); + toast.success('Outlook ???????'); + } catch (error) { + console.error('?? Outlook ??????:', error); + toast.error('?? Outlook ??????: ' + error.message); + } +} diff --git a/static/js/email_services.js b/static/js/email_services.js index 1f9ea7d9..be426a16 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -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,9 +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等)' }; @@ -160,6 +174,7 @@ function initEventListeners() { // 临时邮箱配置 elements.tempmailForm.addEventListener('submit', handleSaveTempmail); elements.testTempmailBtn.addEventListener('click', handleTestTempmail); + elements.testYydsBtn.addEventListener('click', handleTestYyds); // 点击其他地方关闭更多菜单 document.addEventListener('click', () => { @@ -183,8 +198,11 @@ function closeEmailMoreMenu(el) { function switchAddSubType(subType) { elements.customSubType.value = subType; elements.addMoemailFields.style.display = subType === 'moemail' ? '' : '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'; } @@ -193,8 +211,11 @@ function switchAddSubType(subType) { function switchEditSubType(subType) { elements.editCustomSubTypeHidden.value = subType; elements.editMoemailFields.style.display = subType === 'moemail' ? '' : '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; @@ -205,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.cloudmail_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) { @@ -298,6 +319,12 @@ 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'; } @@ -307,6 +334,9 @@ function getCustomServiceTypeBadge(subType) { if (subType === 'duckmail') { return 'DuckMail'; } + if (subType === 'luckmail') { + return 'LuckMail'; + } if (subType === 'freemail') { return 'Freemail'; } @@ -319,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) { @@ -330,21 +382,27 @@ function getCustomServiceAddress(service) { // 加载自定义邮箱服务(moe_mail + temp_mail + cloudmail + duck_mail + freemail + imap_mail 合并) async function loadCustomServices() { try { - const [r1, r2, r3, r4, r5, r6] = 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: 'cloudmail' })), - ...(r4.services || []).map(s => ({ ...s, _subType: 'duckmail' })), - ...(r5.services || []).map(s => ({ ...s, _subType: 'freemail' })), - ...(r6.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) { @@ -410,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) { // 忽略错误 } @@ -467,6 +532,22 @@ async function handleAddCustom(e) { api_key: formData.get('api_key'), default_domain: formData.get('domain') }; + } 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 = { @@ -483,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 = { @@ -585,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); @@ -598,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 @@ -609,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'; } } @@ -635,12 +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' @@ -660,6 +787,17 @@ 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_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 = ''; @@ -671,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 = ''; @@ -706,6 +851,21 @@ async function handleEditCustom(e) { }; const apiKey = formData.get('api_key'); if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); + } 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'), @@ -722,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/settings.js b/static/js/settings.js index de673a43..95a0e508 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -75,6 +75,14 @@ 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 设置 @@ -96,6 +104,7 @@ document.addEventListener('DOMContentLoaded', () => { loadCpaServices(); loadSub2ApiServices(); loadTmServices(); + loadNewApiServices(); initEventListeners(); }); @@ -304,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) { @@ -1715,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/templates/accounts.html b/templates/accounts.html index 803154bd..0ea5dff5 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -246,6 +246,7 @@ + {% include "partials/site_notice.html" %}
+ +
+
+ + +
+
+ + + 留空则保持已保存的 API Key 不变 +
+
+ + +
+
+ +
- +
@@ -214,9 +238,12 @@

➕ 添加自定义邮箱服务

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

➕ 添加自定义邮箱服务

+ + + + + + + + + + + +