diff --git a/.gitmodules b/.gitmodules index ee5c232..b4c2348 100644 --- a/.gitmodules +++ b/.gitmodules @@ -50,3 +50,6 @@ [submodule "packages/general/cursor-auto-register"] path = packages/general/cursor-auto-register url = https://github.com/VictorKTO/cursor-auto-register.git +[submodule "packages/general/ExaFree"] + path = packages/general/ExaFree + url = https://github.com/chengtx809/ExaFree.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 96dc5d7..f68ad5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 创建和管理光标配置文件 - 备份和恢复光标设置 - 适用于 Windows 和 macOS 系统 + - **packages/general/ExaFree** (submodule) - Exa 免费使用工具 + - 提供 Exa 相关服务的免费访问 + - 支持 Exa 功能的使用 + - 简单易用的界面 + - 适用于需要 Exa 服务的用户 - **Codex 相关子模块** - **packages/codex/codex-lb** (submodule) - Codex 负载均衡工具 diff --git a/README.md b/README.md index 4524152..d8dff11 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,17 @@ AI-Account-Toolkit/ **使用指南**:[packages/general/cursor-auto-register/README.md](packages/general/cursor-auto-register/README.md) +### 24. ExaFree - Exa 免费使用工具 + +**功能**:Exa 免费使用工具,提供 Exa 相关服务的免费访问。 + +**主要文件**: +- 主程序文件 +- 配置文件 +- README.md - 项目说明 + +**使用指南**:[packages/general/ExaFree/README.md](packages/general/ExaFree/README.md) + ## 快速开始 ### 1. 环境准备 @@ -344,6 +355,7 @@ git submodule update - `packages/email/Hotmail-Outlook-Create-Account-Register-Auto/` - Hotmail 账号自动创建工具 - `packages/email/outlook-auto-register/` - Outlook 邮箱注册工具集 - `packages/general/cursor-auto-register/` - 光标设置管理工具 +- `packages/general/ExaFree/` - Exa 免费使用工具 ### 3. 配置设置 diff --git a/Register_GPT_v0/.gitignore b/Register_GPT_v0/.gitignore new file mode 100644 index 0000000..43a6c12 --- /dev/null +++ b/Register_GPT_v0/.gitignore @@ -0,0 +1,33 @@ +# 本地数据与数据库(勿提交到仓库) +data/ + +# 数据库文件 +*.db +*.db-journal +*.db-wal + +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ +venv/ +env/ + +# 环境与密钥 +.env +.env.local +*.pem +.git_commit_msg.txt +Cookies + +# 日志与临时 +*.log +.DS_Store +logs/ +tmp_audio_check/ +.learnings/ + +# 调试与测试(勿提交) +debug_*.html +tests/ diff --git a/Register_GPT_v0/LICENSE b/Register_GPT_v0/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/Register_GPT_v0/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Register_GPT_v0/README.md b/Register_GPT_v0/README.md new file mode 100644 index 0000000..d9b054c --- /dev/null +++ b/Register_GPT_v0/README.md @@ -0,0 +1,66 @@ +# Register_GPT_v0 - GPT 注册工具 + +## 项目概述 + +Register_GPT_v0 是一个 GPT 账号注册工具,用于自动化注册 GPT 相关服务的账号。 + +## 核心功能 + +- 自动化 GPT 账号注册流程 +- 支持邮箱验证 +- 支持验证码处理 +- 支持代理配置 + +## 目录结构 + +``` +Register_GPT_v0/ +├── [相关文件] +└── README.md +``` + +## 环境要求 + +- Python 3.10+ +- 依赖项:根据项目需要安装 + +## 安装与使用 + +1. **安装依赖** + +```bash +pip install -r requirements.txt +``` + +2. **运行工具** + +```bash +python main.py +``` + +## 配置说明 + +具体配置选项请参考项目内部的配置文件和文档。 + +## 注意事项 + +- 请遵守各平台的使用条款,不要滥用注册功能 +- 确保网络环境稳定,避免注册过程中断 +- 合理设置注册频率,避免触发平台的反自动化机制 + +## 故障排除 + +如果遇到注册失败的情况,可以检查以下几点: + +- 网络连接是否正常 +- 邮箱服务是否可用 +- 验证码处理是否正确 +- 平台是否有新的注册限制 + +## 贡献 + +欢迎提交 Issue 和 Pull Request 来改进这个项目。 + +## 许可证 + +请参考项目的 LICENSE 文件。 \ No newline at end of file diff --git a/Register_GPT_v0/__init__.py b/Register_GPT_v0/__init__.py new file mode 100644 index 0000000..d6fe2c0 --- /dev/null +++ b/Register_GPT_v0/__init__.py @@ -0,0 +1 @@ +# 协议版注册:纯 HTTP 流程,见 protocol_register、main_protocol diff --git a/Register_GPT_v0/docs/SORA_ACTIVATION_AND_PHONE_BIND_ANALYSIS.md b/Register_GPT_v0/docs/SORA_ACTIVATION_AND_PHONE_BIND_ANALYSIS.md new file mode 100644 index 0000000..14c870f --- /dev/null +++ b/Register_GPT_v0/docs/SORA_ACTIVATION_AND_PHONE_BIND_ANALYSIS.md @@ -0,0 +1,166 @@ +# Sora 激活与手机号绑定 - 参考 genz27/sora-phone-bind 的实现分析 + +参考仓库:https://github.com/genz27/sora-phone-bind + +## 一、sora-phone-bind 核心接口(来自其 main.py,可直接对照源码) + +### 1. RT 转 AT + +- **URL**: `POST https://auth.openai.com/oauth/token` +- **Body** (JSON): + - `client_id`: 默认 `app_LlGpXReQgckcGGUo2JrYvtJK`(iOS/移动端用) + - `grant_type`: `"refresh_token"` + - `redirect_uri`: `"com.openai.chat://auth0.openai.com/ios/com.openai.chat/callback"` + - `refresh_token`: 我们的 RT +- **请求方式**: curl_cffi `AsyncSession`,`impersonate` 使用移动端指纹(如 `safari17_2_ios`、`safari18_0_ios`) +- **响应**: 取 `access_token`、`refresh_token`(若有新 RT 会返回) + +### 2. Sora 激活(有用户名才算“激活”) + +顺序如下: + +| 步骤 | 方法 | URL | 说明 | +|------|------|-----|------| +| 1 | GET | `https://sora.chatgpt.com/backend/m/bootstrap` | 激活 Sora2,必须先调 | +| 2 | GET | `https://sora.chatgpt.com/backend/me` | 获取当前用户信息 | +| 3 | 若 `me` 里已有 `username` | - | 视为已激活,结束 | +| 4 | POST | `https://sora.chatgpt.com/backend/project_y/profile/username/check` | Body: `{"username": "user_xxxxxxxx"}`,检查是否可用 | +| 5 | POST | `https://sora.chatgpt.com/backend/project_y/profile/username/set` | Body: `{"username": "user_xxxxxxxx"}`,设置用户名 | + +- **请求头**(与仓库一致): + - `Origin: https://sora.chatgpt.com` + - `Referer: https://sora.chatgpt.com/` + - `Authorization: Bearer {access_token}` + - `Content-Type: application/json` + - 移动端 UA(如 iPhone Safari / Chrome iOS),`Sec-Ch-Ua-Mobile: ?1`,`Sec-Ch-Ua-Platform: "iOS"` +- **请求方式**: 全部用 curl_cffi,`impersonate` 用移动端(如 `safari17_2_ios`),避免 403/404 因桌面端指纹被拦。 + +### 3. 手机号绑定 + +| 步骤 | 方法 | URL | Body | +|------|------|-----|------| +| 1 | POST | `https://sora.chatgpt.com/backend/project_y/phone_number/enroll/start` | `{"phone_number": "+1xxxxxxxxxx", "verification_expiry_window_ms": null}` | +| 2 | (轮询接码平台 API 获取短信验证码) | - | - | +| 3 | POST | `https://sora.chatgpt.com/backend/project_y/phone_number/enroll/finish` | `{"phone_number": "+1xxxxxxxxxx", "verification_code": "123456"}` | + +- 同一套请求头(Bearer AT + 移动端 UA/指纹)。 +- 若响应里含 `"already verified"` / `"phone number already"` 表示该号已被占用,需换号。 +- 验证码来源:接码平台提供的 API(配置格式为 `phone----api_url`,轮询 `api_url` 取 `data.code` 中 6 位数字)。 + +--- + +## 二、与我们当前实现的差异 + +| 项目 | 我们当前 | sora-phone-bind | +|------|----------|------------------| +| Sora 用户名设置 URL | 同:`/backend/project_y/profile/username/set` | 同 | +| 请求客户端 | requests / 桌面 Chrome 指纹 | curl_cffi + **移动端**指纹(Safari iOS 等) | +| 是否先调 bootstrap | 否 | **是**,先 GET `/backend/m/bootstrap` | +| 是否先 GET /backend/me | 否 | **是**,用于判断是否已有 username | +| 用户名生成 | 邮箱前缀 | 随机 `user_` + 8 位字母数字,且先 **check** 再 **set** | +| 手机号绑定 | 未做 | 有完整 enroll/start → 接码 → enroll/finish | + +我们之前 Sora 返回 403/404 很可能与**未用移动端指纹**、**未先调 bootstrap/me** 有关;路径本身与参考项目一致。 + +--- + +## 三、在我们项目中的实现建议 + +### 阶段 1:对齐 Sora 激活(先能稳定 200) + +1. **请求方式** + - 所有发往 `sora.chatgpt.com` 的请求改为 **curl_cffi**,`impersonate` 使用移动端(如 `safari17_2_ios`),与 sora-phone-bind 一致。 + +2. **调用顺序** + - 先 GET `https://sora.chatgpt.com/backend/m/bootstrap`(激活 Sora2); + - 再 GET `https://sora.chatgpt.com/backend/me`; + - 若 `me.username` 已存在,直接视为激活成功; + - 若无:生成随机 `user_xxxxxxxx`,先 POST `profile/username/check`,可用再 POST `profile/username/set`。 + +3. **请求头** + - 使用与 sora-phone-bind 相同的 Origin / Referer / Sec-Ch-Ua-Mobile / Sec-Ch-Ua-Platform 等(见其 `HEADERS` + 移动端 UA)。 + +4. **配置** + - 保持当前 `SORA_USERNAME_SET_URL` 为 `/backend/project_y/profile/username/set`,仅改调用顺序与指纹,不猜其它路径。 + +### 阶段 2:手机号绑定(独立流程,可选) + +1. **数据与配置** + - 接码平台配置:支持“手机号 + 获取验证码的 API URL”(可参考 sora-phone-bind 的 `phone----api_url` 或我们自己的表结构)。 + - 账号表可增加字段:如 `phone_bound`(是否已绑)、`phone`(脱敏存储可选)。 + +2. **流程** + - 输入:当前账号的 AT(或从 RT 用上述 client_id + redirect_uri 换 AT)。 + - 从池中取一个手机号,POST `phone_number/enroll/start`; + - 轮询接码 API 取 6 位验证码(与 sora-phone-bind 的 `get_code` 逻辑类似); + - POST `phone_number/enroll/finish` 提交验证码; + - 若返回“手机号已被使用”,换号重试或标记该号不可用; + - 成功后可选:再调一次 RT 换 AT 拿新 RT 并落库(若服务端返回新 RT)。 + +3. **与注册流程的关系** + - 注册完成后已有 AT/RT,先做**阶段 1 的 Sora 激活**; + - 手机号绑定可作为**单独任务/接口**(例如“对某账号或某批账号执行绑手机”),不必和注册强绑在同一请求里,便于接码池、重试、限流管理。 + +--- + +## 四、建议落地顺序 + +1. **先改 Sora 激活**:在 `protocol_register.py`(或独立 sora 模块)里,按上面顺序实现 bootstrap → me → check → set,且全部用 curl_cffi 移动端指纹;观察是否仍 403/404。 +2. **再做手机号绑定**:新增“绑手机”服务/接口,配置接码源,实现 enroll/start → 轮询 code → enroll/finish;数据库与前端按需加字段和入口。 +3. **RT 转 AT**:若我们已有用 web client_id 的换 token 逻辑,可保留;若需要与 sora-phone-bind 完全一致(例如为绑手机专门用移动端 client_id 换 AT),可增加一条分支使用其 `client_id` 与 `redirect_uri`。 + +以上接口与顺序均来自 [genz27/sora-phone-bind](https://github.com/genz27/sora-phone-bind) 的 main.py,可作为抓包/查资料之外的可靠实现参考。 + +--- + +## 五、本项目的「开始绑定手机」功能说明(实现文档补充) + +「开始绑定手机」即前端「手机号管理」页的 **开始绑定手机** 按钮所触发的批量任务:从**账号管理**读取待绑定账号,从**手机号管理**读取可用号码,使用**系统设置**里已配置的手机号接码 API 取验证码,依次完成 Sora 激活(若未激活)+ 调用 Sora 的 enroll/start、轮询验证码、enroll/finish,并回写账号与手机号状态。 + +### 5.1 数据来源 + +| 来源 | 表/接口 | 筛选与说明 | +|------|---------|------------| +| **账号** | `accounts`(账号管理) | `phone_bound = 0` 且 `(refresh_token IS NOT NULL OR access_token IS NOT NULL)`;按需排序(如 id 或 registered_at)。 | +| **手机号** | `phone_numbers`(手机号管理) | `used_count < max_use_count` 且 `activation_id IS NOT NULL`。来源:① 在「手机号管理」点「获取 OpenAI 号码」调用 `/api/sms-api/get-numbers` 写入;② **绑定任务执行时若表内无可用号码,会自动调接码 API 拉取**(与 get-numbers 同逻辑:hero_sms.get_number/get_number_v2,写入 phone_numbers 后继续绑定);③ 或手动添加(无 activation_id 则无法自动取码)。 | +| **接码配置** | 系统设置 | 已存在:`sms_api_url`、`sms_api_key`、`sms_openai_service`、`sms_max_price`。取验证码方式:现有接口 `GET /api/phones/{id}/sms-code`(内部调 `hero_sms.get_status_v2(base, key, activation_id)`),或后台任务内直接调 `hero_sms.get_status_v2`。 | + +与 sora-phone-bind 的差异:他们用「每行 phone----api_url」配置取码 URL;我们用「系统设置 sms_api_* + phone_numbers.activation_id」+ Hero-SMS 兼容协议(getStatusV2),无需 per-phone 的 api_url。 + +### 5.2 单条绑定流程(与参考项目对齐) + +对「一条账号 + 一个手机号」执行: + +1. **拿 AT** + 若账号无有效 `access_token`:用 `refresh_token` 调 `POST https://auth.openai.com/oauth/token`(body:client_id、grant_type=refresh_token、redirect_uri、refresh_token);可用 sora-phone-bind 的移动端 client_id/redirect_uri 或现有 web 配置;得到 AT(及可选新 RT 落库)。 + +2. **Sora 激活**(若尚未激活) + 顺序:GET `backend/m/bootstrap` → GET `backend/me`;若 `me.username` 已存在则跳过;否则随机 `user_xxxxxxxx`,POST `profile/username/check` → POST `profile/username/set`。请求均用 curl_cffi 移动端指纹发往 sora.chatgpt.com。 + +3. **发验证码** + `POST https://sora.chatgpt.com/backend/project_y/phone_number/enroll/start`,Body:`{"phone_number": "<当前手机号>", "verification_expiry_window_ms": null}`。若响应含 "already verified" / "phone number already":标记该手机号不可用并换号或跳过。 + +4. **轮询验证码** + 循环调用 `hero_sms.get_status_v2(base, key, activation_id)`(或等价地请求 `GET /api/phones/{id}/sms-code`),从返回中解析 6 位数字;超时或任务取消则结束本条。 + +5. **提交验证码** + `POST .../phone_number/enroll/finish`,Body:`{"phone_number": "<当前手机号>", "verification_code": "<6位码>"}`。 + +6. **落库** + - `accounts`:该账号 `phone_bound = 1`,可选写 `phone`(脱敏);若换 token 返回了新 RT 则更新 `refresh_token`。 + - `phone_numbers`:该行 `used_count = used_count + 1`。 + - `run_logs`:写入本条绑定结果(成功/失败原因)。 + +### 5.3 任务形态建议 + +- **触发**:前端「开始绑定手机」→ 调用后端 `POST /api/phone-bind/start`(可选参数:最大条数、是否仅未绑账号等),返回 `task_id`。 +- **执行**:后台异步任务队列;每次取一条「待绑定账号」+ 一条「可用手机号」,执行上述 5.2 流程;写 run_logs;支持暂停/停止(可复用现有注册任务的 stop 机制或单独 `phone_bind_stop` 状态)。 +- **进度与结果**:通过 `run_logs` 按 `task_id` 查询;或提供 `GET /api/phone-bind/status?task_id=xxx` 返回已处理数、成功数、失败数、当前状态。 + +### 5.4 实现检查清单 + +- [x] 后端:`POST /api/phone-bind/start` 创建任务,从 accounts 筛 `phone_bound=0` 且有 RT/AT,从 phone_numbers 筛可用且带 activation_id,入队执行。 +- [x] 后端:单条逻辑内实现 RT→AT、Sora 激活(bootstrap→me→check→set)、enroll/start、轮询 hero_sms.get_status_v2 取码、enroll/finish、更新 accounts 与 phone_numbers 及 run_logs。 +- [x] 后端:Sora 相关请求统一用 curl_cffi 移动端指纹(与阶段 1 一致)。 +- [x] 前端:「开始绑定手机」按钮改为请求 `POST /api/phone-bind/start`,并展示任务状态/日志(toast 展示 task_id,刷新仪表盘与日志;「停止绑定」调用 `POST /api/phone-bind/stop`;仪表盘加载时根据 `GET /api/phone-bind/status` 显示/隐藏停止按钮)。 +- [x] 配置:接码已用系统设置中的 sms_api_*,无需新增;账号与手机号均从现有「账号管理」「手机号管理」读取。 diff --git a/Register_GPT_v0/docs/SORA_API_KEY_CALL_GUIDE_CN.md b/Register_GPT_v0/docs/SORA_API_KEY_CALL_GUIDE_CN.md new file mode 100644 index 0000000..a1b5d83 --- /dev/null +++ b/Register_GPT_v0/docs/SORA_API_KEY_CALL_GUIDE_CN.md @@ -0,0 +1,387 @@ +# Sora Key 调用说明 + +这份文档说明如何调用本项目生成的 `srk_...` Key。 + +适用范围: + +- 文生视频 Key +- 图生视频 Key +- 文生 + 图生 Key +- 账号轮换池 Key + +## 1. 先说清楚这把 Key 是什么 + +- 这不是 OpenAI 官方 API Key。 +- 这把 Key 只能调用你本机这个项目暴露出来的本地包装接口。 +- 本地后端基址是: + +```text +http://127.0.0.1:1989 +``` + +不要用 `8000`。 + +## 2. 鉴权方式 + +推荐写法: + +```http +Authorization: Bearer srk_xxx +``` + +也支持: + +```http +X-API-Key: srk_xxx +``` + +下面示例统一用 `Authorization`。 + +## 3. Key 类型说明 + +### 3.1 文生视频 Key + +作用: + +- 可以调用文生视频创建接口 +- 可以调用视频任务查询 / 列表 / 取消 / 归档 + +不能做: + +- 图片上传 +- 图生视频创建 + +### 3.2 图生视频 Key + +作用: + +- 可以调用图片上传接口 +- 可以调用图生视频创建接口 +- 可以调用视频任务查询 / 列表 / 取消 / 归档 + +不能做: + +- 纯文生视频创建 + +### 3.3 文生 + 图生 Key + +作用: + +- 文生视频 +- 图生视频 +- 图片上传 +- 任务查询 / 列表 / 取消 / 归档 + +## 4. 轮换池 Key 是怎么工作的 + +如果这把 Key 是“轮换池 Key”,后端会自动: + +- 从可用 `Registered+Sora` 账号里选账号 +- 优先选当前活跃视频任务更少的账号 +- 创建任务前先做短租约占位,降低并发撞号概率 +- 记住 `task_id -> account_id` +- 后续 `get / cancel / archive` 自动回到原账号,不会查错号 + +当前“可用账号”的判断条件是: + +- `has_sora = 1` +- `sora_enabled = 1` +- `sora_quota_exhausted = 0` +- `refresh_token` 或 `access_token` 至少有一个 + +## 5. 当前可调用接口 + +```text +POST /api/sora-api/me +POST /api/sora-api/request + +POST /api/sora-api/video-gen/create +POST /api/sora-api/video-gen/create-and-wait +POST /api/sora-api/video-gen/upload-image +POST /api/sora-api/video-gen/create-with-image +POST /api/sora-api/video-gen/get +POST /api/sora-api/video-gen/list +POST /api/sora-api/video-gen/cancel +POST /api/sora-api/video-gen/archive +``` + +## 6. 最常用调用方式 + +下面把 `srk_xxx` 换成你的真实 Key。 + +### 6.1 检查这把 Key 当前用了哪个账号 + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/me \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{}' +``` + +返回里常见字段: + +- `used_account_id` +- `email` +- `me.username` + +### 6.2 文生视频:创建任务 + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/create \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "prompt": "A cinematic shot of ocean waves at sunrise.", + "n_variants": 1, + "n_frames": 300, + "resolution": 360, + "orientation": "wide" + }' +``` + +说明: + +- 后端会自动注入 `sora_create_task` sentinel +- 成功终态统一识别为 `succeeded` + +常见返回字段: + +- `task_id` +- `used_account_id` +- `used_email` +- `status` +- `normalized_status` + +### 6.3 文生视频:创建并轮询到出片 + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/create-and-wait \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "prompt": "A cinematic shot of ocean waves at sunrise.", + "n_variants": 1, + "n_frames": 300, + "resolution": 360, + "orientation": "wide", + "poll_interval_seconds": 5, + "timeout_seconds": 900 + }' +``` + +如果成功,关键字段一般有: + +- `ok: true` +- `task_id` +- `normalized_status: "succeeded"` +- `video_urls` + +### 6.4 图生视频:先上传图片 + +这一步只适用于: + +- 图生视频 Key +- 文生 + 图生 Key + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/upload-image \ + -H 'Authorization: Bearer srk_xxx' \ + -F 'file=@/absolute/path/to/source.jpg' \ + -F 'auto_rotate=true' +``` + +成功后会拿到: + +- `media_id` +- `used_account_id` +- `used_email` + +### 6.5 图生视频:拿 `media_id` 创建任务 + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/create \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "prompt": "Animate this still image with gentle natural motion.", + "source_image_media_id": "media_xxx", + "n_variants": 1, + "n_frames": 300, + "resolution": 360, + "orientation": "wide" + }' +``` + +### 6.6 图生视频:一步上传并创建 + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/create-with-image \ + -H 'Authorization: Bearer srk_xxx' \ + -F 'prompt=Animate this still image with gentle natural motion.' \ + -F 'file=@/absolute/path/to/source.jpg' \ + -F 'auto_rotate=true' \ + -F 'n_variants=1' \ + -F 'n_frames=300' \ + -F 'resolution=360' \ + -F 'orientation=wide' +``` + +## 7. 任务查询 + +### 7.1 查单个任务 + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/get \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "task_id": "task_xxx" + }' +``` + +### 7.2 查任务列表 + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/list \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "limit": 10, + "task_type_filter": "videos" + }' +``` + +### 7.3 取消任务 + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/cancel \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "task_id": "task_xxx" + }' +``` + +### 7.4 归档任务 + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/archive \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "task_id": "task_xxx" + }' +``` + +## 8. 返回字段怎么看 + +视频任务相关接口现在统一会尽量返回这些字段: + +- `status`: 上游原始状态 +- `normalized_status`: 本地归一化状态 +- `is_terminal`: 是否终态 +- `is_success`: 是否成功终态 +- `video_urls`: 已提取到的视频地址列表 + +成功终态认: + +```text +succeeded +``` + +不是 `completed`。 + +## 9. Python 调用示例 + +```python +import requests + +BASE_URL = "http://127.0.0.1:1989" +API_KEY = "srk_xxx" + +headers = { + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", +} + +payload = { + "prompt": "A cinematic shot of ocean waves at sunrise.", + "n_variants": 1, + "n_frames": 300, + "resolution": 360, + "orientation": "wide", +} + +resp = requests.post( + f"{BASE_URL}/api/sora-api/video-gen/create", + headers=headers, + json=payload, + timeout=120, +) + +resp.raise_for_status() +print(resp.json()) +``` + +## 10. 常见错误 + +### 10.1 `401 Unauthorized` / `Invalid API key` + +通常是: + +- Key 写错了 +- Key 被停用了 +- Header 没带对 + +### 10.2 `403 当前 API Key 类型不能调用...` + +通常是 Key 类型不匹配: + +- 图生 Key 去调文生创建 +- 文生 Key 去调图片上传或图生创建 + +### 10.3 `404` + +通常是: + +- 打错端口 +- 打到了别的服务 +- 路径不是 `/api/sora-api/...` + +### 10.4 `429` / `quota_exceeded` + +表示当前账号额度不足。 + +如果是轮换池 Key: + +- 后端会优先尝试切到下一个可用账号 +- 若所有账号都耗尽,就会报出来 + +### 10.5 `too_many_concurrent_tasks` + +表示当前账号并发繁忙,不一定是额度没了。 + +## 11. 最短可用命令 + +如果你只是要最短的调用示例,直接用这个: + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/create \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "prompt": "A cinematic shot of ocean waves at sunrise.", + "n_variants": 1, + "n_frames": 300, + "resolution": 360, + "orientation": "wide" + }' +``` + +## 12. 文档位置 + +本说明文件就在: + +```text +docs/SORA_API_KEY_CALL_GUIDE_CN.md +``` diff --git a/Register_GPT_v0/docs/SORA_POOL_API_KEY_USAGE.md b/Register_GPT_v0/docs/SORA_POOL_API_KEY_USAGE.md new file mode 100644 index 0000000..ca91047 --- /dev/null +++ b/Register_GPT_v0/docs/SORA_POOL_API_KEY_USAGE.md @@ -0,0 +1,364 @@ +# Sora Pool API Key Usage + +## Scope + +- This key is for this local project only. +- It calls the local wrapper service under `/api/sora-api/*`. +- It is not an OpenAI official API key. + +## Base URL + +```text +http://127.0.0.1:1989 +``` + +Notes: + +- `1989` is the backend port for this project. +- `8000` on this machine is a different service and cannot be used for these routes. + +## Authentication + +Use the key in the `Authorization` header: + +```http +Authorization: Bearer srk_xxx +``` + +You can also use: + +```http +X-API-Key: srk_xxx +``` + +## Key Scope + +- `text_to_video`: can call text-to-video create routes. +- `image_to_video`: can call image upload / image-to-video create routes. +- `all_video`: can call both. +- List / get / cancel / archive routes now allow any video-capable key. + +## Pool Mode + +- Pool keys are created with `account_id = 0`. +- The backend automatically picks one available `Registered+Sora` account from the pool. +- Video task creation now prefers the account with the fewest active video tasks, then uses cursor order as a tie-breaker. +- The backend inserts a short-lived reservation before creating the upstream task, so concurrent create requests are less likely to collide on the same account. +- Active task occupancy is released after the task reaches a terminal state such as `succeeded`, `failed`, or `cancelled` via the tracked task routes. +- You do not need to pass `account_id`. + +## Main Endpoints + +### 1. Check current Sora account + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/me \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{}' +``` + +### 2. Generic Sora backend request + +Only `/backend/*` paths are allowed. + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/request \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "method": "GET", + "path": "/backend/me", + "payload": {} + }' +``` + +### 3. Create video task + +This wrapper route now dispatches by task family: + +- Text-to-video: official Sora app / NF2 (`POST /backend/nf/create` or `/backend/nf/bulk_create`) +- Image-to-video: legacy storyboard path (`POST /backend/video_gen`) + +The backend will automatically inject the required `sora_create_task` sentinel header. + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/create \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "prompt": "A cinematic shot of ocean waves at sunrise.", + "n_variants": 4, + "n_frames": 300, + "resolution": 360, + "orientation": "wide" + }' +``` + +Response example: + +```json +{ + "ok": true, + "status_code": 200, + "task_family": "nf2", + "task_id": "task_01kkxqmadtex1t51mg3z62x0kh", + "used_account_id": 13, + "used_email": "example@outlook.com", + "data": { + "id": "task_01kkxqmadtex1t51mg3z62x0kh" + } +} +``` + +Optional audio-related fields for text-to-video: + +- `audio_caption`: describe ambient sound effects +- `audio_transcript`: provide spoken narration or dialogue + +Pool-mode note: + +- When you create a video task with a pool key, the backend now remembers `task_id -> used_account_id`. +- Later `get/cancel/archive` calls for that `task_id` will automatically reuse the same account. +- You can still pass `account_id` explicitly if you want to pin requests yourself. +- Success terminal state is `succeeded` (not `completed`). + +### 3a. Upload an image for image-to-video + +This route uploads the image to the upstream Sora account first and returns a reusable `media_id`. + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/upload-image \ + -H 'Authorization: Bearer srk_xxx' \ + -F 'file=@/absolute/path/to/source.jpg' \ + -F 'auto_rotate=true' +``` + +Response example: + +```json +{ + "ok": true, + "status_code": 200, + "media_id": "media_01kkxy9ca6eb1vvtmvnsg5zbcq", + "used_account_id": 7, + "used_email": "example@outlook.com" +} +``` + +### 3c. Create image-to-video task from an uploaded `media_id` + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/create \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "prompt": "Animate this still image with gentle natural motion.", + "source_image_media_id": "media_01kkxy9ca6eb1vvtmvnsg5zbcq", + "n_variants": 1, + "n_frames": 300, + "resolution": 360, + "orientation": "wide" + }' +``` + +### 3d. One-shot image upload + create + +This is the easiest route for frontend or scripts that want a single request. + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/create-with-image \ + -H 'Authorization: Bearer srk_xxx' \ + -F 'prompt=Animate this still image with gentle natural motion.' \ + -F 'file=@/absolute/path/to/source.jpg' \ + -F 'auto_rotate=true' \ + -F 'n_variants=1' \ + -F 'n_frames=300' \ + -F 'resolution=360' \ + -F 'orientation=wide' +``` + +### 3b. Create and wait until terminal state + +This route creates the task first, then polls `/video-gen/get` until the task reaches a terminal state or timeout. + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/create-and-wait \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "prompt": "A cinematic shot of ocean waves at sunrise.", + "n_variants": 1, + "n_frames": 300, + "resolution": 360, + "orientation": "wide", + "poll_interval_seconds": 5, + "timeout_seconds": 900 + }' +``` + +Response example: + +```json +{ + "ok": true, + "timed_out": false, + "task_id": "task_01kkxqmadtex1t51mg3z62x0kh", + "status": "succeeded", + "normalized_status": "succeeded", + "is_terminal": true, + "is_success": true, + "video_urls": [ + "https://..." + ], + "used_account_id": 13, + "used_email": "example@outlook.com", + "poll_attempts": 7, + "elapsed_seconds": 31.2 +} +``` + +### 4. Get video task + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/get \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "task_id": "task_01kkxqmadtex1t51mg3z62x0kh" + }' +``` + +`/video-gen/get`, `/video-gen/cancel`, `/video-gen/archive`, and `/video-gen/create-and-wait` now also return: + +- `normalized_status`: normalized task state, with successful terminal state unified to `succeeded` +- `is_terminal`: whether the task is at a terminal state +- `is_success`: whether the terminal state is successful +- `video_urls`: URLs extracted from the task payload when available + +### 5. List video tasks + +Use `task_type_filter: "videos"` for the normal video list. This value is what the live Sora backend currently expects. + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/list \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "limit": 10, + "task_type_filter": "videos" + }' +``` + +### 6. Cancel video task + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/cancel \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "task_id": "task_01kkxqmadtex1t51mg3z62x0kh" + }' +``` + +### 7. Archive video task + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/video-gen/archive \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "task_id": "task_01kkxqmadtex1t51mg3z62x0kh" + }' +``` + +POST example: + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/request \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "method": "POST", + "path": "/backend/some/path", + "payload": { + "foo": "bar" + } + }' +``` + +## Response Shape + +`/api/sora-api/me` usually returns: + +```json +{ + "ok": true, + "account_id": 13, + "email": "example@outlook.com", + "used_account_id": 13, + "me": { + "id": "user_xxx", + "username": "exampleuser" + } +} +``` + +## Common Errors + +### 404 Not Found + +Usually means you hit the wrong service or port. + +Check: + +- URL must be `http://127.0.0.1:1989` +- Path must be `/api/sora-api/...` + +### 401 Invalid API key + +Usually means: + +- the key is wrong +- the key is disabled +- you passed the wrong header + +### 404 / 429 inside Sora payload + +These are upstream Sora/backend results from the selected account, not local routing errors. + +### 400 invalid_request when creating video + +Usually means the payload shape is wrong. + +Use the dedicated route: + +- `/api/sora-api/video-gen/create` + +Do not post arbitrary JSON to `/backend/video_gen` unless you also match the live Sora payload shape. + +## Ready-to-use Script + +```bash +python scripts/sora_video_create_and_wait.py \ + --api-key srk_xxx \ + --prompt "A cinematic shot of ocean waves at sunrise." +``` + +Optional flags: + +- `--base-url http://127.0.0.1:1989` +- `--poll-interval 5` +- `--timeout 900` +- `--json` + +## Quick Verification + +If this command returns `200 OK`, the key is usable: + +```bash +curl -X POST http://127.0.0.1:1989/api/sora-api/me \ + -H 'Authorization: Bearer srk_xxx' \ + -H 'Content-Type: application/json' \ + -d '{}' +``` diff --git a/Register_GPT_v0/main_protocol.py b/Register_GPT_v0/main_protocol.py new file mode 100644 index 0000000..7314248 --- /dev/null +++ b/Register_GPT_v0/main_protocol.py @@ -0,0 +1,227 @@ +""" +协议版批量注册入口 +纯 HTTP 注册 + 可选浏览器绑卡体验 Plus。 + +用法: + 根目录: python run_protocol.py [--count N] [--workers W] [--plus] + 本目录: python run.py [--count N] [--workers W] [--plus] +""" + +import argparse +import builtins +import random +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + + +def _progress_bar(current: int, total: int, width: int = 30, prefix: str = "") -> str: + """生成简易进度条字符串,如 [=========> ] 3/10""" + if total <= 0: + filled = 0 + else: + filled = min(int(width * current / total), width) + bar = "=" * filled + (">" if filled < width else "") + " " * max(0, width - filled - 1) + return f"{prefix}[{bar}] {current}/{total}" + + +def _log(msg: str, flush: bool = True) -> None: + print(msg, flush=flush) + + +_print_lock = threading.Lock() +# 主线程加载时保存一次真实 print,多线程里不能再用 builtins.print 赋值给 _orig_print(否则会变成 locked_print 导致死锁) +_orig_print = getattr(builtins, "print") + + +def _locked_print(*args, **kwargs): + kwargs.setdefault("flush", True) + with _print_lock: + _orig_print(*args, **kwargs) + + +def _register_one_task(do_plus: bool, index: int): + """单任务:设置当前账号索引并执行注册,多线程时用锁包装 print 避免输出交错。返回 (index, email, password, success)。""" + set_current_registration_index(index) + builtins.print = _locked_print + try: + email, password, success = _register_one_with_plus(do_plus) + return (index, email, password, success) + finally: + builtins.print = _orig_print + +from config import ( + cfg, + BATCH_INTERVAL_MIN, + BATCH_INTERVAL_MAX, + TOTAL_ACCOUNTS, + EMAIL_WORKER_URL, + set_current_registration_index, + get_proxy_url_for_session, +) +from email_outlook import load_outlook_accounts +from utils import ( + generate_random_password, + generate_user_info, + save_to_txt, + update_account_status, +) +from email_service import create_temp_email, wait_for_verification_email +from .protocol_register import register_one_protocol, activate_sora + + +def _register_one_with_plus(do_plus: bool): + """ + 单账号:协议注册 + 可选浏览器 Plus 试用。 + 返回: (email, password, success) + """ + email, jwt_token = create_temp_email() + if not email or not jwt_token: + print("[x] Failed to create temp email, skip this account") + return None, None, False + + password = generate_random_password() + user_info = generate_user_info() + + def get_otp(): + return wait_for_verification_email(jwt_token, email=email) + + result = register_one_protocol(email, password, jwt_token, get_otp, user_info) + email, password = result[0], result[1] + success = result[2] + status_extra = result[3] if len(result) > 3 else None + tokens = result[4] if len(result) > 4 else None + refresh_token = (tokens.get("refresh_token") or "") if isinstance(tokens, dict) else None + + if not success: + if status_extra == "finish_setup": + proxy_used = get_proxy_url_for_session() + save_to_txt(email, password, "Finish setup (check email)", proxy=proxy_used) + print("[*] Account saved: check email for 'Finish account setup' or try login with this email/password", flush=True) + return email, password, False + + proxy_used = get_proxy_url_for_session() + save_to_txt(email, password, "Registered", proxy=proxy_used, refresh_token=refresh_token) + + print("[*] Activating Sora2...", flush=True) + sora_ok = activate_sora( + tokens if isinstance(tokens, dict) else {}, + email, + proxy_url=proxy_used, + account_password=password, + get_otp_fn=get_otp, + ) + if sora_ok: + update_account_status(email, "Registered+Sora", password, proxy=proxy_used, refresh_token=refresh_token) + else: + print("[x] Account registered but Sora2 activation failed", flush=True) + return email, password, False + + if do_plus: + try: + from browser import create_driver, login, subscribe_plus_trial, cancel_subscription + driver = create_driver(headless=getattr(cfg.browser, "headless", False)) + try: + if login(driver, email, password): + if subscribe_plus_trial(driver): + update_account_status(email, "Plus activated", password, proxy=proxy_used) + time.sleep(5) + if cancel_subscription(driver): + update_account_status(email, "Subscription cancelled", password, proxy=proxy_used) + else: + update_account_status(email, "Cancel failed", password, proxy=proxy_used) + else: + update_account_status(email, "Plus failed", password, proxy=proxy_used) + finally: + driver.quit() + except Exception as e: + print(f"[!] Plus flow error: {e}") + update_account_status(email, "Plus error", password, proxy=proxy_used) + + return email, password, True + + +def run_batch_protocol(count: int = None, do_plus: bool = False, workers: int = 1): + if count is None: + count = TOTAL_ACCOUNTS + count = max(1, count) + workers = max(1, min(workers, count)) + + backend = (getattr(cfg.email, "backend", None) or "cloudflare").strip().lower() + if backend == "outlook": + accounts = load_outlook_accounts() + if not accounts: + _log("[x] backend=outlook but no accounts loaded; set email.outlook_accounts_file with lines: email----password----uuid----token") + return + else: + if not (EMAIL_WORKER_URL or "").strip(): + _log("[x] email.worker_url not set; configure config.yaml and retry") + return + + _log("\n" + "=" * 60) + _log(f"[*] Protocol batch registration total: {count} workers: {workers}" + (" (with Plus trial)" if do_plus else "")) + _log("=" * 60 + "\n") + _log("[!] For learning/research only; do not use for violations.\n") + time.sleep(2) + + success_count = 0 + fail_count = 0 + + if workers <= 1: + for i in range(count): + n = i + 1 + bar = _progress_bar(n, count, prefix="") + _log("\n" + "#" * 60) + _log(f"[*] Account {n}/{count} {bar}") + _log("#" * 60 + "\n") + set_current_registration_index(i) + email, password, success = _register_one_with_plus(do_plus) + if success: + success_count += 1 + _log(f"[ok] Account done success: {success_count} fail: {fail_count}") + else: + fail_count += 1 + _log(f"[x] Account failed success: {success_count} fail: {fail_count}") + _log("-" * 40) + if i < count - 1: + wait_time = random.randint(BATCH_INTERVAL_MIN, BATCH_INTERVAL_MAX) + _log(f"\n[*] Wait {wait_time}s before next account...") + time.sleep(wait_time) + else: + results = [None] * count + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = {executor.submit(_register_one_task, do_plus, i): i for i in range(count)} + for fut in as_completed(futures): + idx = futures[fut] + try: + _, email, password, success = fut.result() + results[idx] = success + if success: + success_count += 1 + else: + fail_count += 1 + with _print_lock: + _log(f"[{'ok' if success else 'x'}] Account {idx + 1}/{count} success: {success_count} fail: {fail_count}") + except Exception as e: + results[idx] = False + fail_count += 1 + with _print_lock: + _log(f"[x] Account {idx + 1}/{count} exception: {e} success: {success_count} fail: {fail_count}") + + _log("\n" + "=" * 60) + _log("[*] Protocol batch registration finished") + _log(f" total: {count} success: {success_count} fail: {fail_count}") + _log("=" * 60) + + +def main(): + parser = argparse.ArgumentParser(description="Protocol batch ChatGPT registration (optional Plus trial)") + parser.add_argument("--count", "-n", type=int, default=None, help="Number of accounts, default from config") + parser.add_argument("--workers", "-w", type=int, default=1, help="Concurrent workers (threads), default 1") + parser.add_argument("--plus", "-p", action="store_true", help="After each registration, open browser for Plus trial then cancel") + args = parser.parse_args() + run_batch_protocol(count=args.count, do_plus=args.plus, workers=args.workers) + + +if __name__ == "__main__": + main() diff --git a/Register_GPT_v0/protocol_register.py b/Register_GPT_v0/protocol_register.py new file mode 100644 index 0000000..8a42799 --- /dev/null +++ b/Register_GPT_v0/protocol_register.py @@ -0,0 +1,1511 @@ +# -*- coding: utf-8 -*- +""" +协议版 ChatGPT 注册(严格按 protocol_keygen 一套) +入口:register_one_protocol(email, password, jwt_token, get_otp_fn, user_info, **kwargs)。 +流程(keygen 单流程):GET /oauth/authorize(screen_hint=signup) -> POST authorize/continue(sentinel) -> GET create-account/password -> POST user/register(sentinel) -> send_otp -> 邮局取验证码 -> validate_otp -> create_account -> callback -> 取 code 换 AT/RT 或 8.6 登录取 code 换 RT -> 返回 tokens 供 runner 写入账号列表。 +邮箱/代理/OAuth Client ID 等均从配置(Web 系统设置)获取。 +""" + +import base64 +import hashlib +import json +import os +import random +import re +import secrets +import time +import uuid +from urllib.parse import urlparse, parse_qs, urlencode +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry +import urllib3 + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +from config import ( + cfg, + HTTP_TIMEOUT, + get_proxy_url_for_session, +) +from utils import get_user_agent + +try: + from curl_cffi import requests as curl_requests + CURL_CFFI_AVAILABLE = True +except ImportError: + curl_requests = None + CURL_CFFI_AVAILABLE = False + +try: + from protocol_sentinel import build_sentinel_token, build_sentinel_token_pow_only +except Exception: + try: + from protocol.protocol_sentinel import build_sentinel_token, build_sentinel_token_pow_only + except Exception: + build_sentinel_token = None + build_sentinel_token_pow_only = None + +CHATGPT_ORIGIN = "https://chatgpt.com" +AUTH_ORIGIN = "https://auth.openai.com" + +# OAuth Code 换 Token(Codex / ChatGPT),运行时从 cfg.oauth 读(Web 下为系统设置) +OAUTH_ISSUER = AUTH_ORIGIN + + +def _format_error_status(prefix: str, payload) -> str: + """把协议错误转成可供 runner 判定/打标的稳定字符串。""" + code = "" + message = "" + if isinstance(payload, dict): + err = payload.get("error") or {} + if isinstance(err, dict): + code = (err.get("code") or "").strip() + message = (err.get("message") or "").strip() + parts = [prefix] + if code: + parts.append(code) + if message: + parts.append(message) + return ": ".join(parts) + + +def _get_oauth_client_id() -> str: + return (getattr(getattr(cfg, "oauth", None), "client_id", None) or "").strip() + + +def _get_oauth_redirect_uri() -> str: + return (getattr(getattr(cfg, "oauth", None), "redirect_uri", None) or "").strip() or f"{CHATGPT_ORIGIN}/" + + +def _has_cookie(session, name: str) -> bool: + """兼容 requests 与 curl_cffi:判断 session 是否含有名为 name 的 cookie。""" + try: + if getattr(session.cookies, "get", None): + if session.cookies.get(name): + return True + for c in getattr(session, "cookies", []): + if getattr(c, "name", None) == name: + return True + except Exception: + pass + return False + +# 密码规则:OpenAI 要求最少 12 位 +PASSWORD_MIN_LENGTH = 12 + + +class RetryException(Exception): + """需换 IP/会话重试时抛出;主循环捕获后重新开始。""" + pass + + +class RegistrationCancelled(Exception): + """用户请求停止注册时抛出。""" + pass + + +# 与 keygen 一致:使用 requests,TLS 指纹与 keygen 相同,便于过 CF +KEYGEN_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/145.0.0.0 Safari/537.36" +) + + +def _make_trace_headers(): + """与参考 chatgpt_register.py 一致:traceparent + datadog 头。""" + trace_id = random.randint(10**17, 10**18 - 1) + parent_id = random.randint(10**17, 10**18 - 1) + tp = f"00-{uuid.uuid4().hex}-{format(parent_id, '016x')}-01" + return { + "traceparent": tp, "tracestate": "dd=s:1;o:rum", + "x-datadog-origin": "rum", "x-datadog-sampling-priority": "1", + "x-datadog-trace-id": str(trace_id), "x-datadog-parent-id": str(parent_id), + } + + +def _mask_proxy_for_log(proxy: str) -> str: + """日志用:隐藏代理 URL 中的密码部分。""" + if not proxy or "@" not in proxy: + return proxy or "(无)" + try: + # protocol://user:pass@host -> protocol://user:****@host + pre, at_part = proxy.rsplit("@", 1) + if ":" in pre: + scheme_rest = pre.split("//", 1) + if len(scheme_rest) == 2 and ":" in scheme_rest[1]: + user, _ = scheme_rest[1].split(":", 1) + pre = f"{scheme_rest[0]}//{user}:****" + return f"{pre}@{at_part}" + except Exception: + return proxy[:50] + "..." if len(proxy or "") > 50 else (proxy or "(无)") + + +def _make_session(device_id: str = None): + """与 keygen 一致:使用 requests.Session()(TLS 指纹同 keygen,利于过 CF)。""" + proxy = get_proxy_url_for_session() + proxies = {"http": proxy, "https": proxy} if proxy else None + print(f"[*] 代理: {_mask_proxy_for_log(proxy)}", flush=True) + print("[*] Using requests (keygen 同款)", flush=True) + if device_id is None: + device_id = str(uuid.uuid4()) + + session = requests.Session() + retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]) + adapter = HTTPAdapter(max_retries=retry) + session.mount("https://", adapter) + session.mount("http://", adapter) + if proxies: + session.proxies = proxies + session.headers.update({ + "User-Agent": KEYGEN_USER_AGENT, + "Accept-Language": "en-US,en;q=0.9", + }) + try: + session.cookies.set("oai-did", device_id, domain=".auth.openai.com") + session.cookies.set("oai-did", device_id, domain="auth.openai.com") + except Exception: + pass + return session + + +# -------------------- 注册流程步骤(keygen 单流程) -------------------- + +def _ensure_password_page(session, state: str = None) -> None: + """0b 后 GET create-account/password,建立密码页会话后再 POST user/register(keygen 无此步,当前服务端可能依赖)。""" + url = f"{AUTH_ORIGIN}/create-account/password" + if state: + url = f"{url}?state={state}" + headers = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + "referer": f"{AUTH_ORIGIN}/create-account", + } + session.get(url, headers=headers, timeout=HTTP_TIMEOUT, allow_redirects=True, verify=False) + + +def _keygen_step0_oauth_and_continue(session, email: str, device_id: str, code_verifier: str, code_challenge: str, _step) -> str: + """ + keygen 可注册方案:GET /oauth/authorize (screen_hint=signup) + POST authorize/continue 带 sentinel。 + 代理从 config 的 get_proxy_url_for_session 已注入到 session。 + """ + client_id = _get_oauth_client_id() + if not client_id: + _step("[*] keygen 需配置 OAuth Client ID,跳过 Sentinel 流程") + return "" + redirect_uri = _get_oauth_redirect_uri() + state = secrets.token_urlsafe(32) + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": "openid profile email offline_access", + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": state, + "screen_hint": "signup", + "prompt": "login", + } + authorize_url = f"{AUTH_ORIGIN}/oauth/authorize?{urlencode(params)}" + _step("[*] keygen 0a GET /oauth/authorize (screen_hint=signup)") + # 与 keygen NAVIGATE_HEADERS 完全一致(含 user-agent、sec-ch-ua,无 Referer,verify=False) + nav_headers = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + } + try: + r = session.get(authorize_url, headers=nav_headers, timeout=HTTP_TIMEOUT, allow_redirects=True, verify=False) + except Exception as e: + print(f"[x] keygen 0a 失败: {e}", flush=True) + return "" + if not _has_cookie(session, "login_session"): + _step("[*] keygen 0a 未获得 login_session") + try: + preview = (getattr(r, "text", None) or "")[:300] + if preview: + print(f" 响应预览: {preview}", flush=True) + if "just a moment" in preview.lower() or "cloudflare" in preview.lower(): + print("[x] 0a 被 Cloudflare 拦截,请换代理或稍后用下一账号重试", flush=True) + except Exception: + pass + return "" + if not build_sentinel_token: + _step("[*] keygen 需 protocol_sentinel,跳过 Sentinel 流程") + return "" + sentinel_token = build_sentinel_token(session, device_id, flow="authorize_continue") + if not sentinel_token: + _step("[*] keygen 获取 sentinel token 失败") + return "" + _step("[*] keygen 0b POST authorize/continue + sentinel") + # 与 keygen 一致:COMMON_HEADERS + referer + oai-device-id + datadog + openai-sentinel-token + headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "origin": AUTH_ORIGIN, + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "referer": f"{AUTH_ORIGIN}/create-account", + "oai-device-id": device_id, + "openai-sentinel-token": sentinel_token, + } + headers.update(_make_trace_headers()) + try: + r = session.post( + f"{AUTH_ORIGIN}/api/accounts/authorize/continue", + json={"username": {"kind": "email", "value": email}, "screen_hint": "signup"}, + headers=headers, + timeout=HTTP_TIMEOUT, + verify=False, + ) + except Exception as e: + print(f"[x] keygen 0b 失败: {e}", flush=True) + return "" + if r.status_code != 200: + _step(f"[*] keygen 0b 返回 {r.status_code}") + return "" + return state + + +def _register_with_sentinel(session, email: str, password: str, device_id: str, _step) -> tuple: + """keygen 方案:POST user/register 带 openai-sentinel-token。先试完整 token(flow=authorize_continue),否则 PoW 仅串。""" + url = f"{AUTH_ORIGIN}/api/accounts/user/register" + headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "origin": AUTH_ORIGIN, + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "referer": f"{AUTH_ORIGIN}/create-account/password", + "oai-device-id": device_id, + } + headers.update(_make_trace_headers()) + sentinel_val = None + if build_sentinel_token: + sentinel_val = build_sentinel_token(session, device_id, flow="authorize_continue") + if not sentinel_val and build_sentinel_token_pow_only: + sentinel_val = build_sentinel_token_pow_only(device_id) + if sentinel_val: + headers["openai-sentinel-token"] = sentinel_val + r = session.post(url, json={"username": email, "password": password}, headers=headers, timeout=HTTP_TIMEOUT, verify=False) + try: + data = r.json() + except Exception: + data = {"text": (r.text or "")[:500]} + if r.status_code == 409: + err = data.get("error") or {} + err_code = err.get("code") if isinstance(err, dict) else None + if err_code == "invalid_state" or (isinstance(err, dict) and "invalid" in str(err).lower()): + raise RetryException("Step register returned 409 invalid_state") + if r.status_code == 400: + err = data.get("error") or {} + err_code = err.get("code") if isinstance(err, dict) else None + if err_code == "invalid_auth_step": + raise RetryException("Step register returned 400 invalid_auth_step") + return r.status_code, data + + +def _send_otp(session): + # keygen step3: NAVIGATE_HEADERS + referer, verify=False + url = f"{AUTH_ORIGIN}/api/accounts/email-otp/send" + headers = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + "referer": f"{AUTH_ORIGIN}/create-account/password", + } + r = session.get(url, headers=headers, timeout=HTTP_TIMEOUT, allow_redirects=True, verify=False) + try: + data = r.json() + except Exception: + data = {"final_url": str(r.url), "status": r.status_code} + return r.status_code, data + + +def _validate_otp(session, code: str): + url = f"{AUTH_ORIGIN}/api/accounts/email-otp/validate" + headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "origin": AUTH_ORIGIN, + "referer": f"{AUTH_ORIGIN}/email-verification", + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + } + headers.update(_make_trace_headers()) + r = session.post(url, json={"code": code}, headers=headers, timeout=HTTP_TIMEOUT, verify=False) + try: + data = r.json() + except Exception: + data = {"text": (r.text or "")[:500]} + return r.status_code, data + + +def _create_account(session, name: str, birthdate: str): + url = f"{AUTH_ORIGIN}/api/accounts/create_account" + headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "origin": AUTH_ORIGIN, + "referer": f"{AUTH_ORIGIN}/about-you", + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + } + headers.update(_make_trace_headers()) + r = session.post(url, json={"name": name, "birthdate": birthdate}, headers=headers, timeout=HTTP_TIMEOUT, verify=False) + try: + data = r.json() + except Exception: + data = {"text": (r.text or "")[:500]} + return r.status_code, data + + +def _callback(session, url: str): + if not url or not url.startswith("http"): + return None, None + headers = { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Upgrade-Insecure-Requests": "1", + } + r_first = session.get(url, headers=headers, timeout=HTTP_TIMEOUT, allow_redirects=False) + body_first = (r_first.text or "")[:50000] + location = r_first.headers.get("Location") or r_first.headers.get("location") or "" + if r_first.status_code in (301, 302, 303, 307, 308) and location: + r = session.get(location, headers=headers, timeout=HTTP_TIMEOUT, allow_redirects=True) + body = (r.text or "")[:50000] + final_url = str(r.url) + else: + r = r_first + body = body_first + final_url = str(r.url) + if not body and body_first: + body = body_first + return r.status_code, {"final_url": final_url, "body": body, "first_location": location} + + +def _generate_code_verifier() -> str: + """PKCE code_verifier,43~128 字符。""" + return base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii") + + +def _generate_code_challenge(verifier: str) -> str: + """PKCE S256 code_challenge。""" + digest = hashlib.sha256(verifier.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def _parse_code_from_url(final_url: str) -> str: + """从 callback 最终 URL 的 query 或 fragment 中解析 OAuth code。""" + if not final_url or not isinstance(final_url, str): + return "" + try: + parsed = urlparse(final_url) + for part in (parsed.query, parsed.fragment): + if not part: + continue + params = parse_qs(part, keep_blank_values=False) + for key in ("code",): + vals = params.get(key) + if vals and isinstance(vals[0], str) and vals[0].strip(): + return vals[0].strip() + except Exception: + pass + return "" + + +def _parse_code_from_body(body: str) -> str: + """从 callback 响应体(HTML/JSON)中解析 OAuth code。""" + if not body or not isinstance(body, str): + return "" + try: + stripped = body.strip() + if stripped.startswith("{"): + data = json.loads(body) + if isinstance(data, dict): + c = data.get("code") or data.get("authorization_code") + if isinstance(c, str) and len(c.strip()) > 5: + return c.strip() + m = re.search(r"[\?&]code=([^&\s\"'<>]+)", body) + if m and m.group(1) and len(m.group(1).strip()) > 5: + return m.group(1).strip() + m = re.search(r"[\"']code[\"']\s*:\s*[\"']([^\"']{10,})[\"']", body, re.I) + if m: + return m.group(1).strip() + except Exception: + pass + return "" + + +def _parse_tokens_from_body(body: str) -> dict: + """从 callback 响应体(HTML/JSON)中解析 refresh_token、access_token。""" + out = {"refresh_token": "", "access_token": ""} + if not body or not isinstance(body, str): + return out + try: + stripped = body.strip() + if stripped.startswith("{"): + data = json.loads(body) + if isinstance(data, dict): + for key in ("refresh_token", "refresh_token_secret"): + v = data.get(key) + if isinstance(v, str) and len(v.strip()) > 10: + out["refresh_token"] = v.strip() + break + for key in ("access_token", "token"): + v = data.get(key) + if isinstance(v, str) and len(v.strip()) > 10: + out["access_token"] = v.strip() + break + for nest in ("session", "credentials", "auth"): + obj = data.get(nest) + if isinstance(obj, dict): + if not out["refresh_token"]: + v = obj.get("refresh_token") or obj.get("refresh_token_secret") + if isinstance(v, str) and len(v.strip()) > 10: + out["refresh_token"] = v.strip() + if not out["access_token"]: + v = obj.get("access_token") or obj.get("token") + if isinstance(v, str) and len(v.strip()) > 10: + out["access_token"] = v.strip() + for key_rt in ("refresh_token", "refresh_token_secret"): + m = re.search(r"[\"']" + re.escape(key_rt) + r"[\"']\s*:\s*[\"']([^\"']{15,})[\"']", body, re.I) + if m and not out["refresh_token"]: + out["refresh_token"] = m.group(1).strip() + break + for key_at in ("access_token", "token"): + m = re.search(r"[\"']" + re.escape(key_at) + r"[\"']\s*:\s*[\"']([^\"']{15,})[\"']", body, re.I) + if m and not out["access_token"]: + out["access_token"] = m.group(1).strip() + break + if not out["refresh_token"]: + m = re.search(r'"refresh_token"\s*:\s*"([A-Za-z0-9_\-\.]{50,800})"', body) + if m: + out["refresh_token"] = m.group(1).strip() + if not out["access_token"]: + m = re.search(r'"access_token"\s*:\s*"([A-Za-z0-9_\-\.]{50,1200})"', body) + if m: + out["access_token"] = m.group(1).strip() + if not out["refresh_token"] and "refresh_token" in body: + m = re.search(r"refresh_token[=:]\s*[\"']?([A-Za-z0-9_\-\.]{50,800})[\"']?", body, re.I) + if m: + out["refresh_token"] = m.group(1).strip() + except Exception: + pass + return out + + +def codex_exchange_code(session, code: str, code_verifier: str, redirect_uri: str = None): + """ + 用 authorization code 换取 Codex/ChatGPT tokens。与 keygen 一致:重试 1 次、Content-Type form、verify=False。 + POST https://auth.openai.com/oauth/token + redirect_uri 需与拿 code 时一致;不传则用系统设置或 chatgpt.com/。 + 返回含 access_token、refresh_token 等的 dict,失败返回 None。 + """ + client_id = _get_oauth_client_id() + if not client_id: + return None + uri = (redirect_uri or "").strip() or _get_oauth_redirect_uri() + resp = None + for attempt in range(2): + try: + resp = session.post( + f"{OAUTH_ISSUER}/oauth/token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": uri, + "client_id": client_id, + "code_verifier": code_verifier, + }, + verify=False, + timeout=60, + ) + break + except Exception as e: + if attempt == 0: + print(" Token 交换超时,重试...", flush=True) + time.sleep(2) + continue + print(f" Token 交换失败: {e}", flush=True) + return None + if resp and resp.status_code == 200: + data = resp.json() + print(" Codex Token 获取成功!", flush=True) + print(f" Access Token 长度: {len(data.get('access_token', ''))}", flush=True) + print(f" Refresh Token: {'有' if data.get('refresh_token') else '无'}", flush=True) + print(f" ID Token: {'有' if data.get('id_token') else '无'}", flush=True) + return data + if resp: + print(f" Token 交换失败: {resp.status_code}", flush=True) + print(f" 响应: {(resp.text or '')[:300]}", flush=True) + return None + + +def _decode_oai_session_cookie(session) -> dict: + """从 oai-client-auth-session cookie 解码 JSON(尝试各 segment)。""" + val = "" + try: + val = (session.cookies.get("oai-client-auth-session") or "") if hasattr(session, "cookies") else "" + except Exception: + pass + if not val: + for c in getattr(session, "cookies", []): + if getattr(c, "name", None) == "oai-client-auth-session": + val = getattr(c, "value", "") or "" + break + if not val: + return {} + for i, part in enumerate(val.split(".")[:3]): + if not part: + continue + pad = 4 - len(part) % 4 + if pad != 4: + part = part + ("=" * pad) + try: + raw = base64.urlsafe_b64decode(part) + return json.loads(raw.decode("utf-8")) + except Exception: + continue + return {} + + +def _follow_consent_to_code(session, start_url: str, _step, max_depth: int = 15) -> str: + """跟随 consent 重定向链,从 302 Location 或 ConnectionError(重定向到 localhost)中解析 code。与 keygen _follow_and_extract_code 一致。""" + url = start_url + if not url or not url.startswith("http"): + url = f"{AUTH_ORIGIN}{start_url}" if start_url.startswith("/") else "" + if not url: + return "" + nav_headers = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + } + for _ in range(max_depth): + try: + r = session.get(url, headers=nav_headers, timeout=min(HTTP_TIMEOUT, 30), allow_redirects=False, verify=False) + except requests.exceptions.ConnectionError as e: + err_str = str(e) + m = re.search(r"(https?://(?:localhost|127\.0\.0\.1)[^\s\'\"<>]*)", err_str) + if m: + return _parse_code_from_url(m.group(1)) + return "" + except Exception as e: + err_str = str(e) + if "localhost" in err_str or "1455" in err_str or "127.0.0.1" in err_str: + m = re.search(r"(https?://(?:localhost|127\.0\.0\.1)[^\s\'\"<>]*)", err_str) + if m: + return _parse_code_from_url(m.group(1)) + return "" + if r.status_code in (301, 302, 303, 307, 308): + loc = (r.headers.get("Location") or r.headers.get("location") or "").strip() + if not loc: + return "" + code = _parse_code_from_url(loc) + if code: + return code + url = loc if loc.startswith("http") else f"{AUTH_ORIGIN}{loc}" + continue + if r.status_code == 200: + code = _parse_code_from_url(r.url) + if code: + return code + body = (r.text or "")[:200000] + code = _parse_code_from_body(body) + if code: + return code + next_url = "" + for pat in ( + r'"continue_url"\s*:\s*"([^"]+)"', + r'"redirect_url"\s*:\s*"([^"]+)"', + r'window\.location(?:\.href)?\s*=\s*["\']([^"\']+)["\']', + r'location\.replace\(\s*["\']([^"\']+)["\']\s*\)', + ): + m = re.search(pat, body, re.I) + if m and m.group(1): + next_url = m.group(1).replace("\\u0026", "&").replace("\\/", "/").strip() + break + if not next_url: + hrefs = re.findall(r'href=["\']([^"\']+)["\']', body, re.I) + for h in hrefs: + h2 = (h or "").replace("\\u0026", "&").replace("\\/", "/").strip() + if not h2: + continue + if "code=" in h2: + next_url = h2 + break + if "/oauth/" in h2 or "/auth/callback" in h2 or "localhost:1455" in h2: + next_url = h2 + break + if next_url: + code = _parse_code_from_url(next_url) + if code: + return code + url = next_url if next_url.startswith("http") else f"{AUTH_ORIGIN}{next_url}" + continue + _step(f"[*] 8.6 consent 200 但未解析到 code/next_url: {str(r.url)[:120]}") + try: + snippet = re.sub(r"\\s+", " ", body)[:220] + if snippet: + _step(f"[*] 8.6 consent body 片段: {snippet}") + except Exception: + pass + return "" + return "" + + +def _normalize_otp_code(raw) -> str: + digits = re.sub(r"\D", "", str(raw or "").strip()) + return digits[:6] if len(digits) >= 6 else "" + + +def _request_login_email_otp(session, device_id: str, _step) -> bool: + """ + 登录 8.6 邮箱验证码兜底触发:先 POST 再 GET /api/accounts/email-otp/send。 + 返回是否至少一次拿到 2xx。 + """ + ok = False + url = f"{AUTH_ORIGIN}/api/accounts/email-otp/send" + + api_headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "origin": AUTH_ORIGIN, + "referer": f"{AUTH_ORIGIN}/email-verification", + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "oai-device-id": device_id, + } + api_headers.update(_make_trace_headers()) + try: + r = session.post(url, json={}, headers=api_headers, timeout=min(HTTP_TIMEOUT, 30), verify=False) + _step(f"[*] 8.6 触发登录验证码发送 POST {r.status_code}") + if 200 <= r.status_code < 300: + ok = True + except Exception as e: + _step(f"[*] 8.6 触发登录验证码发送 POST 异常: {e}") + + nav_headers = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + "referer": f"{AUTH_ORIGIN}/email-verification", + } + try: + r = session.get(url, headers=nav_headers, timeout=min(HTTP_TIMEOUT, 30), allow_redirects=True, verify=False) + _step(f"[*] 8.6 触发登录验证码发送 GET {r.status_code}") + if 200 <= r.status_code < 300: + ok = True + except Exception as e: + _step(f"[*] 8.6 触发登录验证码发送 GET 异常: {e}") + return ok + + +def _poll_fresh_login_otp(get_otp_fn, _step, excluded_codes=None, attempts: int = 2) -> str: + excluded = set() + for x in (excluded_codes or []): + x_norm = _normalize_otp_code(x) + if x_norm: + excluded.add(x_norm) + rounds = max(1, attempts) + for i in range(rounds): + code = _normalize_otp_code(get_otp_fn() if get_otp_fn else "") + if code and code not in excluded: + return code + if code and code in excluded: + _step("[*] 8.6 收到旧验证码,继续等待新码...") + else: + _step("[*] 8.6 暂未取到新验证码") + if code: + excluded.add(code) + if i < rounds - 1: + time.sleep(2) + return "" + + +def _oauth_login_get_tokens(email: str, password: str, get_otp_fn, _step, prev_used_codes=None) -> dict: + """ + 严格按 keygen perform_codex_oauth_login_http:注册成功后用新 session 走 OAuth 登录, + GET authorize -> POST authorize/continue -> POST password/verify -> [email-otp] -> consent -> code 换 AT/RT。 + """ + client_id = _get_oauth_client_id() + if not client_id: + return {} + _step("[*] 8.6 登录取 RT(keygen 同款:新 session GET authorize -> ... -> code 换 token)") + device_id = str(uuid.uuid4()) + session = _make_session(device_id) + code_verifier = _generate_code_verifier() + code_challenge = _generate_code_challenge(code_verifier) + state = secrets.token_urlsafe(32) + redirect_uri = (_get_oauth_redirect_uri() or "").strip() or "http://localhost:1455/auth/callback" + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": "openid profile email offline_access", + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": state, + } + authorize_url = f"{AUTH_ORIGIN}/oauth/authorize?{urlencode(params)}" + nav_headers = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + } + try: + r = session.get(authorize_url, headers=nav_headers, timeout=HTTP_TIMEOUT, allow_redirects=True, verify=False) + except Exception as e: + _step(f"[*] 8.6 authorize 请求失败: {e}") + return {} + if not _has_cookie(session, "login_session"): + _step("[*] 8.6 未获得 login_session,可能需 sentinel") + api_headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "origin": AUTH_ORIGIN, + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "referer": f"{AUTH_ORIGIN}/log-in", + "oai-device-id": device_id, + } + api_headers.update(_make_trace_headers()) + if build_sentinel_token: + sentinel_ac = build_sentinel_token(session, device_id, flow="authorize_continue") + if sentinel_ac: + api_headers["openai-sentinel-token"] = sentinel_ac + try: + r = session.post( + f"{AUTH_ORIGIN}/api/accounts/authorize/continue", + json={"username": {"kind": "email", "value": email}}, + headers=api_headers, + timeout=HTTP_TIMEOUT, + verify=False, + ) + except Exception as e: + _step(f"[*] 8.6 authorize/continue 失败: {e}") + return {} + if r.status_code != 200: + _step(f"[*] 8.6 authorize/continue {r.status_code}(若 403 可能需 sentinel)") + try: + _step(f"[*] 8.6 响应: {(r.text or '')[:200]}") + except Exception: + pass + return {} + api_headers["referer"] = f"{AUTH_ORIGIN}/log-in/password" + api_headers.update(_make_trace_headers()) + if build_sentinel_token: + sentinel_pw = build_sentinel_token(session, device_id, flow="password_verify") + if sentinel_pw: + api_headers["openai-sentinel-token"] = sentinel_pw + try: + r = session.post( + f"{AUTH_ORIGIN}/api/accounts/password/verify", + json={"password": password}, + headers=api_headers, + timeout=HTTP_TIMEOUT, + allow_redirects=False, + verify=False, + ) + except Exception as e: + _step(f"[*] 8.6 password/verify 失败: {e}") + return {} + if r.status_code != 200: + _step(f"[*] 8.6 password/verify {r.status_code}(若 403 可能需 sentinel)") + try: + _step(f"[*] 8.6 响应: {(r.text or '')[:200]}") + except Exception: + pass + return {} + try: + data = r.json() + continue_url = (data.get("continue_url") or "").strip() + page_type = (data.get("page") or {}).get("type", "") + except Exception: + continue_url = "" + page_type = "" + if not continue_url: + _step("[*] 8.6 password/verify 200 但无 continue_url") + return {} + _step(f"[*] 8.6 continue_url: {continue_url[:80]}...") + if page_type == "email_otp_verification" or "email-verification" in continue_url: + excluded_codes = set(prev_used_codes or []) + # password/verify 进入邮箱验证页后,服务端通常已自动发码。 + # 先等现有新码,拿不到再主动 resend,避免刚发出的旧码被我们自己立刻作废。 + code_otp = _poll_fresh_login_otp(get_otp_fn, _step, excluded_codes=excluded_codes, attempts=1) + if not code_otp: + _request_login_email_otp(session, device_id, _step) + code_otp = _poll_fresh_login_otp(get_otp_fn, _step, excluded_codes=excluded_codes, attempts=2) + if not code_otp: + _step("[*] 8.6 需要邮箱验证码但未提供 get_otp_fn 或未取到") + return {} + api_headers["referer"] = f"{AUTH_ORIGIN}/email-verification" + api_headers.update(_make_trace_headers()) + try: + r = session.post( + f"{AUTH_ORIGIN}/api/accounts/email-otp/validate", + json={"code": code_otp}, + headers=api_headers, + timeout=HTTP_TIMEOUT, + verify=False, + ) + except Exception: + return {} + if r.status_code != 200: + _step(f"[*] 8.6 email-otp/validate {r.status_code}") + # 401 常见于验证码过期/拿到旧码,尝试再拉一枚新码重试一次 + if r.status_code in (400, 401) and get_otp_fn: + excluded_codes.add(code_otp) + code_otp_2 = _poll_fresh_login_otp(get_otp_fn, _step, excluded_codes=excluded_codes, attempts=1) + if not code_otp_2: + _request_login_email_otp(session, device_id, _step) + code_otp_2 = _poll_fresh_login_otp(get_otp_fn, _step, excluded_codes=excluded_codes, attempts=2) + if code_otp_2: + api_headers.update(_make_trace_headers()) + try: + r = session.post( + f"{AUTH_ORIGIN}/api/accounts/email-otp/validate", + json={"code": code_otp_2}, + headers=api_headers, + timeout=HTTP_TIMEOUT, + verify=False, + ) + _step(f"[*] 8.6 email-otp/validate 重试 {r.status_code}") + except Exception: + return {} + if r.status_code != 200: + return {} + try: + data = r.json() + continue_url = (data.get("continue_url") or "").strip() + except Exception: + pass + if continue_url and ( + "/about-you" in continue_url + or page_type in ("about_you", "about-you") + ): + _step("[*] 8.6 检测到 about-you,补提交资料后继续取 code") + yy = random.randint(1988, 2000) + mm = random.randint(1, 12) + dd = random.randint(1, 28) + status_create, data_create = _create_account(session, "User", f"{yy}-{str(mm).zfill(2)}-{str(dd).zfill(2)}") + if status_create not in (200, 201, 204): + _step(f"[*] 8.6 about-you 提交失败: {status_create}") + return {} + try: + next_url = ( + (data_create.get("continue_url") or "").strip() + or (data_create.get("url") or "").strip() + or (data_create.get("redirect_url") or "").strip() + ) + if next_url: + continue_url = next_url + _step(f"[*] 8.6 about-you -> continue_url: {continue_url[:80]}...") + except Exception: + pass + if not continue_url: + return {} + consent_url = continue_url if continue_url.startswith("http") else f"{AUTH_ORIGIN}{continue_url}" + auth_code = _follow_consent_to_code(session, consent_url, _step) + if not auth_code: + _step("[*] 8.6 直接 GET consent 未拿到 code,尝试 workspace/select...") + session_data = _decode_oai_session_cookie(session) + workspaces = (session_data or {}).get("workspaces") or [] + workspace_id = workspaces[0].get("id") if workspaces else None + if workspace_id: + api_headers["referer"] = consent_url + api_headers.update(_make_trace_headers()) + try: + r = session.post( + f"{AUTH_ORIGIN}/api/accounts/workspace/select", + json={"workspace_id": workspace_id}, + headers=api_headers, + timeout=HTTP_TIMEOUT, + allow_redirects=False, + verify=False, + ) + if r.status_code in (301, 302, 303, 307, 308): + loc = (r.headers.get("Location") or r.headers.get("location") or "").strip() + auth_code = _parse_code_from_url(loc) + if not auth_code and loc: + auth_code = _follow_consent_to_code( + session, loc if loc.startswith("http") else f"{AUTH_ORIGIN}{loc}", _step + ) + elif r.status_code == 200: + try: + ws_data = r.json() + ws_next = (ws_data.get("continue_url") or "").strip() + if ws_next: + auth_code = _follow_consent_to_code( + session, + ws_next if ws_next.startswith("http") else f"{AUTH_ORIGIN}{ws_next}", + _step, + ) + if not auth_code: + orgs = (ws_data.get("data") or {}).get("orgs") or [] + if orgs: + org_id = orgs[0].get("id") + proj = (orgs[0].get("projects") or [{}])[0].get("id") if orgs[0].get("projects") else None + body = {"org_id": org_id} + if proj: + body["project_id"] = proj + api_headers["referer"] = consent_url + api_headers.update(_make_trace_headers()) + r2 = session.post( + f"{AUTH_ORIGIN}/api/accounts/organization/select", + json=body, + headers=api_headers, + timeout=HTTP_TIMEOUT, + allow_redirects=False, + verify=False, + ) + if r2.status_code in (301, 302, 303, 307, 308): + loc2 = (r2.headers.get("Location") or r2.headers.get("location") or "").strip() + auth_code = _parse_code_from_url(loc2) or _follow_consent_to_code( + session, loc2 if loc2.startswith("http") else f"{AUTH_ORIGIN}{loc2}", _step + ) + elif r2.status_code == 200: + try: + next_url = (r2.json().get("continue_url") or "").strip() + if next_url: + auth_code = _follow_consent_to_code( + session, + next_url if next_url.startswith("http") else f"{AUTH_ORIGIN}{next_url}", + _step, + ) + except Exception: + pass + except Exception as e: + _step(f"[*] 8.6 workspace 响应解析异常: {e}") + except Exception as e: + _step(f"[*] 8.6 workspace/select 请求异常: {e}") + else: + _step("[*] 8.6 无 workspace_id(cookie 无 workspaces)") + if not auth_code: + _step("[*] 8.6 [4d] 备用: GET consent allow_redirects=True 以从最终 URL 或 ConnectionError 取 code") + nav_headers_4d = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "user-agent": KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + } + try: + r = session.get(consent_url, headers=nav_headers_4d, timeout=min(HTTP_TIMEOUT, 30), allow_redirects=True, verify=False) + auth_code = _parse_code_from_url(r.url) + if not auth_code and getattr(r, "history", None): + for h in r.history: + loc = (h.headers.get("Location") or h.headers.get("location") or "").strip() + auth_code = _parse_code_from_url(loc) + if auth_code: + break + except requests.exceptions.ConnectionError as e: + m = re.search(r"(https?://(?:localhost|127\.0\.0\.1)[^\s\'\"<>]*)", str(e)) + if m: + auth_code = _parse_code_from_url(m.group(1)) + except Exception: + pass + if not auth_code: + _step("[*] 8.6 跟随 consent 未解析到 code") + return {} + _step("[*] 8.6 已从 consent 拿到 code,换取 token...") + login_redirect_uri = (_get_oauth_redirect_uri() or "").strip() or "http://localhost:1455/auth/callback" + exchange = codex_exchange_code(session, auth_code, code_verifier, redirect_uri=login_redirect_uri) + if not exchange: + _step(f"[*] 8.6 code 换 token 失败,请确认 OAuth redirect_uri 与系统设置一致: {login_redirect_uri[:50]}...") + return {} + if not exchange.get("refresh_token"): + _step("[*] 8.6 换 token 成功但响应无 refresh_token") + try: + import protocol_sora_phone as sora_phone + except Exception: + try: + import protocol.protocol_sora_phone as sora_phone + except Exception: + sora_phone = None + if sora_phone: + try: + web_auth = sora_phone.sora_chatgpt_web_login_from_authenticated_session( + session, + email=email, + password=password, + get_otp_fn=get_otp_fn, + log_fn=_step, + ) + except Exception as e: + _step(f"[*] 8.7 复用登录 session 建立 Sora Web session 异常: {e}") + web_auth = {} + web_at = (web_auth.get("access_token") or "").strip() if isinstance(web_auth, dict) else "" + if web_at: + api_access_token = (exchange.get("access_token") or "").strip() + if api_access_token: + exchange["api_access_token"] = api_access_token + exchange["access_token"] = web_at + if isinstance(web_auth.get("session"), dict): + exchange["sora_session"] = dict(web_auth.get("session") or {}) + _step("[*] 8.7 已复用登录 session 建立 Sora Web session") + return dict(exchange) + + +def decode_jwt_payload(token: str) -> dict: + """解析 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) + return json.loads(decoded) + except Exception: + return {} + + +def _parse_tokens_from_url(final_url: str) -> dict: + """从 callback 最终 URL 的 query 或 fragment 中解析 refresh_token、access_token。返回 {\"refresh_token\": \"\", \"access_token\": \"\"}。""" + out = {"refresh_token": "", "access_token": ""} + if not final_url or not isinstance(final_url, str): + return out + try: + parsed = urlparse(final_url) + for part in (parsed.query, parsed.fragment): + if not part: + continue + params = parse_qs(part, keep_blank_values=False) + for key_rt in ("refresh_token", "refresh_token_secret"): + vals = params.get(key_rt) or params.get(key_rt.replace("_", ".")) + if vals and isinstance(vals[0], str) and len(vals[0].strip()) > 10: + out["refresh_token"] = vals[0].strip() + break + for key_at in ("access_token", "token"): + vals = params.get(key_at) or params.get(key_at.replace("_", ".")) + if vals and isinstance(vals[0], str) and len(vals[0].strip()) > 10: + out["access_token"] = vals[0].strip() + break + except Exception: + pass + return out + + +def _parse_refresh_token_from_url(final_url: str) -> str: + """从 callback 最终 URL 的 query 或 fragment 中解析 refresh_token(兼容旧逻辑)。""" + return _parse_tokens_from_url(final_url).get("refresh_token", "") or "" + + +def _get_access_token_from_response(data: dict) -> str: + """从 create_account 等接口的 JSON 响应中提取 access_token(含 page 等嵌套)。""" + if not data or not isinstance(data, dict): + return "" + for key in ("access_token", "token"): + v = data.get(key) + if isinstance(v, str) and len(v.strip()) > 10: + return v.strip() + for nest in ("session", "credentials", "auth", "token", "page"): + obj = data.get(nest) + if isinstance(obj, dict): + v = obj.get("access_token") or obj.get("token") + if isinstance(v, str) and len(v.strip()) > 10: + return v.strip() + return "" + + +def _get_refresh_token_from_response(data: dict) -> str: + """从 create_account 等接口的 JSON 响应中提取 refresh_token(含 page 等嵌套)。""" + if not data or not isinstance(data, dict): + return "" + for key in ("refresh_token", "refresh_token_secret"): + v = data.get(key) + if isinstance(v, str) and len(v.strip()) > 10: + return v.strip() + for nest in ("session", "credentials", "auth", "token", "page"): + obj = data.get(nest) + if isinstance(obj, dict): + v = obj.get("refresh_token") or obj.get("refresh_token_secret") + if isinstance(v, str) and len(v.strip()) > 10: + return v.strip() + return "" + + +# -------------------- 入口 -------------------- + +def register_one_protocol(email: str, password: str, jwt_token: str, get_otp_fn, user_info: dict, **kwargs): + """ + 协议注册入口。 + 入参:email, password, jwt_token, get_otp_fn(), user_info(name/year/month/day), step_log_fn, stop_check 等。 + 返回:(email, password, success: bool[, status_extra[, tokens]])。 + """ + step_log_fn = kwargs.pop("step_log_fn", None) + stop_check = kwargs.pop("stop_check", None) + + def _step(msg: str): + if stop_check and callable(stop_check) and stop_check(): + raise RegistrationCancelled() + if msg: + print(msg, flush=True) + if step_log_fn: + try: + step_log_fn(msg.strip()) + except Exception: + pass + + _step(f"[*] register_one_protocol start {email}") + pwd = (password or "").strip() + if len(pwd) < PASSWORD_MIN_LENGTH: + raise ValueError(f"Password length must be >= {PASSWORD_MIN_LENGTH}, got {len(pwd)}. Set password in email row or use runner which auto-generates.") + password = pwd + name = user_info.get("name", "User") + year = user_info.get("year", "1990") + month = user_info.get("month", "01") + day = user_info.get("day", "01") + birthdate = f"{year}-{month}-{day}" + + device_id = str(uuid.uuid4()) + session = _make_session(device_id) + code_verifier = _generate_code_verifier() + code_challenge = _generate_code_challenge(code_verifier) + if not _get_oauth_client_id(): + _step("[*] 未配置 OAuth Client ID,请在系统设置中填写") + return email, password, False + if not build_sentinel_token: + _step("[*] Sentinel 未加载,请确保 protocol_sentinel 可用") + return email, password, False + try: + _step("[*] 0. GET authorize + POST authorize/continue (sentinel)") + try: + session.cookies.set("oai-did", device_id, domain=".auth.openai.com") + session.cookies.set("oai-did", device_id, domain="auth.openai.com") + except Exception: + pass + time.sleep(random.uniform(0.2, 0.5)) + auth_state = _keygen_step0_oauth_and_continue( + session, email, device_id, code_verifier, code_challenge, _step + ) + if not auth_state: + return email, password, False, "0a_no_session", None + time.sleep(random.uniform(0.5, 1.0)) + _step("[*] 1. GET create-account/password") + _ensure_password_page(session, auth_state) + time.sleep(random.uniform(0.5, 1.0)) + _step("[*] 2. Register (user/register + sentinel)") + status_reg, data_reg = _register_with_sentinel(session, email, password, device_id, _step) + if status_reg not in (200, 201, 204): + print(f"[x] 4. Register failed: status={status_reg} data={data_reg}", flush=True) + if status_reg == 400 and isinstance(data_reg, dict): + err = data_reg.get("error") or {} + if err.get("code") == "bad_request" or "register username" in str(err.get("message", "")).lower(): + print("[x] 若该邮箱已注册过,请换未注册邮箱重试", flush=True) + return email, password, False, _format_error_status("register_failed", data_reg) + print("[ok] 4. Register OK", flush=True) + _step("[*] 3. Send OTP") + status_otp, data_otp = _send_otp(session) + if status_otp not in (200, 201, 204) and (not isinstance(data_otp, dict) or data_otp.get("error")): + print(f"[x] 5. Send OTP failed: status={status_otp} data={data_otp}", flush=True) + return email, password, False, _format_error_status("send_otp_failed", data_otp) + + _step("[*] Waiting for email OTP...") + if stop_check and callable(stop_check) and stop_check(): + return email, password, False + code = get_otp_fn() + if not code or len(str(code).strip()) < 4: + print("[x] No OTP received or invalid", flush=True) + return email, password, False, "otp_missing" + # 规范为纯 6 位数字,避免空格/换行导致 wrong_email_otp_code + code = re.sub(r"\D", "", str(code).strip()) + if len(code) < 6: + print("[x] OTP too short after normalizing", flush=True) + return email, password, False, "otp_invalid" + code = code[:6] + + _step("[*] 6. Validate OTP") + status_val, data_val = _validate_otp(session, code) + if status_val not in (200, 201, 204): + err = (data_val.get("error") or {}) if isinstance(data_val, dict) else {} + err_code = err.get("code") if isinstance(err, dict) else "" + if status_val == 401 and err_code == "wrong_email_otp_code": + print("[x] 验证码错误或过期;正在重试一次获取新验证码...", flush=True) + time.sleep(3) + code2 = get_otp_fn() + if code2 and len(re.sub(r"\D", "", str(code2).strip())) >= 6: + code = re.sub(r"\D", "", str(code2).strip())[:6] + status_val, data_val = _validate_otp(session, code) + if status_val not in (200, 201, 204): + print(f"[x] 6. Validate OTP failed: status={status_val} data={data_val}", flush=True) + if status_val == 401 and (err_code == "wrong_email_otp_code" or "wrong" in str(err).lower()): + print("[x] 请确认验证码来自本邮箱最新一封 OpenAI 邮件且未过期;多任务并发时易拿错邮箱", flush=True) + return email, password, False, _format_error_status("validate_otp_failed", data_val) + + _step("[*] 7. Create account") + status_create, data_create = _create_account(session, name, birthdate) + if status_create not in (200, 201, 204): + print(f"[x] 7. Create account failed: status={status_create} data={data_create}", flush=True) + return email, password, False, _format_error_status("create_account_failed", data_create) + + callback_url = None + if isinstance(data_create, dict): + callback_url = data_create.get("continue_url") or data_create.get("url") or data_create.get("redirect_url") + + _step("[*] 8. Callback") + if callback_url: + _, _ = _callback(session, callback_url) + + print("[ok] Protocol registration success", flush=True) + has_client_id = bool(_get_oauth_client_id()) + if not has_client_id: + _step("[*] 未配置 OAuth Client ID,跳过登录取 RT;请在系统设置中填写") + tokens = {} + else: + _step("[*] 8. 按 keygen 仅通过登录取 RT(新 session GET authorize -> ... -> code 换 token)") + tokens = _oauth_login_get_tokens( + email, + password, + get_otp_fn, + _step, + prev_used_codes={code}, + ) + if tokens.get("refresh_token") or tokens.get("access_token"): + _step("[*] 8.6 登录取 code 已拿到 AT/RT") + else: + _step("[*] 8. 登录取 RT 未拿到 token(可能 403/sentinel 或 consent 未返回 code)") + if tokens.get("refresh_token") or tokens.get("access_token"): + _step(f"[*] 最终: RT={'有' if tokens.get('refresh_token') else '无'}, AT={'有' if tokens.get('access_token') else '无'}") + return email, password, True, None, (tokens if tokens else None) + except RegistrationCancelled: + print("[*] 注册已停止", flush=True) + return email, password, False + except RetryException: + raise + except (requests.RequestException, ValueError) as e: + print(f"[x] {e}", flush=True) + return email, password, False + except Exception as e: + print(f"[x] Unexpected error: {e}", flush=True) + return email, password, False + finally: + try: + session.close() + except Exception: + pass + + +SORA_ORIGIN = "https://sora.chatgpt.com" +# 旧版 project_y 用户名接口仅保留给兼容日志;实际激活逻辑委托 protocol_sora_phone.sora_ensure_activated。 +SORA_USERNAME_SET_URL = f"{SORA_ORIGIN}/backend/project_y/profile/username/set" + + +def _sora_username_from_email(email: str, max_len: int = 20) -> str: + """从邮箱生成 Sora 用户名:取 @ 前部分,只保留字母数字下划线。""" + if not email or "@" not in email: + return "user" + str(random.randint(1000, 9999)) + local = email.split("@", 1)[0].strip().lower() + safe = "".join(c for c in local if c.isalnum() or c == "_") + if not safe: + safe = "user" + safe = (safe[:max_len]) if len(safe) > max_len else safe + return safe if len(safe) >= 3 else f"user{random.randint(100, 999)}" + + +def activate_sora(tokens, email: str, **kwargs): + """ + 注册成功后激活 Sora。 + 优先走 protocol_sora_phone 里的新版 onboarding/me 链路,失败时由其内部回退旧接口。 + tokens 可为空;若无 AT/RT,会回退到 ChatGPT Web 登录补 access_token。 + kwargs: proxy_url, username(可选覆盖), step_log_fn。 + 返回 True 表示设置成功,False 表示未调或失败。 + """ + if isinstance(tokens, dict): + tokens = tokens + else: + tokens = {} + at = (tokens.get("access_token") or "").strip() + rt = (tokens.get("refresh_token") or "").strip() + if not rt and isinstance(tokens.get("session"), dict): + rt = ((tokens.get("session") or {}).get("refresh_token") or "").strip() + proxy_url = (kwargs.get("proxy_url") or "").strip() + step_log = kwargs.get("step_log_fn") + try: + import protocol_sora_phone as sora_phone + except Exception: + try: + import protocol.protocol_sora_phone as sora_phone + except Exception as exc: + if callable(step_log): + try: + step_log(f"[*] Sora helper 导入失败: {exc}") + except Exception: + pass + return False + if rt: + try: + mobile = sora_phone.rt_to_at_mobile(rt, proxy_url=proxy_url or None, log_fn=step_log) + mobile_at = (mobile.get("access_token") or "").strip() + mobile_rt = (mobile.get("refresh_token") or "").strip() + if mobile_at: + at = mobile_at + tokens["access_token"] = mobile_at + if callable(step_log): + try: + step_log("[*] Sora 使用移动端 RT->AT 成功") + except Exception: + pass + if mobile_rt: + rt = mobile_rt + tokens["refresh_token"] = mobile_rt + except Exception as exc: + if callable(step_log): + try: + step_log(f"[*] Sora 移动端 RT->AT 异常: {exc}") + except Exception: + pass + username = (kwargs.get("username") or "").strip() or _sora_username_from_email(email or "") + account_password = (kwargs.get("account_password") or "").strip() + get_otp_fn = kwargs.get("get_otp_fn") + if not at and not rt and not account_password: + if callable(step_log): + try: + step_log("[*] Sora 缺少 AT/RT 且无账号密码,无法走 Web session 回退") + except Exception: + pass + return False + try: + ok = False + if at: + ok = bool( + sora_phone.sora_ensure_activated( + at, + proxy_url=proxy_url or None, + log_fn=step_log, + username=username, + ) + ) + if ok: + return True + if account_password: + web_auth = sora_phone.sora_chatgpt_web_login( + email=email, + password=account_password, + get_otp_fn=get_otp_fn, + proxy_url=proxy_url or None, + log_fn=step_log, + ) + web_at = (web_auth.get("access_token") or "").strip() if isinstance(web_auth, dict) else "" + if web_at: + at = web_at + tokens["access_token"] = web_at + if isinstance(web_auth.get("session"), dict): + tokens["sora_session"] = dict(web_auth.get("session") or {}) + if callable(step_log): + try: + step_log("[*] Sora 已切换到 ChatGPT Web session access_token") + except Exception: + pass + return bool( + sora_phone.sora_ensure_activated( + at, + proxy_url=proxy_url or None, + log_fn=step_log, + username=username, + ) + ) + return False + except Exception as e: + if callable(step_log): + try: + step_log(f"[*] Sora 激活异常: {e}") + except Exception: + pass + return False diff --git a/Register_GPT_v0/protocol_sentinel.py b/Register_GPT_v0/protocol_sentinel.py new file mode 100644 index 0000000..0b655f8 --- /dev/null +++ b/Register_GPT_v0/protocol_sentinel.py @@ -0,0 +1,138 @@ +""" +Sentinel Token 生成(从 protocol_keygen 可注册方案移植)。 +用于 authorize/continue、user/register 等步骤的 openai-sentinel-token 头。 +""" +import base64 +import json +import random +import time +import uuid +from datetime import datetime, timezone + +# 与 keygen 一致,需与 sec-ch-ua 版本匹配 +SENTINEL_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/145.0.0.0 Safari/537.36" +) + + +class SentinelTokenGenerator: + """Sentinel PoW 纯 Python 生成器(逆向 sentinel SDK)。""" + + MAX_ATTEMPTS = 500000 + ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + + def __init__(self, device_id=None): + self.device_id = device_id or str(uuid.uuid4()) + self.requirements_seed = str(random.random()) + self.sid = str(uuid.uuid4()) + + @staticmethod + def _fnv1a_32(text): + h = 2166136261 + for ch in text: + h ^= ord(ch) + h = ((h * 16777619) & 0xFFFFFFFF) + h ^= (h >> 16) + h = ((h * 2246822507) & 0xFFFFFFFF) + h ^= (h >> 13) + h = ((h * 3266489909) & 0xFFFFFFFF) + h ^= (h >> 16) + return format(h & 0xFFFFFFFF, '08x') + + def _get_config(self): + screen_info = "1920x1080" + now = datetime.now(timezone.utc) + date_str = now.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)") + config = [ + screen_info, date_str, 4294705152, random.random(), + SENTINEL_USER_AGENT, "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js", + None, None, "en-US", "en-US,en", random.random(), + random.choice(["vendorSub", "productSub", "vendor", "maxTouchPoints"]) + "\u2212undefined", + random.choice(["location", "implementation", "URL", "documentURI", "compatMode"]), + random.choice(["Object", "Function", "Array", "Number", "parseFloat", "undefined"]), + random.uniform(1000, 50000), self.sid, "", + random.choice([4, 8, 12, 16]), time.time() * 1000 - random.uniform(1000, 50000), + ] + return config + + @staticmethod + def _base64_encode(data): + return base64.b64encode(json.dumps(data, separators=(',', ':'), ensure_ascii=False).encode()).decode() + + def _run_check(self, start_time, seed, difficulty, config, nonce): + config = list(config) + config[3] = nonce + config[9] = round((time.time() - start_time) * 1000) + data = self._base64_encode(config) + hash_hex = self._fnv1a_32(seed + data) + diff_len = len(difficulty) + if hash_hex[:diff_len] <= difficulty: + return data + "~S" + return None + + def generate_token(self, seed=None, difficulty=None): + 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): + config = self._get_config() + config[3] = 1 + config[9] = round(random.uniform(5, 50)) + return "gAAAAAC" + self._base64_encode(config) + + +def fetch_sentinel_challenge(session, device_id, flow="authorize_continue"): + """调用 sentinel 后端获取 challenge(含 c 与 proofofwork)。""" + gen = SentinelTokenGenerator(device_id=device_id) + p_token = gen.generate_requirements_token() + req_body = {"p": p_token, "id": device_id, "flow": flow} + headers = { + "Content-Type": "text/plain;charset=UTF-8", + "Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html", + "User-Agent": SENTINEL_USER_AGENT, + "Origin": "https://sentinel.openai.com", + "sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + } + try: + resp = session.post( + "https://sentinel.openai.com/backend-api/sentinel/req", + data=json.dumps(req_body), headers=headers, timeout=15, verify=False, + ) + if resp.status_code != 200: + return None + return resp.json() + except Exception: + return None + + +def build_sentinel_token(session, device_id, flow="authorize_continue"): + """构建 openai-sentinel-token 头值(JSON 字符串,含 p/t/c/id/flow)。""" + challenge = fetch_sentinel_challenge(session, device_id, flow) + if not challenge: + return None + c_value = challenge.get("token", "") + pow_data = challenge.get("proofofwork", {}) or {} + gen = SentinelTokenGenerator(device_id=device_id) + if pow_data.get("required") and pow_data.get("seed"): + p_value = gen.generate_token(seed=pow_data["seed"], difficulty=pow_data.get("difficulty", "0")) + else: + p_value = gen.generate_requirements_token() + return json.dumps({"p": p_value, "t": "", "c": c_value, "id": device_id, "flow": flow}) + + +def build_sentinel_token_pow_only(device_id): + """仅 PoW 字符串(keygen 的 register 步骤用)。""" + gen = SentinelTokenGenerator(device_id=device_id) + return gen.generate_token() diff --git a/Register_GPT_v0/protocol_sora_phone.py b/Register_GPT_v0/protocol_sora_phone.py new file mode 100644 index 0000000..e01d0e6 --- /dev/null +++ b/Register_GPT_v0/protocol_sora_phone.py @@ -0,0 +1,2440 @@ +# -*- coding: utf-8 -*- +""" +Sora 激活与手机号绑定 HTTP 逻辑。 +优先对齐当前官方前端 bundle 暴露的 onboarding/me 接口,旧 project_y 接口仅作回退。 +全部使用 curl_cffi 移动端指纹请求 sora.chatgpt.com / auth.openai.com。 +供「开始绑定手机」任务调用,参数均显式传入(不依赖 config)。 +""" +import re +import random +import string +import uuid +import time +import os +from urllib.parse import parse_qs, urlencode, urlparse + +try: + from curl_cffi import requests as curl_requests + from curl_cffi import CurlMime + CURL_CFFI_AVAILABLE = True +except ImportError: + curl_requests = None + CurlMime = None + CURL_CFFI_AVAILABLE = False + +import requests + +try: + from protocol_sentinel import build_sentinel_token +except Exception: + try: + from protocol.protocol_sentinel import build_sentinel_token + except Exception: + build_sentinel_token = None + +SORA_ORIGIN = "https://sora.chatgpt.com" +SORA_LEGACY_ORIGIN = "https://sora.com" +CHATGPT_ORIGIN = "https://chatgpt.com" +AUTH_ORIGIN = "https://auth.openai.com" +CHATGPT_BACKEND_API_ORIGIN = f"{CHATGPT_ORIGIN}/backend-api" +CHATGPT_SECURITY_SETTINGS_URL = f"{CHATGPT_ORIGIN}/security-settings" +CHATGPT_MFA_RECENT_AUTH_MAX_AGE_SEC = 240 +CHATGPT_WEB_CLIENT_ID = "app_X8zY6vW2pQ9tR3dE7nK1jL5gH" +# 移动端 client_id / redirect_uri(与 sora-phone-bind 一致,用于 RT 换 AT) +MOBILE_CLIENT_ID = "app_LlGpXReQgckcGGUo2JrYvtJK" +MOBILE_REDIRECT_URI = "com.openai.chat://auth0.openai.com/ios/com.openai.chat/callback" + +MOBILE_FINGERPRINTS = ["safari17_2_ios", "safari18_0_ios"] +MOBILE_USER_AGENTS = [ + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", +] +WEB_FINGERPRINT = "chrome131" +WEB_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" +) +DEFAULT_BROWSER_CDP_URLS = ( + "http://127.0.0.1:9222", + "http://127.0.0.1:9224", +) + +SORA_HEADERS_BASE = { + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + "Origin": SORA_ORIGIN, + "Pragma": "no-cache", + "Referer": f"{SORA_ORIGIN}/", + "Sec-Ch-Ua": '"Chromium";v="131", "Not_A Brand";v="24", "Google Chrome";v="131"', + "Sec-Ch-Ua-Mobile": "?1", + "Sec-Ch-Ua-Platform": '"iOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "Content-Type": "application/json", +} + +DEFAULT_TIMEOUT = 30 +USERNAME_RETRY_CODES = { + "username_taken", + "username_rejected", + "username_invalid", + "username_required", + "reserved_username", +} + + +def _log(log_fn, message: str) -> None: + if callable(log_fn): + try: + log_fn(message) + except Exception: + pass + + +def _make_plain_session(proxy_url: str = None) -> requests.Session: + session = requests.Session() + session.trust_env = False + if proxy_url: + session.proxies = {"http": proxy_url, "https": proxy_url} + return session + + +def _make_web_session(proxy_url: str = None): + proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None + if CURL_CFFI_AVAILABLE and curl_requests: + session = curl_requests.Session(impersonate=WEB_FINGERPRINT) + if proxies: + session.proxies = proxies + return session + return _make_plain_session(proxy_url=proxy_url) + + +def _candidate_origins() -> tuple[str, ...]: + if SORA_LEGACY_ORIGIN == SORA_ORIGIN: + return (SORA_ORIGIN,) + return (SORA_ORIGIN, SORA_LEGACY_ORIGIN) + + +def _candidate_sora_web_origins(preferred_origin: str = "") -> tuple[str, ...]: + seen = [] + for value in ((preferred_origin or "").strip(), SORA_ORIGIN, SORA_LEGACY_ORIGIN): + origin = (value or "").rstrip("/") + if origin and origin not in seen: + seen.append(origin) + return tuple(seen) + + +def _candidate_browser_cdp_urls(cdp_urls=None) -> tuple[str, ...]: + raw_values = [] + if isinstance(cdp_urls, str): + raw_values.extend(cdp_urls.split(",")) + elif cdp_urls: + try: + raw_values.extend(list(cdp_urls)) + except Exception: + pass + env_value = ( + os.getenv("SORA_BROWSER_CDP_URLS") + or os.getenv("SORA_BROWSER_CDP_URL") + or "" + ).strip() + if env_value: + raw_values.extend(env_value.split(",")) + raw_values.extend(DEFAULT_BROWSER_CDP_URLS) + seen = [] + for value in raw_values: + item = str(value or "").strip() + if item and item not in seen: + seen.append(item) + return tuple(seen) + + +def _response_preview(resp, limit: int = 240) -> str: + try: + text = (resp.text or "").strip().replace("\n", " ") + except Exception: + text = "" + return text[:limit] + + +def _strip_nullish(value): + if isinstance(value, dict): + out = {} + for key, item in value.items(): + cleaned = _strip_nullish(item) + if cleaned is None: + continue + out[key] = cleaned + return out + if isinstance(value, list): + return [_strip_nullish(item) for item in value] + return value + + +def _decode_jwt_payload(token: str) -> dict: + value = (token or "").strip() + if not value: + return {} + parts = value.split(".") + if len(parts) != 3: + return {} + try: + payload = parts[1] + padding = (-len(payload)) % 4 + if padding: + payload += "=" * padding + import base64 + import json + return json.loads(base64.urlsafe_b64decode(payload.encode("ascii"))) + except Exception: + return {} + + +def _extract_error(resp) -> tuple[str, str, str]: + code = "" + message = "" + try: + data = resp.json() + except Exception: + return code, message, _response_preview(resp) + if isinstance(data, dict): + err = data.get("error") or {} + if isinstance(err, dict): + code = (err.get("code") or "").strip() + message = (err.get("message") or "").strip() + return code, message, _response_preview(resp) + + +def _build_sentinel_header(device_id: str, flow: str, proxy_url: str = None, log_fn=None) -> str: + if not build_sentinel_token: + return "" + try: + session = _make_plain_session(proxy_url=proxy_url) + return build_sentinel_token(session, device_id, flow=flow) or "" + except Exception as exc: + _log(log_fn, f"[sora] sentinel {flow} 异常: {exc}") + return "" + + +def _session_get(url: str, headers: dict, proxy_url: str = None, timeout: int = DEFAULT_TIMEOUT): + proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None + if CURL_CFFI_AVAILABLE and curl_requests: + return curl_requests.get( + url, + headers=headers, + proxies=proxies, + timeout=timeout, + impersonate=random.choice(MOBILE_FINGERPRINTS), + ) + session = _make_plain_session(proxy_url=proxy_url) + return session.get(url, headers=headers, timeout=timeout, verify=False) + + +def _session_post( + url: str, + headers: dict, + json: dict = None, + data: dict = None, + proxy_url: str = None, + timeout: int = DEFAULT_TIMEOUT, + allow_redirects: bool = True, +): + proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None + if CURL_CFFI_AVAILABLE and curl_requests: + return curl_requests.post( + url, + headers=headers, + json=json, + data=data, + proxies=proxies, + timeout=timeout, + allow_redirects=allow_redirects, + impersonate=random.choice(MOBILE_FINGERPRINTS), + ) + session = _make_plain_session(proxy_url=proxy_url) + kwargs = {"headers": headers, "timeout": timeout, "verify": False, "allow_redirects": allow_redirects} + if data is not None: + kwargs["data"] = data + else: + kwargs["json"] = json or {} + return session.post(url, **kwargs) + + +def _session_multipart_post( + url: str, + headers: dict, + *, + data: dict = None, + file_field_name: str, + filename: str, + file_bytes: bytes = None, + file_path: str = None, + content_type: str = None, + proxy_url: str = None, + timeout: int = DEFAULT_TIMEOUT, + allow_redirects: bool = True, +): + proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None + if CURL_CFFI_AVAILABLE and curl_requests and CurlMime is not None: + multipart_parts = [ + { + "name": file_field_name, + "filename": filename, + "content_type": content_type, + **({"local_path": file_path} if file_path else {"data": file_bytes or b""}), + } + ] + return curl_requests.post( + url, + headers=headers, + data=data or {}, + multipart=CurlMime.from_list(multipart_parts), + proxies=proxies, + timeout=timeout, + allow_redirects=allow_redirects, + impersonate=random.choice(MOBILE_FINGERPRINTS), + ) + session = _make_plain_session(proxy_url=proxy_url) + file_tuple = (filename, file_bytes if file_bytes is not None else open(file_path, "rb"), content_type) + try: + return session.post( + url, + headers=headers, + data=data or {}, + files={file_field_name: file_tuple}, + timeout=timeout, + verify=False, + allow_redirects=allow_redirects, + ) + finally: + if file_bytes is None and hasattr(file_tuple[1], "close"): + try: + file_tuple[1].close() + except Exception: + pass + + +def _web_session_get( + url: str, + headers: dict, + proxy_url: str = None, + timeout: int = DEFAULT_TIMEOUT, + allow_redirects: bool = True, + web_session=None, +): + if web_session is not None: + return web_session.get( + url, + headers=headers, + timeout=timeout, + verify=False, + allow_redirects=allow_redirects, + ) + proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None + if CURL_CFFI_AVAILABLE and curl_requests: + return curl_requests.get( + url, + headers=headers, + proxies=proxies, + timeout=timeout, + allow_redirects=allow_redirects, + impersonate=WEB_FINGERPRINT, + ) + session = _make_plain_session(proxy_url=proxy_url) + return session.get(url, headers=headers, timeout=timeout, verify=False, allow_redirects=allow_redirects) + + +def _web_session_post( + url: str, + headers: dict, + data: dict = None, + proxy_url: str = None, + timeout: int = DEFAULT_TIMEOUT, + allow_redirects: bool = True, + web_session=None, +): + if web_session is not None: + return web_session.post( + url, + headers=headers, + data=data or {}, + timeout=timeout, + verify=False, + allow_redirects=allow_redirects, + ) + proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None + if CURL_CFFI_AVAILABLE and curl_requests: + return curl_requests.post( + url, + headers=headers, + data=data, + proxies=proxies, + timeout=timeout, + allow_redirects=allow_redirects, + impersonate=WEB_FINGERPRINT, + ) + session = _make_plain_session(proxy_url=proxy_url) + return session.post( + url, + headers=headers, + data=data or {}, + timeout=timeout, + verify=False, + allow_redirects=allow_redirects, + ) + + +def _build_headers(access_token: str, device_id: str = None, origin: str = None) -> dict: + base_origin = (origin or SORA_ORIGIN).rstrip("/") + h = dict(SORA_HEADERS_BASE) + h["Origin"] = base_origin + h["Referer"] = f"{base_origin}/" + h["Authorization"] = f"Bearer {access_token}" + h["User-Agent"] = random.choice(MOBILE_USER_AGENTS) + h["oai-device-id"] = device_id or str(uuid.uuid4()) + return h + + +def _build_sora_web_headers( + access_token: str, + device_id: str = None, + referer: str = "", + origin: str = "", +) -> dict: + base_origin = (origin or SORA_ORIGIN).rstrip("/") + return { + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "Origin": base_origin, + "Referer": referer or f"{base_origin}/explore", + "User-Agent": WEB_USER_AGENT, + "Sec-Ch-Ua": '"Google Chrome";v="131", "Not_A Brand";v="24", "Chromium";v="131"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "oai-device-id": device_id or str(uuid.uuid4()), + } + + +def _build_web_headers() -> dict: + return { + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": WEB_USER_AGENT, + } + + +def _web_session_json_post( + url: str, + headers: dict, + json: dict = None, + proxy_url: str = None, + timeout: int = DEFAULT_TIMEOUT, + allow_redirects: bool = True, + web_session=None, +): + if web_session is not None: + return web_session.post( + url, + headers=headers, + json=json or {}, + timeout=timeout, + verify=False, + allow_redirects=allow_redirects, + ) + proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None + if CURL_CFFI_AVAILABLE and curl_requests: + return curl_requests.post( + url, + headers=headers, + json=json or {}, + proxies=proxies, + timeout=timeout, + allow_redirects=allow_redirects, + impersonate=WEB_FINGERPRINT, + ) + session = _make_plain_session(proxy_url=proxy_url) + return session.post( + url, + headers=headers, + json=json or {}, + timeout=timeout, + verify=False, + allow_redirects=allow_redirects, + ) + + +def _build_html_headers(referer: str = "") -> dict: + headers = { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Upgrade-Insecure-Requests": "1", + "User-Agent": WEB_USER_AGENT, + } + if referer: + headers["Referer"] = referer + return headers + + +def _build_chatgpt_backend_headers(access_token: str, device_id: str = None, referer: str = "") -> dict: + return { + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "Origin": CHATGPT_ORIGIN, + "Referer": referer or f"{CHATGPT_SECURITY_SETTINGS_URL}?action=enable&factor=sms", + "User-Agent": WEB_USER_AGENT, + "Sec-Ch-Ua": '"Google Chrome";v="131", "Not_A Brand";v="24", "Chromium";v="131"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "oai-device-id": device_id or str(uuid.uuid4()), + } + + +def _load_register_helpers(): + try: + import protocol_register as pr + return pr + except Exception: + try: + import protocol.protocol_register as pr + return pr + except Exception: + return None + + +def _collect_response_urls(resp) -> list[str]: + urls = [] + seen = set() + + def _push(value: str): + candidate = (value or "").strip() + if not candidate or candidate in seen: + return + seen.add(candidate) + urls.append(candidate) + + try: + _push(str(getattr(resp, "url", "") or "")) + except Exception: + pass + try: + for item in getattr(resp, "history", []) or []: + _push(str(getattr(item, "url", "") or "")) + try: + _push((item.headers.get("Location") or item.headers.get("location") or "").strip()) + except Exception: + pass + except Exception: + pass + try: + _push((resp.headers.get("Location") or resp.headers.get("location") or "").strip()) + except Exception: + pass + return urls + + +def _copy_session_cookies(src_session, dst_session) -> None: + if src_session is None or dst_session is None: + return + try: + cookies = list(getattr(src_session, "cookies", None) or []) + except Exception: + cookies = [] + for cookie in cookies: + try: + dst_session.cookies.set( + cookie.name, + cookie.value, + domain=getattr(cookie, "domain", None), + path=getattr(cookie, "path", "/") or "/", + ) + continue + except Exception: + pass + try: + dst_session.cookies.set(cookie.name, cookie.value) + except Exception: + pass + + +def _copy_browser_cookie_dicts(cookies: list[dict], dst_session) -> int: + if dst_session is None: + return 0 + copied = 0 + for cookie in cookies or []: + if not isinstance(cookie, dict): + continue + name = str(cookie.get("name") or "").strip() + if not name: + continue + value = cookie.get("value") or "" + kwargs = { + "domain": cookie.get("domain") or None, + "path": cookie.get("path") or "/", + } + secure = cookie.get("secure") + if secure is not None: + kwargs["secure"] = bool(secure) + expires = cookie.get("expires") + try: + expires_value = float(expires) + except Exception: + expires_value = None + if expires_value and expires_value > 0: + kwargs["expires"] = int(expires_value) + try: + dst_session.cookies.set(name, value, **kwargs) + copied += 1 + continue + except Exception: + pass + try: + dst_session.cookies.set(name, value) + copied += 1 + except Exception: + pass + return copied + + +def _extract_api_error(resp) -> tuple[str, str, str]: + code, message, preview = _extract_error(resp) + if code or message: + return code, message, preview + try: + data = resp.json() + except Exception: + return code, message, preview + if not isinstance(data, dict): + return code, message, preview + detail = data.get("detail") + if isinstance(detail, dict): + code = (detail.get("code") or detail.get("type") or code or "").strip() + message = (detail.get("message") or detail.get("detail") or message or "").strip() + elif isinstance(detail, str) and detail.strip(): + raw_detail = detail.strip() + try: + import json + nested = json.loads(raw_detail) + except Exception: + nested = None + if isinstance(nested, dict): + nested_err = nested.get("error") or {} + if isinstance(nested_err, dict): + code = (nested_err.get("code") or nested_err.get("type") or code or "").strip() + message = (nested_err.get("message") or message or "").strip() + else: + message = raw_detail[:200] + else: + message = raw_detail[:200] + else: + code = (data.get("code") or data.get("type") or code or "").strip() + message = (data.get("message") or message or "").strip() + return code, message, preview + + +def _normalize_phone_number(phone_number: str) -> str: + raw = (phone_number or "").strip() + if not raw: + return "" + digits = re.sub(r"\D", "", raw) + if digits.startswith("00"): + digits = digits[2:] + if not digits: + return "" + return f"+{digits}" + + +def _chatgpt_pwd_auth_age_seconds(access_token: str): + payload = _decode_jwt_payload(access_token) + raw = payload.get("pwd_auth_time") + try: + value = float(raw) + except Exception: + return None + if value <= 0: + return None + if value > 10_000_000_000: + value = value / 1000.0 + return max(0, int(time.time() - value)) + + +def _chatgpt_needs_recent_auth(access_token: str) -> bool: + age = _chatgpt_pwd_auth_age_seconds(access_token) + return age is None or age > CHATGPT_MFA_RECENT_AUTH_MAX_AGE_SEC + + +def _warm_chatgpt_security_page(web_session, log_fn=None) -> str: + page_url = f"{CHATGPT_SECURITY_SETTINGS_URL}?action=enable&factor=sms" + try: + resp = web_session.get( + page_url, + headers=_build_html_headers(referer=CHATGPT_ORIGIN), + timeout=DEFAULT_TIMEOUT, + allow_redirects=True, + ) + return str(getattr(resp, "url", "") or page_url) + except Exception as exc: + _log(log_fn, f"[phone_bind] security-settings 预热异常: {exc}") + return page_url + + +def _read_sora_web_session(web_session, log_fn=None, preferred_origin: str = "") -> dict: + for origin in _candidate_sora_web_origins(preferred_origin): + sora_session_resp = web_session.get( + f"{origin}/api/auth/session", + headers={**_build_web_headers(), "Referer": f"{origin}/"}, + timeout=DEFAULT_TIMEOUT, + ) + if sora_session_resp.status_code != 200: + _log(log_fn, f"[sora] sora /api/auth/session HTTP {sora_session_resp.status_code} {_response_preview(sora_session_resp, 140)}") + continue + try: + sora_session = sora_session_resp.json() + except Exception: + _log(log_fn, f"[sora] sora /api/auth/session 非 JSON: {_response_preview(sora_session_resp, 140)}") + continue + if not isinstance(sora_session, dict) or not sora_session or sora_session == {"WARNING_BANNER": sora_session.get("WARNING_BANNER")}: + _log(log_fn, f"[sora] sora /api/auth/session 返回空会话: {_response_preview(sora_session_resp, 140)}") + continue + access_token = (sora_session.get("accessToken") or "").strip() + if access_token: + payload = _decode_jwt_payload(access_token) + client_id = (payload.get("client_id") or "").strip() + _log(log_fn, f"[sora] Web session 已建立 origin={origin} client_id={client_id or '-'}") + return {"access_token": access_token, "session": sora_session, "base_origin": origin} + return {} + + +def _read_chatgpt_web_session(web_session, log_fn=None) -> dict: + chatgpt_session_resp = web_session.get( + f"{CHATGPT_ORIGIN}/api/auth/session", + headers={**_build_web_headers(), "Referer": f"{CHATGPT_ORIGIN}/"}, + timeout=DEFAULT_TIMEOUT, + ) + if chatgpt_session_resp.status_code != 200: + _log(log_fn, f"[phone_bind] chatgpt /api/auth/session HTTP {chatgpt_session_resp.status_code} {_response_preview(chatgpt_session_resp, 140)}") + return {} + try: + session_data = chatgpt_session_resp.json() + except Exception: + _log(log_fn, f"[phone_bind] chatgpt /api/auth/session 非 JSON: {_response_preview(chatgpt_session_resp, 140)}") + return {} + if not isinstance(session_data, dict) or not session_data: + _log(log_fn, f"[phone_bind] chatgpt /api/auth/session 返回空会话: {_response_preview(chatgpt_session_resp, 140)}") + return {} + access_token = (session_data.get("accessToken") or "").strip() + if access_token: + payload = _decode_jwt_payload(access_token) + client_id = (payload.get("client_id") or "").strip() + age = _chatgpt_pwd_auth_age_seconds(access_token) + _log(log_fn, f"[phone_bind] ChatGPT Web session 已建立 client_id={client_id or '-'} pwd_auth_age={age if age is not None else '-'}s") + return {"access_token": access_token, "session": session_data} + + +def sora_probe_nf2_session( + access_token: str, + *, + proxy_url: str = None, + web_session=None, + preferred_origin: str = "", + log_fn=None, +) -> dict: + token = (access_token or "").strip() + if not token: + return {} + last_status = 0 + last_preview = "" + for origin in _candidate_sora_web_origins(preferred_origin): + try: + resp = sora_nf2_get_pending( + token, + proxy_url=proxy_url, + web_session=web_session, + base_origin=origin, + ) + except Exception as exc: + _log(log_fn, f"[sora] NF2 probe 请求异常 origin={origin}: {exc}") + continue + status_code = int(getattr(resp, "status_code", 0) or 0) + preview = _response_preview(resp, 160) + if status_code == 200: + return { + "ok": True, + "status_code": status_code, + "base_origin": origin, + "preview": preview, + } + last_status = status_code + last_preview = preview + _log(log_fn, f"[sora] NF2 probe HTTP {status_code} origin={origin} {preview or '-'}") + return { + "ok": False, + "status_code": last_status, + "base_origin": "", + "preview": last_preview, + } + + +def sora_import_browser_web_session( + *, + expected_email: str = "", + preferred_origin: str = "", + cdp_urls=None, + log_fn=None, +) -> dict: + expected = (expected_email or "").strip().lower() + try: + from playwright.sync_api import sync_playwright + except Exception as exc: + _log(log_fn, f"[sora] 浏览器 session fallback 不可用: {exc}") + return {} + + for cdp_url in _candidate_browser_cdp_urls(cdp_urls): + browser = None + try: + with sync_playwright() as playwright: + browser = playwright.chromium.connect_over_cdp(cdp_url) + contexts = list(getattr(browser, "contexts", []) or []) + if not contexts: + _log(log_fn, f"[sora] CDP {cdp_url} 无可用浏览器上下文") + continue + for context in contexts: + pages = list(getattr(context, "pages", []) or []) + page = None + for candidate in pages: + url = str(getattr(candidate, "url", "") or "") + if any(origin in url for origin in (SORA_ORIGIN, SORA_LEGACY_ORIGIN, CHATGPT_ORIGIN)): + page = candidate + break + if page is None: + page = pages[0] if pages else context.new_page() + for origin in _candidate_sora_web_origins(preferred_origin): + target_url = f"{origin}/explore" + try: + page.goto(target_url, wait_until="domcontentloaded", timeout=120000) + except Exception as exc: + _log(log_fn, f"[sora] CDP 导航失败 {target_url}: {exc}") + continue + try: + session_payload = page.evaluate( + """ + async () => { + const resp = await fetch('/api/auth/session', { credentials: 'include' }); + const text = await resp.text(); + let data = null; + try { + data = JSON.parse(text); + } catch (err) {} + return { + status: resp.status, + data, + text_preview: text.slice(0, 400), + }; + } + """ + ) + except Exception as exc: + _log(log_fn, f"[sora] CDP 读取 /api/auth/session 失败 origin={origin}: {exc}") + continue + if not isinstance(session_payload, dict): + continue + session_data = session_payload.get("data") or {} + if int(session_payload.get("status") or 0) != 200 or not isinstance(session_data, dict): + continue + access_token = (session_data.get("accessToken") or "").strip() + session_email = ( + ((session_data.get("user") or {}).get("email") or "") + or ((session_data.get("profile") or {}).get("email") or "") + ).strip().lower() + if expected and session_email and session_email != expected: + _log(log_fn, f"[sora] CDP 登录邮箱不匹配 browser={session_email} expected={expected}") + continue + if not access_token or not is_chatgpt_web_access_token(access_token): + continue + try: + cookies = context.cookies([SORA_ORIGIN, SORA_LEGACY_ORIGIN, CHATGPT_ORIGIN, AUTH_ORIGIN]) + except Exception as exc: + _log(log_fn, f"[sora] CDP 读取 cookies 失败 origin={origin}: {exc}") + continue + web_session = _make_web_session() + copied = _copy_browser_cookie_dicts(cookies, web_session) + if copied <= 0: + try: + web_session.close() + except Exception: + pass + continue + session_state = _read_sora_web_session(web_session, preferred_origin=origin, log_fn=log_fn) + effective_token = (session_state.get("access_token") or access_token).strip() + if not effective_token: + try: + web_session.close() + except Exception: + pass + continue + nf2_probe = sora_probe_nf2_session( + effective_token, + web_session=web_session, + preferred_origin=(session_state.get("base_origin") or origin), + log_fn=log_fn, + ) + if not nf2_probe.get("ok"): + try: + web_session.close() + except Exception: + pass + continue + return { + "access_token": effective_token, + "session": session_state.get("session") or session_data, + "web_session": web_session, + "base_origin": (nf2_probe.get("base_origin") or session_state.get("base_origin") or origin).strip(), + "email": session_email, + "cookie_count": copied, + "cdp_url": cdp_url, + "source": "browser_cdp", + } + except Exception as exc: + _log(log_fn, f"[sora] CDP 连接失败 {cdp_url}: {exc}") + finally: + if browser is not None: + try: + browser.close() + except Exception: + pass + return {} + + +def _complete_chatgpt_provider_flow( + web_session, + authorize_url: str, + *, + referer: str = "", + login_email: str = "", + login_password: str = "", + get_otp_fn=None, + proxy_url: str = None, + log_fn=None, + log_prefix: str = "chatgpt", + session_reader=None, +) -> dict: + pr = _load_register_helpers() + if not pr or web_session is None: + return {} + parsed = urlparse(authorize_url or "") + params = parse_qs(parsed.query, keep_blank_values=False) + redirect_uri = ((params.get("redirect_uri") or [""])[0] or "").strip() + state = ((params.get("state") or [""])[0] or "").strip() + device_id = ((params.get("device_id") or [""])[0] or "").strip() + if not redirect_uri or not state or not device_id: + _log(log_fn, f"[sora] {log_prefix} authorize 缺少 redirect_uri/state/device_id: {(authorize_url or '')[:180]}") + return {} + + auth_start = web_session.get( + authorize_url, + headers=_build_html_headers(referer=referer or CHATGPT_ORIGIN), + timeout=DEFAULT_TIMEOUT, + allow_redirects=True, + ) + _log(log_fn, f"[sora] {log_prefix} provider authorize -> {str(auth_start.url)[:160]}") + + callback_url = "" + auth_code = "" + for candidate in _collect_response_urls(auth_start): + if "api/auth/callback/openai" in candidate: + callback_url = candidate + break + auth_code = pr._parse_code_from_url(candidate) + if auth_code: + break + + continue_url = "" + page_type = "" + consent_url = str(getattr(auth_start, "url", "") or "").strip() or authorize_url + + if not callback_url and not auth_code: + if not login_email: + _log(log_fn, f"[sora] {log_prefix} 缺少登录邮箱,无法继续 provider flow") + return {} + api_headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "origin": AUTH_ORIGIN, + "user-agent": pr.KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "referer": f"{AUTH_ORIGIN}/log-in", + "oai-device-id": device_id, + } + api_headers.update(pr._make_trace_headers()) + sentinel_auth = _build_sentinel_header(device_id, flow="authorize_continue", proxy_url=proxy_url, log_fn=log_fn) + if sentinel_auth: + api_headers["openai-sentinel-token"] = sentinel_auth + continue_resp = web_session.post( + f"{AUTH_ORIGIN}/api/accounts/authorize/continue", + headers=api_headers, + json={"username": {"kind": "email", "value": login_email}}, + timeout=DEFAULT_TIMEOUT, + ) + if continue_resp.status_code != 200: + _log(log_fn, f"[sora] {log_prefix} authorize/continue HTTP {continue_resp.status_code} {_response_preview(continue_resp, 140)}") + return {} + try: + continue_data = continue_resp.json() + except Exception: + continue_data = {} + continue_url = (continue_data.get("continue_url") or "").strip() + page_type = ((continue_data.get("page") or {}).get("type") or "").strip() + + needs_password = ( + not continue_url + or page_type in ("password", "password_verification") + or "/log-in/password" in continue_url + ) + if needs_password: + if not login_password: + _log(log_fn, f"[sora] {log_prefix} 已到 password 阶段但缺少账号密码") + return {} + api_headers["referer"] = f"{AUTH_ORIGIN}/log-in/password" + api_headers.update(pr._make_trace_headers()) + sentinel_pw = _build_sentinel_header(device_id, flow="password_verify", proxy_url=proxy_url, log_fn=log_fn) + if sentinel_pw: + api_headers["openai-sentinel-token"] = sentinel_pw + password_resp = web_session.post( + f"{AUTH_ORIGIN}/api/accounts/password/verify", + headers=api_headers, + json={"password": login_password}, + timeout=DEFAULT_TIMEOUT, + allow_redirects=False, + ) + if password_resp.status_code != 200: + _log(log_fn, f"[sora] {log_prefix} password/verify HTTP {password_resp.status_code} {_response_preview(password_resp, 140)}") + return {} + try: + password_data = password_resp.json() + except Exception: + password_data = {} + continue_url = (password_data.get("continue_url") or continue_url).strip() + page_type = ((password_data.get("page") or {}).get("type") or page_type).strip() + + if page_type == "email_otp_verification" or "email-verification" in continue_url: + otp_code = "" + if callable(get_otp_fn): + try: + otp_code = pr._normalize_otp_code(get_otp_fn() or "") + except Exception: + otp_code = "" + if not otp_code: + pr._request_login_email_otp(web_session, device_id, lambda msg: _log(log_fn, msg)) + if callable(get_otp_fn): + try: + otp_code = pr._normalize_otp_code(get_otp_fn() or "") + except Exception: + otp_code = "" + if not otp_code: + _log(log_fn, f"[sora] {log_prefix} 登录需要邮箱验证码,但未拿到新 OTP") + return {} + api_headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "origin": AUTH_ORIGIN, + "referer": f"{AUTH_ORIGIN}/email-verification", + "user-agent": pr.KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "oai-device-id": device_id, + } + otp_resp = None + for attempt in range(2): + api_headers.update(pr._make_trace_headers()) + otp_resp = web_session.post( + f"{AUTH_ORIGIN}/api/accounts/email-otp/validate", + headers=api_headers, + json={"code": otp_code}, + timeout=DEFAULT_TIMEOUT, + ) + if otp_resp.status_code == 200: + break + _log(log_fn, f"[sora] {log_prefix} email-otp/validate HTTP {otp_resp.status_code} {_response_preview(otp_resp, 140)}") + if attempt == 0 and otp_resp.status_code in (400, 401): + pr._request_login_email_otp(web_session, device_id, lambda msg: _log(log_fn, msg)) + if callable(get_otp_fn): + try: + otp_code = pr._normalize_otp_code(get_otp_fn() or "") + except Exception: + otp_code = "" + if not otp_code: + break + continue + return {} + if not otp_resp or otp_resp.status_code != 200: + return {} + try: + otp_data = otp_resp.json() + except Exception: + otp_data = {} + continue_url = (otp_data.get("continue_url") or continue_url).strip() + page_type = ((otp_data.get("page") or {}).get("type") or page_type).strip() + + if continue_url and ("/about-you" in continue_url or page_type in ("about_you", "about-you")): + status_create, data_create = pr._create_account(web_session, "User", "1992-09-19") + if status_create not in (200, 201, 204): + _log(log_fn, f"[sora] {log_prefix} about-you 提交失败: {status_create}") + return {} + continue_url = ( + (data_create.get("continue_url") or "").strip() + or (data_create.get("url") or "").strip() + or (data_create.get("redirect_url") or "").strip() + or continue_url + ) + + if not callback_url and auth_code: + callback_url = f"{redirect_uri}?{urlencode({'code': auth_code, 'state': state})}" + if not callback_url: + if continue_url and "api/auth/callback/openai" in continue_url: + callback_url = continue_url + else: + consent_url = continue_url if continue_url.startswith("http") else (f"{AUTH_ORIGIN}{continue_url}" if continue_url else consent_url) + auth_code = pr._follow_consent_to_code(web_session, consent_url, lambda msg: _log(log_fn, msg)) + if not auth_code: + session_data = pr._decode_oai_session_cookie(web_session) + workspaces = (session_data or {}).get("workspaces") or [] + workspace_id = workspaces[0].get("id") if workspaces else None + if workspace_id: + api_headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "origin": AUTH_ORIGIN, + "user-agent": pr.KEYGEN_USER_AGENT, + "sec-ch-ua": '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "referer": consent_url, + "oai-device-id": device_id, + } + api_headers.update(pr._make_trace_headers()) + ws_resp = web_session.post( + f"{AUTH_ORIGIN}/api/accounts/workspace/select", + headers=api_headers, + json={"workspace_id": workspace_id}, + timeout=DEFAULT_TIMEOUT, + allow_redirects=False, + ) + if ws_resp.status_code in (301, 302, 303, 307, 308): + loc = (ws_resp.headers.get("Location") or ws_resp.headers.get("location") or "").strip() + auth_code = pr._parse_code_from_url(loc) + if not auth_code and loc: + auth_code = pr._follow_consent_to_code( + web_session, + loc if loc.startswith("http") else f"{AUTH_ORIGIN}{loc}", + lambda msg: _log(log_fn, msg), + ) + if not auth_code: + _log(log_fn, f"[sora] {log_prefix} provider 跟随 consent 后仍未拿到 code") + return {} + callback_url = f"{redirect_uri}?{urlencode({'code': auth_code, 'state': state})}" + + callback_resp = web_session.get( + callback_url, + headers=_build_html_headers(referer=CHATGPT_ORIGIN), + timeout=DEFAULT_TIMEOUT, + allow_redirects=True, + ) + _log(log_fn, f"[sora] {log_prefix} callback -> {str(callback_resp.url)[:160]}") + reader = session_reader or _read_sora_web_session + if not callable(reader): + return {} + return reader(web_session, log_fn=log_fn) + + +def sora_chatgpt_web_login_from_authenticated_session( + web_session, + email: str = "", + password: str = "", + get_otp_fn=None, + log_fn=None, +) -> dict: + """ + 复用已在 auth.openai.com 完成登录的 session,建立 ChatGPT/Sora Web session。 + 优先沿着 provider flow 继续,避免 fresh 注册后再触发一轮完整邮箱 OTP。 + """ + if web_session is None: + return {} + + login_url = f"{CHATGPT_ORIGIN}/auth/login?next=/sora/login?next=%2Fauth%2Flogin_with" + proxy_url = None + try: + proxies = getattr(web_session, "proxies", None) or {} + proxy_url = (proxies.get("https") or proxies.get("http") or "").strip() or None + except Exception: + proxy_url = None + browser_session = _make_web_session(proxy_url=proxy_url) + _copy_session_cookies(web_session, browser_session) + try: + page_resp = browser_session.get( + login_url, + headers=_build_html_headers(), + timeout=DEFAULT_TIMEOUT, + allow_redirects=True, + ) + csrf_resp = browser_session.get( + f"{CHATGPT_ORIGIN}/api/auth/csrf", + headers={**_build_web_headers(), "Referer": str(page_resp.url)}, + timeout=DEFAULT_TIMEOUT, + ) + csrf_token = "" + if csrf_resp.status_code == 200: + try: + csrf_token = (csrf_resp.json().get("csrfToken") or "").strip() + except Exception: + csrf_token = "" + if not csrf_token: + _log(log_fn, f"[sora] 复用 session 获取 chatgpt csrf 失败 HTTP {csrf_resp.status_code} {_response_preview(csrf_resp, 120)}") + return {} + + signin_resp = browser_session.post( + f"{CHATGPT_ORIGIN}/api/auth/signin/openai", + headers={ + **_build_web_headers(), + "Content-Type": "application/x-www-form-urlencoded", + "Referer": str(page_resp.url), + }, + data={ + "csrfToken": csrf_token, + "callbackUrl": f"{CHATGPT_ORIGIN}/", + }, + timeout=DEFAULT_TIMEOUT, + allow_redirects=False, + ) + authorize_url = (signin_resp.headers.get("Location") or signin_resp.headers.get("location") or "").strip() + if signin_resp.status_code not in (301, 302, 303, 307, 308) or not authorize_url: + _log(log_fn, f"[sora] 复用 session chatgpt signin/openai HTTP {signin_resp.status_code} {_response_preview(signin_resp, 120)}") + return {} + + return _complete_chatgpt_provider_flow( + browser_session, + authorize_url, + referer=str(page_resp.url), + login_email=(email or "").strip(), + login_password=(password or "").strip(), + get_otp_fn=get_otp_fn, + proxy_url=proxy_url, + log_fn=log_fn, + log_prefix="复用 session", + ) + except Exception as exc: + _log(log_fn, f"[sora] 复用登录 session 建立 Web session 异常: {exc}") + return {} + finally: + try: + browser_session.close() + except Exception: + pass + + +def sora_chatgpt_web_login( + email: str, + password: str, + get_otp_fn=None, + proxy_url: str = None, + log_fn=None, + return_web_session: bool = False, +) -> dict: + """ + 通过 ChatGPT next-auth + auth.openai.com 登录,建立可供 Sora 使用的 Web session。 + 返回 {"access_token": str, "session": dict};失败返回空 dict。 + """ + login_email = (email or "").strip() + login_password = (password or "").strip() + if not login_email or not login_password: + return {} + pr = _load_register_helpers() + if not pr: + _log(log_fn, "[sora] 无法导入 protocol_register,跳过 ChatGPT Web 登录补链") + return {} + if callable(get_otp_fn): + seed_current_otps = getattr(get_otp_fn, "seed_current_otps", None) + if callable(seed_current_otps): + try: + seeded = seed_current_otps(folders=["junkemail", "inbox"]) + except Exception: + seeded = set() + if seeded: + _log(log_fn, f"[sora] chatgpt 预排除旧 OTP: {','.join(sorted(seeded))}") + + login_url = f"{CHATGPT_ORIGIN}/auth/login?next=/sora/login?next=%2Fauth%2Flogin_with" + web_session = _make_web_session(proxy_url=proxy_url) + try: + page_resp = None + csrf_token = "" + for attempt in range(2): + page_resp = web_session.get( + login_url, + headers=_build_html_headers(), + timeout=DEFAULT_TIMEOUT, + ) + csrf_resp = web_session.get( + f"{CHATGPT_ORIGIN}/api/auth/csrf", + headers={**_build_web_headers(), "Referer": str(page_resp.url)}, + timeout=DEFAULT_TIMEOUT, + ) + if csrf_resp.status_code == 200: + try: + csrf_token = (csrf_resp.json().get("csrfToken") or "").strip() + except Exception: + csrf_token = "" + if csrf_token: + break + _log(log_fn, f"[sora] chatgpt /api/auth/csrf 200 但无 csrfToken {_response_preview(csrf_resp, 120)}") + else: + _log(log_fn, f"[sora] chatgpt /api/auth/csrf HTTP {csrf_resp.status_code} {_response_preview(csrf_resp, 120)}") + if attempt == 0: + time.sleep(2) + if not csrf_token: + return {} + + signin_resp = web_session.post( + f"{CHATGPT_ORIGIN}/api/auth/signin/openai", + headers={ + **_build_web_headers(), + "Content-Type": "application/x-www-form-urlencoded", + "Referer": str(page_resp.url), + }, + data={ + "csrfToken": csrf_token, + "callbackUrl": f"{CHATGPT_ORIGIN}/", + }, + timeout=DEFAULT_TIMEOUT, + allow_redirects=False, + ) + authorize_url = (signin_resp.headers.get("Location") or signin_resp.headers.get("location") or "").strip() + if signin_resp.status_code not in (301, 302, 303, 307, 308) or not authorize_url: + _log(log_fn, f"[sora] chatgpt signin/openai HTTP {signin_resp.status_code} {_response_preview(signin_resp, 120)}") + return {} + + web_auth = _complete_chatgpt_provider_flow( + web_session, + authorize_url, + referer=str(page_resp.url), + login_email=login_email, + login_password=login_password, + get_otp_fn=get_otp_fn, + proxy_url=proxy_url, + log_fn=log_fn, + log_prefix="chatgpt", + ) + if ( + return_web_session + and isinstance(web_auth, dict) + and (web_auth.get("access_token") or "").strip() + ): + web_auth = dict(web_auth) + web_auth["web_session"] = web_session + web_session = None + return web_auth + except Exception as exc: + _log(log_fn, f"[sora] ChatGPT Web 登录异常: {exc}") + return {} + finally: + if web_session is not None: + try: + web_session.close() + except Exception: + pass + + +def chatgpt_mfa_info(access_token: str, proxy_url: str = None, log_fn=None, web_session=None) -> dict: + own_session = web_session is None + session = web_session or _make_web_session(proxy_url=proxy_url) + referer = _warm_chatgpt_security_page(session, log_fn=log_fn) + try: + resp = session.get( + f"{CHATGPT_BACKEND_API_ORIGIN}/accounts/mfa_info", + headers=_build_chatgpt_backend_headers(access_token, referer=referer), + timeout=DEFAULT_TIMEOUT, + ) + if resp.status_code != 200: + code, message, preview = _extract_api_error(resp) + _log(log_fn, f"[phone_bind] mfa_info HTTP {resp.status_code} code={code or '-'} msg={message or preview or '-'}") + return {} + data = resp.json() if hasattr(resp, "json") and callable(resp.json) else {} + if isinstance(data, dict): + _log( + log_fn, + f"[phone_bind] mfa_info mfa_enabled_v2={data.get('mfa_enabled_v2')} show_sms={data.get('show_sms')} show_passkey={data.get('show_passkey')}", + ) + return data if isinstance(data, dict) else {} + except Exception as exc: + _log(log_fn, f"[phone_bind] mfa_info 异常: {exc}") + return {} + finally: + if own_session: + try: + session.close() + except Exception: + pass + + +def _chatgpt_mfa_enroll_once(web_session, access_token: str, phone_number: str, *, channel: str = "sms", log_fn=None) -> tuple: + referer = _warm_chatgpt_security_page(web_session, log_fn=log_fn) + try: + resp = web_session.post( + f"{CHATGPT_BACKEND_API_ORIGIN}/accounts/mfa/enroll", + headers=_build_chatgpt_backend_headers(access_token, referer=referer), + json={ + "factor_type": "sms", + "phone_number": phone_number, + "phone_verification_channel": (channel or "sms").strip() or "sms", + }, + timeout=DEFAULT_TIMEOUT, + ) + except Exception as exc: + _log(log_fn, f"[phone_bind] mfa/enroll 异常: {exc}") + return False, {}, "other" + + if resp.status_code == 200: + try: + data = resp.json() + except Exception: + data = {} + if isinstance(data, dict) and isinstance(data.get("session_id"), str) and data.get("session_id"): + return True, data, "" + _log(log_fn, f"[phone_bind] mfa/enroll 返回异常结构: {_response_preview(resp, 180)}") + return False, data if isinstance(data, dict) else {}, "other" + + code, message, preview = _extract_api_error(resp) + text = " ".join(x for x in (code, message, preview) if x).lower() + _log(log_fn, f"[phone_bind] mfa/enroll HTTP {resp.status_code} code={code or '-'} msg={message or preview or '-'}") + if "recent_auth_required" in text or "re-authenticate" in text or "reauth" in text: + return False, {}, "recent_auth_required" + if "invalid_request" in text: + return False, {}, "invalid_request" + if "already" in text and ("phone" in text or "factor" in text): + return False, {}, "phone_used" + return False, {}, "other" + + +def _chatgpt_mfa_activate_enrollment(web_session, access_token: str, session_id: str, code: str, log_fn=None) -> bool: + referer = _warm_chatgpt_security_page(web_session, log_fn=log_fn) + try: + resp = web_session.post( + f"{CHATGPT_BACKEND_API_ORIGIN}/accounts/mfa/user/activate_enrollment", + headers=_build_chatgpt_backend_headers(access_token, referer=referer), + json={ + "code": code, + "factor_type": "sms", + "session_id": session_id, + }, + timeout=DEFAULT_TIMEOUT, + ) + if resp.status_code == 200: + return True + code_text, message, preview = _extract_api_error(resp) + _log(log_fn, f"[phone_bind] activate_enrollment HTTP {resp.status_code} code={code_text or '-'} msg={message or preview or '-'}") + return False + except Exception as exc: + _log(log_fn, f"[phone_bind] activate_enrollment 异常: {exc}") + return False + + +def chatgpt_open_recent_auth_session_for_mfa( + email: str, + password: str, + get_otp_fn=None, + proxy_url: str = None, + log_fn=None, +) -> dict: + login_email = (email or "").strip() + login_password = (password or "").strip() + if not login_email or not login_password: + return {} + if callable(get_otp_fn): + seed_current_otps = getattr(get_otp_fn, "seed_current_otps", None) + if callable(seed_current_otps): + try: + seeded = seed_current_otps(folders=["junkemail", "inbox"]) + except Exception: + seeded = set() + if seeded: + _log(log_fn, f"[phone_bind] reauth 预排除旧 OTP: {','.join(sorted(seeded))}") + + web_session = _make_web_session(proxy_url=proxy_url) + callback_url = f"{CHATGPT_SECURITY_SETTINGS_URL}?action=enable&factor=sms" + web_auth = {} + try: + page_resp = web_session.get( + callback_url, + headers=_build_html_headers(referer=CHATGPT_ORIGIN), + timeout=DEFAULT_TIMEOUT, + allow_redirects=True, + ) + csrf_resp = web_session.get( + f"{CHATGPT_ORIGIN}/api/auth/csrf", + headers={**_build_web_headers(), "Referer": str(page_resp.url)}, + timeout=DEFAULT_TIMEOUT, + ) + csrf_token = "" + if csrf_resp.status_code == 200: + try: + csrf_token = (csrf_resp.json().get("csrfToken") or "").strip() + except Exception: + csrf_token = "" + if not csrf_token: + _log(log_fn, f"[phone_bind] reauth 获取 csrf 失败 HTTP {csrf_resp.status_code} {_response_preview(csrf_resp, 120)}") + return {} + + device_id = str(uuid.uuid4()) + signin_resp = web_session.post( + f"{CHATGPT_ORIGIN}/api/auth/signin/openai?{urlencode({'reauth': 'password', 'max_age': '0', 'login_hint': login_email, 'ext-oai-did': device_id})}", + headers={ + **_build_web_headers(), + "Content-Type": "application/x-www-form-urlencoded", + "Referer": str(page_resp.url), + }, + data={ + "csrfToken": csrf_token, + "callbackUrl": callback_url, + }, + timeout=DEFAULT_TIMEOUT, + allow_redirects=False, + ) + authorize_url = (signin_resp.headers.get("Location") or signin_resp.headers.get("location") or "").strip() + if signin_resp.status_code not in (301, 302, 303, 307, 308) or not authorize_url: + _log(log_fn, f"[phone_bind] reauth signin/openai HTTP {signin_resp.status_code} {_response_preview(signin_resp, 120)}") + return {} + + web_auth = _complete_chatgpt_provider_flow( + web_session, + authorize_url, + referer=str(page_resp.url), + login_email=login_email, + login_password=login_password, + get_otp_fn=get_otp_fn, + proxy_url=proxy_url, + log_fn=log_fn, + log_prefix="reauth", + session_reader=_read_chatgpt_web_session, + ) + access_token = (web_auth.get("access_token") or "").strip() if isinstance(web_auth, dict) else "" + if not access_token: + return {} + mfa_info = chatgpt_mfa_info(access_token, proxy_url=proxy_url, log_fn=log_fn, web_session=web_session) + return { + "access_token": access_token, + "session": web_auth.get("session") or {}, + "mfa_info": mfa_info or {}, + "web_session": web_session, + } + except Exception as exc: + _log(log_fn, f"[phone_bind] reauth 建立 recent-auth session 异常: {exc}") + return {} + finally: + if not isinstance(web_auth, dict) or not (web_auth.get("access_token") or "").strip(): + try: + web_session.close() + except Exception: + pass + + +def sora_probe_web_auth(access_token: str = "", proxy_url: str = None, log_fn=None) -> dict: + """ + 探测当前 Sora Web 会话入口,帮助定位「Bearer token 不可用」与「Web session 未建立」。 + 返回示例: + { + "session_state": "null" | "present" | "error", + "provider_client_id": "...", + "provider_redirect_uri": "...", + "provider_audience": "...", + "token_client_id": "...", + } + """ + out = { + "session_state": "", + "provider_client_id": "", + "provider_redirect_uri": "", + "provider_audience": "", + "provider_scope": "", + "token_client_id": "", + } + + token_payload = _decode_jwt_payload(access_token) + token_client_id = (token_payload.get("client_id") or "").strip() + if token_client_id: + out["token_client_id"] = token_client_id + + try: + web_session = _make_web_session(proxy_url=proxy_url) + session_resp = web_session.get( + f"{SORA_ORIGIN}/api/auth/session", + headers=_build_web_headers(), + timeout=DEFAULT_TIMEOUT, + ) + if session_resp.status_code == 200: + body = (session_resp.text or "").strip() + if body == "null": + out["session_state"] = "null" + elif body: + out["session_state"] = "present" + else: + out["session_state"] = "empty" + else: + out["session_state"] = f"http_{session_resp.status_code}" + _log(log_fn, f"[sora] web /api/auth/session HTTP {session_resp.status_code} {_response_preview(session_resp, 120)}") + except Exception as exc: + out["session_state"] = "error" + _log(log_fn, f"[sora] web /api/auth/session 异常: {exc}") + + try: + web_session = locals().get("web_session") or _make_web_session(proxy_url=proxy_url) + csrf_resp = web_session.get( + f"{SORA_ORIGIN}/api/auth/csrf", + headers=_build_web_headers(), + timeout=DEFAULT_TIMEOUT, + ) + csrf_token = "" + if csrf_resp.status_code == 200: + try: + csrf_token = (csrf_resp.json().get("csrfToken") or "").strip() + except Exception: + csrf_token = "" + if not csrf_token: + _log(log_fn, f"[sora] web /api/auth/csrf 200 但无 csrfToken {_response_preview(csrf_resp, 120)}") + else: + _log(log_fn, f"[sora] web /api/auth/csrf HTTP {csrf_resp.status_code} {_response_preview(csrf_resp, 120)}") + + if csrf_token: + signin_resp = web_session.post( + f"{SORA_ORIGIN}/api/auth/signin/openai", + headers={ + **_build_web_headers(), + "Content-Type": "application/x-www-form-urlencoded", + }, + data={ + "csrfToken": csrf_token, + "callbackUrl": f"{SORA_ORIGIN}/", + }, + timeout=DEFAULT_TIMEOUT, + allow_redirects=False, + ) + loc = (signin_resp.headers.get("Location") or signin_resp.headers.get("location") or "").strip() + if signin_resp.status_code in (301, 302, 303, 307, 308) and loc: + parsed = urlparse(loc) + params = parse_qs(parsed.query, keep_blank_values=False) + out["provider_client_id"] = ((params.get("client_id") or [""])[0] or "").strip() + out["provider_redirect_uri"] = ((params.get("redirect_uri") or [""])[0] or "").strip() + out["provider_audience"] = ((params.get("audience") or [""])[0] or "").strip() + out["provider_scope"] = ((params.get("scope") or [""])[0] or "").strip() + else: + _log( + log_fn, + f"[sora] web signin/openai HTTP {signin_resp.status_code} {_response_preview(signin_resp, 120)}", + ) + except Exception as exc: + _log(log_fn, f"[sora] web signin/openai 探测异常: {exc}") + finally: + try: + web_session.close() + except Exception: + pass + + provider_client_id = out["provider_client_id"] + if provider_client_id: + _log( + log_fn, + f"[sora] web auth session={out['session_state'] or '-'} provider_client_id={provider_client_id} redirect_uri={out['provider_redirect_uri'] or '-'}", + ) + if token_client_id and provider_client_id and token_client_id != provider_client_id: + _log( + log_fn, + f"[sora] 当前 AT client_id={token_client_id} 与 Sora web provider client_id={provider_client_id} 不一致", + ) + return out + + +def rt_to_at_mobile(refresh_token: str, proxy_url: str = None, log_fn=None) -> dict: + """ + RT 换 AT(移动端 client_id/redirect_uri)。返回 {"access_token": str, "refresh_token": str|None},失败抛异常或返回空。 + """ + rt = (refresh_token or "").strip() + if not rt: + _log(log_fn, "[phone_bind] RT 为空") + return {} + for attempt in range(2): + try: + r = _session_post( + f"{AUTH_ORIGIN}/oauth/token", + headers={"Accept": "application/json", "Content-Type": "application/json"}, + json={ + "client_id": MOBILE_CLIENT_ID, + "grant_type": "refresh_token", + "redirect_uri": MOBILE_REDIRECT_URI, + "refresh_token": rt, + }, + proxy_url=proxy_url, + timeout=30, + ) + if r.status_code == 200: + d = r.json() + at = (d.get("access_token") or "").strip() + if at: + return {"access_token": at, "refresh_token": d.get("refresh_token")} + if log_fn and attempt == 0: + code, message, preview = _extract_error(r) + _log(log_fn, f"[phone_bind] RT 换 AT HTTP {r.status_code} code={code or '-'} msg={message or preview or '-'}") + except Exception as e: + _log(log_fn, f"[phone_bind] RT 换 AT 异常: {e}") + if attempt == 0: + time.sleep(2) + continue + return {} + + +def _legacy_sora_bootstrap(access_token: str, proxy_url: str = None, log_fn=None) -> bool: + """兼容旧版 GET backend/m/bootstrap。""" + for origin in _candidate_origins(): + try: + r = _session_get( + f"{origin}/backend/m/bootstrap", + headers=_build_headers(access_token, origin=origin), + proxy_url=proxy_url, + ) + if r.status_code == 200: + return True + code, message, preview = _extract_error(r) + _log(log_fn, f"[sora] legacy bootstrap {origin} HTTP {r.status_code} code={code or '-'} msg={message or preview or '-'}") + except Exception as e: + _log(log_fn, f"[sora] legacy bootstrap {origin} 异常: {e}") + return False + + +def sora_me(access_token: str, proxy_url: str = None, log_fn=None) -> dict: + """GET backend/me 获取当前用户信息。返回 dict,含 username 等;失败返回 {}.""" + try: + r = _session_get( + f"{SORA_ORIGIN}/backend/me", + headers=_build_headers(access_token), + proxy_url=proxy_url, + ) + if r.status_code == 200: + return r.json() if hasattr(r, "json") and callable(r.json) else {} + code, message, preview = _extract_error(r) + _log(log_fn, f"[sora] me HTTP {r.status_code} code={code or '-'} msg={message or preview or '-'}") + return {} + except Exception as e: + _log(log_fn, f"[sora] me 异常: {e}") + return {} + + +def _normalize_username(username: str) -> str: + value = "".join(c for c in (username or "").strip().lower() if c.isalnum() or c == "_") + if not value: + return "" + if not value[0].isalnum(): + value = "user_" + value + return value[:20] + + +def _normalize_video_orientation(orientation: str) -> str: + value = (orientation or "wide").strip().lower() + aliases = { + "landscape": "wide", + "16:9": "wide", + "wide": "wide", + "portrait": "tall", + "9:16": "tall", + "tall": "tall", + "square": "square", + "1:1": "square", + } + return aliases.get(value, "wide") + + +def _video_dimensions(resolution: int = 360, orientation: str = "wide") -> tuple[int, int]: + base = int(resolution or 360) + if base <= 0: + base = 360 + direction = _normalize_video_orientation(orientation) + if direction == "square": + return base, base + long_edge = int(round(base * 16 / 9)) + if direction == "tall": + return base, long_edge + return long_edge, base + + +def sora_build_simple_video_payload( + prompt: str, + *, + operation: str = "simple_compose", + n_variants: int = 4, + n_frames: int = 300, + resolution: int = 360, + orientation: str = "wide", + model: str = None, + seed: int = None, +) -> dict: + width, height = _video_dimensions(resolution=resolution, orientation=orientation) + payload = { + "type": "video_gen", + "operation": (operation or "simple_compose").strip() or "simple_compose", + "prompt": (prompt or "").strip(), + "n_variants": int(n_variants or 4), + "n_frames": int(n_frames or 300), + "width": width, + "height": height, + "inpaint_items": [], + "is_storyboard": False, + "model": (model or "").strip() or None, + "seed": seed, + } + return _strip_nullish(payload) + + +def is_chatgpt_web_access_token(access_token: str) -> bool: + payload = _decode_jwt_payload(access_token) + return (payload.get("client_id") or "").strip() == CHATGPT_WEB_CLIENT_ID + + +def _normalize_nf2_orientation(orientation: str) -> str: + value = (orientation or "portrait").strip().lower() + aliases = { + "portrait": "portrait", + "tall": "portrait", + "9:16": "portrait", + "wide": "landscape", + "landscape": "landscape", + "16:9": "landscape", + "square": "landscape", + "1:1": "landscape", + } + return aliases.get(value, "portrait") + + +def _nf2_size_from_resolution(resolution: int = 360) -> str: + try: + value = int(resolution or 360) + except Exception: + value = 360 + return "large" if value >= 720 else "small" + + +def sora_build_nf2_video_payload( + prompt: str, + *, + n_variants: int = 1, + n_frames: int = 300, + resolution: int = 360, + orientation: str = "portrait", + model: str = "sy_8", + style_id: str = "", + audio_caption: str = "", + audio_transcript: str = "", + video_caption: str = "", + seed: int = None, +) -> dict: + payload = { + "kind": "video", + "prompt": (prompt or "").strip(), + "title": None, + "orientation": _normalize_nf2_orientation(orientation), + "size": _nf2_size_from_resolution(resolution), + "n_frames": int(n_frames or 300), + "inpaint_items": [], + "remix_target_id": None, + "reroll_target_id": None, + "project_config": None, + "trim_config": None, + "metadata": None, + "cameo_ids": None, + "cameo_replacements": None, + "model": (model or "sy_8").strip() or "sy_8", + "style_id": (style_id or "").strip() or None, + "audio_caption": (audio_caption or "").strip() or None, + "audio_transcript": (audio_transcript or "").strip() or None, + "video_caption": (video_caption or "").strip() or None, + "storyboard_id": None, + "seed": seed, + } + try: + n = int(n_variants or 1) + except Exception: + n = 1 + if n > 1: + payload["nsamples"] = n + return _strip_nullish(payload) + + +def sora_build_image_video_payload( + prompt: str, + upload_media_id: str, + *, + operation: str = "simple_compose", + n_variants: int = 1, + n_frames: int = 300, + resolution: int = 360, + orientation: str = "wide", + model: str = None, + seed: int = None, +) -> dict: + payload = sora_build_simple_video_payload( + prompt, + operation=operation, + n_variants=n_variants, + n_frames=n_frames, + resolution=resolution, + orientation=orientation, + model=model, + seed=seed, + ) + payload["is_storyboard"] = True + payload["inpaint_items"] = [ + { + "type": "image", + "upload_media_id": (upload_media_id or "").strip(), + "frame_index": 0, + "x": 0, + "y": 0, + "width": int(payload.get("width") or 0), + "height": int(payload.get("height") or 0), + } + ] + return _strip_nullish(payload) + + +def sora_video_gen_create( + access_token: str, + prompt: str, + *, + operation: str = "simple_compose", + n_variants: int = 4, + n_frames: int = 300, + resolution: int = 360, + orientation: str = "wide", + model: str = None, + seed: int = None, + proxy_url: str = None, + log_fn=None, +): + device_id = str(uuid.uuid4()) + headers = _build_headers(access_token, device_id=device_id) + sentinel = _build_sentinel_header(device_id, "sora_create_task", proxy_url=proxy_url, log_fn=log_fn) + if sentinel: + headers["openai-sentinel-token"] = sentinel + payload = sora_build_simple_video_payload( + prompt, + operation=operation, + n_variants=n_variants, + n_frames=n_frames, + resolution=resolution, + orientation=orientation, + model=model, + seed=seed, + ) + return _session_post( + f"{SORA_ORIGIN}/backend/video_gen", + headers=headers, + json=payload, + proxy_url=proxy_url, + ) + + +def sora_nf2_create( + access_token: str, + prompt: str, + *, + n_variants: int = 1, + n_frames: int = 300, + resolution: int = 360, + orientation: str = "portrait", + model: str = "sy_8", + style_id: str = "", + audio_caption: str = "", + audio_transcript: str = "", + video_caption: str = "", + seed: int = None, + proxy_url: str = None, + log_fn=None, + web_session=None, + base_origin: str = None, +): + origin = (base_origin or SORA_ORIGIN).rstrip("/") + device_id = str(uuid.uuid4()) + headers = _build_sora_web_headers(access_token, device_id=device_id, origin=origin) + sentinel = _build_sentinel_header(device_id, "sora_2_create_task", proxy_url=proxy_url, log_fn=log_fn) + if sentinel: + headers["openai-sentinel-token"] = sentinel + payload = sora_build_nf2_video_payload( + prompt, + n_variants=n_variants, + n_frames=n_frames, + resolution=resolution, + orientation=orientation, + model=model, + style_id=style_id, + audio_caption=audio_caption, + audio_transcript=audio_transcript, + video_caption=video_caption, + seed=seed, + ) + path = "/backend/nf/bulk_create" if int(payload.get("nsamples") or 1) > 1 else "/backend/nf/create" + return _web_session_json_post( + f"{origin}{path}", + headers=headers, + json=payload, + proxy_url=proxy_url, + web_session=web_session, + ) + + +def sora_nf2_get_task(access_token: str, task_id: str, proxy_url: str = None, web_session=None, base_origin: str = None): + task = (task_id or "").strip() + origin = (base_origin or SORA_ORIGIN).rstrip("/") + return _web_session_get( + f"{origin}/backend/nf/tasks/{task}/v2", + headers=_build_sora_web_headers(access_token, origin=origin), + proxy_url=proxy_url, + web_session=web_session, + ) + + +def sora_nf2_get_pending(access_token: str, proxy_url: str = None, web_session=None, base_origin: str = None): + origin = (base_origin or SORA_ORIGIN).rstrip("/") + return _web_session_get( + f"{origin}/backend/nf/pending/v2", + headers=_build_sora_web_headers(access_token, origin=origin), + proxy_url=proxy_url, + web_session=web_session, + ) + + +def sora_nf2_get_draft(access_token: str, draft_id: str, proxy_url: str = None, web_session=None, base_origin: str = None): + draft = (draft_id or "").strip() + origin = (base_origin or SORA_ORIGIN).rstrip("/") + return _web_session_get( + f"{origin}/backend/project_y/profile/drafts/v2/{draft}", + headers=_build_sora_web_headers(access_token, origin=origin), + proxy_url=proxy_url, + web_session=web_session, + ) + + +def sora_nf2_stitch( + access_token: str, + generation_ids: list[str], + *, + for_download: bool = False, + proxy_url: str = None, + web_session=None, + base_origin: str = None, +): + origin = (base_origin or SORA_ORIGIN).rstrip("/") + query = "?for_download=true" if for_download else "" + payload = {"generation_ids": [str(item).strip() for item in (generation_ids or []) if str(item).strip()]} + return _web_session_json_post( + f"{origin}/backend/editor/stitch{query}", + headers=_build_sora_web_headers(access_token, origin=origin), + json=payload, + proxy_url=proxy_url, + web_session=web_session, + ) + + +def sora_upload_media( + access_token: str, + *, + filename: str, + content_type: str, + file_bytes: bytes = None, + file_path: str = None, + media_type: str = "image", + proxy_url: str = None, +): + headers = _build_headers(access_token, device_id=str(uuid.uuid4())) + headers.pop("Content-Type", None) + return _session_multipart_post( + f"{SORA_ORIGIN}/backend/uploads", + headers=headers, + data={ + "file_name": (filename or "").strip(), + "media_type": (media_type or "image").strip() or "image", + }, + file_field_name="file", + filename=(filename or "upload.bin").strip() or "upload.bin", + file_bytes=file_bytes, + file_path=file_path, + content_type=(content_type or "application/octet-stream").strip() or "application/octet-stream", + proxy_url=proxy_url, + ) + + +def _random_username(prefix: str = "user") -> str: + prefix = _normalize_username(prefix) or "user" + prefix = prefix[:11] + suffix = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(8)) + return _normalize_username(f"{prefix}_{suffix}") + + +def sora_create_account(access_token: str, birth_date: str = None, proxy_url: str = None, log_fn=None) -> bool: + """按当前官方前端链路创建 Sora onboarding 账号。""" + device_id = str(uuid.uuid4()) + headers = _build_headers(access_token, device_id=device_id) + sentinel = _build_sentinel_header(device_id, "sora_create_account", proxy_url=proxy_url, log_fn=log_fn) + if sentinel: + headers["openai-sentinel-token"] = sentinel + payload = {"birth_date": birth_date or None} + try: + r = _session_post( + f"{SORA_ORIGIN}/backend/me/onboarding/create_account", + headers=headers, + json=payload, + proxy_url=proxy_url, + ) + if r.status_code in (200, 201, 204): + return True + code, message, preview = _extract_error(r) + if code == "account_already_created": + return True + _log(log_fn, f"[sora] create_account HTTP {r.status_code} code={code or '-'} msg={message or preview or '-'}") + return False + except Exception as exc: + _log(log_fn, f"[sora] create_account 异常: {exc}") + return False + + +def _update_me(access_token: str, payload: dict, proxy_url: str = None, log_fn=None) -> tuple[bool, str]: + try: + r = _session_post( + f"{SORA_ORIGIN}/backend/me", + headers=_build_headers(access_token), + json=payload, + proxy_url=proxy_url, + ) + if r.status_code == 200: + return True, "" + code, message, preview = _extract_error(r) + _log(log_fn, f"[sora] update_me HTTP {r.status_code} code={code or '-'} msg={message or preview or '-'}") + return False, code + except Exception as exc: + _log(log_fn, f"[sora] update_me 异常: {exc}") + return False, "" + + +def sora_username_check(access_token: str, username: str, proxy_url: str = None, log_fn=None) -> bool: + """保留旧版用户名检查接口;当前激活流程不再依赖它。""" + for origin in _candidate_origins(): + try: + r = _session_post( + f"{origin}/backend/project_y/profile/username/check", + headers=_build_headers(access_token, origin=origin), + json={"username": username}, + proxy_url=proxy_url, + ) + if r.status_code == 200: + d = r.json() if hasattr(r, "json") and callable(r.json) else {} + return d.get("available", False) + code, message, preview = _extract_error(r) + _log(log_fn, f"[sora] legacy username/check {origin} HTTP {r.status_code} code={code or '-'} msg={message or preview or '-'}") + except Exception as exc: + _log(log_fn, f"[sora] legacy username/check {origin} 异常: {exc}") + return False + + +def _legacy_sora_username_set(access_token: str, username: str, proxy_url: str = None, log_fn=None) -> bool: + for origin in _candidate_origins(): + try: + r = _session_post( + f"{origin}/backend/project_y/profile/username/set", + headers=_build_headers(access_token, origin=origin), + json={"username": username}, + proxy_url=proxy_url, + ) + if r.status_code == 200: + return True + code, message, preview = _extract_error(r) + _log(log_fn, f"[sora] legacy username/set {origin} HTTP {r.status_code} code={code or '-'} msg={message or preview or '-'}") + except Exception as exc: + _log(log_fn, f"[sora] legacy username/set {origin} 异常: {exc}") + return False + + +def sora_username_set(access_token: str, username: str, proxy_url: str = None, log_fn=None) -> bool: + """按当前前端链路 POST /backend/me 设置用户名,失败时回退旧接口。""" + normalized = _normalize_username(username) or _random_username() + ok, code = _update_me(access_token, {"username": normalized}, proxy_url=proxy_url, log_fn=log_fn) + if ok: + return True + if code in USERNAME_RETRY_CODES: + return False + return _legacy_sora_username_set(access_token, normalized, proxy_url=proxy_url, log_fn=log_fn) + + +def sora_bootstrap(access_token: str, proxy_url: str = None, log_fn=None) -> bool: + """ + 兼容旧调用名。 + 现版本优先尝试 onboarding create_account;若当前环境仍是旧接口,再回退 legacy bootstrap。 + """ + if sora_create_account(access_token, proxy_url=proxy_url, log_fn=log_fn): + return True + return _legacy_sora_bootstrap(access_token, proxy_url=proxy_url, log_fn=log_fn) + + +def sora_ensure_activated( + access_token: str, + proxy_url: str = None, + log_fn=None, + username: str = None, + birth_date: str = None, +) -> bool: + """ + 确保 Sora 已激活(有 username)。 + 新链路:GET /backend/me -> POST /backend/me/onboarding/create_account -> POST /backend/me(username) + 若新链路失败,再回退旧版 bootstrap + project_y username/set。 + 返回 True 表示已激活或激活成功。 + """ + me = sora_me(access_token, proxy_url, log_fn) + if me and me.get("username"): + _log(log_fn, f"[sora] 已激活 username={me.get('username')}") + return True + sora_probe_web_auth(access_token=access_token, proxy_url=proxy_url, log_fn=log_fn) + + if sora_create_account(access_token, birth_date=birth_date, proxy_url=proxy_url, log_fn=log_fn): + me = sora_me(access_token, proxy_url, log_fn) + if me and me.get("username"): + _log(log_fn, f"[sora] create_account 后已激活 username={me.get('username')}") + return True + + preferred = _normalize_username(username) + candidates = [] + if preferred: + candidates.append(preferred) + for _ in range(5): + candidates.append(_random_username(prefix=preferred or "user")) + + for uname in candidates: + ok, code = _update_me(access_token, {"username": uname}, proxy_url=proxy_url, log_fn=log_fn) + if ok: + _log(log_fn, f"[sora] 设置用户名成功: {uname}") + return True + if code and code not in USERNAME_RETRY_CODES: + break + + _log(log_fn, "[sora] 新版 onboarding/me 流程失败,回退 legacy project_y 接口") + _legacy_sora_bootstrap(access_token, proxy_url, log_fn) + for uname in candidates: + if sora_username_check(access_token, uname, proxy_url, log_fn): + if _legacy_sora_username_set(access_token, uname, proxy_url, log_fn): + _log(log_fn, f"[sora] legacy 设置用户名成功: {uname}") + return True + return False + + +def _legacy_sora_phone_enroll_start(access_token: str, phone_number: str, proxy_url: str = None, log_fn=None) -> tuple: + try: + r = _session_post( + f"{SORA_ORIGIN}/backend/project_y/phone_number/enroll/start", + headers=_build_headers(access_token), + json={"phone_number": phone_number, "verification_expiry_window_ms": None}, + proxy_url=proxy_url, + ) + if r.status_code == 200: + return True, None + text = (r.text or "").lower() + if "already verified" in text or "phone number already" in text: + return False, "phone_used" + _log(log_fn, f"[phone_bind] enroll/start HTTP {r.status_code} {_response_preview(r, 150)}") + return False, "other" + except Exception as e: + _log(log_fn, f"[phone_bind] enroll/start 异常: {e}") + return False, "other" + + +def _legacy_sora_phone_enroll_finish(access_token: str, phone_number: str, verification_code: str, proxy_url: str = None, log_fn=None) -> bool: + code = re.sub(r"\D", "", (verification_code or "").strip())[:6] + if not code: + return False + try: + r = _session_post( + f"{SORA_ORIGIN}/backend/project_y/phone_number/enroll/finish", + headers=_build_headers(access_token), + json={"phone_number": phone_number, "verification_code": code}, + proxy_url=proxy_url, + ) + ok = r.status_code == 200 + if not ok: + _log(log_fn, f"[phone_bind] enroll/finish HTTP {r.status_code} {_response_preview(r, 150)}") + return ok + except Exception as e: + _log(log_fn, f"[phone_bind] enroll/finish 异常: {e}") + return False + + +def sora_phone_enroll_start( + access_token: str, + phone_number: str, + proxy_url: str = None, + log_fn=None, + login_email: str = "", + login_password: str = "", + get_otp_fn=None, +) -> tuple: + """ + 优先走 ChatGPT MFA 手机绑定: + - GET /backend-api/accounts/mfa_info + - POST /backend-api/accounts/mfa/enroll + recent_auth_required 时走 reauth=password + max_age=0。 + + 返回: + - (True, None, context) + - (False, "phone_used"|"reauth_failed"|"sms_unavailable"|"other", None) + """ + normalized_phone = _normalize_phone_number(phone_number) + if not normalized_phone: + _log(log_fn, f"[phone_bind] 非法手机号: {phone_number}") + return False, "other", None + + current_at = (access_token or "").strip() + current_age = _chatgpt_pwd_auth_age_seconds(current_at) + if current_age is not None: + _log(log_fn, f"[phone_bind] 当前 access_token pwd_auth_age={current_age}s") + + def _close_session(obj) -> None: + if obj is None: + return + try: + obj.close() + except Exception: + pass + + def _try_enroll_with_session(web_session, token: str): + mfa_info = chatgpt_mfa_info(token, proxy_url=proxy_url, log_fn=log_fn, web_session=web_session) + if not mfa_info: + return False, "other", None + if mfa_info.get("show_sms") is False: + _log(log_fn, "[phone_bind] 当前账号未开放 SMS MFA 入口") + return False, "sms_unavailable", None + ok, data, err = _chatgpt_mfa_enroll_once( + web_session, + token, + normalized_phone, + channel="sms", + log_fn=log_fn, + ) + if not ok: + return False, err or "other", None + factor = data.get("factor") if isinstance(data, dict) else {} + return True, None, { + "web_session": web_session, + "access_token": token, + "session_id": (data.get("session_id") or "").strip(), + "factor_id": (factor.get("id") or "").strip() if isinstance(factor, dict) else "", + "factor_type": "sms", + "phone_number": normalized_phone, + } + + should_try_direct = bool(current_at) and not _chatgpt_needs_recent_auth(current_at) + if should_try_direct: + direct_session = _make_web_session(proxy_url=proxy_url) + try: + ok, err, context = _try_enroll_with_session(direct_session, current_at) + if ok: + return True, None, context + _close_session(direct_session) + if err == "phone_used": + return False, "phone_used", None + if err == "sms_unavailable": + return False, "sms_unavailable", None + except Exception as exc: + _log(log_fn, f"[phone_bind] 直连 MFA enroll 异常: {exc}") + _close_session(direct_session) + + if (login_email or "").strip() and (login_password or "").strip(): + reauth = chatgpt_open_recent_auth_session_for_mfa( + email=login_email, + password=login_password, + get_otp_fn=get_otp_fn, + proxy_url=proxy_url, + log_fn=log_fn, + ) + reauth_session = reauth.get("web_session") if isinstance(reauth, dict) else None + reauth_at = (reauth.get("access_token") or "").strip() if isinstance(reauth, dict) else "" + if not reauth_session or not reauth_at: + _log(log_fn, "[phone_bind] recent-auth session 建立失败") + return False, "reauth_failed", None + ok, err, context = _try_enroll_with_session(reauth_session, reauth_at) + if ok: + return True, None, context + _close_session(reauth_session) + if err == "phone_used": + return False, "phone_used", None + if err == "sms_unavailable": + return False, "sms_unavailable", None + return False, err or "other", None + + _log(log_fn, "[phone_bind] 无账号密码,回退 legacy project_y 手机绑定") + ok, err = _legacy_sora_phone_enroll_start(access_token, normalized_phone, proxy_url=proxy_url, log_fn=log_fn) + return ok, err, None + + +def sora_phone_enroll_finish( + access_token: str, + phone_number: str, + verification_code: str, + proxy_url: str = None, + log_fn=None, + context: dict = None, +) -> bool: + """优先提交 ChatGPT MFA 验证码;无上下文时回退 legacy project_y。""" + code = re.sub(r"\D", "", (verification_code or "").strip())[:6] + if not code: + return False + ctx = context if isinstance(context, dict) else {} + web_session = ctx.get("web_session") + session_id = (ctx.get("session_id") or "").strip() + effective_at = (ctx.get("access_token") or access_token or "").strip() + if web_session is not None and session_id and effective_at: + try: + return _chatgpt_mfa_activate_enrollment( + web_session, + effective_at, + session_id, + code, + log_fn=log_fn, + ) + finally: + try: + web_session.close() + except Exception: + pass + return _legacy_sora_phone_enroll_finish( + access_token, + _normalize_phone_number(phone_number) or phone_number, + code, + proxy_url=proxy_url, + log_fn=log_fn, + ) diff --git a/Register_GPT_v0/run.py b/Register_GPT_v0/run.py new file mode 100644 index 0000000..626f33b --- /dev/null +++ b/Register_GPT_v0/run.py @@ -0,0 +1,15 @@ +""" +协议版入口:在本目录(protocol/)内直接启动时运行此脚本。 +将上级目录加入 sys.path,使 config、email_service 等可被导入,然后执行批量注册。 +""" +import sys +from pathlib import Path + +_root = Path(__file__).resolve().parent.parent +if str(_root) not in sys.path: + sys.path.insert(0, str(_root)) + +from protocol.main_protocol import main + +if __name__ == "__main__": + main() diff --git a/Register_GPT_v0/screenshots/1.png b/Register_GPT_v0/screenshots/1.png new file mode 100644 index 0000000..08efd83 Binary files /dev/null and b/Register_GPT_v0/screenshots/1.png differ diff --git a/Register_GPT_v0/screenshots/2.png b/Register_GPT_v0/screenshots/2.png new file mode 100644 index 0000000..a9163b4 Binary files /dev/null and b/Register_GPT_v0/screenshots/2.png differ diff --git a/Register_GPT_v0/screenshots/3.png b/Register_GPT_v0/screenshots/3.png new file mode 100644 index 0000000..221c90d Binary files /dev/null and b/Register_GPT_v0/screenshots/3.png differ diff --git a/Register_GPT_v0/screenshots/4.png b/Register_GPT_v0/screenshots/4.png new file mode 100644 index 0000000..1988eee Binary files /dev/null and b/Register_GPT_v0/screenshots/4.png differ diff --git a/Register_GPT_v0/screenshots/5.png b/Register_GPT_v0/screenshots/5.png new file mode 100644 index 0000000..f151a68 Binary files /dev/null and b/Register_GPT_v0/screenshots/5.png differ diff --git a/Register_GPT_v0/scripts/__init__.py b/Register_GPT_v0/scripts/__init__.py new file mode 100644 index 0000000..89d8c00 --- /dev/null +++ b/Register_GPT_v0/scripts/__init__.py @@ -0,0 +1 @@ +# 辅助脚本包 diff --git a/Register_GPT_v0/scripts/get_outlook_refresh_token.py b/Register_GPT_v0/scripts/get_outlook_refresh_token.py new file mode 100644 index 0000000..78d7f81 --- /dev/null +++ b/Register_GPT_v0/scripts/get_outlook_refresh_token.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +用浏览器登录一次,拿到 OAuth2 的 refresh_token,填到 mail.txt 第 4 列。 +用法:项目根目录 python -m protocol.scripts.get_outlook_refresh_token [client_id] +""" +import re +import sys +from pathlib import Path + +_root = Path(__file__).resolve().parent.parent.parent +if str(_root) not in sys.path: + sys.path.insert(0, str(_root)) + +LIVE_AUTHORIZE = "https://login.live.com/oauth20_authorize.srf" +LIVE_TOKEN = "https://login.live.com/oauth20_token.srf" +REDIRECT_URI = "https://login.live.com/oauth20_desktop.srf" +SCOPE = "wl.imap wl.offline_access" + + +def main(): + client_id = None + if len(sys.argv) >= 2 and sys.argv[1].strip(): + client_id = sys.argv[1].strip() + if not client_id: + try: + from config import cfg + client_id = (getattr(cfg.email, "outlook_client_id", None) or "").strip() + except Exception: + pass + if not client_id: + print("[x] Need client_id. Usage: python -m protocol.scripts.get_outlook_refresh_token ") + return + + auth_url = ( + f"{LIVE_AUTHORIZE}?" + f"client_id={client_id}&" + f"scope={SCOPE.replace(' ', '%20')}&" + f"response_type=code&" + f"redirect_uri={REDIRECT_URI}" + ) + print("[*] 1. Open this URL in browser and sign in with your Outlook account:") + print(auth_url) + print() + print("[*] 2. After consent, copy the FULL URL from the address bar (it contains code=...) and paste below.") + try: + raw = input("Paste redirect URL (or line with code=): ").strip() + except EOFError: + print("[x] No input.") + return + if not raw: + print("[x] Empty input.") + return + + if "code=" not in raw: + print("[x] Pasted text does not contain 'code='.") + return + m = re.search(r"code=([^&\s]+)", raw) + code = (m.group(1).strip() if m else "").strip() + if not code: + print("[x] Could not find 'code=' in the pasted text.") + return + + from utils import http_session + from config import HTTP_TIMEOUT + + data = { + "client_id": client_id, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + } + try: + r = http_session.post(LIVE_TOKEN, data=data, timeout=HTTP_TIMEOUT) + body = r.json() if r.text else {} + if r.status_code != 200: + err = body.get("error", "") or body.get("error_description", "") or r.text[:300] + print(f"[x] Token exchange failed: HTTP {r.status_code} - {err}") + return + refresh = body.get("refresh_token") + if not refresh: + print(f"[x] No refresh_token in response. Keys: {list(body.keys())}") + return + print() + print("[ok] refresh_token (copy to mail.txt 4th column):") + print(refresh) + except Exception as e: + print(f"[x] Error: {e}") + + +if __name__ == "__main__": + main() diff --git a/Register_GPT_v0/scripts/sora_video_create_and_wait.py b/Register_GPT_v0/scripts/sora_video_create_and_wait.py new file mode 100644 index 0000000..34679a4 --- /dev/null +++ b/Register_GPT_v0/scripts/sora_video_create_and_wait.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +def _post_json(url: str, body: dict[str, Any], api_key: str, timeout: int = 1800) -> tuple[int, dict[str, Any]]: + payload = json.dumps(body).encode("utf-8") + request = Request( + url, + data=payload, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8", errors="replace") + return response.status, json.loads(raw or "{}") + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + payload = json.loads(raw or "{}") + except Exception: + payload = {"raw": raw} + return exc.code, payload + except URLError as exc: + raise RuntimeError(f"请求失败: {exc}") from exc + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Create a Sora video task and wait until succeeded or timeout.") + parser.add_argument("--base-url", default="http://127.0.0.1:1989", help="Local backend base URL") + parser.add_argument("--api-key", default="", help="Pool or account-bound srk_ API key") + parser.add_argument("--account-id", type=int, default=0, help="Optional fixed account_id for admin-mode calls") + parser.add_argument("--prompt", required=True, help="Video prompt") + parser.add_argument("--n-variants", type=int, default=1, help="Number of variants") + parser.add_argument("--n-frames", type=int, default=300, help="Frame count") + parser.add_argument("--resolution", type=int, default=360, help="Base resolution") + parser.add_argument("--orientation", choices=["wide", "tall", "square"], default="wide", help="Video orientation") + parser.add_argument("--task-family", choices=["video_gen", "nf2"], default="", help="Optional generation chain override") + parser.add_argument("--model", default="", help="Optional model override") + parser.add_argument("--seed", type=int, default=None, help="Optional random seed") + parser.add_argument("--poll-interval", type=float, default=5.0, help="Polling interval in seconds") + parser.add_argument("--timeout", type=int, default=900, help="Polling timeout in seconds") + parser.add_argument("--json", action="store_true", help="Print full JSON response") + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + api_key = (args.api_key or "").strip() + if not api_key: + parser.error("--api-key 不能为空") + + body = { + "prompt": args.prompt, + "n_variants": max(1, int(args.n_variants or 1)), + "n_frames": max(60, int(args.n_frames or 300)), + "resolution": max(360, int(args.resolution or 360)), + "orientation": args.orientation, + "poll_interval_seconds": max(1.0, float(args.poll_interval or 5.0)), + "timeout_seconds": max(5, int(args.timeout or 900)), + } + if (args.task_family or "").strip(): + body["task_family"] = args.task_family.strip() + if args.account_id > 0: + body["account_id"] = int(args.account_id) + if (args.model or "").strip(): + body["model"] = args.model.strip() + if args.seed is not None: + body["seed"] = int(args.seed) + + url = args.base_url.rstrip("/") + "/api/sora-api/video-gen/create-and-wait" + status_code, result = _post_json(url, body, api_key=api_key, timeout=max(30, int(args.timeout) + 60)) + + if args.json: + print(json.dumps({"http_status": status_code, "result": result}, ensure_ascii=False, indent=2)) + else: + print(f"http_status: {status_code}") + print(f"task_id: {result.get('task_id') or ''}") + print(f"status: {result.get('normalized_status') or result.get('status') or ''}") + print(f"used_account_id: {result.get('used_account_id') or ''}") + print(f"used_email: {result.get('used_email') or ''}") + print(f"poll_attempts: {result.get('poll_attempts') or 0}") + print(f"elapsed_seconds: {result.get('elapsed_seconds') or 0}") + print(f"timed_out: {bool(result.get('timed_out'))}") + print(f"message: {result.get('message') or ''}") + video_urls = result.get("video_urls") or [] + if video_urls: + print("video_urls:") + for item in video_urls: + print(f" - {item}") + + if status_code >= 400: + return 1 + if result.get("ok") and result.get("is_success"): + return 0 + if result.get("timed_out"): + return 2 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Register_GPT_v0/tools/capture_sora_mobile.py b/Register_GPT_v0/tools/capture_sora_mobile.py new file mode 100644 index 0000000..fb0371d --- /dev/null +++ b/Register_GPT_v0/tools/capture_sora_mobile.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import json +from datetime import datetime +from pathlib import Path + +from mitmproxy import ctx, http + + +DOMAINS = ( + "sora.chatgpt.com", + "chatgpt.com", + "auth.openai.com", + "openai.com", + "sentinel.openai.com", + "videos.openai.com", +) + +OUT_DIR = Path("/Users/mac/Desktop/Sora-Register-main/logs/mobile_capture") +OUT_DIR.mkdir(parents=True, exist_ok=True) + + +def _matches(host: str) -> bool: + value = (host or "").strip().lower() + return any(value == domain or value.endswith(f".{domain}") for domain in DOMAINS) + + +def _now() -> str: + return datetime.now().strftime("%Y%m%d_%H%M%S_%f") + + +def _save(kind: str, data: dict) -> None: + path = OUT_DIR / f"{_now()}_{kind}.json" + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + ctx.log.info(f"[capture] wrote {path}") + + +def request(flow: http.HTTPFlow) -> None: + if not _matches(flow.request.pretty_host): + return + payload = { + "kind": "request", + "host": flow.request.pretty_host, + "method": flow.request.method, + "url": flow.request.pretty_url, + "headers": dict(flow.request.headers), + "content_length": len(flow.request.raw_content or b""), + "text_preview": (flow.request.get_text(strict=False) or "")[:8000], + } + _save("request", payload) + + +def response(flow: http.HTTPFlow) -> None: + if not _matches(flow.request.pretty_host): + return + payload = { + "kind": "response", + "host": flow.request.pretty_host, + "method": flow.request.method, + "url": flow.request.pretty_url, + "status_code": flow.response.status_code if flow.response else 0, + "headers": dict(flow.response.headers) if flow.response else {}, + "content_length": len((flow.response.raw_content if flow.response else b"") or b""), + "text_preview": ((flow.response.get_text(strict=False) if flow.response else "") or "")[:8000], + } + _save("response", payload) diff --git a/Register_GPT_v0/tools/monitor_sora_create.py b/Register_GPT_v0/tools/monitor_sora_create.py new file mode 100644 index 0000000..cad3280 --- /dev/null +++ b/Register_GPT_v0/tools/monitor_sora_create.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys +import time +from typing import Iterable + +from playwright.sync_api import sync_playwright + + +WATCH_PATHS = ( + "/backend/video_gen", + "/backend/nf/create", + "/backend/nf/bulk_create", +) + + +def _matches(url: str, paths: Iterable[str]) -> bool: + value = (url or "").strip() + return any(path in value for path in paths) + + +def _print_json(prefix: str, payload) -> None: + try: + text = json.dumps(payload, ensure_ascii=False) + except Exception: + text = repr(payload) + print(f"{prefix} {text}", flush=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Monitor Sora create requests in a real Chrome via CDP.") + parser.add_argument("--cdp-url", default="http://127.0.0.1:9222") + parser.add_argument("--timeout", type=int, default=300) + parser.add_argument("--open-explore", action="store_true") + args = parser.parse_args() + + deadline = time.time() + max(1, int(args.timeout)) + hit = {"done": False} + + with sync_playwright() as p: + browser = p.chromium.connect_over_cdp(args.cdp_url) + contexts = browser.contexts + if not contexts: + print("No browser contexts available.", file=sys.stderr, flush=True) + return 2 + context = contexts[0] + + def on_request(request): + if not _matches(request.url, WATCH_PATHS): + return + payload = { + "method": request.method, + "url": request.url, + "headers": { + k: v + for k, v in request.headers.items() + if k.lower() in {"content-type", "origin", "referer", "user-agent", "x-requested-with"} + }, + } + try: + post_data = request.post_data + except Exception: + post_data = None + if post_data: + payload["post_data"] = post_data[:4000] + _print_json("REQUEST", payload) + + def on_response(response): + if not _matches(response.url, WATCH_PATHS): + return + payload = { + "status": response.status, + "url": response.url, + "headers": { + k: v + for k, v in response.headers.items() + if k.lower() in {"content-type", "cf-ray", "server", "location"} + }, + } + try: + text = response.text() + except Exception as exc: + text = f"" + payload["body_preview"] = (text or "")[:4000] + _print_json("RESPONSE", payload) + hit["done"] = True + + for page in context.pages: + page.on("request", on_request) + page.on("response", on_response) + context.on("page", lambda page: (page.on("request", on_request), page.on("response", on_response))) + + pages = context.pages + page = pages[0] if pages else context.new_page() + if args.open_explore: + page.goto("https://sora.chatgpt.com/explore", wait_until="domcontentloaded", timeout=120000) + print( + json.dumps( + { + "page_count": len(context.pages), + "active_url": page.url, + "active_title": page.title(), + "deadline_epoch": int(deadline), + }, + ensure_ascii=False, + ), + flush=True, + ) + + while time.time() < deadline and not hit["done"]: + page.wait_for_timeout(1000) + + browser.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Register_GPT_v0/web/Dockerfile b/Register_GPT_v0/web/Dockerfile new file mode 100644 index 0000000..58dad42 --- /dev/null +++ b/Register_GPT_v0/web/Dockerfile @@ -0,0 +1,12 @@ +# 构建上下文建议为 protocol 目录: docker build -f web/Dockerfile . +FROM python:3.11-slim + +WORKDIR /app +COPY web/ /app/web/ +RUN pip install --no-cache-dir -r /app/web/backend/requirements.txt + +ENV DATA_DIR=/data +VOLUME /data +WORKDIR /app/web/backend +EXPOSE 1989 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "1989"] diff --git a/Register_GPT_v0/web/backend/app/__init__.py b/Register_GPT_v0/web/backend/app/__init__.py new file mode 100644 index 0000000..a8d71df --- /dev/null +++ b/Register_GPT_v0/web/backend/app/__init__.py @@ -0,0 +1 @@ +# Protocol Admin backend diff --git a/Register_GPT_v0/web/backend/app/config.py b/Register_GPT_v0/web/backend/app/config.py new file mode 100644 index 0000000..4c1c874 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/config.py @@ -0,0 +1,18 @@ +# 界面版配置:从环境变量或默认值加载,登录账号密码在此设置 +import os +from pathlib import Path + +def _str(key: str, default: str) -> str: + return os.environ.get(key, default).strip() or default + +# 默认 data 目录在 protocol/data(即 web 的上级的 data) +_default_data_dir = str(Path(__file__).resolve().parent.parent.parent.parent / "data") + +class Settings: + admin_username: str = _str("ADMIN_USERNAME", "admin") + admin_password: str = _str("ADMIN_PASSWORD", "admin123") + secret_key: str = _str("SECRET_KEY", "change-me-in-production") + data_dir: str = _str("DATA_DIR", _default_data_dir) + cors_origins: str = _str("CORS_ORIGINS", "*") + +settings = Settings() diff --git a/Register_GPT_v0/web/backend/app/database.py b/Register_GPT_v0/web/backend/app/database.py new file mode 100644 index 0000000..f103d54 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/database.py @@ -0,0 +1,322 @@ +import os +import sqlite3 +from pathlib import Path +from contextlib import contextmanager +from app.config import settings + +DB_PATH = os.path.join(settings.data_dir, "admin.db") + + +def ensure_data_dir(): + Path(settings.data_dir).mkdir(parents=True, exist_ok=True) + + +def get_conn(): + ensure_data_dir() + return sqlite3.connect(DB_PATH, check_same_thread=False) + + +@contextmanager +def get_db(): + conn = get_conn() + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def init_db(): + ensure_data_dir() + with get_db() as conn: + c = conn.cursor() + # 系统设置(key-value) + c.execute(""" + CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + """) + # 账号表(注册结果) + c.execute(""" + CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + password TEXT, + status TEXT, + registered_at TEXT, + has_sora INTEGER DEFAULT 0, + has_plus INTEGER DEFAULT 0, + phone_bound INTEGER DEFAULT 0, + proxy TEXT, + refresh_token TEXT, + access_token TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + """) + c.execute("CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email)") + c.execute("CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)") + try: + c.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_email_unique ON accounts(LOWER(TRIM(email)))") + except Exception: + pass + # 邮箱管理 + c.execute(""" + CREATE TABLE IF NOT EXISTS emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + password TEXT, + uuid TEXT, + token TEXT, + remark TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + """) + # 手机号管理(activation_id 为 Hero-SMS 激活 ID,用于拉取验证码状态) + c.execute(""" + CREATE TABLE IF NOT EXISTS phone_numbers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT NOT NULL, + activation_id INTEGER, + max_use_count INTEGER DEFAULT 1, + used_count INTEGER DEFAULT 0, + remark TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + """) + c.execute("CREATE INDEX IF NOT EXISTS idx_phone_numbers_phone ON phone_numbers(phone)") + # 银行卡管理 + c.execute(""" + CREATE TABLE IF NOT EXISTS bank_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + card_number_masked TEXT, + card_data TEXT, + max_use_count INTEGER DEFAULT 1, + used_count INTEGER DEFAULT 0, + remark TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + """) + # 运行日志(按任务或按条) + c.execute(""" + CREATE TABLE IF NOT EXISTS run_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT, + level TEXT, + message TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + """) + c.execute("CREATE INDEX IF NOT EXISTS idx_run_logs_task_id ON run_logs(task_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_run_logs_created ON run_logs(created_at)") + # 管理员密码(可覆盖 config 的初始密码,存 bcrypt hash) + c.execute(""" + CREATE TABLE IF NOT EXISTS admin_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT, + updated_at TEXT DEFAULT (datetime('now')) + ) + """) + # Sora 调用 API Key(仅保存 hash,明文只在创建时返回一次) + c.execute(""" + CREATE TABLE IF NOT EXISTS sora_api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + name TEXT, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT, + key_mask TEXT, + created_by TEXT, + scope TEXT DEFAULT 'text_to_video', + is_active INTEGER DEFAULT 1, + last_used_at TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + """) + c.execute("CREATE INDEX IF NOT EXISTS idx_sora_api_keys_account_id ON sora_api_keys(account_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_sora_api_keys_active ON sora_api_keys(is_active)") + # Sora 视频任务归属(池模式下让 task_id 跟随创建它的账号) + c.execute(""" + CREATE TABLE IF NOT EXISTS sora_video_tasks ( + task_id TEXT PRIMARY KEY, + account_id INTEGER NOT NULL, + api_key_id INTEGER, + task_family TEXT DEFAULT 'video_gen', + raw_status TEXT, + normalized_status TEXT, + is_active INTEGER DEFAULT 1, + lease_expires_at TEXT, + succeeded_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ) + """) + c.execute("CREATE INDEX IF NOT EXISTS idx_sora_video_tasks_account_id ON sora_video_tasks(account_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_sora_video_tasks_api_key_id ON sora_video_tasks(api_key_id)") + c.execute(""" + CREATE TABLE IF NOT EXISTS sora_media_assets ( + media_id TEXT PRIMARY KEY, + account_id INTEGER NOT NULL, + api_key_id INTEGER, + media_type TEXT, + filename TEXT, + mime_type TEXT, + width INTEGER, + height INTEGER, + source_url TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ) + """) + c.execute("CREATE INDEX IF NOT EXISTS idx_sora_media_assets_account_id ON sora_media_assets(account_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_sora_media_assets_api_key_id ON sora_media_assets(api_key_id)") + try: + c.execute("ALTER TABLE phone_numbers ADD COLUMN activation_id INTEGER") + except Exception: + pass + try: + c.execute("ALTER TABLE phone_numbers ADD COLUMN expired_at TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE accounts ADD COLUMN access_token TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE accounts ADD COLUMN sora_enabled INTEGER DEFAULT 1") + except Exception: + pass + try: + c.execute("ALTER TABLE accounts ADD COLUMN sora_quota_exhausted INTEGER DEFAULT 0") + except Exception: + pass + try: + c.execute("ALTER TABLE accounts ADD COLUMN sora_quota_note TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE accounts ADD COLUMN sora_quota_updated_at TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE accounts ADD COLUMN sora_last_error TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE sora_api_keys ADD COLUMN scope TEXT DEFAULT 'text_to_video'") + except Exception: + pass + try: + c.execute("ALTER TABLE sora_video_tasks ADD COLUMN task_family TEXT DEFAULT 'video_gen'") + except Exception: + pass + try: + c.execute("ALTER TABLE sora_video_tasks ADD COLUMN raw_status TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE sora_video_tasks ADD COLUMN normalized_status TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE sora_video_tasks ADD COLUMN is_active INTEGER DEFAULT 1") + except Exception: + pass + try: + c.execute("ALTER TABLE sora_video_tasks ADD COLUMN lease_expires_at TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE sora_video_tasks ADD COLUMN succeeded_at TEXT") + except Exception: + pass + try: + c.execute("UPDATE accounts SET sora_enabled = 1 WHERE sora_enabled IS NULL") + c.execute("UPDATE accounts SET sora_quota_exhausted = 0 WHERE sora_quota_exhausted IS NULL") + c.execute("UPDATE sora_api_keys SET scope = 'text_to_video' WHERE COALESCE(scope, '') = ''") + except Exception: + pass + try: + c.execute("UPDATE sora_video_tasks SET task_family = 'video_gen' WHERE COALESCE(task_family, '') = ''") + c.execute("UPDATE sora_video_tasks SET is_active = 1 WHERE is_active IS NULL") + c.execute( + "UPDATE sora_video_tasks SET succeeded_at = updated_at " + "WHERE normalized_status = 'succeeded' AND COALESCE(succeeded_at, '') = ''" + ) + except Exception: + pass + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_sora_api_keys_scope ON sora_api_keys(scope)") + except Exception: + pass + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_sora_video_tasks_active ON sora_video_tasks(is_active)") + except Exception: + pass + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_sora_video_tasks_lease_expires_at ON sora_video_tasks(lease_expires_at)") + except Exception: + pass + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_sora_video_tasks_succeeded_at ON sora_video_tasks(succeeded_at)") + except Exception: + pass + # 插入默认设置键 + defaults = [ + "sms_api_url", "sms_api_key", "thread_count", "proxy_url", "proxy_api_url", + "bank_card_api_url", "bank_card_api_key", "email_api_url", "email_api_key", + "card_use_limit", "phone_bind_limit", + "oauth_client_id", "oauth_redirect_uri", + "sora_daily_video_quota_per_account", + "sora_auto_rotate_cursor", + ] + for key in defaults: + c.execute( + "INSERT OR IGNORE INTO system_settings (key, value) VALUES (?, ?)", + (key, "") + ) + c.execute( + "INSERT OR IGNORE INTO system_settings (key, value) VALUES (?, ?)", + ("thread_count", "1") + ) + c.execute( + "INSERT OR IGNORE INTO system_settings (key, value) VALUES (?, ?)", + ("card_use_limit", "1") + ) + c.execute( + "INSERT OR IGNORE INTO system_settings (key, value) VALUES (?, ?)", + ("phone_bind_limit", "1") + ) + # 首次运行:插入默认管理员 admin / admin123 + c.execute("SELECT COUNT(*) FROM admin_users") + if c.fetchone()[0] == 0: + from app.security import get_password_hash + _hash = get_password_hash("admin123") + c.execute( + "INSERT INTO admin_users (username, password_hash) VALUES (?, ?)", + ("admin", _hash) + ) + # 账号表为空时插入测试数据(便于查看列表效果) + c.execute("SELECT COUNT(*) FROM accounts") + if c.fetchone()[0] == 0: + from datetime import datetime, timedelta + now = datetime.utcnow() + test_rows = [ + ("user1@temp-mail.test", "Pass123!a", "Registered+Sora", (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M"), 1, 0, 0, "http://127.0.0.1:7890", "rt_xxx1"), + ("user2@temp-mail.test", "Pass123!b", "Registered", (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M"), 0, 0, 0, None, "rt_xxx2"), + ("user3@temp-mail.test", "Pass123!c", "Plus activated", (now - timedelta(hours=12)).strftime("%Y-%m-%d %H:%M"), 1, 1, 0, "http://127.0.0.1:7891", "rt_xxx3"), + ("user4@temp-mail.test", "Pass123!d", "Registered+Sora", (now - timedelta(hours=5)).strftime("%Y-%m-%d %H:%M"), 1, 0, 1, None, None), + ("user5@temp-mail.test", "Pass123!e", "Finish setup (check email)", (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M"), 0, 0, 0, None, None), + ("alice.demo@example.com", "Demo#456", "Registered+Sora", now.strftime("%Y-%m-%d %H:%M"), 1, 1, 1, "socks5://proxy:1080", "rt_xxx6"), + ] + for email, pwd, status, reg_at, sora, plus, phone, proxy, rt in test_rows: + c.execute( + """INSERT INTO accounts (email, password, status, registered_at, has_sora, has_plus, phone_bound, proxy, refresh_token, access_token) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (email, pwd, status, reg_at, sora, plus, phone, proxy or None, rt or None, None) + ) diff --git a/Register_GPT_v0/web/backend/app/main.py b/Register_GPT_v0/web/backend/app/main.py new file mode 100644 index 0000000..65cbbfd --- /dev/null +++ b/Register_GPT_v0/web/backend/app/main.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +import logging +from pathlib import Path +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from fastapi.middleware.cors import CORSMiddleware + +from fastapi import Depends +from app.config import settings +from app.database import init_db, get_db, DB_PATH +from app.routers import auth, accounts, settings as settings_router, emails, bank_cards, logs, dashboard, email_api, sms_api, phones, register as register_router, phone_bind as phone_bind_router, sora_api, sora_keys +from app.routers.auth import get_current_user + +# 不把轮询接口打进 access 日志,方便调试协议 +class SkipPollPathsFilter(logging.Filter): + _paths = ("/api/register/status", "/api/dashboard", "/api/logs", "/api/phone-bind/status") + def filter(self, record): + try: + # uvicorn AccessFormatter: record.args = (client_addr, method, full_path, http_version, status_code) + if getattr(record, "args", None) and len(record.args) >= 5: + full_path = record.args[2] + status_code = record.args[4] + if status_code == 200 and isinstance(full_path, str): + for p in self._paths: + if p in full_path: + return False + except Exception: + pass + return True + +app = FastAPI(title="Sora 批量注册", version="1.0.0") +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins.split(",") if settings.cors_origins else ["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(accounts.router) +app.include_router(settings_router.router) +app.include_router(emails.router) +app.include_router(bank_cards.router) +app.include_router(logs.router) +app.include_router(dashboard.router) +app.include_router(email_api.router) +app.include_router(sms_api.router) +app.include_router(phones.router) +app.include_router(register_router.router) +app.include_router(phone_bind_router.router) +app.include_router(sora_api.router) +app.include_router(sora_keys.router) + + +@app.on_event("startup") +def startup(): + init_db() + # 屏蔽 status/dashboard/logs 轮询的 200 访问日志 + skip_filter = SkipPollPathsFilter() + uvicorn_access = logging.getLogger("uvicorn.access") + uvicorn_access.addFilter(skip_filter) + for h in uvicorn_access.handlers: + h.addFilter(skip_filter) + print("[Sora 批量注册] 服务已启动 http://0.0.0.0:1989", flush=True) + + +# 前端:protocol/web/frontend +frontend_dir = Path(__file__).resolve().parent.parent.parent / "frontend" +static_dir = frontend_dir / "static" + + +@app.get("/api/debug/db-info", tags=["debug"]) +def debug_db_info(username: str = Depends(get_current_user)): + """返回当前后端使用的数据目录与 accounts 条数,用于核对「账号管理」是否与注册写入同库。""" + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT COUNT(*) FROM accounts") + n = c.fetchone()[0] + return {"data_dir": settings.data_dir, "db_path": DB_PATH, "accounts_count": n} + + +@app.get("/") +def index(): + index_file = frontend_dir / "index.html" + if index_file.exists(): + return FileResponse(index_file) + return {"message": "Protocol Admin API", "docs": "/docs"} + + +if static_dir.exists(): + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") diff --git a/Register_GPT_v0/web/backend/app/registration_env.py b/Register_GPT_v0/web/backend/app/registration_env.py new file mode 100644 index 0000000..1ef3c1e --- /dev/null +++ b/Register_GPT_v0/web/backend/app/registration_env.py @@ -0,0 +1,109 @@ +""" +Web 端调用 protocol_register 前的 config/utils 注入。 +在首次 import protocol_register 之前调用 inject_registration_modules(), +并在每任务开始前调用 set_task_config() 设置当前线程的 proxy/timeout 等。 +""" +import sys +import threading +import types +from pathlib import Path + +# 线程局部:当前注册任务的 proxy、timeout 等,由 runner 在每任务开始前写入 +_reg_task = threading.local() + +# 默认超时与 UA(与 protocol_register 内默认一致) +DEFAULT_HTTP_TIMEOUT = 60 +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) + + +def set_task_config( + *, + proxy_url=None, + timeout=DEFAULT_HTTP_TIMEOUT, + user_agent=None, + http_max_retries=5, + oauth_client_id=None, + oauth_redirect_uri=None, +): + """由 registration_runner 在每任务开始前调用,设置当前线程的注册配置。""" + _reg_task.proxy_url = proxy_url + _reg_task.timeout = timeout + _reg_task.user_agent = user_agent + _reg_task.http_max_retries = http_max_retries + _reg_task.oauth_client_id = oauth_client_id if oauth_client_id is not None else getattr(_reg_task, "oauth_client_id", "") + _reg_task.oauth_redirect_uri = oauth_redirect_uri if oauth_redirect_uri is not None else getattr(_reg_task, "oauth_redirect_uri", "") + + +def clear_task_config(): + """任务结束后可调用,清理当前线程配置(可选)。""" + for key in ("proxy_url", "timeout", "user_agent", "http_max_retries"): + if hasattr(_reg_task, key): + delattr(_reg_task, key) + + +def get_proxy_url_random(): + """供注入的 config 使用;优先返回当前任务线程的 proxy。""" + return getattr(_reg_task, "proxy_url", None) + + +def get_proxy_url_for_session(): + """供注入的 config 使用。""" + return getattr(_reg_task, "proxy_url", None) + + +def get_http_timeout(): + return getattr(_reg_task, "timeout", DEFAULT_HTTP_TIMEOUT) + + +def get_user_agent(): + return getattr(_reg_task, "user_agent", None) or DEFAULT_USER_AGENT + + +def _make_cfg(): + """最小 cfg:protocol_register 用到的 retry、oauth(按线程动态,oauth 来自系统设置)。""" + + class _Retry: + @property + def http_max_retries(self): + return getattr(_reg_task, "http_max_retries", 5) + + class _OAuth: + @property + def client_id(self): + return getattr(_reg_task, "oauth_client_id", None) or "" + + @property + def redirect_uri(self): + return getattr(_reg_task, "oauth_redirect_uri", None) or "" + + cfg = types.SimpleNamespace() + cfg.retry = _Retry() + cfg.oauth = _OAuth() + return cfg + + +def inject_registration_modules(): + """ + 在首次 import protocol_register 之前调用。 + 向 sys.modules 注入 config 与 utils,并确保协议包根目录在 sys.path 中。 + """ + root = Path(__file__).resolve().parent.parent.parent.parent # app -> backend -> web -> protocol + root_str = str(root) + if root_str not in sys.path: + sys.path.insert(0, root_str) + + # 仅在首次执行注册前注入,保证 protocol_register 的 from config/utils 使用桩 + _config = types.ModuleType("config") + _config.__registration_stub__ = True + _config.HTTP_TIMEOUT = DEFAULT_HTTP_TIMEOUT + _config.get_proxy_url_random = get_proxy_url_random + _config.get_proxy_url_for_session = get_proxy_url_for_session + _config.cfg = _make_cfg() + sys.modules["config"] = _config + + _utils = types.ModuleType("utils") + _utils.__registration_stub__ = True + _utils.get_user_agent = get_user_agent + sys.modules["utils"] = _utils diff --git a/Register_GPT_v0/web/backend/app/registration_state.py b/Register_GPT_v0/web/backend/app/registration_state.py new file mode 100644 index 0000000..7775fe4 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/registration_state.py @@ -0,0 +1,16 @@ +"""注册任务停止标志,供调度线程与 worker 共享。""" +import threading + +_stop_requested = False +_lock = threading.Lock() + + +def set_stop_requested(value: bool) -> None: + with _lock: + global _stop_requested + _stop_requested = value + + +def is_stop_requested() -> bool: + with _lock: + return _stop_requested diff --git a/Register_GPT_v0/web/backend/app/routers/__init__.py b/Register_GPT_v0/web/backend/app/routers/__init__.py new file mode 100644 index 0000000..df5374a --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/__init__.py @@ -0,0 +1 @@ +# API routers diff --git a/Register_GPT_v0/web/backend/app/routers/accounts.py b/Register_GPT_v0/web/backend/app/routers/accounts.py new file mode 100644 index 0000000..89fb2fa --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/accounts.py @@ -0,0 +1,684 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from app.routers.auth import get_current_user +from app.database import get_db, init_db +import csv +import io +from datetime import datetime + +router = APIRouter(prefix="/api/accounts", tags=["accounts"]) + + +class AccountSoraStateBody(BaseModel): + sora_enabled: Optional[bool] = None + reset_quota: bool = False + + +class AccountSoraQuotaRecheckBody(BaseModel): + account_id: Optional[int] = None + limit: int = 10 + auto_cancel: bool = True + prompt: str = "A calm abstract light gradient slowly drifting." + n_frames: int = 60 + resolution: int = 360 + orientation: str = "wide" + + +def _load_quota_recheck_candidates(account_id: Optional[int] = None, limit: int = 10) -> list[dict]: + init_db() + with get_db() as conn: + c = conn.cursor() + if account_id is not None: + c.execute( + """SELECT id, email, status, + COALESCE(refresh_token, '') AS refresh_token, + COALESCE(access_token, '') AS access_token, + COALESCE(proxy, '') AS proxy, + COALESCE(has_sora, 0) AS has_sora, + COALESCE(sora_enabled, 1) AS sora_enabled, + COALESCE(sora_quota_exhausted, 0) AS sora_quota_exhausted, + COALESCE(sora_quota_note, '') AS sora_quota_note, + COALESCE(sora_quota_updated_at, '') AS sora_quota_updated_at + FROM accounts + WHERE id = ? + LIMIT 1""", + (int(account_id),), + ) + else: + c.execute( + """SELECT id, email, status, + COALESCE(refresh_token, '') AS refresh_token, + COALESCE(access_token, '') AS access_token, + COALESCE(proxy, '') AS proxy, + COALESCE(has_sora, 0) AS has_sora, + COALESCE(sora_enabled, 1) AS sora_enabled, + COALESCE(sora_quota_exhausted, 0) AS sora_quota_exhausted, + COALESCE(sora_quota_note, '') AS sora_quota_note, + COALESCE(sora_quota_updated_at, '') AS sora_quota_updated_at + FROM accounts + WHERE has_sora = 1 + AND COALESCE(sora_enabled, 1) = 1 + AND COALESCE(sora_quota_exhausted, 0) = 1 + AND (COALESCE(refresh_token, '') != '' OR COALESCE(access_token, '') != '') + ORDER BY + CASE WHEN COALESCE(sora_quota_updated_at, '') = '' THEN 1 ELSE 0 END, + sora_quota_updated_at ASC, + id ASC + LIMIT ?""", + (max(1, min(int(limit or 10), 50)),), + ) + rows = c.fetchall() + + items = [] + for row in rows: + items.append({ + "id": int(row[0]), + "email": row[1] or "", + "status": row[2] or "", + "refresh_token": (row[3] or "").strip(), + "access_token": (row[4] or "").strip(), + "proxy": (row[5] or "").strip(), + "has_sora": bool(row[6]), + "sora_enabled": bool(row[7]), + "sora_quota_exhausted": bool(row[8]), + "sora_quota_note": row[9] or "", + "sora_quota_updated_at": row[10] or "", + }) + return items + + +def _probe_account_sora_quota(account: dict, body: AccountSoraQuotaRecheckBody) -> dict: + from app.routers import sora_api as sora_router + + account_id = int(account["id"]) + email = account.get("email") or "" + result = { + "account_id": account_id, + "email": email, + "status": account.get("status") or "", + "quota_note": account.get("sora_quota_note") or "", + "quota_updated_at": account.get("sora_quota_updated_at") or "", + "result": "", + "message": "", + "recovered_to_pool": False, + "task_id": "", + "create_status_code": 0, + "cancel_status_code": 0, + "cancel_ok": False, + } + + if not account.get("has_sora"): + result["result"] = "skipped_no_sora" + result["message"] = "账号未开通 Sora" + return result + if not account.get("sora_enabled"): + result["result"] = "skipped_disabled" + result["message"] = "账号已停用" + return result + if not account.get("sora_quota_exhausted"): + result["result"] = "already_available" + result["message"] = "账号当前未标记额度不足,已经在轮换池内" + result["recovered_to_pool"] = True + return result + if not ((account.get("refresh_token") or "").strip() or (account.get("access_token") or "").strip()): + result["result"] = "skipped_no_token" + result["message"] = "账号没有可用 token,无法复检" + return result + + sora_phone = sora_router._import_sora_phone() + access_token = (account.get("access_token") or "").strip() + refresh_token = (account.get("refresh_token") or "").strip() + proxy_url = (account.get("proxy") or "").strip() + refresh_error = "" + + if refresh_token: + try: + token_out = sora_phone.rt_to_at_mobile(refresh_token, proxy_url=proxy_url) + new_access_token = (token_out.get("access_token") or "").strip() + new_refresh_token = (token_out.get("refresh_token") or "").strip() + if new_access_token: + access_token = new_access_token + if new_refresh_token: + refresh_token = new_refresh_token + if new_access_token or new_refresh_token: + sora_router._save_account_tokens( + account_id, + access_token=new_access_token, + refresh_token=new_refresh_token, + ) + except Exception as exc: + refresh_error = str(exc or "").strip()[:300] + + if not access_token: + detail = refresh_error or "缺少 access_token" + sora_router._mark_account_last_error(account_id, f"quota recheck auth failed: {detail}") + result["result"] = "auth_failed" + result["message"] = f"换取 access_token 失败:{detail}" + return result + + prompt = (body.prompt or "").strip() or "A calm abstract light gradient slowly drifting." + orientation = (body.orientation or "wide").strip().lower() + if orientation not in ("wide", "tall", "square"): + orientation = "wide" + payload = sora_phone.sora_build_simple_video_payload( + prompt, + n_variants=1, + n_frames=max(60, int(body.n_frames or 60)), + resolution=max(360, int(body.resolution or 360)), + orientation=orientation, + model=None, + seed=None, + ) + request_body = sora_router.SoraRequestBody( + access_token=access_token, + proxy_url=proxy_url, + method="POST", + path="/backend/video_gen", + payload=payload, + ) + request_data = { + "account": account, + "access_token": access_token, + "refresh_token": refresh_token, + "proxy_url": proxy_url, + } + + try: + response, response_payload, quota_reason = sora_router._do_sora_request( + request_body, + request_data, + inject_watermark_free=False, + ) + except Exception as exc: + detail = str(exc or "").strip()[:300] + sora_router._mark_account_last_error(account_id, f"quota recheck failed: {detail}") + result["result"] = "probe_failed" + result["message"] = f"探针请求异常:{detail}" + return result + + result["create_status_code"] = int(response.status_code or 0) + decorated = sora_router._decorate_video_task_result( + { + "ok": 200 <= response.status_code < 300, + "status_code": response.status_code, + "data": response_payload, + "used_account_id": account_id, + "used_email": email, + } + ) + task_id = (decorated.get("task_id") or "").strip() + result["task_id"] = task_id + busy_reason = sora_router._extract_busy_reason(response_payload, response.text or "") + + if quota_reason: + sora_router._mark_account_quota_exhausted(account_id, quota_reason) + result["result"] = "still_exhausted" + result["message"] = f"账号仍然额度不足:{quota_reason}" + return result + + if busy_reason or sora_router._is_too_many_concurrent_tasks_result(decorated): + sora_router._clear_account_quota_exhausted(account_id) + result["result"] = "recovered_busy" + result["message"] = "额度已恢复,但账号当前并发繁忙,已重新回池" + result["recovered_to_pool"] = True + return result + + if 200 <= response.status_code < 300 and task_id: + if body.auto_cancel: + cancel_request = sora_router.SoraRequestBody( + access_token=access_token, + proxy_url=proxy_url, + method="POST", + path=f"/backend/video_gen/{task_id}/cancel", + payload={}, + ) + try: + cancel_response, cancel_payload, _ = sora_router._do_sora_request( + cancel_request, + request_data, + inject_watermark_free=False, + ) + result["cancel_status_code"] = int(cancel_response.status_code or 0) + result["cancel_ok"] = 200 <= cancel_response.status_code < 300 + cancel_decorated = sora_router._decorate_video_task_result( + { + "ok": 200 <= cancel_response.status_code < 300, + "status_code": cancel_response.status_code, + "data": cancel_payload, + "used_account_id": account_id, + "used_email": email, + }, + task_id=task_id, + ) + sora_router._sync_video_task_result( + task_id, + account_id, + cancel_decorated, + default_active=not bool(result["cancel_ok"]), + ) + except Exception as exc: + detail = str(exc or "").strip()[:300] + sora_router._remember_video_task( + task_id, + account_id, + raw_status=decorated.get("status") or "", + normalized_status=decorated.get("normalized_status") or "", + is_active=True, + ) + result["message"] = f"探针创建成功,但自动取消失败:{detail}" + else: + sora_router._remember_video_task( + task_id, + account_id, + raw_status=decorated.get("status") or "", + normalized_status=decorated.get("normalized_status") or "", + is_active=True, + ) + + sora_router._clear_account_quota_exhausted(account_id) + result["result"] = "recovered" + base_message = "探针创建成功,额度已恢复并重新回池" + if result["message"]: + base_message += f";{result['message']}" + result["message"] = base_message + result["recovered_to_pool"] = True + if body.auto_cancel and not result["cancel_ok"] and result["cancel_status_code"]: + result["message"] += f";取消返回 HTTP {result['cancel_status_code']}" + return result + + detail = f"探针返回 HTTP {response.status_code}" + if refresh_error: + detail += f",RT->AT 报错:{refresh_error}" + sora_router._mark_account_last_error(account_id, detail) + result["result"] = "probe_failed" + result["message"] = detail + return result + + +@router.get("") +def list_accounts( + username: str = Depends(get_current_user), + status: str = Query(None), + has_sora: bool = Query(None), + has_plus: bool = Query(None), + phone_bound: bool = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), +): + init_db() + with get_db() as conn: + c = conn.cursor() + where = [] + params = [] + if status: + where.append("status = ?") + params.append(status) + if has_sora is not None: + where.append("has_sora = ?") + params.append(1 if has_sora else 0) + if has_plus is not None: + where.append("has_plus = ?") + params.append(1 if has_plus else 0) + if phone_bound is not None: + where.append("phone_bound = ?") + params.append(1 if phone_bound else 0) + where_sql = " AND ".join(where) if where else "1=1" + c.execute( + f"SELECT COUNT(*) FROM accounts WHERE {where_sql}", + params + ) + total = c.fetchone()[0] + offset = (page - 1) * page_size + c.execute( + f"""SELECT id, email, password, status, registered_at, + has_sora, has_plus, phone_bound, proxy, refresh_token, access_token, created_at, + COALESCE(sora_enabled, 1) AS sora_enabled, + COALESCE(sora_quota_exhausted, 0) AS sora_quota_exhausted, + COALESCE(sora_quota_note, '') AS sora_quota_note, + COALESCE(sora_quota_updated_at, '') AS sora_quota_updated_at, + COALESCE(sora_last_error, '') AS sora_last_error + FROM accounts WHERE {where_sql} + ORDER BY id DESC LIMIT ? OFFSET ?""", + params + [page_size, offset] + ) + rows = c.fetchall() + items = [] + for r in rows: + items.append({ + "id": r[0], + "email": r[1], + "password": r[2], + "status": r[3], + "registered_at": r[4], + "has_sora": bool(r[5]), + "has_plus": bool(r[6]), + "phone_bound": bool(r[7]), + "proxy": r[8], + "refresh_token": (r[9] or "")[:20] + "..." if r[9] else "", + "access_token": (r[10] or "")[:20] + "..." if r[10] else "", + "created_at": r[11], + "sora_enabled": bool(r[12]), + "sora_quota_exhausted": bool(r[13]), + "sora_quota_note": r[14] or "", + "sora_quota_updated_at": r[15] or "", + "sora_last_error": r[16] or "", + }) + return {"total": total, "page": page, "page_size": page_size, "items": items} + + +@router.post("/sora-quota/recheck") +def recheck_sora_quota( + body: AccountSoraQuotaRecheckBody, + username: str = Depends(get_current_user), +): + limit = max(1, min(int(body.limit or 10), 50)) + candidates = _load_quota_recheck_candidates(account_id=body.account_id, limit=limit) + if body.account_id is not None and not candidates: + raise HTTPException(status_code=404, detail="账号不存在") + + if not candidates: + return { + "ok": True, + "message": "当前没有被标记额度不足的账号", + "checked_count": 0, + "recovered_count": 0, + "still_exhausted_count": 0, + "failed_count": 0, + "busy_count": 0, + "items": [], + } + + items = [_probe_account_sora_quota(account, body) for account in candidates] + recovered_count = sum(1 for item in items if item.get("result") == "recovered") + busy_count = sum(1 for item in items if item.get("result") == "recovered_busy") + still_exhausted_count = sum(1 for item in items if item.get("result") == "still_exhausted") + failed_count = sum( + 1 + for item in items + if item.get("result") in ("probe_failed", "auth_failed", "skipped_no_token", "skipped_disabled", "skipped_no_sora") + ) + message = ( + f"已复检 {len(items)} 个账号,恢复 {recovered_count + busy_count} 个," + f"仍然额度不足 {still_exhausted_count} 个,失败/跳过 {failed_count} 个" + ) + return { + "ok": True, + "message": message, + "checked_count": len(items), + "recovered_count": recovered_count, + "busy_count": busy_count, + "still_exhausted_count": still_exhausted_count, + "failed_count": failed_count, + "items": items, + } + + +@router.get("/next-sora-available") +def next_sora_available_account(username: str = Depends(get_current_user)): + """ + 手动轮换:返回下一个可用 Sora 账号(仅可用且有 token 的账号)。 + """ + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT value FROM system_settings WHERE key = 'sora_manual_rotate_cursor'") + row = c.fetchone() + try: + cursor = int((row[0] if row else "0") or "0") + except Exception: + cursor = 0 + + c.execute( + """SELECT id, email, status, + COALESCE(sora_enabled, 1) AS sora_enabled, + COALESCE(sora_quota_exhausted, 0) AS sora_quota_exhausted, + COALESCE(sora_quota_note, '') AS sora_quota_note, + COALESCE(sora_quota_updated_at, '') AS sora_quota_updated_at + FROM accounts + WHERE has_sora = 1 + AND COALESCE(sora_enabled, 1) = 1 + AND COALESCE(sora_quota_exhausted, 0) = 0 + AND (COALESCE(refresh_token, '') != '' OR COALESCE(access_token, '') != '') + ORDER BY id ASC""" + ) + rows = c.fetchall() + if not rows: + raise HTTPException(status_code=404, detail="暂无可用 Sora 账号(请检查 token/额度/启停状态)") + + pick = None + for r in rows: + if int(r[0]) > cursor: + pick = r + break + if pick is None: + pick = rows[0] + + c.execute( + "INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)", + ("sora_manual_rotate_cursor", str(int(pick[0]))), + ) + + return { + "id": int(pick[0]), + "email": pick[1] or "", + "status": pick[2] or "", + "sora_enabled": bool(pick[3]), + "sora_quota_exhausted": bool(pick[4]), + "sora_quota_note": pick[5] or "", + "sora_quota_updated_at": pick[6] or "", + } + + +@router.get("/{account_id}") +def get_account_detail(account_id: int, username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """SELECT id, email, status, registered_at, + has_sora, has_plus, phone_bound, + COALESCE(refresh_token, '') AS refresh_token, + COALESCE(access_token, '') AS access_token, + COALESCE(sora_enabled, 1) AS sora_enabled, + COALESCE(sora_quota_exhausted, 0) AS sora_quota_exhausted, + COALESCE(sora_quota_note, '') AS sora_quota_note, + COALESCE(sora_quota_updated_at, '') AS sora_quota_updated_at, + COALESCE(sora_last_error, '') AS sora_last_error + FROM accounts + WHERE id = ?""", + (account_id,), + ) + row = c.fetchone() + if not row: + raise HTTPException(status_code=404, detail="账号不存在") + return { + "id": row[0], + "email": row[1] or "", + "status": row[2] or "", + "registered_at": row[3] or "", + "has_sora": bool(row[4]), + "has_plus": bool(row[5]), + "phone_bound": bool(row[6]), + "has_token": bool((row[7] or "").strip() or (row[8] or "").strip()), + "sora_enabled": bool(row[9]), + "sora_quota_exhausted": bool(row[10]), + "sora_quota_note": row[11] or "", + "sora_quota_updated_at": row[12] or "", + "sora_last_error": row[13] or "", + } + + +@router.post("/{account_id}/sora-state") +def update_account_sora_state( + account_id: int, + body: AccountSoraStateBody, + username: str = Depends(get_current_user), +): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT id FROM accounts WHERE id = ?", (account_id,)) + if not c.fetchone(): + raise HTTPException(status_code=404, detail="账号不存在") + + if body.sora_enabled is not None: + c.execute( + "UPDATE accounts SET sora_enabled = ? WHERE id = ?", + (1 if body.sora_enabled else 0, account_id), + ) + + if body.reset_quota: + c.execute( + """UPDATE accounts + SET sora_quota_exhausted = 0, + sora_quota_note = '', + sora_last_error = '', + sora_quota_updated_at = datetime('now') + WHERE id = ?""", + (account_id,), + ) + + c.execute( + """SELECT id, email, + COALESCE(sora_enabled, 1), + COALESCE(sora_quota_exhausted, 0), + COALESCE(sora_quota_note, ''), + COALESCE(sora_quota_updated_at, ''), + COALESCE(sora_last_error, '') + FROM accounts + WHERE id = ?""", + (account_id,), + ) + row = c.fetchone() + + return { + "id": row[0], + "email": row[1] or "", + "sora_enabled": bool(row[2]), + "sora_quota_exhausted": bool(row[3]), + "sora_quota_note": row[4] or "", + "sora_quota_updated_at": row[5] or "", + "sora_last_error": row[6] or "", + } + + +@router.get("/export") +def export_accounts( + username: str = Depends(get_current_user), + status: str = Query(None), + has_sora: bool = Query(None), + has_plus: bool = Query(None), +): + init_db() + with get_db() as conn: + c = conn.cursor() + where = [] + params = [] + if status: + where.append("status = ?") + params.append(status) + if has_sora is not None: + where.append("has_sora = ?") + params.append(1 if has_sora else 0) + if has_plus is not None: + where.append("has_plus = ?") + params.append(1 if has_plus else 0) + where_sql = " AND ".join(where) if where else "1=1" + c.execute( + f"""SELECT email, password, status, registered_at, has_sora, has_plus, phone_bound, proxy, refresh_token, access_token, + COALESCE(sora_enabled, 1), COALESCE(sora_quota_exhausted, 0), COALESCE(sora_quota_note, '') + FROM accounts WHERE {where_sql} ORDER BY id DESC""", + params + ) + rows = c.fetchall() + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["email", "password", "status", "registered_at", "has_sora", "has_plus", "phone_bound", "proxy", "refresh_token", "access_token", "sora_enabled", "sora_quota_exhausted", "sora_quota_note"]) + for r in rows: + writer.writerow([ + r[0], r[1], r[2], r[3], + "Y" if r[4] else "N", "Y" if r[5] else "N", "Y" if r[6] else "N", + r[7] or "", r[8] or "", r[9] or "", + "Y" if r[10] else "N", "Y" if r[11] else "N", r[12] or "" + ]) + buf.seek(0) + return StreamingResponse( + iter([buf.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=accounts.csv"} + ) + + +@router.get("/export-sora2") +def export_sora2_accounts( + username: str = Depends(get_current_user), + has_sora: bool = Query(True, description="是否只导出 has_sora=1 的账号"), + require_token: bool = Query(False, description="是否要求 refresh_token/access_token 至少有一个"), + only_available: bool = Query(False, description="是否仅导出未停用且未标记额度不足账号"), + format: str = Query("txt", description="导出格式: txt 或 csv"), + separator: str = Query("----", description="txt 模式下字段分隔符"), +): + """ + 导出 Sora2 可用账号: + - txt: email----password----refresh_token----access_token(无表头) + - csv: 带表头 + """ + fmt = (format or "txt").strip().lower() + if fmt not in ("txt", "csv"): + fmt = "txt" + sep = separator if isinstance(separator, str) and separator else "----" + + init_db() + with get_db() as conn: + c = conn.cursor() + where = [] + params = [] + if has_sora is not None: + where.append("has_sora = ?") + params.append(1 if has_sora else 0) + if require_token: + where.append("(COALESCE(refresh_token, '') != '' OR COALESCE(access_token, '') != '')") + if only_available: + where.append("COALESCE(sora_enabled, 1) = 1") + where.append("COALESCE(sora_quota_exhausted, 0) = 0") + where_sql = " AND ".join(where) if where else "1=1" + c.execute( + f"""SELECT email, password, refresh_token, access_token, status, registered_at + FROM accounts + WHERE {where_sql} + ORDER BY id DESC""", + params + ) + rows = c.fetchall() + + now = datetime.now().strftime("%Y%m%d-%H%M%S") + if fmt == "csv": + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["email", "password", "refresh_token", "access_token", "status", "registered_at"]) + for r in rows: + writer.writerow([r[0] or "", r[1] or "", r[2] or "", r[3] or "", r[4] or "", r[5] or ""]) + buf.seek(0) + return StreamingResponse( + iter([buf.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=sora2-accounts-{now}.csv"} + ) + + lines = [] + for r in rows: + lines.append(sep.join([ + (r[0] or "").strip(), + (r[1] or "").strip(), + (r[2] or "").strip(), + (r[3] or "").strip(), + ])) + body = "\n".join(lines) + return StreamingResponse( + iter([body]), + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename=sora2-accounts-{now}.txt"} + ) diff --git a/Register_GPT_v0/web/backend/app/routers/auth.py b/Register_GPT_v0/web/backend/app/routers/auth.py new file mode 100644 index 0000000..a6ff0d0 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/auth.py @@ -0,0 +1,78 @@ +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel +from jose import JWTError, jwt +from app.config import settings +from app.database import get_db, init_db +from app.security import get_password_hash, verify_password + +router = APIRouter(prefix="/api/auth", tags=["auth"]) +security = HTTPBearer(auto_error=False) + + +def _check_admin(username: str, password: str) -> bool: + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT password_hash FROM admin_users WHERE username = ?", (username,)) + row = c.fetchone() + if row and row[0]: + return verify_password(password, row[0]) + # 无 DB 记录时:先认默认 admin/admin123,再认配置 + if username == "admin" and password == "admin123": + return True + return username == settings.admin_username and password == settings.admin_password + + +def create_token(username: str) -> str: + expire = datetime.utcnow() + timedelta(hours=24) + payload = {"sub": username, "exp": expire} + return jwt.encode(payload, settings.secret_key, algorithm="HS256") + + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)): + if not credentials or not credentials.credentials: + raise HTTPException(status_code=401, detail="Not authenticated") + try: + payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=["HS256"]) + username = payload.get("sub") + if not username: + raise HTTPException(status_code=401, detail="Invalid token") + return username + except JWTError: + raise HTTPException(status_code=401, detail="Invalid token") + + +def get_optional_user(credentials: HTTPAuthorizationCredentials = Depends(security)): + """不抛错,无凭证或无效时返回 None。用于 debug 等可选鉴权接口。""" + if not credentials or not credentials.credentials: + return None + try: + payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=["HS256"]) + return payload.get("sub") or None + except JWTError: + return None + + +class LoginIn(BaseModel): + username: str + password: str + + +class LoginOut(BaseModel): + token: str + username: str + + +@router.post("/login", response_model=LoginOut) +def login(data: LoginIn): + init_db() + if not _check_admin(data.username, data.password): + raise HTTPException(status_code=401, detail="Wrong username or password") + token = create_token(data.username) + return LoginOut(token=token, username=data.username) + + +@router.get("/me") +def me(username: str = Depends(get_current_user)): + return {"username": username} diff --git a/Register_GPT_v0/web/backend/app/routers/bank_cards.py b/Register_GPT_v0/web/backend/app/routers/bank_cards.py new file mode 100644 index 0000000..3d2b845 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/bank_cards.py @@ -0,0 +1,99 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from app.routers.auth import get_current_user +from app.database import get_db, init_db + +router = APIRouter(prefix="/api/bank-cards", tags=["bank_cards"]) + + +class BankCardCreate(BaseModel): + card_number_masked: str = "" + card_data: str = "" + max_use_count: int = 1 + remark: str = "" + + +class BatchImportBody(BaseModel): + lines: str = "" # 每行一条卡信息(可仅后四位或掩码) + + +@router.get("") +def list_cards(username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + "SELECT id, card_number_masked, card_data, max_use_count, used_count, remark, created_at FROM bank_cards ORDER BY id DESC" + ) + rows = c.fetchall() + return { + "items": [ + { + "id": r[0], "card_number_masked": r[1], "card_data": r[2], + "max_use_count": r[3], "used_count": r[4], "remark": r[5], "created_at": r[6] + } + for r in rows + ] + } + + +@router.post("") +def create_card(body: BankCardCreate, username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT INTO bank_cards (card_number_masked, card_data, max_use_count, remark) VALUES (?, ?, ?, ?)", + (body.card_number_masked, body.card_data, body.max_use_count, body.remark) + ) + lid = c.lastrowid + return {"ok": True, "id": lid} + + +@router.delete("/{id}") +def delete_card(id: int, username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("DELETE FROM bank_cards WHERE id = ?", (id,)) + if c.rowcount == 0: + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +@router.post("/batch-import") +def batch_import(body: BatchImportBody, username: str = Depends(get_current_user)): + """每行一条卡(如后四位或掩码),max_use_count 从系统设置读取""" + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT value FROM system_settings WHERE key = ?", ("card_use_limit",)) + row = c.fetchone() + limit = int(row[0]) if row and row[0] else 1 + added = 0 + with get_db() as conn: + c = conn.cursor() + for line in body.lines.strip().split("\n"): + line = line.strip() + if not line or line.startswith("#"): + continue + c.execute( + "INSERT INTO bank_cards (card_number_masked, card_data, max_use_count, remark) VALUES (?, ?, ?, ?)", + (line[:20], line, limit, "") + ) + added += 1 + return {"ok": True, "added": added} + + +class BatchDeleteBody(BaseModel): + ids: list[int] = [] + + +@router.post("/batch-delete") +def batch_delete(body: BatchDeleteBody, username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + for id in body.ids: + c.execute("DELETE FROM bank_cards WHERE id = ?", (id,)) + return {"ok": True} diff --git a/Register_GPT_v0/web/backend/app/routers/dashboard.py b/Register_GPT_v0/web/backend/app/routers/dashboard.py new file mode 100644 index 0000000..2696ba4 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/dashboard.py @@ -0,0 +1,71 @@ +"""批量注册页仪表盘:统计与 API 配置状态""" +from fastapi import APIRouter, Depends +from app.routers.auth import get_current_user +from app.database import get_db, init_db + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + + +@router.get("") +def get_dashboard(username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT COUNT(*) FROM accounts") + total_registered = c.fetchone()[0] + c.execute( + "SELECT COUNT(*) FROM accounts WHERE date(created_at) = date('now')" + ) + today_registered = c.fetchone()[0] + c.execute("SELECT COUNT(*) FROM accounts WHERE phone_bound = 1") + phone_bound_count = c.fetchone()[0] + c.execute("SELECT COUNT(*) FROM accounts WHERE has_plus = 1") + plus_count = c.fetchone()[0] + c.execute( + """SELECT COUNT(*) FROM accounts + WHERE has_sora = 1 + AND COALESCE(sora_enabled, 1) = 1 + AND COALESCE(sora_quota_exhausted, 0) = 0 + AND (COALESCE(refresh_token, '') != '' OR COALESCE(access_token, '') != '')""" + ) + sora_available_count = c.fetchone()[0] + c.execute( + """SELECT COUNT(*) FROM sora_video_tasks + WHERE task_id NOT LIKE 'lease_%' + AND normalized_status = 'succeeded' + AND date(COALESCE(succeeded_at, updated_at), 'localtime') = date('now', 'localtime')""" + ) + today_generated_videos = c.fetchone()[0] + c.execute( + "SELECT key, value FROM system_settings WHERE key IN ('email_api_key', 'sms_api_key', 'bank_card_api_key', 'captcha_api_key', 'thread_count', 'last_run_success', 'last_run_fail')" + ) + settings = dict(c.fetchall()) + email_api_set = bool(settings.get("email_api_key") and str(settings.get("email_api_key", "")).strip()) + sms_api_set = bool(settings.get("sms_api_key") and str(settings.get("sms_api_key", "")).strip()) + bank_api_set = bool(settings.get("bank_card_api_key") and str(settings.get("bank_card_api_key", "")).strip()) + captcha_api_set = bool(settings.get("captcha_api_key") and str(settings.get("captcha_api_key", "")).strip()) + thread_count = settings.get("thread_count") or "1" + try: + success_count = int(settings.get("last_run_success") or 0) + except (TypeError, ValueError): + success_count = 0 + try: + fail_count = int(settings.get("last_run_fail") or 0) + except (TypeError, ValueError): + fail_count = 0 + return { + "today_registered": today_registered, + "total_registered": total_registered, + "phone_bound_count": phone_bound_count, + "plus_count": plus_count, + "sora_available_count": sora_available_count, + "today_generatable_videos": sora_available_count, + "today_generated_videos": today_generated_videos, + "email_api_set": email_api_set, + "sms_api_set": sms_api_set, + "bank_api_set": bank_api_set, + "captcha_api_set": captcha_api_set, + "thread_count": thread_count, + "success_count": success_count, + "fail_count": fail_count, + } diff --git a/Register_GPT_v0/web/backend/app/routers/email_api.py b/Register_GPT_v0/web/backend/app/routers/email_api.py new file mode 100644 index 0000000..b6c8417 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/email_api.py @@ -0,0 +1,135 @@ +""" +邮箱 API(Hotmail007)对接:余额、库存、拉取并可选导入到邮箱表 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from app.routers.auth import get_current_user +from app.database import get_db, init_db +from app.services.hotmail007 import get_balance, get_stock, get_mail, get_first_mail, MAIL_TYPES + +router = APIRouter(prefix="/api/email-api", tags=["email-api"]) + + +def _get_email_api_settings(): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT key, value FROM system_settings WHERE key IN ('email_api_url', 'email_api_key', 'email_api_default_type')") + rows = c.fetchall() + out = {} + for k, v in rows: + out[k] = (v or "").strip() + base = out.get("email_api_url") or "https://gapi.hotmail007.com" + key = out.get("email_api_key") + default_type = out.get("email_api_default_type") or "outlook" + if default_type not in MAIL_TYPES: + default_type = "outlook" + return base, key, default_type + + +@router.get("/balance") +def api_balance(username: str = Depends(get_current_user)): + """查询 Hotmail007 余额""" + base, key, _ = _get_email_api_settings() + if not key: + raise HTTPException(status_code=400, detail="请先在系统设置中配置邮箱 API KEY (clientKey)") + balance = get_balance(base, key) + if balance is None: + raise HTTPException(status_code=502, detail="请求余额失败,请检查 API 地址与 KEY") + return {"balance": balance} + + +@router.get("/stock") +def api_stock( + mail_type: str = Query(None, description="outlook / hotmail / hotmail Trusted / outlook Trusted"), + username: str = Depends(get_current_user), +): + """查询邮箱库存(不要求 KEY)""" + base, _, _ = _get_email_api_settings() + stock = get_stock(base, mail_type if mail_type in MAIL_TYPES else None) + if stock is None: + raise HTTPException(status_code=502, detail="请求库存失败") + return {"stock": stock, "mail_type": mail_type or "全部"} + + +class FetchMailBody(BaseModel): + mail_type: str = "outlook" + quantity: int = 1 + import_to_emails: bool = True + + +@router.post("/fetch-mail") +def api_fetch_mail(body: FetchMailBody, username: str = Depends(get_current_user)): + """从 Hotmail007 拉取邮箱,可选导入到邮箱管理表""" + base, key, default_type = _get_email_api_settings() + if not key: + raise HTTPException(status_code=400, detail="请先在系统设置中配置邮箱 API KEY (clientKey)") + mail_type = body.mail_type if body.mail_type in MAIL_TYPES else default_type + quantity = max(1, min(body.quantity, 100)) + items = get_mail(base, key, quantity, mail_type) + if not items: + return {"count": 0, "imported": 0, "message": "未拉取到数据或请求失败"} + imported = 0 + if body.import_to_emails: + with get_db() as conn: + c = conn.cursor() + for row in items: + c.execute( + "INSERT INTO emails (email, password, uuid, token, remark) VALUES (?, ?, ?, ?, ?)", + ( + row["email"], + row["password"], + row.get("client_id") or "", + row.get("refresh_token") or "", + "Hotmail007", + ), + ) + imported += 1 + return {"count": len(items), "imported": imported, "items": items} + + +@router.get("/first-mail") +def api_first_mail( + email_id: int = Query(..., description="邮箱表主键 id"), + folder: str = Query("inbox", description="inbox / junkemail"), + username: str = Depends(get_current_user), +): + """通过 Hotmail007 API 获取该邮箱最新一封邮件(收件箱)""" + base, key, _ = _get_email_api_settings() + if not key: + raise HTTPException(status_code=400, detail="请先在系统设置中配置邮箱 API KEY (clientKey)") + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT email, password, token, uuid FROM emails WHERE id = ?", (email_id,)) + row = c.fetchone() + if not row: + raise HTTPException(status_code=404, detail="邮箱不存在") + email, password, token, uuid = row + account = f"{email}:{password or ''}:{token or ''}:{uuid or ''}" + data = get_first_mail(base, key, account, folder=folder) + if data is None: + raise HTTPException(status_code=502, detail="未收到邮件或 API 请求失败") + return {"mail": data} + + +@router.get("/mail-list") +def api_mail_list( + email_id: int = Query(..., description="邮箱表主键 id"), + folder: str = Query("inbox", description="inbox / junkemail"), + username: str = Depends(get_current_user), +): + """获取该邮箱收件箱邮件列表(当前 Hotmail007 仅支持最新一封,返回 list 长度 0 或 1)""" + base, key, _ = _get_email_api_settings() + if not key: + raise HTTPException(status_code=400, detail="请先在系统设置中配置邮箱 API KEY (clientKey)") + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT email, password, token, uuid FROM emails WHERE id = ?", (email_id,)) + row = c.fetchone() + if not row: + raise HTTPException(status_code=404, detail="邮箱不存在") + email, password, token, uuid = row + account = f"{email}:{password or ''}:{token or ''}:{uuid or ''}" + data = get_first_mail(base, key, account, folder=folder) + list_ = [data] if (data and isinstance(data, dict) and len(data) > 0) else [] + return {"list": list_} diff --git a/Register_GPT_v0/web/backend/app/routers/emails.py b/Register_GPT_v0/web/backend/app/routers/emails.py new file mode 100644 index 0000000..9f41177 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/emails.py @@ -0,0 +1,122 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from app.routers.auth import get_current_user +from app.database import get_db, init_db + +router = APIRouter(prefix="/api/emails", tags=["emails"]) + + +class EmailCreate(BaseModel): + email: str + password: str = "" + uuid: str = "" + token: str = "" + remark: str = "" + + +@router.get("") +def list_emails(username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT id, email, password, uuid, token, remark, created_at FROM emails ORDER BY id DESC") + rows = c.fetchall() + c.execute("SELECT email FROM accounts") + registered_emails = {row[0].strip().lower() for row in c.fetchall() if row[0]} + return { + "items": [ + { + "id": r[0], + "email": r[1], + "password": r[2], + "uuid": r[3], + "token": r[4], + "remark": r[5], + "created_at": r[6], + "registered": (r[1] or "").strip().lower() in registered_emails, + } + for r in rows + ] + } + + +@router.post("") +def create_email(body: EmailCreate, username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT INTO emails (email, password, uuid, token, remark) VALUES (?, ?, ?, ?, ?)", + (body.email, body.password, body.uuid, body.token, body.remark) + ) + return {"ok": True, "id": c.lastrowid} + + +@router.get("/export") +def export_emails(username: str = Depends(get_current_user)): + """返回全部邮箱(含密码),用于批量导出,格式与批量导入一致""" + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT email, password, uuid, token, remark FROM emails ORDER BY id DESC") + rows = c.fetchall() + return { + "items": [ + {"email": r[0], "password": r[1] or "", "uuid": r[2] or "", "token": r[3] or "", "remark": r[4] or ""} + for r in rows + ] + } + + +@router.get("/{id}") +def get_email(id: int, username: str = Depends(get_current_user)): + """获取单条邮箱详情(含密码),用于查看邮箱/登录""" + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT id, email, password, uuid, token, remark FROM emails WHERE id = ?", (id,)) + row = c.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Not found") + return {"id": row[0], "email": row[1], "password": row[2] or "", "uuid": row[3] or "", "token": row[4] or "", "remark": row[5] or ""} + + +@router.delete("/{id}") +def delete_email(id: int, username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("DELETE FROM emails WHERE id = ?", (id,)) + if c.rowcount == 0: + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +class BatchImportBody(BaseModel): + lines: str = "" + + +@router.post("/batch-import") +def batch_import(body: BatchImportBody, username: str = Depends(get_current_user)): + """Body: {"lines": "邮箱----密码----uuid----token 多行"}""" + init_db() + added = 0 + with get_db() as conn: + c = conn.cursor() + for line in body.lines.strip().split("\n"): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = [p.strip() for p in line.split("----")] + email = parts[0] if parts else "" + if not email: + continue + password = parts[1] if len(parts) > 1 else "" + uuid = parts[2] if len(parts) > 2 else "" + token = parts[3] if len(parts) > 3 else "" + c.execute( + "INSERT INTO emails (email, password, uuid, token, remark) VALUES (?, ?, ?, ?, ?)", + (email, password, uuid, token, "") + ) + added += 1 + return {"ok": True, "added": added} diff --git a/Register_GPT_v0/web/backend/app/routers/logs.py b/Register_GPT_v0/web/backend/app/routers/logs.py new file mode 100644 index 0000000..c7fafda --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/logs.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, Query +from app.routers.auth import get_current_user +from app.database import get_db, init_db + +router = APIRouter(prefix="/api/logs", tags=["logs"]) + + +@router.get("") +def list_logs( + username: str = Depends(get_current_user), + task_id: str = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(100, ge=1, le=500), +): + init_db() + with get_db() as conn: + c = conn.cursor() + if task_id: + c.execute( + "SELECT COUNT(*) FROM run_logs WHERE task_id = ?", (task_id,) + ) + else: + c.execute("SELECT COUNT(*) FROM run_logs") + total = c.fetchone()[0] + offset = (page - 1) * page_size + if task_id: + c.execute( + "SELECT id, task_id, level, message, created_at FROM run_logs WHERE task_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", + (task_id, page_size, offset) + ) + else: + c.execute( + "SELECT id, task_id, level, message, created_at FROM run_logs ORDER BY id DESC LIMIT ? OFFSET ?", + (page_size, offset) + ) + rows = c.fetchall() + items = [ + {"id": r[0], "task_id": r[1], "level": r[2], "message": r[3], "created_at": r[4]} + for r in rows + ] + return {"total": total, "page": page, "page_size": page_size, "items": items} + + +@router.delete("") +def clear_logs(username: str = Depends(get_current_user)): + """清空 run_logs 表所有记录。""" + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("DELETE FROM run_logs") + return {"ok": True, "message": "已清空日志"} diff --git a/Register_GPT_v0/web/backend/app/routers/phone_bind.py b/Register_GPT_v0/web/backend/app/routers/phone_bind.py new file mode 100644 index 0000000..c0c1bc5 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/phone_bind.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +""" +开始绑定手机:POST /api/phone-bind/start 启动任务,GET /api/phone-bind/status 查状态,POST /api/phone-bind/stop 停止。 +""" +import threading +from datetime import datetime + +from fastapi import APIRouter, Depends + +from app.routers.auth import get_current_user +from app.services.phone_bind_runner import ( + set_phone_bind_stop, + set_phone_bind_task_started, + get_phone_bind_status, + run_phone_bind_loop, + _log, +) + +router = APIRouter(prefix="/api/phone-bind", tags=["phone-bind"]) + + +def _run_bind_task(task_id: str, max_count: int = None): + try: + run_phone_bind_loop(task_id, max_count=max_count) + except Exception as e: + _log(task_id, "error", f"绑定任务异常: {e}") + + +@router.post("/start") +def start_phone_bind( + max_count: int = None, + username: str = Depends(get_current_user), +): + """启动绑定手机任务:从账号管理取未绑账号,从手机号管理取可用号码,逐个绑定。max_count 可选,不传则处理到无数据为止。""" + task_id = f"phone_bind_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}" + if not set_phone_bind_task_started(task_id): + st = get_phone_bind_status() + return {"ok": False, "message": "绑定任务已在运行", "task_id": st.get("task_id")} + t = threading.Thread(target=_run_bind_task, args=(task_id,), kwargs={"max_count": max_count}, daemon=True) + t.start() + return {"ok": True, "message": "绑定任务已启动", "task_id": task_id} + + +@router.get("/status") +def phone_bind_status(username: str = Depends(get_current_user)): + """查询绑定任务状态。""" + return get_phone_bind_status() + + +@router.post("/stop") +def stop_phone_bind(username: str = Depends(get_current_user)): + """请求停止绑定任务。""" + set_phone_bind_stop(True) + return {"ok": True, "message": "已请求停止,当前条完成后退出"} diff --git a/Register_GPT_v0/web/backend/app/routers/phones.py b/Register_GPT_v0/web/backend/app/routers/phones.py new file mode 100644 index 0000000..3b37fcb --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/phones.py @@ -0,0 +1,175 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from app.routers.auth import get_current_user +from app.database import get_db, init_db + +router = APIRouter(prefix="/api/phones", tags=["phones"]) + + +class PhoneCreate(BaseModel): + phone: str = "" + max_use_count: int = 1 + remark: str = "" + + +class BatchImportBody(BaseModel): + lines: str = "" + + +class BatchDeleteBody(BaseModel): + ids: list[int] = [] + + +def _get_sms_settings(): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT key, value FROM system_settings WHERE key IN ('sms_api_url', 'sms_api_key')") + rows = c.fetchall() + out = {} + for k, v in rows: + out[k] = (v or "").strip() + base = out.get("sms_api_url") or "https://hero-sms.com/stubs/handler_api.php" + key = out.get("sms_api_key") + return base, key + + +@router.get("") +def list_phones(username: str = Depends(get_current_user)): + from app.services import hero_sms + init_db() + base, key = _get_sms_settings() + with get_db() as conn: + c = conn.cursor() + c.execute( + "SELECT id, activation_id FROM phone_numbers WHERE expired_at IS NOT NULL AND expired_at < datetime('now')" + ) + expired_rows = c.fetchall() + for row in expired_rows: + aid = row[1] + if aid is not None and key: + try: + hero_sms.set_status(base, key, aid, 8) + except Exception: + pass + c.execute( + "DELETE FROM phone_numbers WHERE expired_at IS NOT NULL AND expired_at < datetime('now')" + ) + c.execute( + "SELECT id, phone, activation_id, max_use_count, used_count, remark, expired_at, created_at FROM phone_numbers ORDER BY id DESC" + ) + rows = c.fetchall() + return { + "items": [ + { + "id": r[0], "phone": r[1], "activation_id": r[2], "max_use_count": r[3], + "used_count": r[4], "remark": r[5], "expired_at": r[6], "created_at": r[7] + } + for r in rows + ] + } + + +@router.post("") +def create_phone(body: PhoneCreate, username: str = Depends(get_current_user)): + init_db() + if not (body.phone or "").strip(): + raise HTTPException(status_code=400, detail="手机号不能为空") + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT INTO phone_numbers (phone, max_use_count, remark) VALUES (?, ?, ?)", + (body.phone.strip(), body.max_use_count, body.remark) + ) + lid = c.lastrowid + return {"ok": True, "id": lid} + + +@router.delete("/{id}") +def delete_phone(id: int, username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("DELETE FROM phone_numbers WHERE id = ?", (id,)) + if c.rowcount == 0: + raise HTTPException(status_code=404, detail="Not found") + return {"ok": True} + + +@router.get("/{id}/sms-code") +def get_phone_sms_code(id: int, username: str = Depends(get_current_user)): + """查询该号码的短信验证码(接码平台 getStatusV2)""" + from app.services import hero_sms + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT activation_id FROM phone_numbers WHERE id = ?", (id,)) + row = c.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Not found") + activation_id = row[0] + if activation_id is None: + raise HTTPException(status_code=400, detail="该号码无 activation_id,无法查码") + base, key = _get_sms_settings() + if not key: + raise HTTPException(status_code=400, detail="请先配置手机号接码 API KEY") + out = hero_sms.get_status_v2(base, key, activation_id) + if not out: + return {"status": "error", "code": None, "message": "请求失败"} + return {"status": out.get("status", "wait"), "code": out.get("code"), "message": "已收到验证码" if out.get("code") else "等待短信中"} + + +@router.post("/{id}/release") +def release_phone(id: int, username: str = Depends(get_current_user)): + """销毁:通知接码平台取消该号码(setStatus=8)并从列表删除""" + from app.services import hero_sms + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT activation_id FROM phone_numbers WHERE id = ?", (id,)) + row = c.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Not found") + activation_id = row[0] + base, key = _get_sms_settings() + if activation_id is not None and key: + hero_sms.set_status(base, key, activation_id, 8) + with get_db() as conn: + c = conn.cursor() + c.execute("DELETE FROM phone_numbers WHERE id = ?", (id,)) + return {"ok": True} + + +@router.post("/batch-import") +def batch_import(body: BatchImportBody, username: str = Depends(get_current_user)): + """每行一个手机号,max_use_count 从系统设置 phone_bind_limit 读取""" + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT value FROM system_settings WHERE key = ?", ("phone_bind_limit",)) + row = c.fetchone() + limit = int(row[0]) if row and row[0] else 1 + added = 0 + with get_db() as conn: + c = conn.cursor() + for line in body.lines.strip().split("\n"): + line = line.strip() + if not line or line.startswith("#"): + continue + c.execute( + "INSERT INTO phone_numbers (phone, max_use_count, remark) VALUES (?, ?, ?)", + (line, limit, "") + ) + added += 1 + return {"ok": True, "added": added} + + +@router.post("/batch-delete") +def batch_delete(body: BatchDeleteBody, username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + for id in body.ids: + c.execute("DELETE FROM phone_numbers WHERE id = ?", (id,)) + return {"ok": True} + diff --git a/Register_GPT_v0/web/backend/app/routers/register.py b/Register_GPT_v0/web/backend/app/routers/register.py new file mode 100644 index 0000000..a448e51 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/register.py @@ -0,0 +1,193 @@ +""" +开启注册:POST /api/register/start 启动调度,GET /api/register/status 查询状态与心跳。 +""" +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends + +from app.registration_state import set_stop_requested, is_stop_requested +from app.routers.auth import get_current_user +from app.database import get_db, init_db +from app.services.registration_runner import ( + _get_registration_settings, + fetch_unregistered_emails, + run_one_task, +) + + +def _log_run(task_id: str, level: str, message: str): + try: + with get_db() as conn: + c = conn.cursor() + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, level, message, created), + ) + except Exception: + pass + +router = APIRouter(prefix="/api/register", tags=["register"]) + +_registration_running = False +_registration_heartbeat: str | None = None +_registration_lock = threading.Lock() + + +def _run_registration_loop(): + """后台线程:按 thread_count 并发取未注册邮箱并执行,写心跳到 system_settings。""" + global _registration_running, _registration_heartbeat + task_id = f"register_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}" + settings = _get_registration_settings() + thread_count = max(1, min(32, int(settings.get("thread_count") or "1"))) + init_db() + + def _update_heartbeat(): + global _registration_heartbeat + with _registration_lock: + _registration_heartbeat = datetime.utcnow().isoformat() + "Z" + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT OR REPLACE INTO system_settings (key, value) VALUES ('last_registration_heartbeat', ?)", + (_registration_heartbeat,), + ) + + try: + _log_run(task_id, "info", "注册任务已启动") + failed_this_run = set() # 本 run 内已失败过的邮箱,不再重复拉取,避免无限重试同一条 + while True: + if is_stop_requested(): + _log_run(task_id, "info", "已请求停止,立即结束") + break + batch = fetch_unregistered_emails(limit=thread_count) + batch = [row for row in batch if (row[1] or "").strip().lower() not in failed_this_run] + if not batch: + _log_run(task_id, "info", "注册任务结束,无更多未注册邮箱") + break + _update_heartbeat() + _log_run(task_id, "info", f"本批开始注册 共 {len(batch)} 条") + ex = ThreadPoolExecutor(max_workers=min(len(batch), thread_count)) + futures = { + ex.submit(run_one_task, task_id, settings, email_row=row): row + for row in batch + } + stopped_early = False + done_iterator = as_completed(futures, timeout=1.0) + num_futures = len(futures) + num_done = 0 + try: + while num_done < num_futures: + try: + fut = next(done_iterator) + except FuturesTimeoutError: + if is_stop_requested(): + _log_run(task_id, "info", "已请求停止,立即结束当前批次") + stopped_early = True + break + _update_heartbeat() + continue + except StopIteration: + break + if is_stop_requested(): + _log_run(task_id, "info", "已请求停止,立即结束当前批次") + stopped_early = True + break + num_done += 1 + try: + result = fut.result() + ok = result[0] if isinstance(result, (tuple, list)) and len(result) > 0 else result + if ok is False: + row = futures.get(fut) + if row and len(row) > 1: + failed_this_run.add((row[1] or "").strip().lower()) + except Exception: + pass + _update_heartbeat() + finally: + ex.shutdown(wait=not stopped_early) + if stopped_early: + break + finally: + try: + _log_run(task_id, "info", "注册调度已退出") + except Exception: + pass + with _registration_lock: + _registration_running = False + set_stop_requested(False) + + +@router.post("/start") +def start_registration(username: str = Depends(get_current_user)): + """启动一次注册任务(后台调度直到无未注册邮箱)。若已在运行则返回 409。""" + global _registration_running + with _registration_lock: + if _registration_running: + return {"ok": False, "message": "注册任务已在运行中"} + _registration_running = True + set_stop_requested(False) + t = threading.Thread(target=_run_registration_loop, daemon=True) + t.start() + return {"ok": True, "message": "已启动注册任务"} + + +@router.post("/stop") +def stop_registration(username: str = Depends(get_current_user)): + """请求停止注册任务(调度与进行中的任务都会尽快退出)。""" + global _registration_running + with _registration_lock: + if not _registration_running: + return {"ok": False, "message": "当前无运行中的注册任务"} + _registration_running = False + set_stop_requested(True) + return {"ok": True, "message": "已请求停止,正在立即结束"} + + +def _parse_heartbeat_time(s: str | None): + """解析 ISO 心跳时间,失败返回 None。""" + if not s or not isinstance(s, str): + return None + s = s.strip().replace("Z", "+00:00") + try: + return datetime.fromisoformat(s) + except Exception: + return None + + +# 心跳超过此分钟数仍视为任务已死,返回 running: false +_STATUS_HEARTBEAT_DEAD_MINUTES = 5 + + +@router.get("/status") +def get_registration_status(username: str = Depends(get_current_user)): + """返回是否运行中、最近心跳时间、last_run_success/fail。running 来自本进程的 _registration_running;若心跳超过 5 分钟则强制视为已停止。""" + with _registration_lock: + running = _registration_running + heartbeat = _registration_heartbeat + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + "SELECT key, value FROM system_settings WHERE key IN ('last_run_success', 'last_run_fail', 'last_registration_heartbeat')" + ) + rows = c.fetchall() + kv = {r[0]: r[1] for r in rows} + last_heartbeat = heartbeat or kv.get("last_registration_heartbeat") + # 若认为在运行但心跳超时,视为已停止(避免重启/异常后一直显示正在注册) + if running and last_heartbeat: + ht = _parse_heartbeat_time(last_heartbeat) + if ht: + now = datetime.now(timezone.utc) + if ht.tzinfo is None: + ht = ht.replace(tzinfo=timezone.utc) + if (now - ht).total_seconds() > _STATUS_HEARTBEAT_DEAD_MINUTES * 60: + running = False + return { + "running": running, + "last_heartbeat": last_heartbeat, + "last_run_success": int(kv.get("last_run_success") or 0), + "last_run_fail": int(kv.get("last_run_fail") or 0), + } diff --git a/Register_GPT_v0/web/backend/app/routers/settings.py b/Register_GPT_v0/web/backend/app/routers/settings.py new file mode 100644 index 0000000..baeac66 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/settings.py @@ -0,0 +1,114 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from app.routers.auth import get_current_user +from app.database import get_db, init_db +from app.security import get_password_hash + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + + +def _clamp_retry(value: str) -> str: + try: + n = int((value or "").strip()) + return str(max(1, min(5, n))) + except (ValueError, TypeError): + return "2" + + +class LoginUpdateBody(BaseModel): + admin_username: str = "" + admin_password: str = "" + + +class SettingsBody(BaseModel): + sms_api_url: str = "" + sms_api_key: str = "" + sms_openai_service: str = "openai" + sms_max_price: str = "0.55" + thread_count: str = "1" + retry_count: str = "2" + proxy_url: str = "" + proxy_api_url: str = "" + bank_card_api_url: str = "" + bank_card_api_key: str = "" + bank_card_api_platform: str = "寻汇" + email_api_url: str = "" + email_api_key: str = "" + email_api_default_type: str = "outlook" + captcha_api_url: str = "" + captcha_api_key: str = "" + card_use_limit: str = "1" + phone_bind_limit: str = "1" + oauth_client_id: str = "" + oauth_redirect_uri: str = "" + admin_username: str = "" + admin_password: str = "" + + +@router.get("") +def get_settings(username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT key, value FROM system_settings") + rows = c.fetchall() + out = {} + for k, v in rows: + out[k] = v or "" + return out + + +@router.put("") +def update_settings(body: SettingsBody, username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + for key, value in [ + ("sms_api_url", body.sms_api_url), + ("sms_api_key", body.sms_api_key), + ("sms_openai_service", (body.sms_openai_service or "dr").strip()), + ("sms_max_price", (body.sms_max_price or "0.55").strip()), + ("thread_count", body.thread_count), + ("retry_count", _clamp_retry(body.retry_count)), + ("proxy_url", body.proxy_url), + ("proxy_api_url", body.proxy_api_url), + ("bank_card_api_url", body.bank_card_api_url), + ("bank_card_api_key", body.bank_card_api_key), + ("bank_card_api_platform", (body.bank_card_api_platform or "寻汇").strip()), + ("email_api_url", body.email_api_url), + ("email_api_key", body.email_api_key), + ("email_api_default_type", (body.email_api_default_type or "outlook").strip()), + ("captcha_api_url", body.captcha_api_url), + ("captcha_api_key", body.captcha_api_key), + ("card_use_limit", body.card_use_limit), + ("phone_bind_limit", body.phone_bind_limit), + ("oauth_client_id", (body.oauth_client_id or "").strip()), + ("oauth_redirect_uri", (body.oauth_redirect_uri or "").strip()), + ]: + c.execute( + "INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)", + (key, value or "") + ) + if body.admin_username and body.admin_password: + hash_val = get_password_hash(body.admin_password) + c.execute( + "INSERT INTO admin_users (username, password_hash, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(username) DO UPDATE SET password_hash=?, updated_at=datetime('now')", + (body.admin_username, hash_val, hash_val) + ) + return {"ok": True} + + +@router.put("/login") +def update_login(body: LoginUpdateBody, username: str = Depends(get_current_user)): + """仅修改登录账号与密码,与系统设置分离""" + if not body.admin_username or not body.admin_password: + raise HTTPException(status_code=400, detail="账号与密码均不能为空") + hash_val = get_password_hash(body.admin_password) + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT INTO admin_users (username, password_hash, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(username) DO UPDATE SET password_hash=?, updated_at=datetime('now')", + (body.admin_username.strip(), hash_val, hash_val) + ) + return {"ok": True} diff --git a/Register_GPT_v0/web/backend/app/routers/sms_api.py b/Register_GPT_v0/web/backend/app/routers/sms_api.py new file mode 100644 index 0000000..17ee261 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/sms_api.py @@ -0,0 +1,258 @@ +""" +手机号接码 API(Hero-SMS / SMS-Activate 兼容) +文档: https://hero-sms.com/cn/api +""" +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel +from jose import JWTError, jwt +from app.config import settings +from app.routers.auth import get_current_user +from app.database import get_db, init_db +from app.services import hero_sms + +# 接码平台未返回到期时间时,默认有效期(分钟) +PHONE_DEFAULT_EXPIRE_MINUTES = 20 + +router = APIRouter(prefix="/api/sms-api", tags=["sms-api"]) +OPENAI_SERVICE = "openai" + + +def _get_sms_settings(): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + "SELECT key, value FROM system_settings WHERE key IN ('sms_api_url', 'sms_api_key', 'sms_openai_service', 'sms_max_price')" + ) + rows = c.fetchall() + out = {} + for k, v in rows: + out[k] = (v or "").strip() + base = out.get("sms_api_url") or "https://hero-sms.com/stubs/handler_api.php" + key = out.get("sms_api_key") + openai_service = (out.get("sms_openai_service") or "").strip() or "dr" + try: + max_price = float(out.get("sms_max_price") or "0.55") + except (TypeError, ValueError): + max_price = 0.55 + return base, key, openai_service, max_price + + +@router.get("/balance") +def api_balance(username: str = Depends(get_current_user)): + """查询 Hero-SMS 余额""" + base, key, _, _ = _get_sms_settings() + if not key: + raise HTTPException(status_code=400, detail="请先在系统设置中配置手机号接码 API KEY") + balance = hero_sms.get_balance(base, key) + if balance is None: + raise HTTPException(status_code=502, detail="请求余额失败,请检查 API 地址与 KEY") + return {"balance": balance} + + +@router.get("/countries") +def api_countries(username: str = Depends(get_current_user)): + """国家列表""" + base, key, _, _ = _get_sms_settings() + if not key: + raise HTTPException(status_code=400, detail="请先配置手机号接码 API KEY") + data = hero_sms.get_countries(base, key) + return {"countries": data} + + +@router.get("/services") +def api_services( + country: int = Query(0, description="国家 ID"), + username: str = Depends(get_current_user), +): + """服务列表(如 openai 等)""" + base, key, _, _ = _get_sms_settings() + if not key: + raise HTTPException(status_code=400, detail="请先配置手机号接码 API KEY") + data = hero_sms.get_services_list(base, key, country=country, lang="cn") + return {"services": data} + + +@router.get("/prices") +def api_prices( + service: str = Query(None), + country: int = Query(None), + username: str = Depends(get_current_user), +): + """价格/库存""" + base, key, _, _ = _get_sms_settings() + if not key: + raise HTTPException(status_code=400, detail="请先配置手机号接码 API KEY") + data = hero_sms.get_prices(base, key, service=service, country=country) + return data if data is not None else {} + + +def _collect_service_keys(prices) -> list: + """从 getPrices 全量返回中收集所有服务代号(用于 service is incorrect 时提示).""" + keys = [] + if isinstance(prices, dict) and prices.get("status") != "false": + for country_id, val in prices.items(): + if isinstance(val, dict): + for k, v in val.items(): + if isinstance(v, dict) and (v.get("count") is not None or v.get("cost") is not None): + keys.append(k) + return list(dict.fromkeys(keys)) + + +def _parse_prices_to_count(prices, service_name: str) -> tuple: + """从 getPrices 返回中解析指定服务的数量,兼容 list 或 dict。返回 (total_count, by_country).""" + total_count = 0 + by_country = [] + + def add_info(country_id, info): + nonlocal total_count + if not isinstance(info, dict): + return + c = info.get("count") or info.get("physicalCount") or 0 + try: + n = int(c) + except (TypeError, ValueError): + return + total_count += n + by_country.append({"country": country_id, "count": n, "cost": info.get("cost")}) + + if isinstance(prices, dict) and prices and set(prices.keys()) <= {"prices", "data", "result"}: + inner = prices.get("prices") or prices.get("data") or prices.get("result") + if inner is not None: + prices = inner + + if isinstance(prices, list): + for item in prices: + if not isinstance(item, dict): + continue + for country_id, val in item.items(): + if not isinstance(val, dict): + continue + if service_name in val: + add_info(country_id, val[service_name]) + else: + add_info(country_id, val) + elif isinstance(prices, dict): + if service_name in prices and isinstance(prices[service_name], dict): + for country_id, info in prices[service_name].items(): + add_info(country_id, info) + else: + for country_id, val in prices.items(): + if not isinstance(val, dict): + continue + if service_name in val: + add_info(country_id, val[service_name]) + else: + add_info(country_id, val) + return total_count, by_country + + +def _openai_availability_auth(request: Request): + """debug=1 时一律放行;否则要求 Authorization: Bearer 。""" + if request.query_params.get("debug") == "1": + return + auth = request.headers.get("Authorization") or "" + if not auth.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Not authenticated") + token = auth[7:].strip() + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + try: + jwt.decode(token, settings.secret_key, algorithms=["HS256"]) + except JWTError: + raise HTTPException(status_code=401, detail="Invalid token") + + +@router.get("/openai-availability") +def api_openai_availability( + request: Request, + debug: int = Query(0, description="1 时返回 getPrices 原始数据,便于排查数量为 0"), +): + """OpenAI 可用数量汇总:余额 + 各国家库存。debug=1 时免登录访问。""" + _openai_availability_auth(request) + base, key, openai_service, _ = _get_sms_settings() + if not key: + raise HTTPException(status_code=400, detail="请先配置手机号接码 API KEY") + balance = hero_sms.get_balance(base, key) + prices = hero_sms.get_prices(base, key, service=openai_service) + service_hint = [] + if isinstance(prices, dict) and prices.get("status") == "false" and "incorrect" in (prices.get("msg") or ""): + full_prices = hero_sms.get_prices(base, key) + if isinstance(full_prices, dict) and full_prices.get("status") != "false": + service_hint = _collect_service_keys(full_prices) + elif isinstance(full_prices, list): + for item in full_prices: + if isinstance(item, dict): + for val in item.values(): + if isinstance(val, dict): + service_hint.extend(k for k in val if isinstance(val.get(k), dict)) + service_hint = list(dict.fromkeys(service_hint)) + total_count, by_country = _parse_prices_to_count(prices, openai_service) if prices and not service_hint else (0, []) + out = { + "balance": balance if balance is not None else 0, + "total_count": total_count, + "by_country": by_country, + } + if service_hint: + out["service_hint"] = service_hint + if debug: + out["prices_raw"] = prices + return out + + +class GetNumbersBody(BaseModel): + service: str = "openai" + country: int = 0 + quantity: int = 1 + + +@router.post("/get-numbers") +def api_get_numbers(body: GetNumbersBody, username: str = Depends(get_current_user)): + """从接码平台获取号码并写入手机号管理表(可绑定次数取自系统设置)""" + base, key, openai_service, max_price = _get_sms_settings() + if not key: + raise HTTPException(status_code=400, detail="请先配置手机号接码 API KEY") + quantity = max(1, min(body.quantity, 20)) + service = (body.service or "").strip() + if not service or service == "openai": + service = openai_service + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT value FROM system_settings WHERE key = ?", ("phone_bind_limit",)) + row = c.fetchone() + limit = int(row[0]) if row and row[0] else 1 + got = [] + errors = [] + for _ in range(quantity): + result = hero_sms.get_number_auto(base, key, service, body.country, max_price=max_price) + if not result: + break + if result.get("error"): + errors.append(result["error"]) + break + expired_at = result.get("expired_at") + if not (expired_at and str(expired_at).strip()): + default_end = (datetime.utcnow() + timedelta(minutes=PHONE_DEFAULT_EXPIRE_MINUTES)).strftime("%Y-%m-%d %H:%M:%S") + expired_at = default_end + else: + raw = str(expired_at).strip() + if "T" in raw: + raw = raw.replace("Z", "").split(".")[0].replace("T", " ") + expired_at = raw + with get_db() as conn: + c = conn.cursor() + country_code = result.get("country") + remark = "Hero-SMS" + if country_code not in (None, "", 0): + remark = f"Hero-SMS(country={country_code})" + c.execute( + "INSERT INTO phone_numbers (phone, activation_id, max_use_count, remark, expired_at) VALUES (?, ?, ?, ?, ?)", + (result["phone_number"], result["activation_id"], limit, remark, expired_at), + ) + got.append({"id": c.lastrowid, "phone": result["phone_number"], "activation_id": result["activation_id"]}) + out = {"got": len(got), "items": got} + if errors: + out["errors"] = errors + return out diff --git a/Register_GPT_v0/web/backend/app/routers/sora_api.py b/Register_GPT_v0/web/backend/app/routers/sora_api.py new file mode 100644 index 0000000..aff9ea6 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/sora_api.py @@ -0,0 +1,2892 @@ +# -*- coding: utf-8 -*- +""" +Sora API 调用接口: +- rt -> at +- bootstrap +- me +- ensure activate +- 通用请求(限制 /backend/* 路径) +- 账号池自动轮换(额度耗尽自动切换下一个可用账号) +- API Key 请求自动注入去水印 header +""" +import json +import mimetypes +import time +import uuid +from typing import Any, Dict, Optional +from urllib.parse import unquote, urlencode + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from pydantic import BaseModel, Field +import requests + +from app.database import get_db, init_db +from app.registration_env import inject_registration_modules +from app.services.otp_resolver import build_otp_fetcher +from app.services.sora_api_key import ( + SORA_API_KEY_SCOPE_IMAGE, + SORA_API_KEY_SCOPE_TEXT, + get_sora_api_caller, + sora_api_key_scope_allows, + sora_api_key_scope_label, +) + +router = APIRouter(prefix="/api/sora-api", tags=["sora-api"]) + +_NF2_WEB_SESSION_CACHE: dict[int, dict] = {} +_NF2_WEB_SESSION_TTL_SECONDS = 20 * 60 +_TASK_FAMILY_VIDEO_GEN = "video_gen" +_TASK_FAMILY_NF2 = "nf2" + + +def _normalize_task_family(value: str) -> str: + normalized = (value or "").strip().lower() + if normalized in {"nf2", "sora_app", "sora_app_nf2"}: + return _TASK_FAMILY_NF2 + return _TASK_FAMILY_VIDEO_GEN + + +def _wants_legacy_text_video(value: str) -> bool: + normalized = (value or "").strip().lower() + return normalized in {"video_gen", "legacy", "old", "old_chain"} + + +def _close_nf2_web_session(web_session) -> None: + if web_session is None: + return + try: + web_session.close() + except Exception: + pass + + +def _drop_nf2_web_session(account_id: Optional[int]) -> None: + if account_id in (None, ""): + return + try: + key = int(account_id) + except Exception: + return + entry = _NF2_WEB_SESSION_CACHE.pop(key, None) + if isinstance(entry, dict): + _close_nf2_web_session(entry.get("web_session")) + + +def _store_nf2_web_session( + account_id: Optional[int], + web_session, + *, + access_token: str = "", + proxy_url: str = "", + web_origin: str = "", +) -> None: + if account_id in (None, "") or web_session is None: + return + try: + key = int(account_id) + except Exception: + return + previous = _NF2_WEB_SESSION_CACHE.get(key) + previous_session = previous.get("web_session") if isinstance(previous, dict) else None + if previous_session is not None and previous_session is not web_session: + _close_nf2_web_session(previous_session) + _NF2_WEB_SESSION_CACHE[key] = { + "web_session": web_session, + "access_token": (access_token or "").strip(), + "proxy_url": (proxy_url or "").strip(), + "web_origin": (web_origin or "").strip(), + "updated_at": time.time(), + } + + +def _get_nf2_web_session(account_id: Optional[int]) -> Optional[dict]: + if account_id in (None, ""): + return None + try: + key = int(account_id) + except Exception: + return None + entry = _NF2_WEB_SESSION_CACHE.get(key) + if not isinstance(entry, dict): + return None + updated_at = float(entry.get("updated_at") or 0.0) + if not updated_at or (time.time() - updated_at) > _NF2_WEB_SESSION_TTL_SECONDS: + _drop_nf2_web_session(key) + return None + if entry.get("web_session") is None: + _NF2_WEB_SESSION_CACHE.pop(key, None) + return None + return entry + + +def _touch_nf2_web_session(account_id: Optional[int], data: dict) -> None: + if account_id in (None, ""): + return + if not isinstance(data, dict): + return + web_session = data.get("web_session") + if web_session is None: + return + _store_nf2_web_session( + account_id, + web_session, + access_token=(data.get("access_token") or "").strip(), + proxy_url=(data.get("proxy_url") or "").strip(), + web_origin=(data.get("web_origin") or "").strip(), + ) + + +def _import_sora_phone(): + inject_registration_modules() + import protocol_sora_phone as sora_phone + return sora_phone + + +def _import_protocol_register(): + inject_registration_modules() + import protocol_register as pr + return pr + + +def _load_account(account_id: int) -> Optional[dict]: + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """SELECT id, email, password, refresh_token, access_token, proxy, has_sora, + COALESCE(sora_enabled, 1) AS sora_enabled, + COALESCE(sora_quota_exhausted, 0) AS sora_quota_exhausted, + COALESCE(sora_quota_note, '') AS sora_quota_note, + COALESCE(sora_quota_updated_at, '') AS sora_quota_updated_at + FROM accounts WHERE id = ?""", + (account_id,), + ) + row = c.fetchone() + if not row: + return None + return { + "id": row[0], + "email": row[1] or "", + "password": row[2] or "", + "refresh_token": (row[3] or "").strip(), + "access_token": (row[4] or "").strip(), + "proxy": (row[5] or "").strip(), + "has_sora": bool(row[6]), + "sora_enabled": bool(row[7]), + "sora_quota_exhausted": bool(row[8]), + "sora_quota_note": row[9] or "", + "sora_quota_updated_at": row[10] or "", + } + + +def _load_email_mailbox(email: str) -> Optional[dict]: + account_email = (email or "").strip() + if not account_email: + return None + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """SELECT email, password, uuid, token + FROM emails + WHERE LOWER(TRIM(email)) = LOWER(TRIM(?)) + LIMIT 1""", + (account_email,), + ) + row = c.fetchone() + if not row: + return None + return { + "email": row[0] or "", + "password": row[1] or "", + "uuid": row[2] or "", + "token": row[3] or "", + } + + +def _build_account_otp_fetcher(email: str): + mailbox = _load_email_mailbox(email) + if not mailbox: + return None + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """SELECT key, value FROM system_settings + WHERE key IN ('email_api_url', 'email_api_key')""" + ) + rows = c.fetchall() + settings = {k: (v or "").strip() for k, v in rows} + base_url = (settings.get("email_api_url") or "https://gapi.hotmail007.com").rstrip("/") + client_key = settings.get("email_api_key") or "" + if not client_key: + return None + account_str = f"{mailbox['email']}:{mailbox['password']}:{mailbox['token']}:{mailbox['uuid']}" + return build_otp_fetcher(base_url, client_key, account_str, timeout_sec=120, interval_sec=5) + + +def _pick_next_available_account(exclude_ids: list = None) -> dict | None: + """Round-robin 从可用 Sora 账号池中挑选下一个账号。 + 返回 account dict 或 None(无可用账号)。 + """ + exclude = set(exclude_ids or []) + init_db() + with get_db() as conn: + c = conn.cursor() + # 读取上次使用的游标 + c.execute("SELECT value FROM system_settings WHERE key = 'sora_auto_rotate_cursor'") + row = c.fetchone() + try: + cursor = int((row[0] if row else "0") or "0") + except Exception: + cursor = 0 + + c.execute( + """SELECT id, email, refresh_token, access_token, proxy, has_sora, + COALESCE(sora_enabled, 1) AS sora_enabled, + COALESCE(sora_quota_exhausted, 0) AS sora_quota_exhausted, + COALESCE(sora_quota_note, '') AS sora_quota_note, + COALESCE(sora_quota_updated_at, '') AS sora_quota_updated_at + FROM accounts + WHERE has_sora = 1 + AND COALESCE(sora_enabled, 1) = 1 + AND COALESCE(sora_quota_exhausted, 0) = 0 + AND (COALESCE(refresh_token, '') != '' OR COALESCE(access_token, '') != '') + ORDER BY id ASC""" + ) + rows = c.fetchall() + if not rows: + return None + + # 从 cursor 之后的第一个可用账号开始 + pick = None + for r in rows: + if int(r[0]) > cursor and int(r[0]) not in exclude: + pick = r + break + # 没找到则回绕到最早的可用账号 + if pick is None: + for r in rows: + if int(r[0]) not in exclude: + pick = r + break + if pick is None: + return None + + # 更新游标 + c.execute( + "INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)", + ("sora_auto_rotate_cursor", str(int(pick[0]))), + ) + + return { + "id": pick[0], + "email": pick[1] or "", + "refresh_token": (pick[2] or "").strip(), + "access_token": (pick[3] or "").strip(), + "proxy": (pick[4] or "").strip(), + "has_sora": bool(pick[5]), + "sora_enabled": bool(pick[6]), + "sora_quota_exhausted": bool(pick[7]), + "sora_quota_note": pick[8] or "", + "sora_quota_updated_at": pick[9] or "", + } + + +def _save_account_tokens(account_id: int, access_token: str = "", refresh_token: str = "") -> None: + init_db() + with get_db() as conn: + c = conn.cursor() + if access_token: + c.execute("UPDATE accounts SET access_token = ? WHERE id = ?", (access_token, account_id)) + if refresh_token: + c.execute("UPDATE accounts SET refresh_token = ? WHERE id = ?", (refresh_token, account_id)) + + +def _mark_account_sora(account_id: int) -> None: + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """UPDATE accounts + SET has_sora = 1, + status = CASE + WHEN COALESCE(status, '') = 'Registered' THEN 'Registered+Sora' + ELSE status + END + WHERE id = ?""", + (account_id,), + ) + + +def _mark_account_quota_exhausted(account_id: int, note: str = "") -> None: + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """UPDATE accounts + SET sora_quota_exhausted = 1, + sora_quota_note = ?, + sora_last_error = ?, + sora_quota_updated_at = datetime('now') + WHERE id = ?""", + ((note or "quota_exceeded").strip(), (note or "quota_exceeded").strip(), account_id), + ) + + +def _clear_account_quota_exhausted(account_id: int) -> None: + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """UPDATE accounts + SET sora_quota_exhausted = 0, + sora_quota_note = '', + sora_last_error = '', + sora_quota_updated_at = datetime('now') + WHERE id = ?""", + (account_id,), + ) + + +def _mark_account_last_error(account_id: int, message: str = "") -> None: + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """UPDATE accounts + SET sora_last_error = ?, + sora_quota_updated_at = datetime('now') + WHERE id = ?""", + ((message or "").strip()[:500], account_id), + ) + + +def _remember_media_asset( + media_id: str, + account_id: int, + payload: Optional[dict] = None, + api_key_id: Optional[int] = None, +) -> None: + asset_id = (media_id or "").strip() + if not asset_id or not account_id: + return + data = payload or {} + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """INSERT INTO sora_media_assets + (media_id, account_id, api_key_id, media_type, filename, mime_type, width, height, source_url, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(media_id) DO UPDATE SET + account_id = excluded.account_id, + api_key_id = COALESCE(excluded.api_key_id, sora_media_assets.api_key_id), + media_type = COALESCE(excluded.media_type, sora_media_assets.media_type), + filename = COALESCE(excluded.filename, sora_media_assets.filename), + mime_type = COALESCE(excluded.mime_type, sora_media_assets.mime_type), + width = COALESCE(excluded.width, sora_media_assets.width), + height = COALESCE(excluded.height, sora_media_assets.height), + source_url = COALESCE(excluded.source_url, sora_media_assets.source_url), + updated_at = datetime('now')""", + ( + asset_id, + int(account_id), + int(api_key_id) if api_key_id is not None else None, + (data.get("type") or "").strip() or None, + (data.get("filename") or "").strip() or None, + (data.get("mime_type") or "").strip() or None, + int(data.get("width")) if str(data.get("width") or "").isdigit() else None, + int(data.get("height")) if str(data.get("height") or "").isdigit() else None, + (data.get("url") or "").strip() or None, + ), + ) + + +def _load_media_asset(media_id: str) -> Optional[dict]: + asset_id = (media_id or "").strip() + if not asset_id: + return None + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """SELECT media_id, account_id, api_key_id, media_type, filename, mime_type, width, height, source_url, created_at, updated_at + FROM sora_media_assets + WHERE media_id = ? + LIMIT 1""", + (asset_id,), + ) + row = c.fetchone() + if not row: + return None + return { + "media_id": row[0] or "", + "account_id": int(row[1] or 0), + "api_key_id": row[2], + "media_type": row[3] or "", + "filename": row[4] or "", + "mime_type": row[5] or "", + "width": row[6], + "height": row[7], + "url": row[8] or "", + "created_at": row[9] or "", + "updated_at": row[10] or "", + } + + +def _remember_video_task( + task_id: str, + account_id: int, + api_key_id: Optional[int] = None, + task_family: str = _TASK_FAMILY_VIDEO_GEN, + raw_status: str = "", + normalized_status: str = "", + is_active: Optional[bool] = True, +) -> None: + task = (task_id or "").strip() + if not task or not account_id: + return + raw_value = (raw_status or "").strip() or None + normalized_value = _normalize_video_status(normalized_status or raw_status) or None + task_family_value = _normalize_task_family(task_family) + active_value = None if is_active is None else (1 if is_active else 0) + succeeded_value = "now" if normalized_value == "succeeded" else "" + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """INSERT INTO sora_video_tasks (task_id, account_id, api_key_id, task_family, raw_status, normalized_status, is_active, lease_expires_at, succeeded_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NULL, CASE WHEN ? = 'now' THEN datetime('now') ELSE NULL END, datetime('now'), datetime('now')) + ON CONFLICT(task_id) DO UPDATE SET + account_id = excluded.account_id, + api_key_id = COALESCE(excluded.api_key_id, sora_video_tasks.api_key_id), + task_family = COALESCE(excluded.task_family, sora_video_tasks.task_family), + raw_status = COALESCE(excluded.raw_status, sora_video_tasks.raw_status), + normalized_status = COALESCE(excluded.normalized_status, sora_video_tasks.normalized_status), + is_active = COALESCE(excluded.is_active, sora_video_tasks.is_active), + lease_expires_at = NULL, + succeeded_at = CASE + WHEN sora_video_tasks.succeeded_at IS NOT NULL THEN sora_video_tasks.succeeded_at + WHEN COALESCE(excluded.normalized_status, sora_video_tasks.normalized_status) = 'succeeded' THEN datetime('now') + ELSE sora_video_tasks.succeeded_at + END, + updated_at = datetime('now')""", + ( + task, + int(account_id), + int(api_key_id) if api_key_id is not None else None, + task_family_value, + raw_value, + normalized_value, + active_value, + succeeded_value, + ), + ) + + +def _claim_reserved_video_task( + reservation_task_id: str, + task_id: str, + account_id: int, + api_key_id: Optional[int] = None, + task_family: str = _TASK_FAMILY_VIDEO_GEN, + raw_status: str = "", + normalized_status: str = "", + is_active: Optional[bool] = True, +) -> None: + reservation = (reservation_task_id or "").strip() + task = (task_id or "").strip() + if not task or not account_id: + return + raw_value = (raw_status or "").strip() or None + normalized_value = _normalize_video_status(normalized_status or raw_status) or None + task_family_value = _normalize_task_family(task_family) + active_value = None if is_active is None else (1 if is_active else 0) + succeeded_value = "now" if normalized_value == "succeeded" else "" + init_db() + with get_db() as conn: + c = conn.cursor() + if reservation: + c.execute("DELETE FROM sora_video_tasks WHERE task_id = ?", (reservation,)) + c.execute( + """INSERT INTO sora_video_tasks (task_id, account_id, api_key_id, task_family, raw_status, normalized_status, is_active, lease_expires_at, succeeded_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NULL, CASE WHEN ? = 'now' THEN datetime('now') ELSE NULL END, datetime('now'), datetime('now')) + ON CONFLICT(task_id) DO UPDATE SET + account_id = excluded.account_id, + api_key_id = COALESCE(excluded.api_key_id, sora_video_tasks.api_key_id), + task_family = COALESCE(excluded.task_family, sora_video_tasks.task_family), + raw_status = COALESCE(excluded.raw_status, sora_video_tasks.raw_status), + normalized_status = COALESCE(excluded.normalized_status, sora_video_tasks.normalized_status), + is_active = COALESCE(excluded.is_active, sora_video_tasks.is_active), + lease_expires_at = NULL, + succeeded_at = CASE + WHEN sora_video_tasks.succeeded_at IS NOT NULL THEN sora_video_tasks.succeeded_at + WHEN COALESCE(excluded.normalized_status, sora_video_tasks.normalized_status) = 'succeeded' THEN datetime('now') + ELSE sora_video_tasks.succeeded_at + END, + updated_at = datetime('now')""", + ( + task, + int(account_id), + int(api_key_id) if api_key_id is not None else None, + task_family_value, + raw_value, + normalized_value, + active_value, + succeeded_value, + ), + ) + + +def _release_video_task_reservation(reservation_task_id: str) -> None: + reservation = (reservation_task_id or "").strip() + if not reservation: + return + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("DELETE FROM sora_video_tasks WHERE task_id = ?", (reservation,)) + + +def _sync_video_task_result( + task_id: str, + account_id: int, + result: Optional[dict], + api_key_id: Optional[int] = None, + default_active: Optional[bool] = None, + task_family: str = "", +) -> None: + task = (task_id or "").strip() + if not task or not account_id: + return + raw_status = ((result or {}).get("status") or "").strip() + normalized_status = _normalize_video_status((result or {}).get("normalized_status") or raw_status) + if normalized_status: + is_active = normalized_status not in _VIDEO_TERMINAL_STATUSES + else: + is_active = default_active + _remember_video_task( + task, + account_id, + api_key_id=api_key_id, + task_family=task_family or (result or {}).get("task_family") or _TASK_FAMILY_VIDEO_GEN, + raw_status=raw_status, + normalized_status=normalized_status, + is_active=is_active, + ) + + +def _reserve_pool_video_account( + api_key_id: Optional[int] = None, + exclude_ids: list = None, + task_family: str = _TASK_FAMILY_VIDEO_GEN, +) -> Optional[dict]: + exclude = {int(x) for x in (exclude_ids or []) if int(x)} + task_family_value = _normalize_task_family(task_family) + init_db() + with get_db() as conn: + conn.execute("BEGIN IMMEDIATE") + c = conn.cursor() + c.execute( + "DELETE FROM sora_video_tasks WHERE task_id LIKE ? AND lease_expires_at IS NOT NULL AND lease_expires_at <= datetime('now')", + (f"{_VIDEO_RESERVATION_PREFIX}%",), + ) + c.execute("SELECT value FROM system_settings WHERE key = 'sora_auto_rotate_cursor'") + row = c.fetchone() + try: + cursor = int((row[0] if row else "0") or "0") + except Exception: + cursor = 0 + c.execute( + """SELECT a.id, a.email, a.refresh_token, a.access_token, a.proxy, a.has_sora, + COALESCE(a.sora_enabled, 1) AS sora_enabled, + COALESCE(a.sora_quota_exhausted, 0) AS sora_quota_exhausted, + COALESCE(a.sora_quota_note, '') AS sora_quota_note, + COALESCE(a.sora_quota_updated_at, '') AS sora_quota_updated_at, + COALESCE(t.active_count, 0) AS active_count + FROM accounts a + LEFT JOIN ( + SELECT account_id, COUNT(*) AS active_count + FROM sora_video_tasks + WHERE is_active = 1 + AND (lease_expires_at IS NULL OR lease_expires_at > datetime('now')) + GROUP BY account_id + ) t ON t.account_id = a.id + WHERE a.has_sora = 1 + AND COALESCE(a.sora_enabled, 1) = 1 + AND COALESCE(a.sora_quota_exhausted, 0) = 0 + AND (COALESCE(a.refresh_token, '') != '' OR COALESCE(a.access_token, '') != '') + ORDER BY a.id ASC""" + ) + rows = [r for r in c.fetchall() if int(r["id"]) not in exclude] + if not rows: + return None + min_active = min(int(r["active_count"] or 0) for r in rows) + candidates = [r for r in rows if int(r["active_count"] or 0) == min_active] + pick = None + for r in candidates: + if int(r["id"]) > cursor: + pick = r + break + if pick is None: + pick = candidates[0] + c.execute( + "INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)", + ("sora_auto_rotate_cursor", str(int(pick["id"]))), + ) + reservation_task_id = f"{_VIDEO_RESERVATION_PREFIX}{uuid.uuid4().hex}" + c.execute( + """INSERT INTO sora_video_tasks + (task_id, account_id, api_key_id, task_family, raw_status, normalized_status, is_active, lease_expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, datetime('now', ?), datetime('now'), datetime('now'))""", + ( + reservation_task_id, + int(pick["id"]), + int(api_key_id) if api_key_id is not None else None, + task_family_value, + "reserving", + "running", + f"+{_VIDEO_RESERVATION_SECONDS} seconds", + ), + ) + return { + "id": int(pick["id"]), + "email": pick["email"] or "", + "refresh_token": (pick["refresh_token"] or "").strip(), + "access_token": (pick["access_token"] or "").strip(), + "proxy": (pick["proxy"] or "").strip(), + "has_sora": bool(pick["has_sora"]), + "sora_enabled": bool(pick["sora_enabled"]), + "sora_quota_exhausted": bool(pick["sora_quota_exhausted"]), + "sora_quota_note": pick["sora_quota_note"] or "", + "sora_quota_updated_at": pick["sora_quota_updated_at"] or "", + "active_task_count": int(pick["active_count"] or 0) + 1, + "reservation_task_id": reservation_task_id, + } + + +def _lookup_video_task_meta(task_id: str) -> Optional[dict]: + task = (task_id or "").strip() + if not task: + return None + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + "SELECT account_id, COALESCE(task_family, ?) FROM sora_video_tasks WHERE task_id = ? LIMIT 1", + (_TASK_FAMILY_VIDEO_GEN, task), + ) + row = c.fetchone() + if not row: + return None + try: + account_id = int(row[0]) + except Exception: + account_id = None + return { + "account_id": account_id, + "task_family": _normalize_task_family(row[1] or _TASK_FAMILY_VIDEO_GEN), + } + + +def _lookup_video_task_account(task_id: str) -> Optional[int]: + meta = _lookup_video_task_meta(task_id) + if not meta: + return None + try: + return int(meta.get("account_id")) + except Exception: + return None + + +def _extract_quota_reason(status_code: int, payload: Any, raw_text: str = "") -> str: + code = "" + parts = [] + if raw_text: + parts.append(raw_text) + if payload is not None: + try: + parts.append(json.dumps(payload, ensure_ascii=False)) + except Exception: + parts.append(str(payload)) + if isinstance(payload, dict): + err = payload.get("error") or {} + if isinstance(err, dict): + code = (err.get("code") or "").strip().lower() + merged = " ".join(parts).lower() + if code == "too_many_concurrent_tasks": + return "" + if not merged and status_code not in (402, 429): + return "" + + keywords = [ + "insufficient_quota", + "quota_exceeded", + "billing_hard_limit_reached", + "out of credits", + "insufficient credits", + "rate_limit_exceeded", + "usage limit", + "credit balance", + ] + if code in keywords: + return code + if status_code == 402: + return f"http_{status_code}" + if status_code == 429 and not any(k in merged for k in keywords): + return "" + for k in keywords: + if k in merged: + return k + return "" + + +def _extract_sora_error_code(payload: Any) -> str: + if not isinstance(payload, dict): + return "" + error = payload.get("error") or {} + if isinstance(error, dict): + return (error.get("code") or "").strip().lower() + return "" + + +def _is_too_many_concurrent_tasks_result(result: Optional[dict]) -> bool: + payload = (result or {}).get("data") + code = _extract_sora_error_code(payload) + if code == "too_many_concurrent_tasks": + return True + text = "" + try: + text = json.dumps(payload, ensure_ascii=False).lower() + except Exception: + text = str(payload or "").lower() + return "too_many_concurrent_tasks" in text + + +def _extract_busy_reason(payload: Any, raw_text: str = "") -> str: + code = "" + message = "" + if isinstance(payload, dict): + err = payload.get("error") or {} + if isinstance(err, dict): + code = (err.get("code") or "").strip().lower() + message = (err.get("message") or "").strip().lower() + merged = " ".join( + part for part in [raw_text.lower() if raw_text else "", message] if part + ) + if code == "too_many_concurrent_tasks": + return code + if "generations in progress" in merged: + return "too_many_concurrent_tasks" + return "" + + +class SoraUpstreamTransportError(RuntimeError): + pass + + +_TRANSPORT_ERROR_HINTS = ( + "connect tunnel failed", + "proxyerror", + "proxy error", + "connection refused", + "connection reset", + "failed to connect", + "curl: (7)", + "curl: (35)", + "curl: (56)", +) +_TRANSPORT_RETRY_COUNT = 2 + + +def _extract_transport_error_message(exc: Exception) -> str: + if exc is None: + return "" + message = (str(exc) or exc.__class__.__name__ or "").strip() + if not message: + return "" + lowered = message.lower() + if isinstance(exc, (requests.exceptions.ProxyError, requests.exceptions.ConnectionError)): + return message[:400] + if any(token in lowered for token in _TRANSPORT_ERROR_HINTS): + return message[:400] + module_name = (exc.__class__.__module__ or "").lower() + class_name = (exc.__class__.__name__ or "").lower() + if "curl_cffi" in module_name and class_name in {"proxyerror", "connectionerror"}: + return message[:400] + return "" + + +def _raise_transport_http_error(detail: str, all_accounts: bool = False) -> None: + prefix = "所有可用账号代理或网络异常" if all_accounts else "Sora 上游代理或网络异常" + suffix = f":{detail}" if detail else "" + raise HTTPException(status_code=503, detail=f"{prefix}{suffix}") + + +def _run_transport_safe_request(fn): + last_detail = "" + for attempt in range(max(1, _TRANSPORT_RETRY_COUNT + 1)): + try: + return fn() + except Exception as exc: + detail = _extract_transport_error_message(exc) + if not detail: + raise + last_detail = detail + if attempt >= _TRANSPORT_RETRY_COUNT: + raise SoraUpstreamTransportError(last_detail) from exc + time.sleep(0.35 * attempt) + raise SoraUpstreamTransportError(last_detail or "unknown transport error") + + +class SoraTokenBody(BaseModel): + account_id: Optional[int] = None + access_token: str = "" + refresh_token: str = "" + proxy_url: str = "" + + +class SoraRequestBody(SoraTokenBody): + method: str = "GET" + path: str = "/backend/me" + payload: Dict[str, Any] = Field(default_factory=dict) + + +class SoraVideoGenCreateBody(SoraTokenBody): + prompt: str + auto_rotate: bool = False + task_family: str = "" + operation: str = "simple_compose" + n_variants: int = 4 + n_frames: int = 300 + resolution: int = 360 + orientation: str = "wide" + model: str = "" + style_id: str = "" + audio_caption: str = "" + audio_transcript: str = "" + video_caption: str = "" + seed: Optional[int] = None + source_image_media_id: str = "" + extra_payload: Dict[str, Any] = Field(default_factory=dict) + + +class SoraVideoTaskBody(SoraTokenBody): + task_id: str + + +class SoraVideoListBody(SoraTokenBody): + limit: int = 20 + last_id: str = "" + task_type_filter: str = "videos" + + +class SoraVideoGenCreateAndWaitBody(SoraVideoGenCreateBody): + poll_interval_seconds: float = Field(default=5.0, ge=1.0, le=60.0) + timeout_seconds: int = Field(default=900, ge=5, le=7200) + + +class SoraVideoGenNfCreateBody(SoraTokenBody): + prompt: str + auto_rotate: bool = False + n_variants: int = 1 + n_frames: int = 300 + resolution: int = 360 + orientation: str = "portrait" + model: str = "sy_8" + style_id: str = "" + audio_caption: str = "" + audio_transcript: str = "" + video_caption: str = "" + seed: Optional[int] = None + extra_payload: Dict[str, Any] = Field(default_factory=dict) + + +class SoraDraftBody(SoraTokenBody): + draft_id: str + + +class SoraStitchBody(SoraTokenBody): + generation_ids: list[str] = Field(default_factory=list) + for_download: bool = False + + +_VIDEO_SUCCESS_STATUSES = {"succeeded"} +_VIDEO_FAILURE_STATUSES = {"failed", "cancelled", "rejected", "expired", "error"} +_VIDEO_TERMINAL_STATUSES = _VIDEO_SUCCESS_STATUSES | _VIDEO_FAILURE_STATUSES +_VIDEO_RETRYABLE_POLL_STATUS_CODES = {404, 409, 425} +_VIDEO_RESERVATION_PREFIX = "lease_" +_VIDEO_RESERVATION_SECONDS = 180 +_VIDEO_URL_KEYS = { + "url", + "src", + "uri", + "download_url", + "downloadurl", + "signed_url", + "signedurl", + "stream_url", + "streamurl", + "video_url", + "videourl", + "playback_url", + "playbackurl", +} +_VIDEO_URL_EXTENSIONS = (".mp4", ".mov", ".webm", ".m3u8") + + +def _normalize_video_status(status: str) -> str: + value = (status or "").strip().lower() + if not value: + return "" + aliases = { + "complete": "succeeded", + "completed": "succeeded", + "done": "succeeded", + "success": "succeeded", + "succeed": "succeeded", + "succeeded": "succeeded", + "canceled": "cancelled", + "cancelled": "cancelled", + "in_progress": "running", + "inprogress": "running", + "processing": "running", + } + return aliases.get(value, value) + + +def _find_string_field(payload: Any, keys: tuple[str, ...], depth: int = 0) -> str: + if depth > 6: + return "" + if isinstance(payload, dict): + for key in keys: + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + for value in payload.values(): + found = _find_string_field(value, keys, depth + 1) + if found: + return found + elif isinstance(payload, list): + for item in payload[:50]: + found = _find_string_field(item, keys, depth + 1) + if found: + return found + return "" + + +def _video_url_priority(url: str) -> tuple[int, int, int, int]: + value = (url or "").strip() + lowered = value.lower() + decoded = unquote(lowered) + base = decoded.split("?", 1)[0] + manifest_penalty = 1 if base.endswith(".m3u8") else 0 + extension_rank = 0 + if base.endswith(".mov"): + extension_rank = 1 + elif base.endswith(".webm"): + extension_rank = 2 + elif base.endswith(".m3u8"): + extension_rank = 3 + quality_rank = 50 + quality_checks = ( + (0, ("no_watermark", "downloadable", "/src.mp4", "/source.mp4", "/source_wm.mp4", "original")), + (1, ("/hd.mp4", "_hd.mp4", "/high.mp4", "_high.mp4")), + (2, ("/md.mp4", "_md.mp4", "/medium.mp4", "_medium.mp4")), + (3, ("/ld.mp4", "_ld.mp4", "/low.mp4", "_low.mp4")), + (4, ("watermark", "_wm.mp4", "/wm.mp4")), + ) + for rank, needles in quality_checks: + if any(needle in decoded for needle in needles): + quality_rank = rank + break + watermark_penalty = 0 + if ( + "watermark" in decoded + or "_wm." in base + or "/wm." in base + or "/wm/" in base + ) and "no_watermark" not in decoded: + watermark_penalty = 1 + return (manifest_penalty, quality_rank, watermark_penalty, extension_rank) + + +def _merge_video_urls(*groups: Any) -> list[str]: + ranked: list[tuple[tuple[int, int, int, int], int, str]] = [] + seen: set[str] = set() + sequence = 0 + for group in groups: + if isinstance(group, str): + candidates = [group] + elif isinstance(group, (list, tuple, set)): + candidates = list(group) + else: + candidates = [] + for item in candidates: + if not isinstance(item, str): + continue + value = item.strip() + if not value or value in seen: + continue + seen.add(value) + ranked.append((_video_url_priority(value), sequence, value)) + sequence += 1 + ranked.sort(key=lambda item: (item[0], item[1])) + return [item[2] for item in ranked] + + +def _collect_video_urls(payload: Any, depth: int = 0, urls: Optional[list[str]] = None) -> list[str]: + if urls is None: + urls = [] + if depth > 6: + return urls + if isinstance(payload, dict): + for key, value in payload.items(): + lowered_key = str(key or "").strip().lower() + if isinstance(value, str): + candidate = value.strip() + lower_candidate = candidate.lower() + if candidate.startswith(("http://", "https://")): + base_url = lower_candidate.split("?", 1)[0] + if lowered_key in _VIDEO_URL_KEYS or base_url.endswith(_VIDEO_URL_EXTENSIONS): + if candidate not in urls: + urls.append(candidate) + elif isinstance(value, (dict, list)): + _collect_video_urls(value, depth + 1, urls) + elif isinstance(payload, list): + for item in payload[:50]: + _collect_video_urls(item, depth + 1, urls) + return urls + + +def _decorate_video_task_result(result: dict, task_id: str = "") -> dict: + payload = result.get("data") + raw_status = _find_string_field(payload, ("status", "state")) + normalized_status = _normalize_video_status(raw_status) + resolved_task_id = (task_id or _find_string_field(payload, ("task_id", "id"))).strip() + video_urls = _merge_video_urls(_collect_video_urls(payload)) + return { + **result, + "task_family": _TASK_FAMILY_VIDEO_GEN, + "task_id": resolved_task_id, + "status": raw_status, + "normalized_status": normalized_status, + "is_terminal": normalized_status in _VIDEO_TERMINAL_STATUSES, + "is_success": normalized_status in _VIDEO_SUCCESS_STATUSES, + "video_urls": video_urls, + } + + +def _find_dict_matching(payload: Any, predicate, depth: int = 0): + if depth > 8: + return None + if isinstance(payload, dict): + try: + if predicate(payload): + return payload + except Exception: + pass + for value in payload.values(): + found = _find_dict_matching(value, predicate, depth + 1) + if found is not None: + return found + elif isinstance(payload, list): + for item in payload[:80]: + found = _find_dict_matching(item, predicate, depth + 1) + if found is not None: + return found + return None + + +def _extract_nf2_task_id(payload: Any) -> str: + if isinstance(payload, dict): + task = payload.get("task") + if isinstance(task, dict): + task_id = (task.get("id") or task.get("task_id") or "").strip() + if task_id: + return task_id + top_id = (payload.get("task_id") or "").strip() + if top_id: + return top_id + kind = (payload.get("kind") or "").strip().lower() + status = (payload.get("status") or payload.get("state") or "").strip() + top_level_id = (payload.get("id") or "").strip() + if top_level_id and status and kind != "sora_draft": + return top_level_id + nested = _find_dict_matching( + payload, + lambda item: isinstance(item.get("id"), str) + and isinstance(item.get("status"), str) + and (item.get("kind") or "").strip().lower() != "sora_draft", + ) + if isinstance(nested, dict): + return (nested.get("id") or "").strip() + return _find_string_field(payload, ("task_id",)) + + +def _extract_nf2_draft_id(payload: Any) -> str: + if isinstance(payload, dict): + draft = payload.get("draft") + if isinstance(draft, dict): + draft_id = (draft.get("id") or "").strip() + if draft_id: + return draft_id + kind = (payload.get("kind") or "").strip().lower() + top_level_id = (payload.get("id") or "").strip() + if kind == "sora_draft" and top_level_id: + return top_level_id + nested = _find_dict_matching( + payload, + lambda item: ((item.get("kind") or "").strip().lower() == "sora_draft") and isinstance(item.get("id"), str), + ) + if isinstance(nested, dict): + return (nested.get("id") or "").strip() + return "" + + +def _extract_nf2_download_urls(payload: Any) -> dict: + watermark_url = _find_string_field(payload, ("watermark",)) + no_watermark_url = _find_string_field(payload, ("no_watermark",)) + downloadable_url = _find_string_field(payload, ("downloadable_url",)) + urls = _merge_video_urls( + [no_watermark_url, downloadable_url, watermark_url], + _collect_video_urls(payload), + ) + return { + "watermark_url": watermark_url, + "no_watermark_url": no_watermark_url, + "downloadable_url": downloadable_url, + "media_urls": urls, + "video_urls": urls, + } + + +def _decorate_nf2_result(result: dict, task_id: str = "") -> dict: + payload = result.get("data") + raw_status = _find_string_field(payload, ("status", "state")) + normalized_status = _normalize_video_status(raw_status) + resolved_task_id = (task_id or _extract_nf2_task_id(payload)).strip() + draft_id = _extract_nf2_draft_id(payload) + download_info = _extract_nf2_download_urls(payload) + return { + **result, + "task_family": _TASK_FAMILY_NF2, + "task_id": resolved_task_id, + "draft_id": draft_id, + "status": raw_status, + "normalized_status": normalized_status, + "is_terminal": normalized_status in _VIDEO_TERMINAL_STATUSES, + "is_success": normalized_status in _VIDEO_SUCCESS_STATUSES, + **download_info, + } + + +def _merge_nf2_lookup_result(task_result: dict, draft_result: dict) -> dict: + merged = {**task_result} + for key in ("draft_id", "no_watermark_url", "downloadable_url", "watermark_url"): + if draft_result.get(key): + merged[key] = draft_result.get(key) + merged_urls = _merge_video_urls( + [ + draft_result.get("no_watermark_url"), + draft_result.get("downloadable_url"), + draft_result.get("watermark_url"), + ], + draft_result.get("video_urls") or [], + [ + task_result.get("no_watermark_url"), + task_result.get("downloadable_url"), + task_result.get("watermark_url"), + ], + task_result.get("video_urls") or [], + ) + if merged_urls: + merged["video_urls"] = merged_urls + merged["media_urls"] = merged_urls + return merged + + +def _is_pool_api_key_caller(caller: dict) -> bool: + return (caller.get("auth_type") or "") == "api_key" and caller.get("account_id") is None + + +def _require_api_key_video_scope(caller: dict, capability: str) -> None: + if (caller.get("auth_type") or "") != "api_key": + return + scope = caller.get("api_key_scope") or SORA_API_KEY_SCOPE_TEXT + if sora_api_key_scope_allows(scope, capability): + return + target = "文生视频" if capability == SORA_API_KEY_SCOPE_TEXT else "图生视频" + raise HTTPException( + status_code=403, + detail=f"当前 API Key 类型是「{sora_api_key_scope_label(scope)}」,不能调用{target}接口", + ) + + +def _require_api_key_any_video_scope(caller: dict) -> None: + if (caller.get("auth_type") or "") != "api_key": + return + scope = caller.get("api_key_scope") or SORA_API_KEY_SCOPE_TEXT + if scope in (SORA_API_KEY_SCOPE_TEXT, SORA_API_KEY_SCOPE_IMAGE) or sora_api_key_scope_allows(scope, SORA_API_KEY_SCOPE_TEXT): + return + raise HTTPException(status_code=403, detail="当前 API Key 不能调用视频接口") + + +def _payload_is_image_to_video(payload: Any) -> bool: + if not isinstance(payload, dict): + return False + media_id = (payload.get("source_image_media_id") or "").strip() + if media_id: + return True + if bool(payload.get("is_storyboard")) and isinstance(payload.get("inpaint_items"), list) and payload.get("inpaint_items"): + return True + for item in payload.get("inpaint_items") or []: + if not isinstance(item, dict): + continue + if (item.get("upload_media_id") or item.get("uploaded_file_id") or item.get("generation_id") or "").strip(): + return True + return False + + +def _resolve_tokens( + body: SoraTokenBody, + allow_refresh: bool = True, + prefer_refresh_token_for_sora: bool = False, + default_account_id: Optional[int] = None, + locked_account_id: Optional[int] = None, + allow_direct_tokens: bool = True, + allow_pool_rotation: bool = False, + exclude_account_ids: list = None, +) -> dict: + account = None + request_account_id = body.account_id + if locked_account_id is not None: + if request_account_id is not None and int(request_account_id) != int(locked_account_id): + raise HTTPException(status_code=403, detail="API Key 仅允许访问绑定账号") + request_account_id = locked_account_id + elif request_account_id is None: + request_account_id = default_account_id + + access_token = (body.access_token or "").strip() if allow_direct_tokens else "" + refresh_token = (body.refresh_token or "").strip() if allow_direct_tokens else "" + proxy_url = (body.proxy_url or "").strip() if allow_direct_tokens else "" + + # 池模式自动选账号 + if request_account_id is None and allow_pool_rotation and not access_token and not refresh_token: + picked = _pick_next_available_account(exclude_ids=exclude_account_ids) + if not picked: + raise HTTPException(status_code=404, detail="账号池中无可用 Sora 账号(请检查 token/额度/启停状态)") + account = picked + request_account_id = picked["id"] + access_token = picked["access_token"] + refresh_token = picked["refresh_token"] + proxy_url = picked["proxy"] + elif request_account_id is not None: + account = _load_account(int(request_account_id)) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + if not account["sora_enabled"]: + raise HTTPException(status_code=403, detail="该账号已停用,请在账号管理中启用后再调用") + if account["sora_quota_exhausted"]: + # 池模式下遇到额度不足不直接报错,而是跳过该账号 + if allow_pool_rotation: + excl = list(exclude_account_ids or []) + [int(request_account_id)] + picked = _pick_next_available_account(exclude_ids=excl) + if not picked: + raise HTTPException(status_code=429, detail="所有账号额度已耗尽,请添加新账号或重置额度") + account = picked + request_account_id = picked["id"] + access_token = picked["access_token"] + refresh_token = picked["refresh_token"] + proxy_url = picked["proxy"] + else: + note = account["sora_quota_note"] or "quota_exceeded" + when = account["sora_quota_updated_at"] or "" + suffix = f"({when})" if when else "" + raise HTTPException(status_code=429, detail=f"该账号已标记额度不足{suffix}:{note},请切换账号或重置额度状态") + if not access_token: + access_token = account["access_token"] + if not refresh_token: + refresh_token = account["refresh_token"] + if not proxy_url: + proxy_url = account["proxy"] + + if refresh_token and allow_refresh and (prefer_refresh_token_for_sora or not access_token): + sora_phone = _import_sora_phone() + out = sora_phone.rt_to_at_mobile(refresh_token, proxy_url=proxy_url) + new_access_token = (out.get("access_token") or "").strip() + new_rt = (out.get("refresh_token") or "").strip() + if new_access_token: + access_token = new_access_token + if new_rt: + refresh_token = new_rt + if request_account_id is not None and (new_access_token or new_rt): + _save_account_tokens(int(request_account_id), access_token=new_access_token, refresh_token=new_rt) + + return { + "account": account, + "access_token": access_token, + "refresh_token": refresh_token, + "proxy_url": proxy_url, + } + + +def _ensure_nf2_access_token( + data: dict, + account_id: Optional[int] = None, + force_web_login: bool = False, +) -> dict: + sora_phone = _import_sora_phone() + current = dict(data or {}) + access_token = (current.get("access_token") or "").strip() + web_session = current.get("web_session") + account = current.get("account") + if account is None and account_id is not None: + account = _load_account(int(account_id)) + current["account"] = account + if ( + web_session is not None + and access_token + and sora_phone.is_chatgpt_web_access_token(access_token) + and not force_web_login + ): + return current + if account is not None and force_web_login: + _drop_nf2_web_session(account.get("id")) + current["web_session"] = None + if account is not None and not force_web_login: + cached = _get_nf2_web_session(account.get("id")) + if cached: + cached_session = cached.get("web_session") + cached_access_token = "" + try: + session_state = sora_phone._read_sora_web_session(cached_session) + except Exception: + session_state = {} + if isinstance(session_state, dict): + cached_access_token = (session_state.get("access_token") or "").strip() + if not cached_access_token: + cached_access_token = (cached.get("access_token") or "").strip() + if cached_access_token and sora_phone.is_chatgpt_web_access_token(cached_access_token): + cached_origin = (session_state.get("base_origin") or cached.get("web_origin") or "").strip() + probe = sora_phone.sora_probe_nf2_session( + cached_access_token, + web_session=cached_session, + preferred_origin=cached_origin, + ) + if probe.get("ok"): + current["access_token"] = cached_access_token + current["web_session"] = cached_session + current["web_origin"] = (probe.get("base_origin") or cached_origin or "").strip() + _save_account_tokens(int(account["id"]), access_token=cached_access_token) + _store_nf2_web_session( + int(account["id"]), + cached_session, + access_token=cached_access_token, + proxy_url=(current.get("proxy_url") or cached.get("proxy_url") or "").strip(), + web_origin=(current.get("web_origin") or "").strip(), + ) + return current + _drop_nf2_web_session(account.get("id")) + current["web_session"] = None + if access_token and sora_phone.is_chatgpt_web_access_token(access_token) and not account: + return current + if not account: + return current + browser_auth = sora_phone.sora_import_browser_web_session( + expected_email=account.get("email") or "", + preferred_origin=current.get("web_origin") or "", + ) + browser_access_token = (browser_auth.get("access_token") or "").strip() if isinstance(browser_auth, dict) else "" + browser_web_session = browser_auth.get("web_session") if isinstance(browser_auth, dict) else None + if browser_access_token and browser_web_session is not None: + current["access_token"] = browser_access_token + current["web_session"] = browser_web_session + current["web_origin"] = (browser_auth.get("base_origin") or "").strip() + _store_nf2_web_session( + int(account["id"]), + browser_web_session, + access_token=browser_access_token, + proxy_url=(current.get("proxy_url") or "").strip(), + web_origin=(current.get("web_origin") or "").strip(), + ) + _save_account_tokens(int(account["id"]), access_token=browser_access_token) + return current + if not (account.get("email") or "").strip() or not (account.get("password") or "").strip(): + return current + otp_fetcher = _build_account_otp_fetcher(account.get("email") or "") + web_auth = sora_phone.sora_chatgpt_web_login( + account.get("email") or "", + account.get("password") or "", + get_otp_fn=otp_fetcher, + proxy_url=current.get("proxy_url") or "", + return_web_session=True, + ) + new_access_token = (web_auth.get("access_token") or "").strip() + new_web_session = web_auth.get("web_session") if isinstance(web_auth, dict) else None + if not new_access_token: + if new_web_session is not None: + _close_nf2_web_session(new_web_session) + return current + new_origin = (web_auth.get("base_origin") or "").strip() + if new_web_session is not None: + probe = sora_phone.sora_probe_nf2_session( + new_access_token, + web_session=new_web_session, + preferred_origin=new_origin, + ) + if probe.get("ok"): + new_origin = (probe.get("base_origin") or new_origin or "").strip() + else: + _close_nf2_web_session(new_web_session) + return current + current["access_token"] = new_access_token + current["web_session"] = new_web_session + current["web_origin"] = new_origin + if new_web_session is not None: + _store_nf2_web_session( + int(account["id"]), + new_web_session, + access_token=new_access_token, + proxy_url=(current.get("proxy_url") or "").strip(), + web_origin=(current.get("web_origin") or "").strip(), + ) + _save_account_tokens(int(account["id"]), access_token=new_access_token) + return current + + +def _candidate_nf2_origins(data: dict) -> list[str]: + sora_phone = _import_sora_phone() + seen = [] + for value in ( + (data.get("web_origin") or "").strip(), + (getattr(sora_phone, "SORA_ORIGIN", "") or "").strip(), + (getattr(sora_phone, "SORA_LEGACY_ORIGIN", "") or "").strip(), + ): + origin = (value or "").rstrip("/") + if origin and origin not in seen: + seen.append(origin) + return seen + + +def _run_nf2_request_with_origin_fallback(data: dict, request_fn): + origins = _candidate_nf2_origins(data or {}) + last_resp = None + last_exc = None + for index, origin in enumerate(origins): + try: + resp = _run_transport_safe_request(lambda: request_fn(data, origin)) + except SoraUpstreamTransportError as exc: + last_exc = exc + if index + 1 < len(origins): + continue + raise + last_resp = resp + status_code = int(getattr(resp, "status_code", 0) or 0) + if index + 1 < len(origins) and status_code in (401, 403, 404): + continue + data["web_origin"] = origin + return resp + if last_resp is not None: + return last_resp + if last_exc is not None: + raise last_exc + raise SoraUpstreamTransportError("nf2 request failed without response") + + +def _run_nf2_session_request( + body: SoraTokenBody, + *, + default_account_id: Optional[int] = None, + locked_account_id: Optional[int] = None, + allow_pool_rotation: bool = False, + request_fn, + decorate_fn=None, +) -> dict: + data = _resolve_tokens( + body, + allow_refresh=False, + default_account_id=default_account_id, + locked_account_id=locked_account_id, + allow_pool_rotation=allow_pool_rotation, + ) + resolved_account_id = (data.get("account") or {}).get("id") + if resolved_account_id is None: + resolved_account_id = default_account_id + + final_result = None + for attempt in range(2): + data = _ensure_nf2_access_token( + data, + account_id=resolved_account_id, + force_web_login=bool(attempt), + ) + if not (data.get("access_token") or "").strip(): + raise HTTPException(status_code=400, detail="缺少可用的 ChatGPT Web access_token") + if resolved_account_id and data.get("web_session") is None: + if attempt == 0: + data["access_token"] = "" + continue + raise HTTPException(status_code=400, detail="无法建立可用的 ChatGPT/Sora Web session") + try: + resp = _run_nf2_request_with_origin_fallback(data, request_fn) + except SoraUpstreamTransportError as exc: + if data["account"] is not None: + _mark_account_last_error(data["account"]["id"], f"transport error: {exc}") + _raise_transport_http_error(str(exc), all_accounts=False) + result = _build_account_result(resp, _parse_response_payload(resp), data) + if int(resp.status_code or 0) == 401 and resolved_account_id and attempt == 0: + _drop_nf2_web_session(int(resolved_account_id)) + data["access_token"] = "" + data["web_session"] = None + final_result = result + continue + if resolved_account_id and data.get("web_session") is not None: + _touch_nf2_web_session(int(resolved_account_id), data) + final_result = result + break + + if callable(decorate_fn): + return decorate_fn(final_result or {}) + return final_result or {} + + +def _sora_caller_rules(caller: dict) -> dict: + auth_type = caller.get("auth_type") or "admin" + if auth_type == "api_key": + bound_account_id = caller.get("account_id") + # account_id 为 None 表示池模式(创建 Key 时 account_id=0) + if bound_account_id is None: + return { + "default_account_id": None, + "locked_account_id": None, + "allow_direct_tokens": False, + "allow_pool_rotation": True, + "inject_watermark_free": True, + } + return { + "default_account_id": int(bound_account_id), + "locked_account_id": int(bound_account_id), + "allow_direct_tokens": False, + "allow_pool_rotation": False, + "inject_watermark_free": True, + } + return { + "default_account_id": None, + "locked_account_id": None, + "allow_direct_tokens": True, + "allow_pool_rotation": False, + "inject_watermark_free": False, + } + + +def _locked_sora_caller_rules(caller: dict, account_id: int) -> dict: + rules = dict(_sora_caller_rules(caller)) + rules["default_account_id"] = int(account_id) + rules["locked_account_id"] = int(account_id) + rules["allow_pool_rotation"] = False + return rules + + +@router.post("/rt-to-at") +def rt_to_at(body: SoraTokenBody, caller: dict = Depends(get_sora_api_caller)): + rules = _sora_caller_rules(caller) + resolve_keys = {k: v for k, v in rules.items() if k not in ("inject_watermark_free",)} + data = _resolve_tokens(body, allow_refresh=True, **resolve_keys) + if not data["access_token"]: + if data["account"] is not None: + _mark_account_last_error(data["account"]["id"], "RT->AT failed") + raise HTTPException(status_code=502, detail="RT 换 AT 失败,请检查 refresh_token/代理") + return { + "ok": True, + "account_id": data["account"]["id"] if data["account"] else None, + "email": data["account"]["email"] if data["account"] else "", + "access_token": data["access_token"], + "refresh_token": data["refresh_token"], + } + + +@router.post("/bootstrap") +def sora_bootstrap(body: SoraTokenBody, caller: dict = Depends(get_sora_api_caller)): + rules = _sora_caller_rules(caller) + resolve_keys = {k: v for k, v in rules.items() if k not in ("inject_watermark_free",)} + data = _resolve_tokens(body, allow_refresh=True, prefer_refresh_token_for_sora=True, **resolve_keys) + at = data["access_token"] + if not at: + raise HTTPException(status_code=400, detail="缺少 access_token(或 refresh_token)") + sora_phone = _import_sora_phone() + ok = sora_phone.sora_bootstrap(at, proxy_url=data["proxy_url"]) + if not ok: + if data["account"] is not None: + _mark_account_last_error(data["account"]["id"], "Sora bootstrap failed") + raise HTTPException(status_code=502, detail="Sora bootstrap 失败") + if data["account"] is not None: + _clear_account_quota_exhausted(data["account"]["id"]) + return {"ok": True, "used_account_id": data["account"]["id"] if data["account"] else None} + + +@router.post("/me") +def sora_me(body: SoraTokenBody, caller: dict = Depends(get_sora_api_caller)): + rules = _sora_caller_rules(caller) + resolve_keys = {k: v for k, v in rules.items() if k not in ("inject_watermark_free",)} + data = _resolve_tokens(body, allow_refresh=True, prefer_refresh_token_for_sora=True, **resolve_keys) + at = data["access_token"] + if not at: + raise HTTPException(status_code=400, detail="缺少 access_token(或 refresh_token)") + sora_phone = _import_sora_phone() + me = sora_phone.sora_me(at, proxy_url=data["proxy_url"]) + if not me: + if data["account"] is not None: + _mark_account_last_error(data["account"]["id"], "Sora me failed") + raise HTTPException(status_code=502, detail="Sora me 请求失败") + if data["account"] is not None: + _clear_account_quota_exhausted(data["account"]["id"]) + return { + "ok": True, + "account_id": data["account"]["id"] if data["account"] else None, + "email": data["account"]["email"] if data["account"] else "", + "used_account_id": data["account"]["id"] if data["account"] else None, + "me": me, + } + + +@router.post("/activate") +def sora_activate(body: SoraTokenBody, caller: dict = Depends(get_sora_api_caller)): + rules = _sora_caller_rules(caller) + resolve_keys = {k: v for k, v in rules.items() if k not in ("inject_watermark_free",)} + data = _resolve_tokens(body, allow_refresh=True, prefer_refresh_token_for_sora=True, **resolve_keys) + account = data["account"] + tokens = { + "access_token": data["access_token"], + "refresh_token": data["refresh_token"], + } + logs = [] + + def _step(msg: str) -> None: + text = (msg or "").strip() + if text: + logs.append(text[:500]) + + sora_phone = _import_sora_phone() + ok = False + if account is not None: + pr = _import_protocol_register() + get_otp_fn = _build_account_otp_fetcher(account["email"]) + ok = pr.activate_sora( + tokens, + account["email"], + proxy_url=data["proxy_url"], + step_log_fn=_step, + account_password=(account.get("password") or "").strip(), + get_otp_fn=get_otp_fn, + ) + else: + at = tokens["access_token"] + if not at: + raise HTTPException(status_code=400, detail="缺少 access_token(或 refresh_token)") + ok = sora_phone.sora_ensure_activated(at, proxy_url=data["proxy_url"], log_fn=_step) + + if not ok: + detail = logs[-1] if logs else "Sora 激活失败" + if account is not None: + _mark_account_last_error(account["id"], detail) + raise HTTPException(status_code=502, detail=detail) + + if account is not None: + _save_account_tokens( + account["id"], + access_token=(tokens.get("access_token") or "").strip(), + refresh_token=(tokens.get("refresh_token") or "").strip(), + ) + + at = (tokens.get("access_token") or "").strip() + me = sora_phone.sora_me(at, proxy_url=data["proxy_url"]) if at else {} + if account is not None: + _clear_account_quota_exhausted(account["id"]) + _mark_account_sora(account["id"]) + return { + "ok": True, + "account_id": account["id"] if account else None, + "email": account["email"] if account else "", + "used_account_id": account["id"] if account else None, + "username": (me or {}).get("username") or "", + "me": me or {}, + } + + +# 最大自动重试次数(池模式下额度耗尽时自动切换账号重试) +_MAX_POOL_RETRIES = 5 + + +def _validate_sora_request(body: SoraRequestBody) -> tuple[str, str]: + method = (body.method or "GET").strip().upper() + path = (body.path or "").strip() + if method not in ("GET", "POST"): + raise HTTPException(status_code=400, detail="method 仅支持 GET/POST") + if not path.startswith("/backend/"): + raise HTTPException(status_code=400, detail="path 仅允许 /backend/*") + return method, path + + +def _is_video_gen_create_request(method: str, path: str) -> bool: + return method == "POST" and path.rstrip("/") == "/backend/video_gen" + + +def _do_sora_request(body: SoraRequestBody, data: dict, inject_watermark_free: bool = False): + """执行单次 Sora 后端请求,返回 (response, payload, quota_reason)。""" + at = data["access_token"] + method, path = _validate_sora_request(body) + + sora_phone = _import_sora_phone() + url = f"{sora_phone.SORA_ORIGIN}{path}" + device_id = None + if method == "POST": + device_id = sora_phone.uuid.uuid4() + headers = sora_phone._build_headers(at, device_id=str(device_id) if device_id else None) + + # API Key 调用注入去水印 header + if inject_watermark_free: + headers["x-sora-watermark"] = "disabled" + + if _is_video_gen_create_request(method, path): + sentinel = sora_phone._build_sentinel_header( + headers.get("oai-device-id") or str(device_id), + "sora_create_task", + proxy_url=data["proxy_url"], + ) + if sentinel: + headers["openai-sentinel-token"] = sentinel + + if method == "GET": + r = sora_phone._session_get(url, headers=headers, proxy_url=data["proxy_url"]) + else: + payload = body.payload or {} + if _is_video_gen_create_request(method, path): + payload = sora_phone._strip_nullish(payload) + r = sora_phone._session_post( + url, + headers=headers, + json=payload, + proxy_url=data["proxy_url"], + ) + try: + payload = r.json() + except Exception: + payload = {"text": (r.text or "")[:1000]} + + quota_reason = _extract_quota_reason(r.status_code, payload, r.text or "") + return r, payload, quota_reason + + +def _run_sora_request(body: SoraRequestBody, caller: dict, rules_override: Optional[dict] = None) -> dict: + rules = dict(rules_override) if rules_override is not None else _sora_caller_rules(caller) + inject_watermark_free = rules.pop("inject_watermark_free", False) + allow_pool = rules.get("allow_pool_rotation", False) + data = _resolve_tokens(body, allow_refresh=True, prefer_refresh_token_for_sora=True, **rules) + at = data["access_token"] + if not at: + raise HTTPException(status_code=400, detail="缺少 access_token(或 refresh_token)") + + _validate_sora_request(body) + + try: + r, payload, quota_reason = _run_transport_safe_request( + lambda: _do_sora_request(body, data, inject_watermark_free=inject_watermark_free) + ) + except SoraUpstreamTransportError as exc: + if data["account"] is not None: + _mark_account_last_error(data["account"]["id"], f"transport error: {exc}") + _raise_transport_http_error(str(exc), all_accounts=False) + + if quota_reason: + tried_ids = [] + if data["account"] is not None: + _mark_account_quota_exhausted(data["account"]["id"], quota_reason) + tried_ids.append(data["account"]["id"]) + + if allow_pool: + for _ in range(_MAX_POOL_RETRIES): + next_account = _pick_next_available_account(exclude_ids=tried_ids) + if not next_account: + break + try: + next_data = _resolve_tokens( + SoraTokenBody(account_id=next_account["id"]), + allow_refresh=True, + prefer_refresh_token_for_sora=True, + default_account_id=next_account["id"], + locked_account_id=next_account["id"], + allow_direct_tokens=False, + ) + except Exception: + tried_ids.append(next_account["id"]) + continue + if not next_data["access_token"]: + tried_ids.append(next_account["id"]) + continue + try: + r2, payload2, quota_reason2 = _run_transport_safe_request( + lambda: _do_sora_request(body, next_data, inject_watermark_free=inject_watermark_free) + ) + except SoraUpstreamTransportError as exc: + _mark_account_last_error(next_account["id"], f"transport error: {exc}") + tried_ids.append(next_account["id"]) + continue + if quota_reason2: + _mark_account_quota_exhausted(next_account["id"], quota_reason2) + tried_ids.append(next_account["id"]) + continue + data = next_data + r, payload, quota_reason = r2, payload2, quota_reason2 + break + + if quota_reason: + raise HTTPException( + status_code=429, + detail=f"{'所有' if allow_pool else ''}账号额度不足,已自动标记不可用:{quota_reason}" + ) + + if 200 <= r.status_code < 300: + if data["account"] is not None: + _clear_account_quota_exhausted(data["account"]["id"]) + else: + if data["account"] is not None: + _mark_account_last_error(data["account"]["id"], f"HTTP {r.status_code}") + return { + "ok": 200 <= r.status_code < 300, + "status_code": r.status_code, + "data": payload, + "used_account_id": data["account"]["id"] if data["account"] else None, + "used_email": data["account"]["email"] if data["account"] else "", + } + + +def _parse_response_payload(resp) -> Any: + try: + return resp.json() + except Exception: + return {"text": (resp.text or "")[:1000]} + + +def _build_account_result(resp, payload: Any, data: dict) -> dict: + if 200 <= resp.status_code < 300: + if data["account"] is not None: + _clear_account_quota_exhausted(data["account"]["id"]) + else: + if data["account"] is not None: + _mark_account_last_error(data["account"]["id"], f"HTTP {resp.status_code}") + return { + "ok": 200 <= resp.status_code < 300, + "status_code": resp.status_code, + "data": payload, + "used_account_id": data["account"]["id"] if data["account"] else None, + "used_email": data["account"]["email"] if data["account"] else "", + } + + +def _run_nf2_create_request(data: dict, body: SoraVideoGenNfCreateBody, payload: dict) -> tuple[dict, str]: + sora_phone = _import_sora_phone() + def _request(current: dict, origin: str): + device_id = str(sora_phone.uuid.uuid4()) + headers = sora_phone._build_sora_web_headers( + current["access_token"], + device_id=device_id, + origin=origin, + ) + sentinel = sora_phone._build_sentinel_header( + device_id, + "sora_2_create_task", + proxy_url=current["proxy_url"], + ) + if sentinel: + headers["openai-sentinel-token"] = sentinel + path = "/backend/nf/bulk_create" if int((payload or {}).get("nsamples") or 1) > 1 else "/backend/nf/create" + return sora_phone._web_session_json_post( + f"{origin}{path}", + headers=headers, + json=sora_phone._strip_nullish(payload), + proxy_url=current["proxy_url"], + web_session=current.get("web_session"), + ) + + try: + resp = _run_nf2_request_with_origin_fallback(data, _request) + except SoraUpstreamTransportError as exc: + if data["account"] is not None: + _mark_account_last_error(data["account"]["id"], f"transport error: {exc}") + _raise_transport_http_error(str(exc), all_accounts=False) + parsed = _parse_response_payload(resp) + quota_reason = _extract_quota_reason(resp.status_code, parsed, getattr(resp, "text", "") or "") + return _build_account_result(resp, parsed, data), quota_reason + + +def _run_nf2_task_lookup(task_id: str, body: SoraTokenBody, caller: dict) -> dict: + sora_phone = _import_sora_phone() + account_id = body.account_id or _lookup_video_task_account(task_id) + rules_override = _locked_sora_caller_rules(caller, account_id) if account_id and _is_pool_api_key_caller(caller) else None + decorated = _run_nf2_session_request( + body, + default_account_id=account_id, + locked_account_id=account_id if account_id and rules_override else None, + allow_pool_rotation=False, + request_fn=lambda data, origin: sora_phone.sora_nf2_get_task( + data["access_token"], + task_id, + proxy_url=data["proxy_url"], + web_session=data.get("web_session"), + base_origin=origin, + ), + decorate_fn=lambda result: _decorate_nf2_result(result, task_id=task_id), + ) + draft_id = (decorated.get("draft_id") or "").strip() + if decorated.get("ok") and decorated.get("is_success") and draft_id: + draft_result = _run_nf2_session_request( + SoraDraftBody( + account_id=body.account_id, + access_token=body.access_token, + refresh_token=body.refresh_token, + proxy_url=body.proxy_url, + draft_id=draft_id, + ), + default_account_id=account_id, + locked_account_id=account_id if account_id and rules_override else None, + allow_pool_rotation=False, + request_fn=lambda data, origin: sora_phone.sora_nf2_get_draft( + data["access_token"], + draft_id, + proxy_url=data["proxy_url"], + web_session=data.get("web_session"), + base_origin=origin, + ), + decorate_fn=_decorate_nf2_result, + ) + if draft_result.get("ok"): + decorated = _merge_nf2_lookup_result(decorated, draft_result) + if account_id: + _sync_video_task_result( + task_id, + int(account_id), + decorated, + caller.get("api_key_id"), + default_active=None, + task_family=_TASK_FAMILY_NF2, + ) + return decorated + + +@router.post("/request") +def sora_request(body: SoraRequestBody, caller: dict = Depends(get_sora_api_caller)): + _, path = _validate_sora_request(body) + if path.startswith("/backend/video_gen"): + if path.rstrip("/") == "/backend/video_gen" and (body.method or "GET").strip().upper() == "POST": + _require_api_key_video_scope( + caller, + SORA_API_KEY_SCOPE_IMAGE if _payload_is_image_to_video(body.payload or {}) else SORA_API_KEY_SCOPE_TEXT, + ) + else: + _require_api_key_any_video_scope(caller) + return _run_sora_request(body, caller) + + +def _build_video_gen_list_path(limit: int = 20, last_id: str = "", task_type_filter: str = "videos") -> str: + params = { + "limit": max(1, min(int(limit or 20), 100)), + } + if (last_id or "").strip(): + params["last_id"] = (last_id or "").strip() + if (task_type_filter or "").strip(): + params["task_type_filters"] = (task_type_filter or "").strip() + return f"/backend/video_gen?{urlencode(params)}" + + +def _run_video_task_lookup(task_id: str, body: SoraTokenBody, caller: dict) -> dict: + meta = _lookup_video_task_meta(task_id) + task_family = _normalize_task_family((meta or {}).get("task_family") or "") + if task_family == _TASK_FAMILY_NF2: + return _run_nf2_task_lookup(task_id, body, caller) + account_id = body.account_id or ((meta or {}).get("account_id") if meta else None) + rules_override = _locked_sora_caller_rules(caller, account_id) if account_id and _is_pool_api_key_caller(caller) else None + result = _run_sora_request( + SoraRequestBody( + account_id=account_id, + access_token=body.access_token, + refresh_token=body.refresh_token, + proxy_url=body.proxy_url, + method="GET", + path=f"/backend/video_gen/{task_id}", + payload={}, + ), + caller, + rules_override=rules_override, + ) + decorated = _decorate_video_task_result(result, task_id=task_id) + if account_id: + _sync_video_task_result( + task_id, + int(account_id), + decorated, + caller.get("api_key_id"), + default_active=None, + task_family=_TASK_FAMILY_VIDEO_GEN, + ) + return decorated + + +@router.post("/video-gen/create") +def sora_video_gen_create(body: SoraVideoGenCreateBody, caller: dict = Depends(get_sora_api_caller)): + sora_phone = _import_sora_phone() + source_image_media_id = (body.source_image_media_id or "").strip() + source_asset = _load_media_asset(source_image_media_id) if source_image_media_id else None + forced_account_id = None + wants_legacy_text_video = _wants_legacy_text_video(body.task_family) + + if not source_image_media_id and not wants_legacy_text_video: + return sora_video_gen_nf_create( + SoraVideoGenNfCreateBody( + account_id=body.account_id, + access_token=body.access_token, + refresh_token=body.refresh_token, + proxy_url=body.proxy_url, + prompt=body.prompt, + auto_rotate=body.auto_rotate, + n_variants=body.n_variants, + n_frames=body.n_frames, + resolution=body.resolution, + orientation=body.orientation, + model=(body.model or "sy_8").strip() or "sy_8", + style_id=body.style_id, + audio_caption=body.audio_caption, + audio_transcript=body.audio_transcript, + video_caption=body.video_caption, + seed=body.seed, + extra_payload=body.extra_payload, + ), + caller, + ) + + if source_image_media_id: + _require_api_key_video_scope(caller, SORA_API_KEY_SCOPE_IMAGE) + if source_asset: + forced_account_id = int(source_asset["account_id"]) + if body.account_id is not None and int(body.account_id) != forced_account_id: + raise HTTPException(status_code=403, detail="source_image_media_id 绑定的账号与当前请求账号不一致") + elif body.account_id is None and not (body.access_token or "").strip() and not (body.refresh_token or "").strip(): + raise HTTPException(status_code=400, detail="source_image_media_id 未在本地记录,请先调用 /api/sora-api/video-gen/upload-image 或 /create-with-image") + payload = sora_phone.sora_build_image_video_payload( + body.prompt, + source_image_media_id, + operation=body.operation, + n_variants=body.n_variants, + n_frames=body.n_frames, + resolution=body.resolution, + orientation=body.orientation, + model=(body.model or "").strip() or None, + seed=body.seed, + ) + else: + payload = sora_phone.sora_build_simple_video_payload( + body.prompt, + operation=body.operation, + n_variants=body.n_variants, + n_frames=body.n_frames, + resolution=body.resolution, + orientation=body.orientation, + model=(body.model or "").strip() or None, + seed=body.seed, + ) + if body.extra_payload: + payload.update(body.extra_payload) + admin_auto_rotate = bool(body.auto_rotate) and body.account_id is None and not (body.access_token or "").strip() and not (body.refresh_token or "").strip() + pool_dispatch = not forced_account_id and (_is_pool_api_key_caller(caller) or admin_auto_rotate) and body.account_id is None + tried_ids = [] + result = None + reservation_task_id = "" + fixed_account_id = forced_account_id or (int(body.account_id) if body.account_id is not None else None) + + while True: + request_body = SoraRequestBody( + account_id=fixed_account_id, + access_token=body.access_token, + refresh_token=body.refresh_token, + proxy_url=body.proxy_url, + method="POST", + path="/backend/video_gen", + payload=payload, + ) + rules_override = _locked_sora_caller_rules(caller, fixed_account_id) if fixed_account_id else None + reserved_account_id = None + reserved_email = "" + + if pool_dispatch: + reserved = _reserve_pool_video_account( + caller.get("api_key_id"), + exclude_ids=tried_ids, + task_family=_TASK_FAMILY_VIDEO_GEN, + ) + if not reserved: + if result is None: + raise HTTPException(status_code=404, detail="账号池中无可用 Sora 账号(请检查 token/额度/启停状态)") + break + reservation_task_id = reserved["reservation_task_id"] + reserved_account_id = int(reserved["id"]) + reserved_email = reserved["email"] or "" + request_body.account_id = reserved_account_id + request_body.access_token = "" + request_body.refresh_token = "" + request_body.proxy_url = "" + rules_override = _locked_sora_caller_rules(caller, reserved_account_id) + + try: + result = _run_sora_request(request_body, caller, rules_override=rules_override) + except HTTPException as exc: + if reservation_task_id: + _release_video_task_reservation(reservation_task_id) + if pool_dispatch and reserved_account_id is not None and exc.status_code in (429, 503): + tried_ids.append(reserved_account_id) + result = { + "ok": False, + "status_code": exc.status_code, + "data": { + "error": { + "code": "quota_exceeded" if exc.status_code == 429 else "upstream_transport_error", + "message": str(exc.detail), + } + }, + "used_account_id": reserved_account_id, + "used_email": reserved_email, + } + reservation_task_id = "" + continue + raise + + task_id = ((result.get("data") or {}).get("id") or "").strip() + decorated = _decorate_video_task_result({ + **result, + "task_id": task_id, + "request_payload": payload, + "source_image_media_id": source_image_media_id, + }, task_id=task_id) + + used_account_id = decorated.get("used_account_id") + busy_reason = _extract_busy_reason(decorated.get("data"), "") + should_retry_pool = pool_dispatch and used_account_id and (busy_reason or _is_too_many_concurrent_tasks_result(decorated)) + + if reservation_task_id: + if task_id and used_account_id: + _claim_reserved_video_task( + reservation_task_id, + task_id, + int(used_account_id), + api_key_id=caller.get("api_key_id"), + task_family=_TASK_FAMILY_VIDEO_GEN, + raw_status=decorated.get("status") or "", + normalized_status=decorated.get("normalized_status") or "", + is_active=not bool(decorated.get("is_terminal")), + ) + else: + _release_video_task_reservation(reservation_task_id) + reservation_task_id = "" + elif task_id and used_account_id: + _sync_video_task_result( + task_id, + int(used_account_id), + decorated, + caller.get("api_key_id"), + default_active=True, + task_family=_TASK_FAMILY_VIDEO_GEN, + ) + + if should_retry_pool: + tried_ids.append(int(used_account_id)) + continue + return decorated + + if result is None: + raise HTTPException(status_code=500, detail="视频任务创建失败") + task_id = ((result.get("data") or {}).get("id") or "").strip() + decorated = _decorate_video_task_result({ + **result, + "task_id": task_id, + "request_payload": payload, + "source_image_media_id": source_image_media_id, + }, task_id=task_id) + if task_id and decorated.get("used_account_id"): + _sync_video_task_result( + task_id, + int(decorated["used_account_id"]), + decorated, + caller.get("api_key_id"), + default_active=True, + task_family=_TASK_FAMILY_VIDEO_GEN, + ) + return decorated + + +@router.post("/video-gen/create-and-wait") +def sora_video_gen_create_and_wait(body: SoraVideoGenCreateAndWaitBody, caller: dict = Depends(get_sora_api_caller)): + create_result = sora_video_gen_create( + SoraVideoGenCreateBody( + account_id=body.account_id, + access_token=body.access_token, + refresh_token=body.refresh_token, + proxy_url=body.proxy_url, + prompt=body.prompt, + auto_rotate=body.auto_rotate, + task_family=body.task_family, + operation=body.operation, + n_variants=body.n_variants, + n_frames=body.n_frames, + resolution=body.resolution, + orientation=body.orientation, + model=body.model, + style_id=body.style_id, + audio_caption=body.audio_caption, + audio_transcript=body.audio_transcript, + video_caption=body.video_caption, + seed=body.seed, + source_image_media_id=body.source_image_media_id, + extra_payload=body.extra_payload, + ), + caller, + ) + task_id = (create_result.get("task_id") or "").strip() + if not create_result.get("ok") or not task_id: + return { + "ok": False, + "timed_out": False, + "task_id": task_id, + "task_family": create_result.get("task_family") or "", + "status": create_result.get("status") or "", + "normalized_status": create_result.get("normalized_status") or "", + "is_terminal": bool(create_result.get("is_terminal")), + "is_success": bool(create_result.get("is_success")), + "video_urls": create_result.get("video_urls") or [], + "used_account_id": create_result.get("used_account_id"), + "used_email": create_result.get("used_email") or "", + "poll_attempts": 0, + "elapsed_seconds": 0.0, + "message": "视频任务创建失败", + "create_result": create_result, + "final_result": create_result, + } + + started_at = time.time() + poll_attempts = 0 + final_result = None + timed_out = False + poll_interval_seconds = float(body.poll_interval_seconds or 5.0) + timeout_seconds = int(body.timeout_seconds or 900) + + while True: + poll_attempts += 1 + final_result = _run_video_task_lookup(task_id, body, caller) + if final_result.get("is_terminal"): + break + if (not final_result.get("ok")) and int(final_result.get("status_code") or 0) not in _VIDEO_RETRYABLE_POLL_STATUS_CODES: + break + if (time.time() - started_at) >= timeout_seconds: + timed_out = True + break + time.sleep(poll_interval_seconds) + + elapsed_seconds = round(max(0.0, time.time() - started_at), 2) + normalized_status = (final_result or {}).get("normalized_status") or "" + ok = bool(final_result and final_result.get("is_success")) + if ok: + message = "视频任务已成功出片(succeeded)" + elif timed_out: + current_status = normalized_status or (final_result or {}).get("status") or "unknown" + message = f"轮询超时,当前状态:{current_status}" + elif final_result and not final_result.get("ok"): + code = final_result.get("status_code") + message = f"轮询查询失败,HTTP {code}" + else: + current_status = normalized_status or (final_result or {}).get("status") or "unknown" + message = f"视频任务已结束,状态:{current_status}" + + return { + "ok": ok, + "timed_out": timed_out, + "task_id": task_id, + "task_family": (final_result or {}).get("task_family") or create_result.get("task_family") or "", + "status": (final_result or {}).get("status") or "", + "normalized_status": normalized_status, + "is_terminal": bool((final_result or {}).get("is_terminal")), + "is_success": bool((final_result or {}).get("is_success")), + "video_urls": (final_result or {}).get("video_urls") or [], + "used_account_id": create_result.get("used_account_id"), + "used_email": create_result.get("used_email") or "", + "poll_attempts": poll_attempts, + "elapsed_seconds": elapsed_seconds, + "message": message, + "create_result": create_result, + "final_result": final_result, + } + + +@router.post("/video-gen-nf/create") +def sora_video_gen_nf_create(body: SoraVideoGenNfCreateBody, caller: dict = Depends(get_sora_api_caller)): + sora_phone = _import_sora_phone() + _require_api_key_video_scope(caller, SORA_API_KEY_SCOPE_TEXT) + payload = sora_phone.sora_build_nf2_video_payload( + body.prompt, + n_variants=body.n_variants, + n_frames=body.n_frames, + resolution=body.resolution, + orientation=body.orientation, + model=body.model, + style_id=body.style_id, + audio_caption=body.audio_caption, + audio_transcript=body.audio_transcript, + video_caption=body.video_caption, + seed=body.seed, + ) + if body.extra_payload: + payload.update(body.extra_payload) + payload = sora_phone._strip_nullish(payload) + + admin_auto_rotate = bool(body.auto_rotate) and body.account_id is None and not (body.access_token or "").strip() and not (body.refresh_token or "").strip() + pool_dispatch = (_is_pool_api_key_caller(caller) or admin_auto_rotate) and body.account_id is None + tried_ids = [] + result = None + reservation_task_id = "" + fixed_account_id = int(body.account_id) if body.account_id is not None else None + + while True: + reserved_account_id = None + reserved_email = "" + request_account_id = fixed_account_id + if pool_dispatch: + reserved = _reserve_pool_video_account( + caller.get("api_key_id"), + exclude_ids=tried_ids, + task_family=_TASK_FAMILY_NF2, + ) + if not reserved: + if result is None: + raise HTTPException(status_code=404, detail="账号池中无可用 Sora 账号(请检查 token/额度/启停状态)") + break + reservation_task_id = reserved["reservation_task_id"] + reserved_account_id = int(reserved["id"]) + reserved_email = reserved["email"] or "" + request_account_id = reserved_account_id + + data = _resolve_tokens( + SoraTokenBody( + account_id=request_account_id, + access_token=body.access_token if reserved_account_id is None else "", + refresh_token=body.refresh_token if reserved_account_id is None else "", + proxy_url=body.proxy_url if reserved_account_id is None else "", + ), + allow_refresh=False, + default_account_id=request_account_id, + locked_account_id=request_account_id if request_account_id and (reserved_account_id is not None or caller.get("account_id") is not None) else None, + allow_direct_tokens=reserved_account_id is None, + allow_pool_rotation=False, + ) + quota_reason = "" + for auth_attempt in range(2): + data = _ensure_nf2_access_token( + data, + account_id=request_account_id, + force_web_login=bool(auth_attempt), + ) + if not (data.get("access_token") or "").strip(): + break + if request_account_id and data.get("web_session") is None: + if auth_attempt == 0: + data["access_token"] = "" + continue + result = { + "ok": False, + "status_code": 400, + "data": {"error": {"code": "missing_web_session", "message": "无法建立可用的 ChatGPT/Sora Web session"}}, + "used_account_id": request_account_id, + "used_email": reserved_email or ((data.get("account") or {}).get("email") or ""), + } + break + result, quota_reason = _run_nf2_create_request(data, body, payload) + if int(result.get("status_code") or 0) == 401 and request_account_id and auth_attempt == 0: + _drop_nf2_web_session(int(request_account_id)) + data["access_token"] = "" + data["web_session"] = None + continue + if request_account_id and data.get("web_session") is not None: + _touch_nf2_web_session(int(request_account_id), data) + break + + if not (data.get("access_token") or "").strip(): + if reservation_task_id: + _release_video_task_reservation(reservation_task_id) + reservation_task_id = "" + if pool_dispatch and request_account_id: + tried_ids.append(int(request_account_id)) + result = { + "ok": False, + "status_code": 400, + "data": {"error": {"code": "missing_web_access_token", "message": "缺少可用的 ChatGPT Web access_token"}}, + "used_account_id": request_account_id, + "used_email": reserved_email or ((data.get("account") or {}).get("email") or ""), + } + continue + raise HTTPException(status_code=400, detail="缺少可用的 ChatGPT Web access_token") + + if quota_reason: + if data["account"] is not None: + _mark_account_quota_exhausted(data["account"]["id"], quota_reason) + if reservation_task_id: + _release_video_task_reservation(reservation_task_id) + reservation_task_id = "" + if pool_dispatch and request_account_id: + tried_ids.append(int(request_account_id)) + continue + raise HTTPException(status_code=429, detail=f"账号额度不足,已自动标记不可用:{quota_reason}") + + decorated = _decorate_nf2_result({ + **result, + "request_payload": payload, + }) + task_id = (decorated.get("task_id") or "").strip() + used_account_id = decorated.get("used_account_id") + busy_reason = _extract_busy_reason(decorated.get("data"), "") + should_retry_pool = pool_dispatch and used_account_id and ( + busy_reason + or _is_too_many_concurrent_tasks_result(decorated) + or int(decorated.get("status_code") or 0) == 401 + ) + + if reservation_task_id: + if task_id and used_account_id: + _claim_reserved_video_task( + reservation_task_id, + task_id, + int(used_account_id), + api_key_id=caller.get("api_key_id"), + task_family=_TASK_FAMILY_NF2, + raw_status=decorated.get("status") or "", + normalized_status=decorated.get("normalized_status") or "", + is_active=not bool(decorated.get("is_terminal")), + ) + else: + _release_video_task_reservation(reservation_task_id) + reservation_task_id = "" + elif task_id and used_account_id: + _sync_video_task_result( + task_id, + int(used_account_id), + decorated, + caller.get("api_key_id"), + default_active=True, + task_family=_TASK_FAMILY_NF2, + ) + + if should_retry_pool: + tried_ids.append(int(used_account_id)) + continue + return decorated + + if result is None: + raise HTTPException(status_code=500, detail="NF2 视频任务创建失败") + return _decorate_nf2_result({**result, "request_payload": payload}) + + +@router.post("/video-gen-nf/get") +def sora_video_gen_nf_get(body: SoraVideoTaskBody, caller: dict = Depends(get_sora_api_caller)): + _require_api_key_any_video_scope(caller) + task_id = (body.task_id or "").strip() + if not task_id: + raise HTTPException(status_code=400, detail="缺少 task_id") + return _run_nf2_task_lookup(task_id, body, caller) + + +@router.post("/video-gen-nf/pending") +def sora_video_gen_nf_pending(body: SoraTokenBody, caller: dict = Depends(get_sora_api_caller)): + _require_api_key_any_video_scope(caller) + sora_phone = _import_sora_phone() + default_account_id = caller.get("account_id") if (caller.get("auth_type") or "") == "api_key" else None + return _run_nf2_session_request( + body, + default_account_id=default_account_id, + locked_account_id=default_account_id, + allow_pool_rotation=_is_pool_api_key_caller(caller), + request_fn=lambda data, origin: sora_phone.sora_nf2_get_pending( + data["access_token"], + proxy_url=data["proxy_url"], + web_session=data.get("web_session"), + base_origin=origin, + ), + ) + + +@router.post("/video-gen-nf/draft/get") +def sora_video_gen_nf_draft_get(body: SoraDraftBody, caller: dict = Depends(get_sora_api_caller)): + _require_api_key_any_video_scope(caller) + sora_phone = _import_sora_phone() + draft_id = (body.draft_id or "").strip() + if not draft_id: + raise HTTPException(status_code=400, detail="缺少 draft_id") + default_account_id = caller.get("account_id") if (caller.get("auth_type") or "") == "api_key" else None + return _run_nf2_session_request( + body, + default_account_id=default_account_id, + locked_account_id=default_account_id, + allow_pool_rotation=_is_pool_api_key_caller(caller), + request_fn=lambda data, origin: sora_phone.sora_nf2_get_draft( + data["access_token"], + draft_id, + proxy_url=data["proxy_url"], + web_session=data.get("web_session"), + base_origin=origin, + ), + decorate_fn=_decorate_nf2_result, + ) + + +@router.post("/video-gen-nf/stitch") +def sora_video_gen_nf_stitch(body: SoraStitchBody, caller: dict = Depends(get_sora_api_caller)): + _require_api_key_any_video_scope(caller) + sora_phone = _import_sora_phone() + generation_ids = [str(item).strip() for item in (body.generation_ids or []) if str(item).strip()] + if not generation_ids: + raise HTTPException(status_code=400, detail="缺少 generation_ids") + default_account_id = caller.get("account_id") if (caller.get("auth_type") or "") == "api_key" else None + return _run_nf2_session_request( + body, + default_account_id=default_account_id, + locked_account_id=default_account_id, + allow_pool_rotation=_is_pool_api_key_caller(caller), + request_fn=lambda data, origin: sora_phone.sora_nf2_stitch( + data["access_token"], + generation_ids, + for_download=bool(body.for_download), + proxy_url=data["proxy_url"], + web_session=data.get("web_session"), + base_origin=origin, + ), + decorate_fn=_decorate_nf2_result, + ) + + +def _upload_image_bytes_with_retry( + *, + filename: str, + content_type: str, + file_bytes: bytes, + account_id: Optional[int], + auto_rotate: bool, + access_token: str, + refresh_token: str, + proxy_url: str, + caller: dict, + exclude_account_ids: Optional[list[int]] = None, +) -> dict: + _require_api_key_video_scope(caller, SORA_API_KEY_SCOPE_IMAGE) + token_body = SoraTokenBody( + account_id=account_id, + access_token=access_token, + refresh_token=refresh_token, + proxy_url=proxy_url, + ) + rules = dict(_sora_caller_rules(caller)) + if bool(auto_rotate) and account_id is None and not access_token.strip() and not refresh_token.strip(): + rules["allow_pool_rotation"] = True + resolve_keys = {k: v for k, v in rules.items() if k not in ("inject_watermark_free",)} + sora_phone = _import_sora_phone() + allow_pool_upload = bool(resolve_keys.get("allow_pool_rotation")) and account_id is None and not access_token.strip() and not refresh_token.strip() + tried_ids = [int(x) for x in (exclude_account_ids or []) if int(x)] + last_transport_error = "" + + while True: + try: + data = _resolve_tokens( + token_body, + allow_refresh=True, + prefer_refresh_token_for_sora=True, + exclude_account_ids=tried_ids, + **resolve_keys, + ) + except HTTPException: + if allow_pool_upload and last_transport_error: + _raise_transport_http_error(last_transport_error, all_accounts=True) + raise + + at = data["access_token"] + if not at: + raise HTTPException(status_code=400, detail="缺少 access_token(或 refresh_token)") + + try: + resp = _run_transport_safe_request( + lambda: sora_phone.sora_upload_media( + at, + filename=filename, + content_type=content_type, + file_bytes=file_bytes, + media_type="image", + proxy_url=data["proxy_url"], + ) + ) + except SoraUpstreamTransportError as exc: + used_account = data["account"] + last_transport_error = str(exc) + if used_account is not None: + _mark_account_last_error(used_account["id"], f"image upload transport error: {exc}") + if allow_pool_upload and used_account is not None: + tried_ids.append(int(used_account["id"])) + if len(tried_ids) <= _MAX_POOL_RETRIES: + continue + _raise_transport_http_error(last_transport_error, all_accounts=True) + _raise_transport_http_error(last_transport_error, all_accounts=False) + + try: + payload = resp.json() + except Exception: + payload = {"text": (resp.text or "")[:1000]} + + ok = 200 <= resp.status_code < 300 + used_account = data["account"] + if ok and used_account is not None: + _clear_account_quota_exhausted(used_account["id"]) + elif used_account is not None: + _mark_account_last_error(used_account["id"], f"image upload HTTP {resp.status_code}") + + media_id = ((payload or {}).get("id") or "").strip() if isinstance(payload, dict) else "" + if ok and media_id and used_account is not None: + _remember_media_asset(media_id, int(used_account["id"]), payload if isinstance(payload, dict) else {}, caller.get("api_key_id")) + + return { + "ok": ok, + "status_code": resp.status_code, + "media_id": media_id, + "media": payload, + "used_account_id": used_account["id"] if used_account else None, + "used_email": used_account["email"] if used_account else "", + "source_image_media_id": media_id, + } + + +@router.post("/video-gen/upload-image") +async def sora_video_gen_upload_image( + file: UploadFile = File(...), + account_id: Optional[int] = Form(default=None), + auto_rotate: bool = Form(default=False), + access_token: str = Form(default=""), + refresh_token: str = Form(default=""), + proxy_url: str = Form(default=""), + caller: dict = Depends(get_sora_api_caller), +): + filename = (file.filename or "").strip() + if not filename: + raise HTTPException(status_code=400, detail="缺少图片文件名") + content_type = (file.content_type or mimetypes.guess_type(filename)[0] or "").strip().lower() or "application/octet-stream" + if not content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="仅支持图片文件上传") + file_bytes = await file.read() + if not file_bytes: + raise HTTPException(status_code=400, detail="上传文件为空") + + return _upload_image_bytes_with_retry( + filename=filename, + content_type=content_type, + file_bytes=file_bytes, + account_id=account_id, + auto_rotate=auto_rotate, + access_token=access_token, + refresh_token=refresh_token, + proxy_url=proxy_url, + caller=caller, + exclude_account_ids=None, + ) + + +@router.post("/video-gen/create-with-image") +async def sora_video_gen_create_with_image( + prompt: str = Form(...), + file: UploadFile = File(...), + account_id: Optional[int] = Form(default=None), + auto_rotate: bool = Form(default=False), + access_token: str = Form(default=""), + refresh_token: str = Form(default=""), + proxy_url: str = Form(default=""), + operation: str = Form(default="simple_compose"), + n_variants: int = Form(default=1), + n_frames: int = Form(default=300), + resolution: int = Form(default=360), + orientation: str = Form(default="wide"), + model: str = Form(default=""), + seed: Optional[int] = Form(default=None), + caller: dict = Depends(get_sora_api_caller), +): + filename = (file.filename or "").strip() + if not filename: + raise HTTPException(status_code=400, detail="缺少图片文件名") + content_type = (file.content_type or mimetypes.guess_type(filename)[0] or "").strip().lower() or "application/octet-stream" + if not content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="仅支持图片文件上传") + file_bytes = await file.read() + if not file_bytes: + raise HTTPException(status_code=400, detail="上传文件为空") + + allow_pool_retry = bool(auto_rotate) and account_id is None and not access_token.strip() and not refresh_token.strip() + tried_ids: list[int] = [] + last_result: Optional[dict] = None + + while True: + try: + upload_result = _upload_image_bytes_with_retry( + filename=filename, + content_type=content_type, + file_bytes=file_bytes, + account_id=account_id, + auto_rotate=auto_rotate, + access_token=access_token, + refresh_token=refresh_token, + proxy_url=proxy_url, + caller=caller, + exclude_account_ids=tried_ids, + ) + except HTTPException as exc: + if allow_pool_retry and last_result is not None and exc.status_code in (404, 503): + return last_result + raise + + if not upload_result.get("ok"): + return upload_result + + used_account_id = upload_result.get("used_account_id") + try: + create_result = sora_video_gen_create( + SoraVideoGenCreateBody( + account_id=used_account_id, + prompt=prompt, + auto_rotate=False, + operation=operation, + n_variants=n_variants, + n_frames=n_frames, + resolution=resolution, + orientation=orientation, + model=model, + seed=seed, + source_image_media_id=upload_result.get("media_id") or "", + extra_payload={}, + ), + caller, + ) + except HTTPException as exc: + if allow_pool_retry and used_account_id and exc.status_code in (429, 503): + last_result = { + "ok": False, + "status_code": exc.status_code, + "data": {"error": {"code": "upstream_transport_error" if exc.status_code == 503 else "quota_exceeded", "message": str(exc.detail)}}, + "used_account_id": used_account_id, + "used_email": upload_result.get("used_email") or "", + "task_id": "", + "request_payload": {}, + "source_image_media_id": upload_result.get("media_id") or "", + "status": "", + "normalized_status": "", + "is_terminal": False, + "is_success": False, + "video_urls": [], + "uploaded_media": upload_result.get("media") or {}, + } + tried_ids.append(int(used_account_id)) + if len(tried_ids) <= _MAX_POOL_RETRIES: + continue + return last_result + raise + + create_result["uploaded_media"] = upload_result.get("media") or {} + create_result["source_image_media_id"] = upload_result.get("media_id") or "" + if allow_pool_retry and used_account_id: + busy_reason = _extract_busy_reason(create_result.get("data"), "") + if busy_reason or _is_too_many_concurrent_tasks_result(create_result): + last_result = create_result + tried_ids.append(int(used_account_id)) + if len(tried_ids) <= _MAX_POOL_RETRIES: + continue + return create_result + + +@router.post("/video-gen/list") +def sora_video_gen_list(body: SoraVideoListBody, caller: dict = Depends(get_sora_api_caller)): + _require_api_key_any_video_scope(caller) + return _run_sora_request( + SoraRequestBody( + account_id=body.account_id, + access_token=body.access_token, + refresh_token=body.refresh_token, + proxy_url=body.proxy_url, + method="GET", + path=_build_video_gen_list_path( + limit=body.limit, + last_id=body.last_id, + task_type_filter=body.task_type_filter, + ), + payload={}, + ), + caller, + ) + + +@router.post("/video-gen/get") +def sora_video_gen_get(body: SoraVideoTaskBody, caller: dict = Depends(get_sora_api_caller)): + _require_api_key_any_video_scope(caller) + task_id = (body.task_id or "").strip() + if not task_id: + raise HTTPException(status_code=400, detail="缺少 task_id") + return _run_video_task_lookup(task_id, body, caller) + + +@router.post("/video-gen/cancel") +def sora_video_gen_cancel(body: SoraVideoTaskBody, caller: dict = Depends(get_sora_api_caller)): + _require_api_key_any_video_scope(caller) + task_id = (body.task_id or "").strip() + if not task_id: + raise HTTPException(status_code=400, detail="缺少 task_id") + account_id = body.account_id or _lookup_video_task_account(task_id) + rules_override = _locked_sora_caller_rules(caller, account_id) if account_id and _is_pool_api_key_caller(caller) else None + result = _run_sora_request( + SoraRequestBody( + account_id=account_id, + access_token=body.access_token, + refresh_token=body.refresh_token, + proxy_url=body.proxy_url, + method="POST", + path=f"/backend/video_gen/{task_id}/cancel", + payload={}, + ), + caller, + rules_override=rules_override, + ) + decorated = _decorate_video_task_result(result, task_id=task_id) + if account_id: + _sync_video_task_result(task_id, int(account_id), decorated, caller.get("api_key_id"), default_active=None) + return decorated + + +@router.post("/video-gen/archive") +def sora_video_gen_archive(body: SoraVideoTaskBody, caller: dict = Depends(get_sora_api_caller)): + _require_api_key_any_video_scope(caller) + task_id = (body.task_id or "").strip() + if not task_id: + raise HTTPException(status_code=400, detail="缺少 task_id") + account_id = body.account_id or _lookup_video_task_account(task_id) + rules_override = _locked_sora_caller_rules(caller, account_id) if account_id and _is_pool_api_key_caller(caller) else None + result = _run_sora_request( + SoraRequestBody( + account_id=account_id, + access_token=body.access_token, + refresh_token=body.refresh_token, + proxy_url=body.proxy_url, + method="POST", + path=f"/backend/video_gen/{task_id}/archive", + payload={}, + ), + caller, + rules_override=rules_override, + ) + decorated = _decorate_video_task_result(result, task_id=task_id) + if account_id: + _sync_video_task_result(task_id, int(account_id), decorated, caller.get("api_key_id"), default_active=None) + return decorated diff --git a/Register_GPT_v0/web/backend/app/routers/sora_keys.py b/Register_GPT_v0/web/backend/app/routers/sora_keys.py new file mode 100644 index 0000000..f82e9a1 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/routers/sora_keys.py @@ -0,0 +1,151 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from app.database import get_db, init_db +from app.routers.auth import get_current_user +from app.services.sora_api_key import ( + SORA_API_KEY_SCOPE_TEXT, + generate_sora_api_key, + hash_sora_api_key, + mask_sora_api_key, + normalize_sora_api_key_scope, + sora_api_key_scope_label, +) + +router = APIRouter(prefix="/api/sora-keys", tags=["sora-keys"]) + + +class CreateSoraKeyBody(BaseModel): + account_id: int = 0 # 0 = 池模式(自动轮换) + name: str = "" + scope: str = SORA_API_KEY_SCOPE_TEXT + + +@router.get("") +def list_sora_api_keys( + username: str = Depends(get_current_user), + account_id: Optional[int] = Query(None, ge=0), + active_only: bool = Query(True), + scope: str = Query(""), + key_mode: str = Query(""), +): + init_db() + where = [] + params = [] + if account_id is not None: + where.append("k.account_id = ?") + params.append(account_id) + if active_only: + where.append("k.is_active = 1") + normalized_scope = "" + if (scope or "").strip(): + normalized_scope = normalize_sora_api_key_scope(scope) + where.append("COALESCE(k.scope, ?) = ?") + params.extend([SORA_API_KEY_SCOPE_TEXT, normalized_scope]) + mode = (key_mode or "").strip().lower() + if mode == "pool": + where.append("k.account_id = 0") + elif mode == "bound": + where.append("k.account_id != 0") + where_sql = " AND ".join(where) if where else "1=1" + with get_db() as conn: + c = conn.cursor() + c.execute( + f"""SELECT k.id, k.account_id, a.email, k.name, k.key_prefix, k.key_mask, + k.is_active, k.last_used_at, k.created_at, k.created_by, + COALESCE(k.scope, ?) + FROM sora_api_keys k + LEFT JOIN accounts a ON a.id = k.account_id + WHERE {where_sql} + ORDER BY k.id DESC + LIMIT 500""", + [SORA_API_KEY_SCOPE_TEXT] + params, + ) + rows = c.fetchall() + items = [] + for r in rows: + email_display = r[2] or "" + if r[1] == 0: + email_display = "[自动轮换池]" + items.append( + { + "id": r[0], + "account_id": r[1], + "email": email_display, + "name": r[3] or "", + "key_prefix": r[4] or "", + "key_mask": r[5] or "", + "is_active": bool(r[6]), + "last_used_at": r[7] or "", + "created_at": r[8] or "", + "created_by": r[9] or "", + "scope": normalize_sora_api_key_scope(r[10] or SORA_API_KEY_SCOPE_TEXT), + "scope_label": sora_api_key_scope_label(r[10] or SORA_API_KEY_SCOPE_TEXT), + "key_mode": "pool" if int(r[1] or 0) == 0 else "bound", + } + ) + return {"items": items} + + +@router.post("") +def create_sora_api_key(body: CreateSoraKeyBody, username: str = Depends(get_current_user)): + account_id = int(body.account_id if body.account_id is not None else -1) + if account_id < 0: + raise HTTPException(status_code=400, detail="account_id 无效") + scope = normalize_sora_api_key_scope(body.scope) + + raw_key = generate_sora_api_key() + key_hash = hash_sora_api_key(raw_key) + key_mask = mask_sora_api_key(raw_key) + key_prefix = raw_key[:12] + name = (body.name or "").strip() + + init_db() + with get_db() as conn: + c = conn.cursor() + email_display = "" + if account_id == 0: + # 池模式:不绑定固定账号,自动轮换 + email_display = "[自动轮换池]" + else: + c.execute("SELECT id, email FROM accounts WHERE id = ?", (account_id,)) + account = c.fetchone() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + email_display = account[1] or "" + + c.execute( + """INSERT INTO sora_api_keys + (account_id, name, key_hash, key_prefix, key_mask, created_by, scope, is_active, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))""", + (account_id, name, key_hash, key_prefix, key_mask, username, scope), + ) + key_id = c.lastrowid + c.execute("SELECT created_at FROM sora_api_keys WHERE id = ?", (key_id,)) + created_at = (c.fetchone() or [""])[0] or "" + + return { + "id": key_id, + "account_id": account_id, + "email": email_display, + "name": name, + "api_key": raw_key, + "key_mask": key_mask, + "created_at": created_at, + "scope": scope, + "scope_label": sora_api_key_scope_label(scope), + "key_mode": "pool" if account_id == 0 else "bound", + } + + +@router.delete("/{key_id}") +def disable_sora_api_key(key_id: int, username: str = Depends(get_current_user)): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("UPDATE sora_api_keys SET is_active = 0 WHERE id = ?", (key_id,)) + if c.rowcount <= 0: + raise HTTPException(status_code=404, detail="API Key 不存在") + return {"ok": True} diff --git a/Register_GPT_v0/web/backend/app/security.py b/Register_GPT_v0/web/backend/app/security.py new file mode 100644 index 0000000..b9c39f0 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/security.py @@ -0,0 +1,26 @@ +import bcrypt +from passlib.context import CryptContext + +pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain: str, hashed: str) -> bool: + hash_value = (hashed or "").strip() + if not hash_value: + return False + try: + return pwd_ctx.verify(plain, hash_value) + except Exception: + pass + try: + return bcrypt.checkpw((plain or "").encode("utf-8"), hash_value.encode("utf-8")) + except Exception: + return False + + +def get_password_hash(password: str) -> str: + secret = (password or "").encode("utf-8") + try: + return pwd_ctx.hash(password) + except Exception: + return bcrypt.hashpw(secret, bcrypt.gensalt()).decode("utf-8") diff --git a/Register_GPT_v0/web/backend/app/services/__init__.py b/Register_GPT_v0/web/backend/app/services/__init__.py new file mode 100644 index 0000000..a0b12d7 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/services/__init__.py @@ -0,0 +1 @@ +# services diff --git a/Register_GPT_v0/web/backend/app/services/hero_sms.py b/Register_GPT_v0/web/backend/app/services/hero_sms.py new file mode 100644 index 0000000..c5cfe17 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/services/hero_sms.py @@ -0,0 +1,324 @@ +""" +Hero-SMS 接码平台 API(兼容 SMS-Activate 协议) +文档: https://hero-sms.com/cn/api +服务端: https://hero-sms.com/stubs/handler_api.php +""" +import re +import json +import requests +from typing import Optional, List, Dict, Any + +BASE_URL = "https://hero-sms.com/stubs/handler_api.php" +TIMEOUT = 30 +SERVICE_NOT_AVAILABLE_TOKENS = ( + "SERVICE_NOT_AVAILABLE", + "service not available", +) + + +def _get(base_url: str, api_key: str, action: str, **params) -> Optional[str]: + url = (base_url or BASE_URL).strip() + params["action"] = action + params["api_key"] = api_key + try: + r = requests.get(url, params=params, timeout=TIMEOUT) + r.raise_for_status() + return (r.text or "").strip() + except Exception: + return None + + +def get_balance(base_url: str, api_key: str) -> Optional[float]: + """查询余额. action=getBalance → 返回 ACCESS_BALANCE:数字""" + text = _get(base_url, api_key, "getBalance") + if not text or not text.startswith("ACCESS_BALANCE"): + return None + m = re.search(r"ACCESS_BALANCE[:\s]+([\d.]+)", text) + if m: + try: + return float(m.group(1)) + except ValueError: + pass + return None + + +def get_number( + base_url: str, + api_key: str, + service: str, + country: int = 0, + operator: Optional[str] = None, + max_price: Optional[float] = None, +) -> Optional[Dict[str, Any]]: + """ + 申请号码. action=getNumber&service=xxx&country=n + 返回 ACCESS_NUMBER:activationId:phoneNumber 或错误前缀(返回 {"error": "原始响应"}) + """ + params = {"service": service, "country": country} + if operator: + params["operator"] = operator + if max_price is not None: + params["maxPrice"] = max_price + text = _get(base_url, api_key, "getNumber", **params) + if not text: + return {"error": "无响应"} + if not text.startswith("ACCESS_NUMBER"): + return {"error": text} + parts = text.split(":") + if len(parts) >= 3: + try: + return { + "activation_id": int(parts[1]), + "phone_number": parts[2], + "raw": text, + } + except (ValueError, IndexError): + pass + return {"error": text} + + +def get_number_v2( + base_url: str, + api_key: str, + service: str, + country: int = 0, + max_price: Optional[float] = None, + **kwargs: Any, +) -> Optional[Dict[str, Any]]: + """ + 申请号码 V2。文档: action=getNumberV2&service=xxx&country=n&maxPrice=n。 + 支持两种返回格式:单条 { activationId, phoneNumber } 或 { data: [ { id, phone, ... } ] }。 + 统一返回 { activation_id, phone_number [, raw ] } 或 { error }。 + """ + try: + url = (base_url or BASE_URL).strip() + params = {"action": "getNumberV2", "api_key": api_key, "service": service, "country": country, **kwargs} + if max_price is not None: + params["maxPrice"] = max_price + r = requests.get(url, params=params, timeout=TIMEOUT) + r.raise_for_status() + raw_text = (r.text or "").strip() + if not raw_text: + return {"error": "接口返回为空,请检查 API 地址与网络"} + try: + data = json.loads(raw_text) + except json.JSONDecodeError: + if raw_text and len(raw_text) < 200 and "<" not in raw_text: + return {"error": raw_text} + return {"error": "接口返回非 JSON: " + (raw_text[:150] if raw_text else "空")} + + item = None + if isinstance(data, dict): + if data.get("activationId") is not None: + item = data + elif isinstance(data.get("data"), list) and len(data["data"]) > 0: + item = data["data"][0] + + if not item or not isinstance(item, dict): + err = (data.get("message") or data.get("error") if isinstance(data, dict) else None) or getattr(r, "text", str(data)) + return {"error": str(err)[:300] if err else "无可用号码或返回格式异常"} + + activation_id = item.get("activationId") or item.get("id") + phone = item.get("phoneNumber") or item.get("phone") or item.get("number") or "" + if activation_id is None: + return {"error": "返回中无 activationId/id"} + expired_at = item.get("activationEndTime") or item.get("expiredAt") or item.get("expired_at") or None + out = { + "activation_id": int(activation_id), + "phone_number": str(phone), + "raw": data, + } + if expired_at: + out["expired_at"] = str(expired_at).strip() + return out + except Exception as e: + return {"error": str(e)} + + +def _is_service_not_available_error(error: Optional[str]) -> bool: + text = str(error or "") + return any(token in text for token in SERVICE_NOT_AVAILABLE_TOKENS) + + +def _country_candidates_from_prices( + base_url: str, + api_key: str, + service: str, + max_price: Optional[float] = None, + limit: int = 8, +) -> List[int]: + prices = get_prices(base_url, api_key, service=service) + candidates = [] + if isinstance(prices, dict): + for country_id, val in prices.items(): + if not isinstance(val, dict): + continue + info = val.get(service) if isinstance(val.get(service), dict) else None + if not info: + continue + try: + count = int(info.get("count") or info.get("physicalCount") or 0) + cost = float(info.get("cost")) + cid = int(country_id) + except (TypeError, ValueError): + continue + if count <= 0: + continue + if max_price is not None and cost > max_price: + continue + candidates.append((cost, -count, cid)) + candidates.sort() + return [cid for _, _, cid in candidates[:limit]] + + +def get_number_auto( + base_url: str, + api_key: str, + service: str, + country: int = 0, + operator: Optional[str] = None, + max_price: Optional[float] = None, + country_limit: int = 8, +) -> Optional[Dict[str, Any]]: + """ + 申请号码,优先使用传入 country。 + 若 country=0 命中 Hero-SMS 的 SERVICE_NOT_AVAILABLE,则自动按价格/库存挑选具体国家重试。 + """ + + def _try_one(country_code: int) -> Optional[Dict[str, Any]]: + result = get_number( + base_url, + api_key, + service, + country=country_code, + operator=operator, + max_price=max_price, + ) + if result and not result.get("error"): + result["country"] = country_code + return result + fallback = get_number_v2( + base_url, + api_key, + service, + country=country_code, + max_price=max_price, + ) + if fallback and not fallback.get("error"): + fallback["country"] = country_code + return fallback + err = "" + if isinstance(result, dict) and result.get("error"): + err = str(result.get("error")) + elif isinstance(fallback, dict) and fallback.get("error"): + err = str(fallback.get("error")) + return {"error": err or "无可用号码"} + + first = _try_one(country) + if first and not first.get("error"): + return first + if country != 0 or not _is_service_not_available_error((first or {}).get("error")): + return first + + for country_code in _country_candidates_from_prices( + base_url, + api_key, + service, + max_price=max_price, + limit=country_limit, + ): + if country_code == country: + continue + result = _try_one(country_code) + if result and not result.get("error"): + result["auto_country_fallback"] = True + return result + return first + + +def get_status(base_url: str, api_key: str, activation_id: int) -> Optional[Dict[str, Any]]: + """ + 查询激活状态. action=getStatus&id=xxx + 返回 STATUS_WAIT_CODE | STATUS_OK:code | 其他 + """ + text = _get(base_url, api_key, "getStatus", id=activation_id) + if not text: + return None + if text == "STATUS_WAIT_CODE": + return {"status": "wait", "code": None} + if text.startswith("STATUS_OK"): + code = text.split(":", 1)[-1].strip() if ":" in text else "" + return {"status": "ok", "code": code or None} + return {"status": "raw", "raw": text} + + +def get_status_v2(base_url: str, api_key: str, activation_id: int) -> Optional[Dict[str, Any]]: + """查询状态 V2,返回 JSON(含 sms.code)。""" + try: + url = (base_url or BASE_URL).strip() + params = {"action": "getStatusV2", "api_key": api_key, "id": activation_id} + r = requests.get(url, params=params, timeout=TIMEOUT) + r.raise_for_status() + data = r.json() + if isinstance(data, dict): + sms = data.get("sms") or {} + code = sms.get("code") or (data.get("call") or {}).get("code") + return {"status": "ok" if code else "wait", "code": code, "data": data} + except Exception: + pass + return None + + +def set_status(base_url: str, api_key: str, activation_id: int, status: int) -> bool: + """ + 设置激活状态. action=setStatus&id=xxx&status=n + status: 1=已发短信(准备收码) 3=请求重发 6=完成 8=取消退款 + """ + text = _get(base_url, api_key, "setStatus", id=activation_id, status=status) + return text is not None and ("ACCESS" in (text or "") or text == "OK") + + +def get_countries(base_url: str, api_key: str) -> List[Dict[str, Any]]: + """国家列表。文档: action=getCountries。返回国家列表(含 id 等)。""" + try: + url = (base_url or BASE_URL).strip() + params = {"action": "getCountries", "api_key": api_key} + r = requests.get(url, params=params, timeout=TIMEOUT) + r.raise_for_status() + data = r.json() + if isinstance(data, list): + return data + except Exception: + pass + return [] + + +def get_services_list(base_url: str, api_key: str, country: int = 0, lang: str = "cn") -> List[Dict[str, Any]]: + """服务列表。文档: action=getServicesList&country=n&lang=cn。返回 services 数组。""" + try: + url = (base_url or BASE_URL).strip() + params = {"action": "getServicesList", "api_key": api_key, "country": country, "lang": lang} + r = requests.get(url, params=params, timeout=TIMEOUT) + r.raise_for_status() + data = r.json() + if isinstance(data, dict) and "services" in data: + return data.get("services") or [] + except Exception: + pass + return [] + + +def get_prices(base_url: str, api_key: str, service: Optional[str] = None, country: Optional[int] = None) -> Any: + """价格/库存. action=getPrices&service=xxx&country=n""" + try: + url = (base_url or BASE_URL).strip() + params = {"action": "getPrices", "api_key": api_key} + if service: + params["service"] = service + if country is not None: + params["country"] = country + r = requests.get(url, params=params, timeout=TIMEOUT) + r.raise_for_status() + return r.json() + except Exception: + return None diff --git a/Register_GPT_v0/web/backend/app/services/hotmail007.py b/Register_GPT_v0/web/backend/app/services/hotmail007.py new file mode 100644 index 0000000..cbc7d3a --- /dev/null +++ b/Register_GPT_v0/web/backend/app/services/hotmail007.py @@ -0,0 +1,113 @@ +""" +Hotmail007 邮箱 API 接入 +文档: https://hotmail007.com/zh/apiDoc +请求 host: https://gapi.hotmail007.com +""" +import requests +from typing import Optional, List, Dict, Any + +BASE_URL = "https://gapi.hotmail007.com" +TIMEOUT = 30 + +MAIL_TYPES = ("outlook", "hotmail", "hotmail Trusted", "outlook Trusted") + + +def get_balance(base_url: str, client_key: str) -> Optional[float]: + """查询余额. GET /api/user/balance?clientKey=xxx""" + url = (base_url or BASE_URL).rstrip("/") + "/api/user/balance" + try: + r = requests.get(url, params={"clientKey": client_key}, timeout=TIMEOUT) + r.raise_for_status() + data = r.json() + if data.get("success") and data.get("code") == 0: + return float(data.get("data", 0)) + except Exception: + pass + return None + + +def get_stock(base_url: str, mail_type: Optional[str] = None) -> Optional[int]: + """查询库存. GET /api/mail/getStock?mailType=xxx (mailType 可选)""" + url = (base_url or BASE_URL).rstrip("/") + "/api/mail/getStock" + params = {} + if mail_type: + params["mailType"] = mail_type + try: + r = requests.get(url, params=params or None, timeout=TIMEOUT) + r.raise_for_status() + data = r.json() + if data.get("success") and data.get("code") == 0: + return int(data.get("data", 0)) + except Exception: + pass + return None + + +def get_mail( + base_url: str, + client_key: str, + quantity: int, + mail_type: Optional[str] = None, +) -> List[Dict[str, Any]]: + """ + 拉取邮箱. GET /api/mail/getMail?clientKey=xxx&mailType=xxx&quantity=n + 返回 data 为 ["Account:Password:Refresh_token:Client_id", ...] + 解析为 [{"email","password","refresh_token","client_id"}, ...] + """ + url = (base_url or BASE_URL).rstrip("/") + "/api/mail/getMail" + params = {"clientKey": client_key, "quantity": quantity} + if mail_type: + params["mailType"] = mail_type + out = [] + try: + r = requests.get(url, params=params, timeout=TIMEOUT) + r.raise_for_status() + data = r.json() + if not data.get("success") or data.get("code") != 0: + return out + raw_list = data.get("data") or [] + for raw in raw_list: + if not isinstance(raw, str): + continue + # Account:Password:Refresh_token:Client_id,其中 Refresh_token 可能含冒号 + parts = raw.split(":") + if len(parts) < 4: + continue + email = parts[0].strip() + password = parts[1].strip() + client_id = parts[-1].strip() + refresh_token = ":".join(parts[2:-1]).strip() + out.append({ + "email": email, + "password": password, + "refresh_token": refresh_token, + "client_id": client_id, + }) + except Exception: + pass + return out + + +def get_first_mail( + base_url: str, + client_key: str, + account: str, + folder: str = "inbox", +) -> Optional[Dict[str, Any]]: + """ + 获取该邮箱最新一封邮件. GET /v1/mail/getFirstMail + account 格式: email:password:refresh_token:client_id + folder: inbox / junkemail + """ + url = (base_url or BASE_URL).rstrip("/") + "/v1/mail/getFirstMail" + params = {"clientKey": client_key, "account": account, "folder": folder} + try: + r = requests.get(url, params=params, timeout=TIMEOUT) + r.raise_for_status() + data = r.json() + if not data.get("success") or data.get("code") != 0: + return None + return data.get("data") + except Exception: + pass + return None diff --git a/Register_GPT_v0/web/backend/app/services/otp_resolver.py b/Register_GPT_v0/web/backend/app/services/otp_resolver.py new file mode 100644 index 0000000..1c36fa9 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/services/otp_resolver.py @@ -0,0 +1,161 @@ +""" +注册 OTP:通过 Hotmail007 拉信,从邮件正文/标题解析 6 位验证码。 +与参考 chatgpt_register.py 对齐:多模式优先匹配(code:、verify、>xxx<、纯 6 位),只返回 6 位数字。 +""" +import re +import time +from typing import Callable, Optional + +from app.services.hotmail007 import get_first_mail + +# 与参考一致:优先匹配 OpenAI 邮件里常见格式,再回退到任意 6 位 +_OTP_PATTERNS = [ + r">\s*(\d{6})\s*<", # HTML 中 >123456< + r"(\d{6})\s*\n", # 行末 6 位 + r"code[:\s]+(\d{6})", # code: 123456 / code 123456 + r"verify.*?(\d{6})", # verify...123456 + r"\b(\d{6})\b", # 独立 6 位数字 + r"(\d{6})", # 任意 6 位(最后回退) +] + + +def _extract_otp_from_mail(data: Optional[dict]) -> Optional[str]: + """从 get_first_mail 返回的 data 中解析 6 位验证码,与参考提取顺序一致。""" + if not data or not isinstance(data, dict): + return None + texts = [] + for key in ("Subject", "subject", "Text", "text", "Body", "body", "Html", "html", "Content", "content"): + v = data.get(key) + if isinstance(v, str) and v.strip(): + texts.append(v) + combined = " ".join(texts) + for pattern in _OTP_PATTERNS: + m = re.search(pattern, combined, re.IGNORECASE | re.DOTALL) + if m: + raw = m.group(1) + digits = re.sub(r"\D", "", raw) + if len(digits) >= 6: + return digits[:6] + return None + + +def get_otp_for_email( + base_url: str, + client_key: str, + account_str: str, + timeout_sec: float = 120, + interval_sec: float = 5, + folder: str = "inbox", + folders=None, + exclude_codes=None, + stop_check=None, +) -> Optional[str]: + """ + 轮询该邮箱最新一封邮件,解析 6 位验证码,超时返回 None。 + stop_check: 可调用对象,返回 True 时立即结束并返回 None。 + """ + if not client_key or not account_str: + return None + excluded = set(exclude_codes or []) + folder_list = [f for f in (folders or [folder]) if isinstance(f, str) and f.strip()] + if not folder_list: + folder_list = ["inbox"] + deadline = time.monotonic() + timeout_sec + while time.monotonic() < deadline: + if stop_check and callable(stop_check) and stop_check(): + return None + for current_folder in folder_list: + data = get_first_mail(base_url, client_key, account_str, folder=current_folder) + otp = _extract_otp_from_mail(data) + if otp and otp not in excluded: + return otp + for _ in range(int(interval_sec)): + if stop_check and callable(stop_check) and stop_check(): + return None + time.sleep(1) + return None + + +def peek_latest_otps( + base_url: str, + client_key: str, + account_str: str, + folder: str = "inbox", + folders=None, +) -> set[str]: + """ + 读取指定文件夹当前“最新一封”邮件里的验证码,用于在发起新 OTP 前先排除旧码。 + """ + if not client_key or not account_str: + return set() + folder_list = [f for f in (folders or [folder]) if isinstance(f, str) and f.strip()] + if not folder_list: + folder_list = ["inbox"] + out: set[str] = set() + for current_folder in folder_list: + data = get_first_mail(base_url, client_key, account_str, folder=current_folder) + otp = _extract_otp_from_mail(data) + if otp: + out.add(otp) + return out + + +def build_otp_fetcher( + base_url: str, + client_key: str, + account_str: str, + timeout_sec: float = 120, + interval_sec: float = 5, + stop_check=None, +) -> Callable[[], Optional[str]]: + """ + 构造带状态的 OTP 获取器。 + - 自动排除已使用过的验证码 + - 可在登录二次验证前预先排除收件箱/垃圾箱当前顶上的旧验证码 + """ + used_otps: set[str] = set() + + def seed_current_otps(folder: str = "inbox", folders=None) -> set[str]: + current = peek_latest_otps( + base_url, + client_key, + account_str, + folder=folder, + folders=folders, + ) + used_otps.update(current) + return set(current) + + def get_used_otps() -> set[str]: + return set(used_otps) + + def get_otp_fn() -> Optional[str]: + search_folders = ["inbox"] if not used_otps else ["junkemail", "inbox"] + otp = get_otp_for_email( + base_url, + client_key, + account_str, + timeout_sec=timeout_sec, + interval_sec=interval_sec, + folders=search_folders, + exclude_codes=used_otps, + stop_check=stop_check, + ) + # 首次取码可兜底一次;后续步骤必须拿“新码”,避免复用旧码导致 401。 + if not otp and not used_otps: + otp = get_otp_for_email( + base_url, + client_key, + account_str, + timeout_sec=20, + interval_sec=3, + folders=["inbox", "junkemail"], + stop_check=stop_check, + ) + if otp: + used_otps.add(otp) + return otp + + setattr(get_otp_fn, "seed_current_otps", seed_current_otps) + setattr(get_otp_fn, "get_used_otps", get_used_otps) + return get_otp_fn diff --git a/Register_GPT_v0/web/backend/app/services/phone_bind_runner.py b/Register_GPT_v0/web/backend/app/services/phone_bind_runner.py new file mode 100644 index 0000000..55e01d6 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/services/phone_bind_runner.py @@ -0,0 +1,460 @@ +# -*- coding: utf-8 -*- +""" +开始绑定手机:只从账号管理取已具备 Sora 资格的账号 +(Registered+Sora、has_sora=1、sora_enabled=1、phone_bound=0 且有 RT/AT), +从手机号管理取可用号码,执行 Sora 激活 + enroll/start -> 轮询验证码 -> enroll/finish, +更新 accounts.phone_bound、phone_numbers.used_count。 +""" +import re +import random +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta + +from app.database import get_db, init_db +from app.services import hero_sms +from app.services.otp_resolver import build_otp_fetcher + +# 绑定任务状态(与 register 类似,独立 stop 标志) +_phone_bind_running = False +_phone_bind_task_id = None +_phone_bind_heartbeat = None +_phone_bind_stop = False +_phone_bind_lock = threading.Lock() + +PHONE_CODE_POLL_INTERVAL = 5 +PHONE_CODE_MAX_RETRIES = 60 +PHONE_BIND_PREFERRED_COUNTRY = 52 +PHONE_BIND_PREFERRED_PHONE_PREFIXES = ("66",) + + +def is_phone_bind_stop_requested() -> bool: + with _phone_bind_lock: + return _phone_bind_stop + + +def set_phone_bind_stop(value: bool) -> None: + with _phone_bind_lock: + global _phone_bind_stop + _phone_bind_stop = value + + +def set_phone_bind_task_started(task_id: str) -> bool: + """返回 False 表示已在运行。""" + with _phone_bind_lock: + global _phone_bind_running, _phone_bind_task_id, _phone_bind_heartbeat, _phone_bind_stop + if _phone_bind_running: + return False + _phone_bind_running = True + _phone_bind_task_id = task_id + _phone_bind_heartbeat = datetime.utcnow().isoformat() + "Z" + _phone_bind_stop = False + return True + + +def get_phone_bind_status() -> dict: + with _phone_bind_lock: + return { + "running": _phone_bind_running, + "task_id": _phone_bind_task_id, + "heartbeat": _phone_bind_heartbeat, + } + + +# 接码平台未返回到期时间时,默认有效期(分钟),与 sms_api 一致 +_PHONE_DEFAULT_EXPIRE_MINUTES = 20 + + +def _get_settings(): + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + "SELECT key, value FROM system_settings WHERE key IN (" + "'sms_api_url', 'sms_api_key', 'proxy_url', 'sms_openai_service', 'sms_max_price', 'phone_bind_limit'," + "'email_api_url', 'email_api_key')" + ) + rows = c.fetchall() + out = {} + for k, v in rows: + out[k] = (v or "").strip() + out.setdefault("sms_api_url", "https://hero-sms.com/stubs/handler_api.php") + return out + + +def _log(task_id: str, level: str, message: str): + try: + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, level, (message or "")[:500], datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + except Exception: + pass + + +def fetch_accounts_to_bind(limit: int = 50, exclude_ids=None): + """账号管理:仅挑已具备 Sora 资格且未绑手机、仍可进入轮换池的账号。""" + init_db() + with get_db() as conn: + c = conn.cursor() + where = [ + "phone_bound = 0", + "COALESCE(status, '') = 'Registered+Sora'", + "COALESCE(has_sora, 0) = 1", + "COALESCE(sora_enabled, 1) = 1", + """( + (refresh_token IS NOT NULL AND refresh_token != '') + OR (access_token IS NOT NULL AND access_token != '') + )""", + ] + params = [] + exclude_ids = [int(x) for x in (exclude_ids or []) if str(x).strip()] + if exclude_ids: + where.append("id NOT IN ({})".format(",".join(["?"] * len(exclude_ids)))) + params.extend(exclude_ids) + params.append(limit) + c.execute( + f"""SELECT id, email, password, refresh_token, access_token, proxy FROM accounts + WHERE {' AND '.join(where)} + ORDER BY id ASC LIMIT ?""", + tuple(params), + ) + return c.fetchall() + + +def _load_email_mailbox(email: str): + account_email = (email or "").strip() + if not account_email: + return None + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """SELECT email, password, uuid, token + FROM emails + WHERE LOWER(TRIM(email)) = LOWER(TRIM(?)) + LIMIT 1""", + (account_email,), + ) + row = c.fetchone() + if not row: + return None + return { + "email": row[0] or "", + "password": row[1] or "", + "uuid": row[2] or "", + "token": row[3] or "", + } + + +def _build_account_otp_fetcher(email: str): + mailbox = _load_email_mailbox(email) + if not mailbox: + return None + settings = _get_settings() + base_url = (settings.get("email_api_url") or "https://gapi.hotmail007.com").rstrip("/") + client_key = settings.get("email_api_key") or "" + if not client_key: + return None + account_str = f"{mailbox['email']}:{mailbox['password']}:{mailbox['token']}:{mailbox['uuid']}" + return build_otp_fetcher(base_url, client_key, account_str, timeout_sec=120, interval_sec=5, stop_check=is_phone_bind_stop_requested) + + +def fetch_phones_available(limit: int = 50): + """手机号管理:仅挑当前已验证更稳定的泰国号。""" + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """SELECT id, phone, activation_id, max_use_count, used_count FROM phone_numbers + WHERE activation_id IS NOT NULL AND used_count < max_use_count + AND ( + COALESCE(remark, '') LIKE ? + OR REPLACE(REPLACE(COALESCE(phone, ''), '+', ''), ' ', '') LIKE ? + ) + ORDER BY id ASC LIMIT ?""", + (f"%country={PHONE_BIND_PREFERRED_COUNTRY}%", f"{PHONE_BIND_PREFERRED_PHONE_PREFIXES[0]}%", limit), + ) + return c.fetchall() + + +def _fetch_numbers_from_api(task_id: str, max_try: int = 3) -> int: + """无可用手机号时从接码 API 拉取泰国号并写入 phone_numbers,返回成功写入条数。""" + settings = _get_settings() + base = settings.get("sms_api_url") or "https://hero-sms.com/stubs/handler_api.php" + key = settings.get("sms_api_key") or "" + if not key: + _log(task_id, "warning", "未配置接码 API KEY,无法自动拉取手机号") + return 0 + service = (settings.get("sms_openai_service") or "openai").strip() or "openai" + try: + max_price = float(settings.get("sms_max_price") or "0") + except (TypeError, ValueError): + max_price = 0 + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute("SELECT value FROM system_settings WHERE key = ?", ("phone_bind_limit",)) + row = c.fetchone() + limit = int(row[0]) if row and row[0] else 1 + country = PHONE_BIND_PREFERRED_COUNTRY + inserted = 0 + for _ in range(max_try): + result = hero_sms.get_number_auto(base, key, service, country, max_price=max_price) + if not result: + break + if result.get("error"): + _log(task_id, "warning", f"自动拉取泰国手机号失败: {str(result['error'])[:200]}") + break + expired_at = result.get("expired_at") + if not (expired_at and str(expired_at).strip()): + default_end = (datetime.utcnow() + timedelta(minutes=_PHONE_DEFAULT_EXPIRE_MINUTES)).strftime("%Y-%m-%d %H:%M:%S") + expired_at = default_end + else: + raw = str(expired_at).strip() + if "T" in raw: + raw = raw.replace("Z", "").split(".")[0].replace("T", " ") + expired_at = raw + with get_db() as conn: + c = conn.cursor() + country_code = result.get("country") + remark = "Hero-SMS(自动)" + if country_code not in (None, "", 0): + remark = f"Hero-SMS(自动 country={country_code})" + c.execute( + "INSERT INTO phone_numbers (phone, activation_id, max_use_count, remark, expired_at) VALUES (?, ?, ?, ?, ?)", + (result["phone_number"], result["activation_id"], limit, remark, expired_at), + ) + inserted += 1 + suffix = f" country={country_code}" if country_code not in (None, "", 0) else "" + _log(task_id, "info", "自动拉取泰国手机号: " + str(result["phone_number"]) + suffix) + return inserted + + +def _ensure_phone_inventory(task_id: str, needed: int) -> list: + """确保本轮至少拿到 needed 条可用泰国号,拿不到则返回当前能拿到的全部。""" + needed = max(0, int(needed or 0)) + phones = fetch_phones_available(limit=max(needed, 1)) + while len(phones) < needed: + missing = needed - len(phones) + _log(task_id, "info", f"无足够泰国手机号,尝试自动补拉 {missing} 条") + inserted = _fetch_numbers_from_api(task_id, max_try=max(missing, 1)) + if inserted <= 0: + break + phones = fetch_phones_available(limit=max(needed, 1)) + return phones + + +def run_one_phone_bind(task_id: str, account_id: int, email: str, account_password: str, refresh_token: str, access_token: str, account_proxy: str, + phone_id: int, phone: str, activation_id: int, + sms_base: str, sms_key: str, proxy_url: str) -> bool: + """ + 单条绑定:拿 AT -> Sora 激活 -> enroll/start -> 轮询验证码 -> enroll/finish -> 更新 DB。 + 返回 True 表示成功。 + """ + from app.registration_env import inject_registration_modules + inject_registration_modules() + + import protocol_sora_phone as sora_phone + + def log(msg): + _log(task_id, "info", msg) + + def close_enroll_ctx(ctx): + if not isinstance(ctx, dict): + return + sess = ctx.get("web_session") + if sess is None: + return + try: + sess.close() + except Exception: + pass + + get_otp_fn = _build_account_otp_fetcher(email) + if get_otp_fn: + seed_current_otps = getattr(get_otp_fn, "seed_current_otps", None) + if callable(seed_current_otps): + try: + seeded = seed_current_otps(folders=["junkemail", "inbox"]) + except Exception: + seeded = set() + if seeded: + log(f"[绑定] {email} 预排除旧 OTP: {','.join(sorted(seeded))}") + + at = (access_token or "").strip() + if not at and (refresh_token or "").strip(): + log(f"[绑定] {email} RT 换 AT...") + out = sora_phone.rt_to_at_mobile(refresh_token.strip(), proxy_url=proxy_url or account_proxy, log_fn=log) + at = (out.get("access_token") or "").strip() + new_rt = out.get("refresh_token") + if new_rt and isinstance(new_rt, str): + try: + with get_db() as conn: + c = conn.cursor() + c.execute("UPDATE accounts SET refresh_token = ? WHERE id = ?", (new_rt.strip(), account_id)) + except Exception: + pass + if not at: + log(f"[绑定] {email} 无 AT,跳过") + return False + + if is_phone_bind_stop_requested(): + return False + + log(f"[绑定] {email} Sora 激活...") + if not sora_phone.sora_ensure_activated(at, proxy_url=proxy_url or account_proxy, log_fn=log): + log(f"[绑定] {email} Sora 激活失败") + return False + + if is_phone_bind_stop_requested(): + return False + + log(f"[绑定] {email} 发送验证码 -> {phone}") + ok, err, enroll_ctx = sora_phone.sora_phone_enroll_start( + at, + phone, + proxy_url=proxy_url or account_proxy, + log_fn=log, + login_email=email, + login_password=(account_password or "").strip(), + get_otp_fn=get_otp_fn, + ) + if not ok: + if err == "phone_used": + log(f"[绑定] 手机号已被使用: {phone}") + elif err == "reauth_failed": + log(f"[绑定] {email} recent reauth 失败") + elif err == "sms_unavailable": + log(f"[绑定] {email} 当前未开放 SMS MFA") + elif err == "invalid_request": + log(f"[绑定] {email} 手机号参数被上游拒绝: {phone}") + return False + + code = None + for i in range(PHONE_CODE_MAX_RETRIES): + if is_phone_bind_stop_requested(): + close_enroll_ctx(enroll_ctx) + return False + out = hero_sms.get_status_v2(sms_base, sms_key, activation_id) + if out and out.get("code"): + raw = out.get("code") + m = re.search(r"\d{6}", str(raw)) + if m: + code = m.group() + break + import time + time.sleep(PHONE_CODE_POLL_INTERVAL) + + if not code: + log(f"[绑定] {email} 获取验证码超时") + close_enroll_ctx(enroll_ctx) + return False + + log(f"[绑定] {email} 提交验证码...") + if not sora_phone.sora_phone_enroll_finish( + at, + phone, + code, + proxy_url=proxy_url or account_proxy, + log_fn=log, + context=enroll_ctx, + ): + log(f"[绑定] {email} 验证码提交失败") + close_enroll_ctx(enroll_ctx) + return False + + with get_db() as conn: + c = conn.cursor() + c.execute("UPDATE accounts SET phone_bound = 1 WHERE id = ?", (account_id,)) + c.execute("UPDATE phone_numbers SET used_count = used_count + 1 WHERE id = ?", (phone_id,)) + close_enroll_ctx(enroll_ctx) + log(f"[绑定] 成功 {email} -> {phone}") + return True + + +def run_phone_bind_loop(task_id: str, max_count: int = None): + """后台循环:取待绑定账号与可用手机号,按 max_count 并发执行直到达到目标成功数或停止。""" + global _phone_bind_running, _phone_bind_heartbeat, _phone_bind_stop + settings = _get_settings() + sms_base = settings.get("sms_api_url") or "https://hero-sms.com/stubs/handler_api.php" + sms_key = settings.get("sms_api_key") or "" + proxy_url = settings.get("proxy_url") or "" + if not sms_key: + _log(task_id, "error", "请先在系统设置中配置手机号接码 API KEY") + with _phone_bind_lock: + _phone_bind_running = False + return + + processed = 0 + success_count = 0 + skipped_account_ids = set() + try: + while True: + if is_phone_bind_stop_requested(): + _log(task_id, "info", "已请求停止绑定") + break + if max_count is not None and success_count >= max_count: + _log(task_id, "info", f"已达到目标绑定数量 {max_count}") + break + + remaining_target = max_count - success_count if max_count is not None else 1 + batch_size = max(1, int(remaining_target)) + + accounts = fetch_accounts_to_bind(limit=max(batch_size * 2, batch_size), exclude_ids=skipped_account_ids) + if not accounts: + if skipped_account_ids: + _log(task_id, "info", "无更多可尝试账号(本轮失败账号已跳过)") + else: + _log(task_id, "info", "无待绑定账号(需满足 Registered+Sora、has_sora=1、sora_enabled=1、phone_bound=0 且有 RT/AT)") + break + + batch_size = min(batch_size, len(accounts)) + phones = _ensure_phone_inventory(task_id, batch_size) + if not phones: + _log(task_id, "info", "无可用泰国手机号(已尝试自动拉取仍无)") + break + + batch_size = min(batch_size, len(phones)) + accounts = accounts[:batch_size] + phones = phones[:batch_size] + + with _phone_bind_lock: + _phone_bind_heartbeat = datetime.utcnow().isoformat() + "Z" + + if batch_size > 1: + _log(task_id, "info", f"开始并发绑定,本轮并发 {batch_size} 条,目标剩余 {remaining_target}") + + with ThreadPoolExecutor(max_workers=batch_size) as executor: + future_map = {} + for acc, ph in zip(accounts, phones): + account_id, email, account_password, rt, at, account_proxy = acc[0], acc[1], acc[2] or "", acc[3], acc[4], acc[5] or "" + phone_id, phone, act_id = ph[0], ph[1], ph[2] + future = executor.submit( + run_one_phone_bind, + task_id, + account_id, email, account_password, rt or "", at or "", account_proxy, + phone_id, phone, act_id, + sms_base, sms_key, proxy_url, + ) + future_map[future] = (account_id, email, phone) + + for future in as_completed(future_map): + account_id, email, phone = future_map[future] + processed += 1 + try: + ok = bool(future.result()) + except Exception as exc: + ok = False + _log(task_id, "error", f"单条绑定线程异常 {email} -> {phone}: {exc}") + if ok: + success_count += 1 + else: + skipped_account_ids.add(account_id) + _log(task_id, "warning", f"单条绑定失败,跳过账号继续下一条: {email} -> {phone}") + finally: + with _phone_bind_lock: + _phone_bind_running = False + _log(task_id, "info", f"绑定任务结束 处理 {processed} 条 成功 {success_count} 条") diff --git a/Register_GPT_v0/web/backend/app/services/registration_runner.py b/Register_GPT_v0/web/backend/app/services/registration_runner.py new file mode 100644 index 0000000..72170eb --- /dev/null +++ b/Register_GPT_v0/web/backend/app/services/registration_runner.py @@ -0,0 +1,483 @@ +# -*- coding: utf-8 -*- +""" +单条注册任务运行器:从 DB 取未注册邮箱与配置,调 protocol_register,落库 accounts/run_logs,更新 last_run_success/fail。 +不实现 8 步协议,仅「取配置 → 调 register_one_protocol / activate_sora → 落库」。 +环境变量:PRINT_STEP_LOGS=1 时步骤日志同时输出到 stdout,便于终端跑测。 +""" +import os +import random +from datetime import datetime +from typing import Callable, Optional, Tuple + +from app.database import get_db, init_db +from app.registration_env import inject_registration_modules, set_task_config, clear_task_config +from app.registration_state import is_stop_requested +from app.services.otp_resolver import build_otp_fetcher + +# 首次执行注册前注入 config/utils,再懒加载 protocol_register,避免未注入时导入 +_injected = False + + +def _ensure_injected(): + global _injected + if not _injected: + inject_registration_modules() + _injected = True + + +def _get_registration_settings() -> dict: + """从 system_settings 读取注册所需配置。""" + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """SELECT key, value FROM system_settings WHERE key IN ( + 'thread_count', 'retry_count', 'proxy_url', 'email_api_url', 'email_api_key', + 'oauth_client_id', 'oauth_redirect_uri' + )""" + ) + rows = c.fetchall() + out = {} + for k, v in rows: + out[k] = (v or "").strip() + out.setdefault("retry_count", "2") + out.setdefault("thread_count", "1") + return out + + +def fetch_one_unregistered_email(conn, order_random: bool = False) -> Optional[Tuple]: + """取一条未注册邮箱。返回 (id, email, password, uuid, token) 或 None。order_random=True 时随机取一条便于轮换邮箱。""" + c = conn.cursor() + order = "ORDER BY RANDOM()" if order_random else "" + c.execute( + f"""SELECT e.id, e.email, e.password, e.uuid, e.token + FROM emails e + LEFT JOIN accounts a ON LOWER(TRIM(e.email)) = LOWER(TRIM(a.email)) + WHERE a.email IS NULL + AND e.email IS NOT NULL + AND TRIM(e.email) != '' + AND COALESCE(e.remark, '') NOT LIKE '%[skip_bad_request]%' + {order} + LIMIT 1""" + ) + row = c.fetchone() + return tuple(row) if row else None + + +def fetch_unregistered_emails(limit: int = 10): + """取最多 limit 条未注册邮箱(随机顺序),用于多线程分配。返回 [(id, email, password, uuid, token), ...]。""" + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """SELECT e.id, e.email, e.password, e.uuid, e.token + FROM emails e + LEFT JOIN accounts a ON LOWER(TRIM(e.email)) = LOWER(TRIM(a.email)) + WHERE a.email IS NULL + AND e.email IS NOT NULL + AND TRIM(e.email) != '' + AND COALESCE(e.remark, '') NOT LIKE '%[skip_bad_request]%' + ORDER BY RANDOM() + LIMIT ?""", + (max(1, limit),), + ) + rows = c.fetchall() + return [tuple(r) for r in rows] + + +def _default_user_info() -> dict: + """协议需要的 name, year, month, day(字符串)。""" + y = random.randint(1985, 2000) + m = random.randint(1, 12) + d = random.randint(1, 28) + return { + "name": "User", + "year": str(y), + "month": str(m).zfill(2), + "day": str(d).zfill(2), + } + + +def _run_one_registration( + email_id: int, + email: str, + password: str, + uuid_val: str, + token: str, + settings: dict, + task_id: str, +) -> Tuple[bool, Optional[str], Optional[dict], Callable[[], Optional[str]]]: + """ + 执行单条注册(不重试)。返回 (success, status_extra, tokens, get_otp_fn)。 + """ + _ensure_injected() + import protocol_register as pr # 注入后导入 + + base = (settings.get("email_api_url") or "https://gapi.hotmail007.com").rstrip("/") + key = settings.get("email_api_key") or "" + # 支持多行 proxy_url:每行一个代理,随机选用其一 + proxy_raw = (settings.get("proxy_url") or "").strip() + proxy_lines = [p.strip() for p in proxy_raw.splitlines() if p.strip()] + proxy_url = random.choice(proxy_lines) if proxy_lines else None + + account_str = f"{email}:{password or ''}:{token or ''}:{uuid_val or ''}" + get_otp_fn = build_otp_fetcher( + base, + key, + account_str, + timeout_sec=120, + interval_sec=5, + stop_check=is_stop_requested, + ) + + _print_steps = os.environ.get("PRINT_STEP_LOGS", "").strip().lower() in ("1", "true", "yes") + + def _step_log(msg: str) -> None: + try: + with get_db() as conn: + c = conn.cursor() + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "info", (msg or "")[:500], created), + ) + except Exception: + pass + if _print_steps and msg: + print(f" [step] {msg}", flush=True) + + set_task_config( + proxy_url=proxy_url, + timeout=60, + http_max_retries=5, + oauth_client_id=settings.get("oauth_client_id") or "", + oauth_redirect_uri=settings.get("oauth_redirect_uri") or "", + ) + try: + result = pr.register_one_protocol( + email, + password, + token or "", + get_otp_fn, + _default_user_info(), + proxy_url=proxy_url, + step_log_fn=_step_log, + stop_check=is_stop_requested, + ) + except pr.RetryException as e: + with get_db() as conn: + c = conn.cursor() + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "info", f"409 会话已清理,将重试 {email}: {e!s}", created), + ) + return False, str(e), None, get_otp_fn + except Exception as e: + with get_db() as conn: + c = conn.cursor() + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "error", f"注册异常 {email}: {e!s}", created), + ) + return False, str(e), None, get_otp_fn + finally: + clear_task_config() + + # result: (email, password, success[, status_extra[, tokens]]) + success = bool(result[2]) if len(result) > 2 else False + status_extra = result[3] if len(result) > 3 else None + tokens = result[4] if len(result) > 4 else None + if isinstance(tokens, dict): + pass + else: + tokens = None + return success, status_extra, tokens, get_otp_fn + + +# 密码规则:与 protocol_register.PASSWORD_MIN_LENGTH 一致,OpenAI 要求最少 12 位,建议含大小写+数字+符号 +PASSWORD_MIN_LENGTH = 12 + + +def _random_password() -> str: + """生成随机密码:至少 12 位,含大小写、数字、符号,满足 OpenAI 协议要求。""" + import string + upper = random.choices(string.ascii_uppercase, k=2) + lower = random.choices(string.ascii_lowercase, k=2) + digit = random.choices(string.digits, k=2) + symbol = random.choices("!@#$%&*", k=2) + rest = random.choices(string.ascii_letters + string.digits + "!@#$%&*", k=PASSWORD_MIN_LENGTH - 8) + parts = upper + lower + digit + symbol + rest + random.shuffle(parts) + return "".join(parts) + + +def run_one_with_retry( + email_id: int, + email: str, + password: str, + uuid_val: str, + token: str, + settings: dict, + task_id: str, +) -> bool: + """ + 单条任务带重试(1~5 次),成功写 accounts、run_logs、last_run_success,失败写 run_logs、last_run_fail。 + 返回是否最终成功。 + """ + pwd = (password or "").strip() or _random_password() + if len(pwd) < PASSWORD_MIN_LENGTH: + pwd = _random_password() + retry_count = max(1, min(5, int(settings.get("retry_count") or "2"))) + last_error = None + _now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "info", f"正在注册账号 {email}", _now), + ) + if is_stop_requested(): + with get_db() as conn: + c = conn.cursor() + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "info", f"任务已停止,跳过 {email}", created), + ) + return False + for attempt in range(retry_count): + if is_stop_requested(): + with get_db() as conn: + c = conn.cursor() + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "info", f"任务已停止,跳过 {email}", created), + ) + return False + use_settings = settings + _restore_direct_auth = None + err = str(last_error or "").lower() + retry_no_proxy = ( + attempt == 1 + and last_error + and ( + "409" in str(last_error) + or "invalid_state" in err + or "invalid_auth_step" in err + or "invalid authorization step" in err + or "tls" in err + or "connection timed out" in err + or "curl: (28)" in str(last_error) + or "curl: (35)" in str(last_error) + ) + ) + if retry_no_proxy: + use_settings = {**settings, "proxy_url": ""} + _restore_direct_auth = os.environ.get("USE_DIRECT_AUTH") + os.environ["USE_DIRECT_AUTH"] = "1" + print("[*] retry: no proxy + USE_DIRECT_AUTH=1", flush=True) + with get_db() as conn: + c = conn.cursor() + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "info", "重试:无代理 + 直连 auth", created), + ) + try: + success, status_extra, tokens, get_otp_fn = _run_one_registration( + email_id, email, pwd, uuid_val, token, use_settings, task_id + ) + finally: + if _restore_direct_auth is not None: + if _restore_direct_auth: + os.environ["USE_DIRECT_AUTH"] = _restore_direct_auth + else: + os.environ.pop("USE_DIRECT_AUTH", None) + if success: + _ensure_injected() + import protocol_register as pr + proxy_raw = (settings.get("proxy_url") or "").strip() + proxy_lines = [p.strip() for p in proxy_raw.splitlines() if p.strip()] + same_proxy = random.choice(proxy_lines) if proxy_lines else None + sora_ok = False + sora_tokens = dict(tokens or {}) if isinstance(tokens, dict) else {} + + def _sora_step_log(msg: str): + try: + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "info", (msg or "")[:500], datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + except Exception: + pass + + try: + sora_ok = pr.activate_sora( + sora_tokens, + email, + proxy_url=same_proxy, + step_log_fn=_sora_step_log, + account_password=pwd, + get_otp_fn=get_otp_fn, + ) + except Exception as exc: + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "error", f"Sora2 激活异常 {email}: {exc!s}", datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + + if (not sora_ok) and same_proxy: + _sora_step_log("[*] Sora2 激活失败,尝试直连再次补激活") + try: + sora_ok = pr.activate_sora( + sora_tokens, + email, + proxy_url="", + step_log_fn=_sora_step_log, + account_password=pwd, + get_otp_fn=get_otp_fn, + ) + except Exception as exc: + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "error", f"Sora2 直连补激活异常 {email}: {exc!s}", datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + try: + rt = "" + at = "" + if isinstance(sora_tokens, dict): + rt = sora_tokens.get("refresh_token") or "" + if not rt and isinstance(sora_tokens.get("session"), dict): + rt = (sora_tokens.get("session") or {}).get("refresh_token") or "" + rt = (rt or "").strip() if rt else "" + at = (sora_tokens.get("access_token") or "").strip() or None + with get_db() as conn: + c = conn.cursor() + c.execute( + """INSERT OR REPLACE INTO accounts (email, password, status, registered_at, has_sora, has_plus, phone_bound, proxy, refresh_token, access_token) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + email, + pwd, + "Registered+Sora" if sora_ok else "Registered", + datetime.now().strftime("%Y-%m-%d %H:%M"), + 1 if sora_ok else 0, + 0, + 0, + (same_proxy or ""), + rt or None, + at, + ), + ) + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "info", (f"Sora2 注册成功 {email}" if sora_ok else f"账号已注册但未完成 Sora2 激活 {email}"), created), + ) + try: + from app.database import DB_PATH + c.execute("SELECT COUNT(*) FROM accounts") + n = c.fetchone()[0] + created2 = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "info", f"账号已写入 accounts 表,数据文件: {DB_PATH},当前共 {n} 条", created2), + ) + except Exception: + pass + stat_key = "last_run_success" if sora_ok else "last_run_fail" + c.execute("SELECT value FROM system_settings WHERE key = ?", (stat_key,)) + r2 = c.fetchone() + prev = int((r2[0] or "0")) if r2 else 0 + c.execute( + "INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)", + (stat_key, str(prev + 1)), + ) + except Exception as e: + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with get_db() as conn: + c = conn.cursor() + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "error", f"注册成功但写入账号列表失败 {email}: {e!s}", created), + ) + return False + return bool(sora_ok) + if not success and (str(status_extra or "").strip() == "0a_no_session"): + print(f"[*] 0a 未过 {email},跳过该邮箱,下一批自动换用其他账号", flush=True) + with get_db() as conn: + c = conn.cursor() + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "info", f"0a 未过 {email},跳过该邮箱改用下一账号", created), + ) + return False + last_error = status_extra or "注册失败" + with get_db() as conn: + c = conn.cursor() + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "error", f"尝试 {attempt + 1}/{retry_count} 失败 {email}: {last_error}", created), + ) + + with get_db() as conn: + c = conn.cursor() + created = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + c.execute( + "INSERT INTO run_logs (task_id, level, message, created_at) VALUES (?, ?, ?, ?)", + (task_id, "error", f"注册失败 {email} (已重试 {retry_count} 次)", created), + ) + c.execute("SELECT value FROM system_settings WHERE key = 'last_run_fail'") + row = c.fetchone() + prev = int((row[0] or "0")) if row else 0 + c.execute( + "INSERT OR REPLACE INTO system_settings (key, value) VALUES ('last_run_fail', ?)", + (str(prev + 1),), + ) + # 该错误通常表示邮箱已注册或已不可用于该注册流程,打标避免后续反复重试同邮箱。 + err_text = str(last_error or "").lower() + if "bad_request" in err_text or "failed to register username" in err_text: + c.execute("SELECT COALESCE(remark, '') FROM emails WHERE id = ?", (email_id,)) + row2 = c.fetchone() + old_remark = (row2[0] if row2 else "") or "" + if "[skip_bad_request]" not in old_remark: + new_remark = (old_remark + " [skip_bad_request]").strip() + c.execute("UPDATE emails SET remark = ? WHERE id = ?", (new_remark, email_id)) + return False + + +def run_one_task( + task_id: str, + settings: Optional[dict] = None, + email_row: Optional[Tuple] = None, +) -> bool: + """ + 执行单条注册任务。若传 email_row 则用该行;否则从 DB 取一条未注册邮箱。 + 返回是否执行并成功(无任务可执行时返回 False)。 + """ + init_db() + if settings is None: + settings = _get_registration_settings() + if email_row is not None: + row = email_row + else: + with get_db() as conn: + # 随机取一条,避免单个异常邮箱(如 0a/429)长期卡住队列头部。 + row = fetch_one_unregistered_email(conn, order_random=True) + if not row: + return False + if is_stop_requested(): + return False + email_id, email, password, uuid_val, token = row + return run_one_with_retry(email_id, email, password or "", uuid_val or "", token or "", settings, task_id) diff --git a/Register_GPT_v0/web/backend/app/services/sora_api_key.py b/Register_GPT_v0/web/backend/app/services/sora_api_key.py new file mode 100644 index 0000000..2cb46c5 --- /dev/null +++ b/Register_GPT_v0/web/backend/app/services/sora_api_key.py @@ -0,0 +1,166 @@ +import hashlib +import secrets +from typing import Dict, Optional + +from fastapi import Depends, Header, HTTPException + +from app.database import get_db, init_db +from app.routers.auth import get_optional_user + + +SORA_API_KEY_SCOPE_TEXT = "text_to_video" +SORA_API_KEY_SCOPE_IMAGE = "image_to_video" +SORA_API_KEY_SCOPE_ALL = "all_video" + +_SORA_API_KEY_SCOPE_ALIASES = { + "text": SORA_API_KEY_SCOPE_TEXT, + "text_to_video": SORA_API_KEY_SCOPE_TEXT, + "text-video": SORA_API_KEY_SCOPE_TEXT, + "text2video": SORA_API_KEY_SCOPE_TEXT, + "文生视频": SORA_API_KEY_SCOPE_TEXT, + "image": SORA_API_KEY_SCOPE_IMAGE, + "image_to_video": SORA_API_KEY_SCOPE_IMAGE, + "image-video": SORA_API_KEY_SCOPE_IMAGE, + "image2video": SORA_API_KEY_SCOPE_IMAGE, + "图生视频": SORA_API_KEY_SCOPE_IMAGE, + "all": SORA_API_KEY_SCOPE_ALL, + "all_video": SORA_API_KEY_SCOPE_ALL, + "both": SORA_API_KEY_SCOPE_ALL, + "combined": SORA_API_KEY_SCOPE_ALL, + "hybrid": SORA_API_KEY_SCOPE_ALL, + "文生+图生": SORA_API_KEY_SCOPE_ALL, +} + +_SORA_API_KEY_SCOPE_LABELS = { + SORA_API_KEY_SCOPE_TEXT: "文生视频", + SORA_API_KEY_SCOPE_IMAGE: "图生视频", + SORA_API_KEY_SCOPE_ALL: "文生+图生", +} + +_SORA_API_KEY_SCOPE_CAPABILITIES = { + SORA_API_KEY_SCOPE_TEXT: {SORA_API_KEY_SCOPE_TEXT}, + SORA_API_KEY_SCOPE_IMAGE: {SORA_API_KEY_SCOPE_IMAGE}, + SORA_API_KEY_SCOPE_ALL: {SORA_API_KEY_SCOPE_TEXT, SORA_API_KEY_SCOPE_IMAGE}, +} + + +def generate_sora_api_key() -> str: + # 32 bytes random -> 43 chars base64url;统一加前缀方便识别 + token = secrets.token_urlsafe(32).replace("-", "").replace("_", "") + return f"srk_{token}" + + +def hash_sora_api_key(raw_key: str) -> str: + return hashlib.sha256((raw_key or "").encode("utf-8")).hexdigest() + + +def mask_sora_api_key(raw_key: str) -> str: + key = (raw_key or "").strip() + if not key: + return "" + if len(key) <= 14: + return f"{key[:4]}***{key[-2:]}" + return f"{key[:12]}...{key[-4:]}" + + +def normalize_sora_api_key_scope(scope: str) -> str: + value = (scope or "").strip().lower() + return _SORA_API_KEY_SCOPE_ALIASES.get(value, SORA_API_KEY_SCOPE_TEXT) + + +def sora_api_key_scope_label(scope: str) -> str: + normalized = normalize_sora_api_key_scope(scope) + return _SORA_API_KEY_SCOPE_LABELS.get(normalized, _SORA_API_KEY_SCOPE_LABELS[SORA_API_KEY_SCOPE_TEXT]) + + +def sora_api_key_scope_allows(scope: str, capability: str) -> bool: + normalized_scope = normalize_sora_api_key_scope(scope) + normalized_capability = normalize_sora_api_key_scope(capability) + return normalized_capability in _SORA_API_KEY_SCOPE_CAPABILITIES.get(normalized_scope, set()) + + +def _extract_api_key( + authorization: Optional[str], + x_api_key: Optional[str], + x_sora_api_key: Optional[str], +) -> str: + key = (x_sora_api_key or "").strip() or (x_api_key or "").strip() + if key: + return key + auth = (authorization or "").strip() + if not auth: + return "" + parts = auth.split(" ", 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + candidate = parts[1].strip() + else: + candidate = auth + if candidate.startswith("srk_"): + return candidate + return "" + + +def authenticate_sora_api_key(raw_key: str) -> Optional[Dict]: + key = (raw_key or "").strip() + if not key: + return None + key_hash = hash_sora_api_key(key) + init_db() + with get_db() as conn: + c = conn.cursor() + c.execute( + """SELECT id, account_id, name, is_active, COALESCE(scope, ?) + FROM sora_api_keys + WHERE key_hash = ? + LIMIT 1""", + (SORA_API_KEY_SCOPE_TEXT, key_hash), + ) + row = c.fetchone() + if not row: + return None + if not bool(row[3]): + return None + c.execute("UPDATE sora_api_keys SET last_used_at = datetime('now') WHERE id = ?", (row[0],)) + return { + "id": row[0], + "account_id": row[1] if row[1] != 0 else None, # 0 = 池模式 → None + "name": row[2] or "", + "scope": normalize_sora_api_key_scope(row[4] or SORA_API_KEY_SCOPE_TEXT), + } + + +def get_sora_api_caller( + username: Optional[str] = Depends(get_optional_user), + authorization: Optional[str] = Header(default=None), + x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"), + x_sora_api_key: Optional[str] = Header(default=None, alias="X-Sora-Api-Key"), +): + """ + Sora 调用支持两种鉴权: + 1) 管理员 JWT(Authorization: Bearer ) + 2) 本地 Sora API Key(Authorization: Bearer srk_xxx / X-API-Key / X-Sora-Api-Key) + """ + if username: + return { + "auth_type": "admin", + "username": username, + "api_key_id": None, + "account_id": None, + } + + raw_key = _extract_api_key(authorization=authorization, x_api_key=x_api_key, x_sora_api_key=x_sora_api_key) + if not raw_key: + raise HTTPException(status_code=401, detail="Not authenticated") + + key_row = authenticate_sora_api_key(raw_key) + if not key_row: + raise HTTPException(status_code=401, detail="Invalid API key") + + return { + "auth_type": "api_key", + "username": "", + "api_key_id": key_row["id"], + "account_id": key_row["account_id"], + "api_key_scope": key_row["scope"], + "api_key_scope_label": sora_api_key_scope_label(key_row["scope"]), + } diff --git a/Register_GPT_v0/web/backend/requirements.txt b/Register_GPT_v0/web/backend/requirements.txt new file mode 100644 index 0000000..2bb8f05 --- /dev/null +++ b/Register_GPT_v0/web/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +python-multipart>=0.0.6 +passlib[bcrypt]>=1.7.4 +python-jose[cryptography]>=3.3.0 +aiosqlite>=0.19.0 +pydantic>=2.0.0 diff --git a/Register_GPT_v0/web/docker-compose.yml b/Register_GPT_v0/web/docker-compose.yml new file mode 100644 index 0000000..5bfb9ac --- /dev/null +++ b/Register_GPT_v0/web/docker-compose.yml @@ -0,0 +1,19 @@ +# 在 protocol 目录执行: docker-compose -f web/docker-compose.yml up -d +# 默认账号 admin / admin123,请通过环境变量修改 +version: "3" +services: + admin: + build: + context: .. + dockerfile: web/Dockerfile + ports: + - "1989:1989" + environment: + - ADMIN_USERNAME=admin + - ADMIN_PASSWORD=admin123 + - SECRET_KEY=change-me-in-production + - DATA_DIR=/data + volumes: + - admin-data:/data +volumes: + admin-data: diff --git a/Register_GPT_v0/web/frontend/index.html b/Register_GPT_v0/web/frontend/index.html new file mode 100644 index 0000000..03df0cc --- /dev/null +++ b/Register_GPT_v0/web/frontend/index.html @@ -0,0 +1,566 @@ + + + + + + Sora 批量注册 + + + + + + +
+
+ +
+ +
+ +
+ + + diff --git a/Register_GPT_v0/web/frontend/static/app.js b/Register_GPT_v0/web/frontend/static/app.js new file mode 100644 index 0000000..346f035 --- /dev/null +++ b/Register_GPT_v0/web/frontend/static/app.js @@ -0,0 +1,2729 @@ +const API_BASE = ""; +let token = localStorage.getItem("admin_token"); +let currentPage = 1; +let accountsTotal = 0; + +function api(url, options = {}) { + const headers = { "Content-Type": "application/json", ...options.headers }; + if (token) headers["Authorization"] = "Bearer " + token; + return fetch(API_BASE + url, { ...options, headers }).then(async (r) => { + if (r.status === 401) { + if (!url.includes("/auth/login")) { + localStorage.removeItem("admin_token"); + window.location.reload(); + } + const text = await r.text(); + let msg = "Unauthorized"; + try { + const j = JSON.parse(text); + if (j.detail) msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); + } catch (_) {} + throw new Error(msg); + } + if (!r.ok) throw new Error(await r.text()); + const ct = r.headers.get("content-type"); + if (ct && ct.includes("application/json")) return r.json(); + return r.text(); + }); +} + +function showPage(name) { + document.querySelectorAll(".panel").forEach((el) => el.classList.add("hidden")); + document.querySelectorAll(".nav a").forEach((a) => a.classList.remove("active")); + const panel = document.getElementById("panel-" + name); + const link = document.querySelector('.nav a[data-tab="' + name + '"]'); + if (panel) panel.classList.remove("hidden"); + if (link) link.classList.add("active"); + if (name === "accounts") loadAccounts(); + if (name === "emails") { + loadEmails(); + api("/api/settings").then((d) => { + const sel = document.getElementById("email-api-mail-type"); + if (sel && d.email_api_default_type) { + if ([].some.call(sel.options, (o) => o.value === d.email_api_default_type)) sel.value = d.email_api_default_type; + } + }).catch(() => {}); + } + if (name === "bank-cards") loadBankCards(); + if (name === "phones") loadPhones(); + if (name === "video") loadSoraVideoWorkspace(); + if (name === "keys") loadSoraKeyManagement(); + if (name === "logs") { + loadDashboard(); + loadLogs(); + updateRegisterStatusOnce(); + startRegisterStatusPoll(); + } else { + stopRegisterStatusPoll(); + } + if (name === "settings") loadSettings(); +} + +var registerStatusPollTimer = null; +var REGISTER_POLL_INTERVAL_MS = 1500; + +/** 状态来源:GET /api/register/status 的 running 字段(后端 _registration_running,重启后必为 false) */ +function updateRegisterButtonFromStatus(s) { + var btnStart = document.getElementById("btn-start-register"); + var btnStop = document.getElementById("btn-stop-register"); + var heartbeatEl = document.getElementById("register-status-heartbeat"); + if (!btnStart) return; + var running = !!(s && s.running === true); + if (running) { + btnStart.textContent = "正在注册"; + btnStart.disabled = true; + btnStart.classList.add("btn-dash-disabled"); + if (btnStop) { btnStop.style.display = ""; } + if (heartbeatEl) { + heartbeatEl.style.display = ""; + heartbeatEl.textContent = s.last_heartbeat ? "最后心跳时间 " + (s.last_heartbeat.replace("T", " ").replace("Z", "").slice(0, 19)) : ""; + } + } else { + btnStart.textContent = "开启注册"; + btnStart.disabled = false; + btnStart.classList.remove("btn-dash-disabled"); + if (btnStop) { btnStop.style.display = "none"; } + if (heartbeatEl) { + heartbeatEl.style.display = "none"; + heartbeatEl.textContent = ""; + } + } +} + +function updateRegisterStatusOnce() { + api("/api/register/status").then(function(s) { + updateRegisterButtonFromStatus(s); + }).catch(function() { + updateRegisterButtonFromStatus({ running: false }); + }); +} + +function startRegisterStatusPoll() { + stopRegisterStatusPoll(); + registerStatusPollTimer = setInterval(function() { + api("/api/register/status").then(function(s) { + updateRegisterButtonFromStatus(s); + loadDashboard(); + loadLogs(); + }).catch(function() { + updateRegisterButtonFromStatus({ running: false }); + }); + }, REGISTER_POLL_INTERVAL_MS); +} + +document.addEventListener("visibilitychange", function() { + if (document.visibilityState === "visible") { + var panelLogs = document.getElementById("panel-logs"); + if (panelLogs && !panelLogs.classList.contains("hidden")) { + updateRegisterStatusOnce(); + } + } +}); + +function stopRegisterStatusPoll() { + if (registerStatusPollTimer) { + clearInterval(registerStatusPollTimer); + registerStatusPollTimer = null; + } +} + +function showModal(html) { + document.getElementById("modal-body").innerHTML = html; + document.getElementById("modal").classList.remove("hidden"); +} +function hideModal() { + document.getElementById("modal").classList.add("hidden"); + var mc = document.querySelector(".modal-content"); + if (mc) mc.classList.remove("modal-content-wide"); +} +document.querySelector(".modal-close").addEventListener("click", hideModal); +document.getElementById("modal").addEventListener("click", (e) => { + if (e.target.id === "modal") hideModal(); +}); + +function toast(msg, type) { + type = type || "success"; + var container = document.getElementById("toast-container"); + var el = document.createElement("div"); + el.className = "toast " + type; + el.textContent = msg; + container.appendChild(el); + setTimeout(function() { + el.style.opacity = "0"; + el.style.transform = "translateX(100%)"; + setTimeout(function() { el.remove(); }, 250); + }, 2500); +} +function confirmBox(msg, onConfirm) { + showModal( + '
' + + '

' + escapeHtml(msg) + '

' + + '
' + + '' + + '' + + '
' + + '
' + ); + document.querySelector(".btn-cancel").addEventListener("click", function() { hideModal(); }); + document.querySelector(".btn-ok").addEventListener("click", function() { + hideModal(); + if (onConfirm) onConfirm(); + }); +} + +// Login +if (!token) { + document.getElementById("login-page").classList.remove("hidden"); + document.getElementById("admin-page").classList.add("hidden"); +} else { + document.getElementById("login-page").classList.add("hidden"); + document.getElementById("admin-page").classList.remove("hidden"); + api("/api/auth/me").then((d) => { + var u = document.getElementById("current-user"); if (u) { var t = u.querySelector(".user-name-text"); if (t) t.textContent = d.username; else u.textContent = d.username; } + }).catch(() => { + localStorage.removeItem("admin_token"); + window.location.reload(); + }); +} + +document.getElementById("login-form").addEventListener("submit", (e) => { + e.preventDefault(); + const username = document.getElementById("login-username").value.trim(); + const password = document.getElementById("login-password").value; + const errEl = document.getElementById("login-error"); + errEl.textContent = ""; + api("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }) + .then((d) => { + if (!d || !d.token) { + errEl.textContent = "登录返回异常,请重试"; + return; + } + token = d.token; + localStorage.setItem("admin_token", token); + document.getElementById("login-page").classList.add("hidden"); + document.getElementById("admin-page").classList.remove("hidden"); + var cu = document.getElementById("current-user"); if (cu) { var ct = cu.querySelector(".user-name-text"); if (ct) ct.textContent = d.username || username; else cu.textContent = d.username || username; } + errEl.textContent = ""; + showPage("accounts"); + }) + .catch((err) => { + errEl.textContent = err.message || "登录失败"; + }); +}); + +document.getElementById("btn-logout").addEventListener("click", () => { + localStorage.removeItem("admin_token"); + window.location.reload(); +}); + +// 侧栏默认收起,可展开;状态存 localStorage;收起时用底部按钮,展开时用头部按钮 +(function() { + var sidebar = document.getElementById("sidebar"); + var key = "sidebarCollapsed"; + function toggleSidebar() { + sidebar.classList.toggle("collapsed"); + localStorage.setItem(key, sidebar.classList.contains("collapsed") ? "1" : "0"); + } + if (sidebar) { + var saved = localStorage.getItem(key); + if (saved === "0" || saved === "false") sidebar.classList.remove("collapsed"); + else sidebar.classList.add("collapsed"); + var btnHeader = document.getElementById("sidebar-toggle"); + var btnFooter = document.getElementById("sidebar-toggle-footer"); + if (btnHeader) btnHeader.addEventListener("click", toggleSidebar); + if (btnFooter) btnFooter.addEventListener("click", toggleSidebar); + } +})(); + +// Nav tabs +document.querySelectorAll('.nav a[data-tab]').forEach((a) => { + a.addEventListener("click", (e) => { + e.preventDefault(); + showPage(a.getAttribute("data-tab")); + }); +}); + +// Accounts +function setCurrentSoraAccountId(accountId) { + var id = parseInt(accountId, 10) || 0; + if (!id) return; + var idInput = document.getElementById("sora-api-account-id"); + if (idInput) idInput.value = String(id); + localStorage.setItem("sora_api_last_account_id", String(id)); +} + +function isSoraAccountUsable(account) { + return !!(account && account.has_sora && account.sora_enabled && !account.sora_quota_exhausted && account.has_token); +} + +function formatAccountStatus(status, account) { + var text = status || ""; + if (text === "Registered+Sora" && account && account.has_sora) return "Registered+Sora2"; + return text; +} + +function getSoraAccountAvailabilityMessage(account) { + if (!account) return "尚未选择生成账号"; + if (!account.has_sora) return "该账号尚未开通 Sora"; + if (!account.sora_enabled) return "该账号已停用"; + if (account.sora_quota_exhausted) return "该账号已标记额度不足"; + if (!account.has_token) return "该账号缺少 token"; + return "当前账号可用于视频生成"; +} + +function renderSoraVideoAccountSummary(account) { + var box = document.getElementById("sora-video-account-summary"); + if (!box) return; + if (!account) { + box.innerHTML = ''; + return; + } + var quotaText = getSoraQuotaText(account); + var quotaClass = account.sora_quota_exhausted ? "is-bad" : "is-ok"; + var enableClass = account.sora_enabled ? "is-ok" : "is-bad"; + var soraClass = account.has_sora ? "is-ok" : "is-warn"; + var tokenClass = account.has_token ? "is-ok" : "is-bad"; + var statusText = formatAccountStatus(account.status, account) || "未设置"; + var registeredAt = account.registered_at || "未记录"; + var lastError = account.sora_last_error || ""; + box.innerHTML = + '" + + '" + + (lastError ? ('") : ""); +} + +function loadSoraAccountDetails(accountId, options) { + var id = parseInt(accountId, 10) || 0; + var msgEl = document.getElementById("sora-api-msg"); + if (!id) { + renderSoraVideoAccountSummary(null); + if (!(options && options.silent) && msgEl) msgEl.textContent = "请先输入有效账号 ID"; + return Promise.reject(new Error("请先输入有效账号 ID")); + } + if (!(options && options.silent) && msgEl) msgEl.textContent = "加载账号状态..."; + return api("/api/accounts/" + id).then(function(d) { + setCurrentSoraAccountId(d.id); + renderSoraVideoAccountSummary(d); + if (!(options && options.skipKeyList)) loadSoraApiKeyList(d.id); + if (!(options && options.silent) && msgEl) msgEl.textContent = getSoraAccountAvailabilityMessage(d); + return d; + }).catch(function(err) { + renderSoraVideoAccountSummary(null); + if (!(options && options.silent) && msgEl) msgEl.textContent = "加载失败:" + parseApiErrorMessage(err); + throw err; + }); +} + +function pickNextAvailableSoraAccount(options) { + var msgEl = document.getElementById("sora-api-msg"); + if (!(options && options.silent) && msgEl) msgEl.textContent = "切换中..."; + return api("/api/accounts/next-sora-available").then(function(d) { + return loadSoraAccountDetails(d.id, { silent: true }).then(function(account) { + var text = ((options && options.messagePrefix) || "已切换到可用账号") + " ID " + d.id + "(" + (d.email || "") + ")"; + if (msgEl) msgEl.textContent = text; + if (options && options.toast) toast(text, options.toastType || "success"); + return account; + }); + }).catch(function(err) { + var message = parseApiErrorMessage(err); + if (msgEl) msgEl.textContent = "切换失败:" + message; + throw err; + }); +} + +function ensureSoraVideoAccountReady() { + var currentId = getSoraAccountIdFromInput(); + if (!currentId) { + return pickNextAvailableSoraAccount({ messagePrefix: "已自动选择可用账号" }).catch(function() { + return null; + }); + } + return loadSoraAccountDetails(currentId, { silent: true }).then(function(account) { + if (isSoraAccountUsable(account)) { + var msgEl = document.getElementById("sora-api-msg"); + if (msgEl) msgEl.textContent = "当前账号可用于视频生成"; + return account; + } + return pickNextAvailableSoraAccount({ messagePrefix: "当前账号不可用,已自动切换到可用账号" }); + }).catch(function() { + return pickNextAvailableSoraAccount({ messagePrefix: "当前账号不存在或不可用,已自动切换到可用账号" }).catch(function() { + return null; + }); + }); +} + +function loadSoraVideoWorkspace() { + ensureSoraVideoAccountReady(); +} + +function getSoraQuotaText(r) { + if (!r || !r.sora_enabled) return "已停用"; + if (r.sora_quota_exhausted) { + var note = (r.sora_quota_note || "额度不足"); + return "额度不足(" + note + ")"; + } + return "可用"; +} + +function updateSoraAccountState(accountId, payload, successMsg) { + var id = parseInt(accountId, 10) || 0; + if (!id) return; + api("/api/accounts/" + id + "/sora-state", { + method: "POST", + body: JSON.stringify(payload || {}), + }).then(function () { + if (successMsg) toast(successMsg); + loadAccounts(); + loadSoraApiKeyList(id); + }).catch(function (err) { + toast("操作失败: " + parseApiErrorMessage(err), "error"); + }); +} + +function getSoraQuotaRecheckLabel(result) { + var labels = { + recovered: "已恢复并回池", + recovered_busy: "已恢复,当前繁忙", + still_exhausted: "仍然额度不足", + probe_failed: "复检失败", + auth_failed: "鉴权失败", + skipped_no_token: "跳过,无 token", + skipped_disabled: "跳过,已停用", + skipped_no_sora: "跳过,未开通 Sora", + already_available: "本来就在池中", + }; + return labels[result] || result || "未知"; +} + +function showSoraQuotaRecheckReport(report) { + var items = Array.isArray(report && report.items) ? report.items : []; + var rows = items.length + ? items.map(function(item) { + return ( + "" + + "" + escapeHtml(String(item.account_id || "")) + "" + + "" + escapeHtml(item.email || "") + "" + + "" + escapeHtml(getSoraQuotaRecheckLabel(item.result)) + "" + + "" + escapeHtml(item.message || "") + "" + + "" + escapeHtml(item.task_id || "-") + "" + + "" + ); + }).join("") + : '没有可展示的复检结果'; + showModal( + '
' + + '

额度复检结果

' + + '' + + '
' + + '' + + '' + + '' + rows + '' + + '
ID邮箱结果说明探针任务
' + + '
' + + '
' + ); +} + +function runSoraQuotaRecheck(options) { + var opts = options || {}; + var payload = { + limit: opts.accountId ? 1 : 10, + auto_cancel: true, + }; + if (opts.accountId) payload.account_id = parseInt(opts.accountId, 10) || 0; + var button = opts.button || null; + var originalText = button ? button.textContent : ""; + if (button) { + button.disabled = true; + button.textContent = "复检中..."; + } + return api("/api/accounts/sora-quota/recheck", { + method: "POST", + body: JSON.stringify(payload), + }).then(function(report) { + showSoraQuotaRecheckReport(report); + toast(report.message || "额度复检已完成"); + loadAccounts(); + var currentId = getSoraAccountIdFromInput(); + if (currentId) loadSoraAccountDetails(currentId, { silent: true }).catch(function() {}); + return report; + }).catch(function(err) { + toast("额度复检失败: " + parseApiErrorMessage(err), "error"); + throw err; + }).finally(function() { + if (button) { + button.disabled = false; + button.textContent = originalText; + } + }); +} + +function loadAccounts() { + const status = document.getElementById("filter-status").value; + const sora = document.getElementById("filter-sora").value; + const plus = document.getElementById("filter-plus").value; + const params = new URLSearchParams({ page: currentPage, page_size: 20 }); + if (status) params.set("status", status); + if (sora) params.set("has_sora", sora); + if (plus) params.set("has_plus", plus); + api("/api/debug/db-info").then(function (info) { + const el = document.getElementById("accounts-db-hint"); + if (el) el.textContent = "共 " + (info.accounts_count != null ? info.accounts_count : "?") + " 条。若用脚本注册,请用相同 DATA_DIR 启动本后端,否则新账号不会出现在本列表。"; + }).catch(function () {}); + api("/api/accounts?" + params).then((d) => { + accountsTotal = d.total; + const tbody = document.getElementById("accounts-tbody"); + tbody.innerHTML = d.items + .map( + (r) => + ` + ${r.id} + ${escapeHtml(r.email)} + ${escapeHtml(r.password || "")} + ${escapeHtml(formatAccountStatus(r.status, r) || "")} + ${r.has_sora ? "是" : "否"} + ${r.has_plus ? "是" : "否"} + ${r.phone_bound ? "是" : "否"} + ${escapeHtml((r.refresh_token || "").slice(0, 24))}${(r.refresh_token || "").length > 24 ? "…" : ""} + ${escapeHtml(r.registered_at || r.created_at || "")} + ${escapeHtml(getSoraQuotaText(r))} + + + + ${r.sora_quota_exhausted ? `` : ""} + + + + ` + ) + .join(""); + const pag = document.getElementById("accounts-pagination"); + const totalPages = Math.ceil(d.total / d.page_size) || 1; + pag.innerHTML = `共 ${d.total} 条 ` + (totalPages > 1 ? ` ${currentPage}/${totalPages} ` : ""); + pag.querySelectorAll("button").forEach((btn) => { + btn.addEventListener("click", () => { + if (btn.dataset.page === "prev" && currentPage > 1) currentPage--; + if (btn.dataset.page === "next" && currentPage < totalPages) currentPage++; + loadAccounts(); + }); + }); + tbody.querySelectorAll(".btn-create-sora-key").forEach((btn) => { + btn.addEventListener("click", () => { + var id = parseInt(btn.dataset.id, 10) || 0; + if (!id) return; + setCurrentSoraAccountId(id); + createSoraApiKey(id); + }); + }); + tbody.querySelectorAll(".btn-list-sora-key").forEach((btn) => { + btn.addEventListener("click", () => { + var id = parseInt(btn.dataset.id, 10) || 0; + if (!id) return; + setCurrentSoraAccountId(id); + loadSoraApiKeyList(id); + }); + }); + tbody.querySelectorAll(".btn-use-sora-account").forEach((btn) => { + btn.addEventListener("click", () => { + var id = parseInt(btn.dataset.id, 10) || 0; + if (!id) return; + setCurrentSoraAccountId(id); + loadSoraAccountDetails(id, { silent: true }).catch(function() {}); + var msgEl = document.getElementById("sora-api-msg"); + if (msgEl) msgEl.textContent = "已切换到账号 ID " + id + ",可去左侧“视频生成”直接创建任务"; + toast("已切换到视频生成账号 ID " + id); + }); + }); + tbody.querySelectorAll(".btn-reset-sora-quota").forEach((btn) => { + btn.addEventListener("click", () => { + var id = parseInt(btn.dataset.id, 10) || 0; + if (!id) return; + updateSoraAccountState(id, { reset_quota: true }, "已重置额度状态"); + }); + }); + tbody.querySelectorAll(".btn-probe-sora-quota").forEach((btn) => { + btn.addEventListener("click", () => { + var id = parseInt(btn.dataset.id, 10) || 0; + if (!id) return; + confirmBox( + "这会对账号 ID " + id + " 发起一个最小视频探针,创建成功后会立即取消,并在额度恢复时自动回池。继续吗?", + function() { runSoraQuotaRecheck({ accountId: id, button: btn }); } + ); + }); + }); + tbody.querySelectorAll(".btn-toggle-sora-account").forEach((btn) => { + btn.addEventListener("click", () => { + var id = parseInt(btn.dataset.id, 10) || 0; + var enable = String(btn.dataset.enable || "1") === "1"; + if (!id) return; + updateSoraAccountState(id, { sora_enabled: enable }, enable ? "账号已启用" : "账号已停用"); + }); + }); + }); +} +document.getElementById("filter-status").addEventListener("change", () => { currentPage = 1; loadAccounts(); }); +document.getElementById("filter-sora").addEventListener("change", () => { currentPage = 1; loadAccounts(); }); +document.getElementById("filter-plus").addEventListener("change", () => { currentPage = 1; loadAccounts(); }); +document.getElementById("btn-recheck-sora-quota").addEventListener("click", function() { + var btn = this; + confirmBox( + "这会对当前被标记“额度不足”的账号逐个发起最小视频探针,创建成功后立即取消,并自动让已恢复账号重新回池。继续吗?", + function() { runSoraQuotaRecheck({ button: btn }); } + ); +}); + +document.getElementById("btn-export-accounts").addEventListener("click", () => { + const status = document.getElementById("filter-status").value; + const sora = document.getElementById("filter-sora").value; + const plus = document.getElementById("filter-plus").value; + const params = new URLSearchParams(); + if (status) params.set("status", status); + if (sora) params.set("has_sora", sora); + if (plus) params.set("has_plus", plus); + fetch(API_BASE + "/api/accounts/export?" + params, { headers: { Authorization: "Bearer " + token } }) + .then((r) => { if (!r.ok) throw new Error(r.statusText); return r.blob(); }) + .then((blob) => { + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "accounts.csv"; + a.click(); + URL.revokeObjectURL(a.href); + }) + .catch((err) => toast("导出失败: " + err.message, "error")); +}); + +function parseApiErrorMessage(err) { + var msg = (err && err.message) ? err.message : "请求错误"; + try { + var obj = JSON.parse(msg); + if (obj && obj.detail) { + return typeof obj.detail === "string" ? obj.detail : JSON.stringify(obj.detail); + } + } catch (_) {} + return msg; +} + +function copyTextToClipboard(text, successMsg) { + var value = (text == null) ? "" : String(text); + var done = function() { toast(successMsg || "已复制"); }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value).then(done).catch(function() {}); + return; + } + var helper = document.createElement("textarea"); + helper.value = value; + document.body.appendChild(helper); + helper.select(); + try { document.execCommand("copy"); done(); } catch (_) {} + helper.remove(); +} + +function getSoraAccountIdFromInput() { + var raw = (document.getElementById("sora-api-account-id").value || "").trim(); + var id = parseInt(raw, 10); + if (!id || id < 1) return null; + localStorage.setItem("sora_api_last_account_id", String(id)); + return id; +} + +var SORA_KEY_SCOPE_TEXT = "text_to_video"; +var SORA_KEY_SCOPE_IMAGE = "image_to_video"; +var SORA_KEY_SCOPE_ALL = "all_video"; +var SORA_TASK_FAMILY_VIDEO_GEN = "video_gen"; +var SORA_TASK_FAMILY_NF2 = "nf2"; + +function normalizeSoraKeyScope(scope) { + var value = (scope || "").toString().trim().toLowerCase(); + var aliases = { + text: SORA_KEY_SCOPE_TEXT, + text_to_video: SORA_KEY_SCOPE_TEXT, + "text-video": SORA_KEY_SCOPE_TEXT, + text2video: SORA_KEY_SCOPE_TEXT, + image: SORA_KEY_SCOPE_IMAGE, + image_to_video: SORA_KEY_SCOPE_IMAGE, + "image-video": SORA_KEY_SCOPE_IMAGE, + image2video: SORA_KEY_SCOPE_IMAGE, + all: SORA_KEY_SCOPE_ALL, + all_video: SORA_KEY_SCOPE_ALL, + both: SORA_KEY_SCOPE_ALL, + combined: SORA_KEY_SCOPE_ALL, + hybrid: SORA_KEY_SCOPE_ALL, + }; + return aliases[value] || SORA_KEY_SCOPE_TEXT; +} + +function getSoraKeyScopeLabel(scope) { + var normalized = normalizeSoraKeyScope(scope); + if (normalized === SORA_KEY_SCOPE_IMAGE) return "图生视频"; + if (normalized === SORA_KEY_SCOPE_ALL) return "文生+图生"; + return "文生视频"; +} + +function getSoraKeyScopeClass(scope) { + var normalized = normalizeSoraKeyScope(scope); + if (normalized === SORA_KEY_SCOPE_IMAGE) return "key-chip-scope-image"; + if (normalized === SORA_KEY_SCOPE_ALL) return "key-chip-scope-all"; + return "key-chip-scope-text"; +} + +function getSoraKeyModeLabel(mode, accountId) { + var normalizedMode = (mode || "").toString().trim().toLowerCase(); + if (!normalizedMode) normalizedMode = (parseInt(accountId, 10) === 0 ? "pool" : "bound"); + return normalizedMode === "pool" ? "轮换池" : "账号绑定"; +} + +function syncSoraKeyScopeOptions() { + document.querySelectorAll(".key-scope-option").forEach(function(option) { + var input = option.querySelector('input[type="radio"]'); + option.classList.toggle("is-selected", !!(input && input.checked)); + }); +} + +function renderSoraApiKeyList(items) { + var listEl = document.getElementById("sora-key-list"); + if (!listEl) return; + var rows = items || []; + if (!rows.length) { + listEl.innerHTML = "该账号暂无可用 API Key"; + return; + } + listEl.innerHTML = rows + .map(function (r) { + var when = r.created_at ? ("创建于 " + escapeHtml(r.created_at)) : ""; + var used = r.last_used_at ? (",最近调用 " + escapeHtml(r.last_used_at)) : ""; + var name = r.name ? ("" + escapeHtml(r.name) + "") : ""; + var scope = '' + escapeHtml(r.scope_label || getSoraKeyScopeLabel(r.scope)) + ""; + return "
" + name + scope + "" + escapeHtml(r.key_mask || "") + "" + when + used + "
"; + }) + .join(""); +} + +function loadSoraApiKeyList(accountId) { + var id = parseInt(accountId, 10) || 0; + var listEl = document.getElementById("sora-key-list"); + if (!id) { + if (listEl) listEl.innerHTML = ""; + return; + } + if (listEl) listEl.innerHTML = "加载中..."; + api("/api/sora-keys?account_id=" + id + "&active_only=true") + .then(function (d) { + renderSoraApiKeyList((d && d.items) || []); + }) + .catch(function (err) { + if (listEl) listEl.textContent = "查询失败:" + parseApiErrorMessage(err); + }); +} + +function showCreatedSoraApiKey(result) { + var raw = (result && result.api_key) ? result.api_key : ""; + var email = (result && result.email) ? result.email : ""; + var accountId = parseInt((result && result.account_id) || 0, 10) || 0; + var scopeLabel = (result && result.scope_label) ? result.scope_label : getSoraKeyScopeLabel(result && result.scope); + var modeLabel = getSoraKeyModeLabel(result && result.key_mode, accountId); + var accountText = accountId === 0 ? "[自动轮换池]" : (email + " (ID " + String(accountId || "") + ")"); + showModal( + '" + ); + var btnCopy = document.getElementById("btn-copy-sora-api-key"); + if (!btnCopy) return; + btnCopy.addEventListener("click", function () { + copyTextToClipboard(raw, "API Key 已复制"); + }); +} + +function createSoraApiKey(accountId, options) { + var opts = options || {}; + var id = parseInt(accountId, 10); + var allowPool = !!opts.allowPool; + var msgEl = opts.msgEl || document.getElementById(opts.messageElementId || "sora-api-msg"); + if ((isNaN(id) || id < 1) && !allowPool) { + if (msgEl) msgEl.textContent = "请先输入有效账号 ID"; + toast("请先输入有效账号 ID", "info"); + return Promise.resolve(null); + } + if (allowPool && (isNaN(id) || id < 0)) id = 0; + var scope = normalizeSoraKeyScope(opts.scope || SORA_KEY_SCOPE_TEXT); + var name = (opts.name || "").trim(); + if (msgEl) msgEl.textContent = id === 0 ? "正在生成轮换池 API Key..." : "正在生成 API Key..."; + if (id > 0) setCurrentSoraAccountId(id); + return api("/api/sora-keys", { + method: "POST", + body: JSON.stringify({ account_id: id, name: name, scope: scope }), + }).then(function(d) { + if (msgEl) msgEl.textContent = "API Key 已生成(可在弹窗中复制)"; + showCreatedSoraApiKey(d || {}); + if (id > 0) loadSoraApiKeyList(id); + if (typeof opts.onSuccess === "function") opts.onSuccess(d || {}); + return d || {}; + }).catch(function(err) { + if (msgEl) msgEl.textContent = "生成失败:" + parseApiErrorMessage(err); + throw err; + }); +} + +function buildSoraKeyStats(items) { + var rows = items || []; + var stats = { + total: rows.length, + active: 0, + pool: 0, + text: 0, + image: 0, + all: 0, + }; + rows.forEach(function(item) { + var scope = normalizeSoraKeyScope(item.scope); + if (item.is_active) stats.active += 1; + if ((item.key_mode || "") === "pool" || parseInt(item.account_id || 0, 10) === 0) stats.pool += 1; + if (scope === SORA_KEY_SCOPE_IMAGE) stats.image += 1; + else if (scope === SORA_KEY_SCOPE_ALL) stats.all += 1; + else stats.text += 1; + }); + return stats; +} + +function renderSoraKeyStats(items) { + var wrap = document.getElementById("sora-key-stats"); + if (!wrap) return; + var stats = buildSoraKeyStats(items); + wrap.innerHTML = [ + ['全部 Key', stats.total], + ['启用中', stats.active], + ['轮换池 Key', stats.pool], + ['文生视频', stats.text], + ['图生视频', stats.image], + ['文生+图生', stats.all], + ].map(function(entry) { + return '
' + escapeHtml(entry[0]) + '' + escapeHtml(String(entry[1])) + '
'; + }).join(""); +} + +function renderSoraKeyManagementTable(items) { + var tbody = document.getElementById("sora-key-table-body"); + if (!tbody) return; + var rows = items || []; + if (!rows.length) { + tbody.innerHTML = '当前条件下没有找到 Key'; + return; + } + tbody.innerHTML = rows.map(function(item) { + var accountId = parseInt(item.account_id || 0, 10) || 0; + var scopeLabel = item.scope_label || getSoraKeyScopeLabel(item.scope); + var modeLabel = getSoraKeyModeLabel(item.key_mode, accountId); + var accountText = accountId === 0 + ? '自动轮换池' + : ('
ID ' + String(accountId) + '' + escapeHtml(item.email || "") + '
'); + var statusBadge = item.is_active + ? '启用中' + : '已停用'; + var actions = item.is_active + ? '' + : '已停用'; + return '' + + '' + + '' + String(item.id) + '' + + '' + + '
' + + '' + escapeHtml(item.name || "未命名 Key") + '' + + '创建人 ' + escapeHtml(item.created_by || "-") + '' + + '
' + + '' + + '' + escapeHtml(scopeLabel) + '' + + '' + escapeHtml(modeLabel) + '' + + '' + accountText + '' + + '' + escapeHtml(item.key_mask || "") + '' + + '' + statusBadge + '' + + '' + escapeHtml(item.last_used_at || "未调用") + '' + + '' + escapeHtml(item.created_at || "") + '' + + '
' + actions + '
' + + ''; + }).join(""); +} + +function loadSoraKeyManagement() { + var msgEl = document.getElementById("sora-key-manager-msg"); + var mode = (document.getElementById("sora-key-filter-mode").value || "").trim(); + var scopeInput = (document.getElementById("sora-key-filter-scope").value || "").trim(); + var status = (document.getElementById("sora-key-filter-status").value || "all").trim(); + var qs = ["active_only=false"]; + if (mode) qs.push("key_mode=" + encodeURIComponent(mode)); + if (scopeInput) qs.push("scope=" + encodeURIComponent(normalizeSoraKeyScope(scopeInput))); + if (msgEl) msgEl.textContent = "正在加载 Key 列表..."; + api("/api/sora-keys?" + qs.join("&")) + .then(function(d) { + var items = (d && d.items) || []; + if (status === "active") items = items.filter(function(item) { return !!item.is_active; }); + if (status === "inactive") items = items.filter(function(item) { return !item.is_active; }); + renderSoraKeyStats(items); + renderSoraKeyManagementTable(items); + if (msgEl) msgEl.textContent = "已加载 " + String(items.length) + " 条 Key"; + }) + .catch(function(err) { + renderSoraKeyStats([]); + renderSoraKeyManagementTable([]); + if (msgEl) msgEl.textContent = "加载失败:" + parseApiErrorMessage(err); + }); +} + +function createSoraPoolApiKey() { + var name = (document.getElementById("sora-key-create-name").value || "").trim(); + var selected = document.querySelector('input[name="sora-key-scope"]:checked'); + var scope = selected ? selected.value : SORA_KEY_SCOPE_TEXT; + createSoraApiKey(0, { + allowPool: true, + name: name, + scope: scope, + messageElementId: "sora-key-manager-msg", + onSuccess: function() { + loadSoraKeyManagement(); + }, + }).catch(function() {}); +} + +function disableSoraApiKey(keyId) { + var numericId = parseInt(keyId, 10); + if (!numericId || numericId < 1) return; + confirmBox("确定停用这把 API Key?停用后会立刻失效。", function() { + api("/api/sora-keys/" + numericId, { method: "DELETE" }) + .then(function() { + toast("API Key 已停用"); + loadSoraKeyManagement(); + }) + .catch(function(err) { + toast("停用失败:" + parseApiErrorMessage(err), "error"); + }); + }); +} + +(function initSoraAccountInput() { + var saved = localStorage.getItem("sora_api_last_account_id"); + var id = parseInt(saved || "", 10); + if (!id || id < 1) return; + var input = document.getElementById("sora-api-account-id"); + if (input && !input.value) input.value = String(id); +})(); + +document.getElementById("sora-api-account-id").addEventListener("change", function() { + var id = parseInt(this.value || "", 10); + if (!id || id < 1) return; + setCurrentSoraAccountId(id); + loadSoraAccountDetails(id, { silent: true }).catch(function() {}); +}); + +document.getElementById("btn-sora-me").addEventListener("click", function() { + var id = getSoraAccountIdFromInput(); + var msgEl = document.getElementById("sora-api-msg"); + if (!id) { + msgEl.textContent = "请先输入有效账号 ID"; + toast("请先输入有效账号 ID", "info"); + return; + } + msgEl.textContent = "请求中..."; + api("/api/sora-api/me", { + method: "POST", + body: JSON.stringify({ account_id: id }), + }).then(function(d) { + var me = d.me || {}; + var uname = me.username ? ("username=" + me.username) : "未设置 username"; + msgEl.textContent = "调用成功," + uname; + toast("Sora API 调用成功"); + }).catch(function(err) { + msgEl.textContent = "失败:" + parseApiErrorMessage(err); + }); +}); + +document.getElementById("btn-sora-pick-next").addEventListener("click", function() { + pickNextAvailableSoraAccount({ toast: true }).catch(function() {}); +}); + +document.getElementById("btn-sora-key-create").addEventListener("click", function() { + var id = getSoraAccountIdFromInput(); + createSoraApiKey(id, { scope: SORA_KEY_SCOPE_TEXT }).catch(function() {}); +}); + +document.getElementById("btn-sora-key-list").addEventListener("click", function() { + var id = getSoraAccountIdFromInput(); + if (!id) { + document.getElementById("sora-api-msg").textContent = "请先输入有效账号 ID"; + return; + } + loadSoraApiKeyList(id); +}); + +document.getElementById("btn-sora-activate").addEventListener("click", function() { + var id = getSoraAccountIdFromInput(); + var msgEl = document.getElementById("sora-api-msg"); + if (!id) { + msgEl.textContent = "请先输入有效账号 ID"; + toast("请先输入有效账号 ID", "info"); + return; + } + msgEl.textContent = "激活中..."; + api("/api/sora-api/activate", { + method: "POST", + body: JSON.stringify({ account_id: id }), + }).then(function(d) { + var uname = d.username ? ("username=" + d.username) : "未获取到 username"; + msgEl.textContent = "激活成功," + uname; + toast("Sora 激活成功"); + loadAccounts(); + }).catch(function(err) { + msgEl.textContent = "失败:" + parseApiErrorMessage(err); + }); +}); + +document.getElementById("btn-key-manager-create").addEventListener("click", createSoraPoolApiKey); +document.getElementById("btn-key-manager-refresh").addEventListener("click", loadSoraKeyManagement); +document.getElementById("btn-sora-key-filter-refresh").addEventListener("click", loadSoraKeyManagement); +document.getElementById("sora-key-filter-mode").addEventListener("change", loadSoraKeyManagement); +document.getElementById("sora-key-filter-scope").addEventListener("change", loadSoraKeyManagement); +document.getElementById("sora-key-filter-status").addEventListener("change", loadSoraKeyManagement); +document.querySelectorAll('input[name="sora-key-scope"]').forEach(function(input) { + input.addEventListener("change", syncSoraKeyScopeOptions); +}); +document.getElementById("sora-key-table-body").addEventListener("click", function(e) { + var btn = e.target.closest('button[data-action="disable-key"]'); + if (!btn) return; + disableSoraApiKey(btn.getAttribute("data-key-id")); +}); +syncSoraKeyScopeOptions(); + +var soraVideoTasks = []; +var soraVideoSelectedTaskId = localStorage.getItem("sora_video_selected_task_id") || ""; +var soraVideoPollers = {}; +var soraVideoUiClock = null; +var soraVideoCreateInFlight = false; +var soraVideoSnapshotRefreshInFlight = {}; +var soraVideoMediaReloadAttempts = {}; +var SORA_VIDEO_TASKS_STORAGE_KEY = "sora_video_workspace_tasks_v2"; +var SORA_VIDEO_AUTO_ROTATE_STORAGE_KEY = "sora_video_auto_rotate"; + +function normalizeSoraVideoStatus(status) { + var value = (status || "").toString().trim().toLowerCase(); + if (!value) return ""; + var aliases = { + complete: "succeeded", + completed: "succeeded", + done: "succeeded", + success: "succeeded", + succeed: "succeeded", + succeeded: "succeeded", + canceled: "cancelled", + cancelled: "cancelled", + in_progress: "running", + inprogress: "running", + processing: "running" + }; + return aliases[value] || value; +} + +function isSoraVideoSuccessStatus(status) { + return normalizeSoraVideoStatus(status) === "succeeded"; +} + +function isSoraVideoTerminalStatus(status) { + var value = normalizeSoraVideoStatus(status); + return ["succeeded", "failed", "cancelled", "rejected", "expired", "error"].indexOf(value) >= 0; +} + +function parseNumeric(value) { + var num = Number(value); + return Number.isFinite(num) ? num : null; +} + +function parseSoraDateMs(value) { + var raw = (value || "").toString().trim(); + if (!raw) return 0; + var normalized = raw.indexOf("T") >= 0 ? raw : raw.replace(" ", "T"); + var ms = Date.parse(normalized); + return Number.isFinite(ms) ? ms : 0; +} + +function formatSoraDateTime(value) { + var ms = parseSoraDateMs(value); + if (!ms) return value || "--"; + return new Date(ms).toLocaleString("zh-CN", { hour12: false }); +} + +function formatDuration(totalSeconds) { + var seconds = Math.max(0, Math.round(Number(totalSeconds) || 0)); + var hours = Math.floor(seconds / 3600); + var minutes = Math.floor((seconds % 3600) / 60); + var remain = seconds % 60; + if (hours > 0) return String(hours) + ":" + String(minutes).padStart(2, "0") + ":" + String(remain).padStart(2, "0"); + return String(minutes).padStart(2, "0") + ":" + String(remain).padStart(2, "0"); +} + +function trimVideoPrompt(value, maxLength) { + var text = (value || "").toString().trim(); + if (!text) return ""; + var max = Math.max(8, parseInt(maxLength || "48", 10) || 48); + return text.length > max ? text.slice(0, max - 1) + "…" : text; +} + +function pickSoraPreviewUrl(videoUrls) { + var urls = Array.isArray(videoUrls) ? videoUrls.slice() : []; + if (!urls.length) return ""; + function getMatchText(url) { + var text = (url || "").toLowerCase(); + try { + return decodeURIComponent(text); + } catch (_) { + return text; + } + } + function findByKeyword(keyword) { + for (var i = 0; i < urls.length; i += 1) { + if (getMatchText(urls[i]).indexOf(keyword) >= 0) return urls[i]; + } + return ""; + } + return findByKeyword("no_watermark") || + findByKeyword("downloadable") || + findByKeyword("/src.mp4") || + findByKeyword("/source.mp4") || + findByKeyword("/source_wm.mp4") || + findByKeyword("original") || + findByKeyword("/hd.mp4") || + findByKeyword("/md.mp4") || + findByKeyword("/ld.mp4") || + findByKeyword("watermarked.mp4") || + urls[0] || + ""; +} + +function getSoraVideoView(result) { + if (!result || typeof result !== "object") return {}; + return result.final_result && typeof result.final_result === "object" ? result.final_result : result; +} + +function setSoraVideoTaskId(taskId) { + var value = (taskId || "").toString().trim(); + var input = document.getElementById("sora-video-task-id"); + if (input) input.value = value; + if (value) localStorage.setItem("sora_video_last_task_id", value); + else localStorage.removeItem("sora_video_last_task_id"); +} + +function getSoraVideoTaskId() { + return (document.getElementById("sora-video-task-id").value || "").trim(); +} + +function isSoraVideoAutoRotateEnabled() { + var el = document.getElementById("sora-video-auto-rotate"); + return !!(el && el.checked); +} + +function setSoraVideoAutoRotateEnabled(enabled) { + var checked = !!enabled; + var el = document.getElementById("sora-video-auto-rotate"); + if (el) el.checked = checked; + localStorage.setItem(SORA_VIDEO_AUTO_ROTATE_STORAGE_KEY, checked ? "1" : "0"); +} + +function getSoraVideoTaskMode() { + return normalizeSoraKeyScope((document.getElementById("sora-video-task-mode").value || "text_to_video").trim()); +} + +function getSoraVideoTaskFamily() { + var el = document.getElementById("sora-video-task-family"); + var value = ((el && el.value) || SORA_TASK_FAMILY_VIDEO_GEN).trim().toLowerCase(); + return value === SORA_TASK_FAMILY_NF2 ? SORA_TASK_FAMILY_NF2 : SORA_TASK_FAMILY_VIDEO_GEN; +} + +function getSoraVideoSelectedImageFile() { + var input = document.getElementById("sora-video-image-file"); + return input && input.files && input.files[0] ? input.files[0] : null; +} + +function updateSoraVideoImageMeta() { + var metaEl = document.getElementById("sora-video-image-meta"); + var file = getSoraVideoSelectedImageFile(); + if (!metaEl) return; + if (!file) { + metaEl.textContent = "上传一张图片作为视频的起始画面。"; + return; + } + var sizeKb = Math.max(1, Math.round((Number(file.size || 0) / 1024))); + metaEl.textContent = file.name + " · " + sizeKb + " KB"; +} + +function updateSoraVideoComposerMode() { + var mode = getSoraVideoTaskMode(); + var imageField = document.getElementById("sora-video-image-field"); + var audioFields = document.getElementById("sora-video-audio-fields"); + var familyField = document.getElementById("sora-video-task-family-field"); + var promptEl = document.getElementById("sora-video-prompt"); + if (imageField) imageField.classList.toggle("hidden", mode !== SORA_KEY_SCOPE_IMAGE); + if (audioFields) audioFields.classList.toggle("hidden", mode !== SORA_KEY_SCOPE_TEXT); + if (familyField) familyField.classList.toggle("hidden", mode !== SORA_KEY_SCOPE_TEXT); + if (promptEl && !promptEl.value.trim()) { + promptEl.placeholder = mode === SORA_KEY_SCOPE_IMAGE + ? "例如:让画面中的人物轻轻转头,头发和光线自然摆动。" + : "例如:A cinematic shot of ocean waves at sunrise."; + } + updateSoraVideoImageMeta(); +} + +function apiForm(url, formData, extraOptions) { + var options = extraOptions || {}; + var headers = Object.assign({}, options.headers || {}); + if (token) headers.Authorization = "Bearer " + token; + return fetch(API_BASE + url, { + method: options.method || "POST", + body: formData, + headers: headers + }).then(async function(r) { + if (r.status === 401) { + if (!url.includes("/auth/login")) { + localStorage.removeItem("admin_token"); + window.location.reload(); + } + throw new Error(await r.text()); + } + if (!r.ok) throw new Error(await r.text()); + var ct = r.headers.get("content-type"); + if (ct && ct.includes("application/json")) return r.json(); + return r.text(); + }); +} + +function getSoraVideoPollOptions() { + return { + pollIntervalSeconds: Math.max(1, parseInt(document.getElementById("sora-video-poll-interval").value || "5", 10) || 5), + timeoutSeconds: Math.max(30, parseInt(document.getElementById("sora-video-timeout").value || "900", 10) || 900) + }; +} + +function getSoraVideoComposerPayload() { + var prompt = (document.getElementById("sora-video-prompt").value || "").trim(); + if (!prompt) throw new Error("请输入视频 prompt"); + var taskMode = getSoraVideoTaskMode(); + var autoRotate = isSoraVideoAutoRotateEnabled(); + var accountId = getSoraAccountIdFromInput(); + if (!autoRotate && !accountId) { + throw new Error("关闭自动轮换时,请先选择一个有效账号"); + } + var imageFile = getSoraVideoSelectedImageFile(); + if (taskMode === SORA_KEY_SCOPE_IMAGE && !imageFile) { + throw new Error("图生视频模式下请先上传参考图"); + } + return { + prompt: prompt, + taskMode: taskMode, + taskFamily: taskMode === SORA_KEY_SCOPE_IMAGE ? SORA_TASK_FAMILY_VIDEO_GEN : getSoraVideoTaskFamily(), + imageFile: imageFile, + autoRotate: autoRotate, + account_id: accountId, + audio_caption: taskMode === SORA_KEY_SCOPE_TEXT ? (document.getElementById("sora-video-audio-caption").value || "").trim() : "", + audio_transcript: taskMode === SORA_KEY_SCOPE_TEXT ? (document.getElementById("sora-video-audio-transcript").value || "").trim() : "", + batchCount: Math.max(1, Math.min(parseInt(document.getElementById("sora-video-batch-count").value || "1", 10) || 1, 6)), + n_variants: Math.max(1, Math.min(parseInt(document.getElementById("sora-video-variants").value || "1", 10) || 1, 4)), + n_frames: Math.max(60, parseInt(document.getElementById("sora-video-frames").value || "300", 10) || 300), + resolution: Math.max(360, parseInt(document.getElementById("sora-video-resolution").value || "360", 10) || 360), + orientation: (document.getElementById("sora-video-orientation").value || "wide").trim(), + pollIntervalSeconds: getSoraVideoPollOptions().pollIntervalSeconds, + timeoutSeconds: getSoraVideoPollOptions().timeoutSeconds + }; +} + +function serializeSoraVideoTask(task) { + return { + task_id: task.task_id, + prompt: task.prompt || "", + task_mode: task.task_mode || SORA_KEY_SCOPE_TEXT, + task_family: task.task_family || "", + status: task.status || "", + normalized_status: task.normalized_status || "", + is_terminal: !!task.is_terminal, + is_success: !!task.is_success, + used_account_id: task.used_account_id || null, + used_email: task.used_email || "", + created_local_at: task.created_local_at || "", + remote_created_at: task.remote_created_at || "", + last_update_at: task.last_update_at || "", + progress_pct: task.progress_pct, + progress_pos_in_queue: task.progress_pos_in_queue, + estimated_queue_wait_time: task.estimated_queue_wait_time, + video_urls: Array.isArray(task.video_urls) ? task.video_urls.slice(0, 4) : [], + poll_interval_seconds: task.poll_interval_seconds || 5, + timeout_seconds: task.timeout_seconds || 900, + error_message: task.error_message || "", + auto_rotate: !!task.auto_rotate, + source_image_media_id: task.source_image_media_id || "", + source_image_name: task.source_image_name || "" + }; +} + +function persistSoraVideoWorkspace() { + localStorage.setItem(SORA_VIDEO_TASKS_STORAGE_KEY, JSON.stringify(soraVideoTasks.slice(0, 18).map(serializeSoraVideoTask))); + if (soraVideoSelectedTaskId) localStorage.setItem("sora_video_selected_task_id", soraVideoSelectedTaskId); + else localStorage.removeItem("sora_video_selected_task_id"); +} + +function loadPersistedSoraVideoTasks() { + var raw = localStorage.getItem(SORA_VIDEO_TASKS_STORAGE_KEY); + if (!raw) return []; + try { + var parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter(function(task) { + return task && typeof task.task_id === "string" && task.task_id.trim(); + }).map(function(task) { + return { + task_id: task.task_id.trim(), + prompt: task.prompt || "", + task_mode: normalizeSoraKeyScope(task.task_mode || SORA_KEY_SCOPE_TEXT), + task_family: (task.task_family || "").trim(), + status: task.status || "", + normalized_status: task.normalized_status || "", + is_terminal: !!task.is_terminal, + is_success: !!task.is_success, + used_account_id: task.used_account_id || null, + used_email: task.used_email || "", + created_local_at: task.created_local_at || "", + remote_created_at: task.remote_created_at || "", + last_update_at: task.last_update_at || "", + progress_pct: task.progress_pct, + progress_pos_in_queue: task.progress_pos_in_queue, + estimated_queue_wait_time: task.estimated_queue_wait_time, + video_urls: Array.isArray(task.video_urls) ? task.video_urls : [], + poll_interval_seconds: task.poll_interval_seconds || 5, + timeout_seconds: task.timeout_seconds || 900, + error_message: task.error_message || "", + auto_rotate: !!task.auto_rotate, + source_image_media_id: task.source_image_media_id || "", + source_image_name: task.source_image_name || "", + polling: false, + raw_result: null + }; + }); + } catch (_) { + return []; + } +} + +function getSoraVideoTaskIndex(taskId) { + for (var i = 0; i < soraVideoTasks.length; i += 1) { + if (soraVideoTasks[i].task_id === taskId) return i; + } + return -1; +} + +function getSoraVideoTask(taskId) { + var index = getSoraVideoTaskIndex(taskId); + return index >= 0 ? soraVideoTasks[index] : null; +} + +function ensureSelectedSoraVideoTask() { + if (soraVideoSelectedTaskId && getSoraVideoTask(soraVideoSelectedTaskId)) return; + soraVideoSelectedTaskId = soraVideoTasks.length ? soraVideoTasks[0].task_id : ""; +} + +function extractSoraVideoResultMessage(result) { + var data = (result && result.data) || {}; + var error = (data && data.error) || {}; + if (typeof data.message === "string" && data.message.trim()) return data.message.trim(); + if (typeof error.message === "string" && error.message.trim()) return error.message.trim(); + if (typeof error.code === "string" && error.code.trim()) return error.code.trim(); + if (typeof result.message === "string" && result.message.trim()) return result.message.trim(); + return ""; +} + +function buildSoraVideoTaskFromResult(result, meta) { + var seed = meta || {}; + var view = getSoraVideoView(result); + var data = (view && view.data) || (result && result.data) || {}; + var normalizedStatus = normalizeSoraVideoStatus(view.normalized_status || result.normalized_status || data.status || result.status || seed.normalized_status || ""); + var rawStatus = (view.status || result.status || data.status || seed.status || "").toString().trim(); + var isSuccess = typeof view.is_success === "boolean" ? view.is_success : (typeof result.is_success === "boolean" ? result.is_success : isSoraVideoSuccessStatus(normalizedStatus || rawStatus)); + var isTerminal = typeof view.is_terminal === "boolean" ? view.is_terminal : (typeof result.is_terminal === "boolean" ? result.is_terminal : isSoraVideoTerminalStatus(normalizedStatus || rawStatus)); + var progressPct = parseNumeric(data.progress_pct); + if (isSuccess) progressPct = 100; + return { + task_id: (result.task_id || view.task_id || seed.task_id || "").trim(), + prompt: data.prompt || seed.prompt || "", + task_mode: normalizeSoraKeyScope((seed.task_mode || result.task_mode || view.task_mode || (seed.source_image_media_id || result.source_image_media_id ? SORA_KEY_SCOPE_IMAGE : SORA_KEY_SCOPE_TEXT))), + task_family: (result.task_family || view.task_family || seed.task_family || "").trim(), + status: rawStatus, + normalized_status: normalizedStatus || rawStatus, + is_terminal: !!isTerminal, + is_success: !!isSuccess, + used_account_id: result.used_account_id || view.used_account_id || seed.used_account_id || null, + used_email: result.used_email || view.used_email || seed.used_email || "", + created_local_at: seed.created_local_at || new Date().toISOString(), + remote_created_at: data.created_at || seed.remote_created_at || "", + last_update_at: new Date().toISOString(), + progress_pct: progressPct, + progress_pos_in_queue: parseNumeric(data.progress_pos_in_queue), + estimated_queue_wait_time: parseNumeric(data.estimated_queue_wait_time), + video_urls: Array.isArray(view.video_urls) ? view.video_urls : (Array.isArray(result.video_urls) ? result.video_urls : []), + poll_interval_seconds: seed.poll_interval_seconds || 5, + timeout_seconds: seed.timeout_seconds || 900, + error_message: !result.ok ? (extractSoraVideoResultMessage(result) || ("HTTP " + String(result.status_code || ""))) : "", + auto_rotate: !!seed.auto_rotate, + source_image_media_id: result.source_image_media_id || view.source_image_media_id || seed.source_image_media_id || "", + source_image_name: seed.source_image_name || "", + polling: !isTerminal, + raw_result: result + }; +} + +function upsertSoraVideoTask(taskPatch) { + var patch = taskPatch || {}; + if (!patch.task_id) return null; + var index = getSoraVideoTaskIndex(patch.task_id); + var current = index >= 0 ? soraVideoTasks[index] : null; + var next = { + task_id: patch.task_id, + prompt: patch.prompt || (current ? current.prompt : ""), + task_mode: patch.task_mode || (current ? current.task_mode : SORA_KEY_SCOPE_TEXT), + task_family: patch.task_family != null ? patch.task_family : (current ? current.task_family : ""), + status: patch.status || (current ? current.status : ""), + normalized_status: patch.normalized_status || (current ? current.normalized_status : ""), + is_terminal: typeof patch.is_terminal === "boolean" ? patch.is_terminal : (current ? current.is_terminal : false), + is_success: typeof patch.is_success === "boolean" ? patch.is_success : (current ? current.is_success : false), + used_account_id: patch.used_account_id != null ? patch.used_account_id : (current ? current.used_account_id : null), + used_email: patch.used_email || (current ? current.used_email : ""), + created_local_at: patch.created_local_at || (current ? current.created_local_at : new Date().toISOString()), + remote_created_at: patch.remote_created_at || (current ? current.remote_created_at : ""), + last_update_at: patch.last_update_at || new Date().toISOString(), + progress_pct: patch.progress_pct != null ? patch.progress_pct : (current ? current.progress_pct : null), + progress_pos_in_queue: patch.progress_pos_in_queue != null ? patch.progress_pos_in_queue : (current ? current.progress_pos_in_queue : null), + estimated_queue_wait_time: patch.estimated_queue_wait_time != null ? patch.estimated_queue_wait_time : (current ? current.estimated_queue_wait_time : null), + video_urls: Array.isArray(patch.video_urls) ? patch.video_urls : (current ? current.video_urls : []), + poll_interval_seconds: patch.poll_interval_seconds || (current ? current.poll_interval_seconds : 5), + timeout_seconds: patch.timeout_seconds || (current ? current.timeout_seconds : 900), + error_message: patch.error_message != null ? patch.error_message : (current ? current.error_message : ""), + auto_rotate: typeof patch.auto_rotate === "boolean" ? patch.auto_rotate : (current ? current.auto_rotate : false), + source_image_media_id: patch.source_image_media_id != null ? patch.source_image_media_id : (current ? current.source_image_media_id : ""), + source_image_name: patch.source_image_name != null ? patch.source_image_name : (current ? current.source_image_name : ""), + polling: typeof patch.polling === "boolean" ? patch.polling : (current ? current.polling : false), + raw_result: patch.raw_result || (current ? current.raw_result : null) + }; + if (index >= 0) soraVideoTasks[index] = next; + else soraVideoTasks.unshift(next); + soraVideoTasks = soraVideoTasks.slice(0, 18); + ensureSelectedSoraVideoTask(); + persistSoraVideoWorkspace(); + return next; +} + +function upsertSoraVideoTaskFromResult(result, meta) { + return upsertSoraVideoTask(buildSoraVideoTaskFromResult(result, meta)); +} + +function getSoraVideoTaskElapsedSeconds(task) { + var startedMs = parseSoraDateMs((task && task.remote_created_at) || (task && task.created_local_at) || ""); + if (!startedMs) return 0; + return Math.max(0, Math.round((Date.now() - startedMs) / 1000)); +} + +function getSoraVideoTaskProgressPercent(task) { + if (!task) return 0; + if (parseNumeric(task.progress_pct) != null) return Math.max(0, Math.min(100, parseNumeric(task.progress_pct))); + if (task.is_success) return 100; + if (task.is_terminal) return 100; + if (task.normalized_status === "running") return 62; + if (task.normalized_status === "queued") return 18; + return 8; +} + +function getSoraVideoTaskProgressText(task) { + if (!task) return "0%"; + if (parseNumeric(task.progress_pct) != null) return String(Math.round(parseNumeric(task.progress_pct))) + "%"; + if (task.is_success) return "100%"; + if (task.normalized_status === "running") return "生成中"; + if (task.normalized_status === "queued") return "排队中"; + return task.normalized_status || "等待中"; +} + +function getSoraVideoTaskQueueText(task) { + if (!task) return "等待中"; + var parts = []; + if (task.progress_pos_in_queue != null) parts.push("队列 #" + String(task.progress_pos_in_queue)); + if (task.estimated_queue_wait_time != null) parts.push("预计 " + formatDuration(task.estimated_queue_wait_time)); + if (parts.length) return parts.join(" · "); + if (task.is_success) return "已完成"; + if (task.is_terminal) return "已结束"; + if (task.normalized_status === "running") return "正在生成"; + return "等待中"; +} + +function setSelectedSoraVideoTask(taskId) { + soraVideoSelectedTaskId = (taskId || "").trim(); + ensureSelectedSoraVideoTask(); + persistSoraVideoWorkspace(); + renderSoraVideoWorkspace(); +} + +function renderSoraVideoOverview() { + var box = document.getElementById("sora-video-overview"); + if (!box) return; + var activeCount = soraVideoTasks.filter(function(task) { return !task.is_terminal; }).length; + var successCount = soraVideoTasks.filter(function(task) { return task.is_success; }).length; + var pollingCount = Object.keys(soraVideoPollers).length; + box.innerHTML = [ + { label: "任务总数", value: String(soraVideoTasks.length) }, + { label: "并行中", value: String(activeCount) }, + { label: "轮询中", value: String(pollingCount) }, + { label: "自动轮换", value: isSoraVideoAutoRotateEnabled() ? "已开启" : "手动账号" } + ].map(function(item) { + return '
' + escapeHtml(item.label) + '' + escapeHtml(item.value) + '
'; + }).join(""); +} + +function renderSoraVideoStage() { + var mediaEl = document.getElementById("sora-video-stage-media"); + var titleEl = document.getElementById("sora-video-stage-title"); + var promptEl = document.getElementById("sora-video-stage-prompt"); + var statusEl = document.getElementById("sora-video-stage-status"); + var progressFillEl = document.getElementById("sora-video-stage-progress-fill"); + var progressTextEl = document.getElementById("sora-video-stage-progress-text"); + var elapsedEl = document.getElementById("sora-video-stage-elapsed"); + var queueEl = document.getElementById("sora-video-stage-queue"); + var accountEl = document.getElementById("sora-video-stage-account"); + var createdEl = document.getElementById("sora-video-stage-created"); + var hintEl = document.getElementById("sora-video-stage-hint"); + var kickerEl = document.getElementById("sora-video-stage-kicker"); + var task = getSoraVideoTask(soraVideoSelectedTaskId); + if (!task) { + if (mediaEl) { + mediaEl.innerHTML = '
Sora

还没有任务,点击右上角黄色按钮开始生成。

'; + mediaEl.setAttribute("data-task-id", ""); + mediaEl.setAttribute("data-preview-url", ""); + } + if (titleEl) titleEl.textContent = "选择一个任务进行预览"; + if (promptEl) promptEl.textContent = "生成完成后,视频会显示在这个大框里;任务未完成时会显示当前进度和时间。"; + if (statusEl) { statusEl.textContent = "idle"; statusEl.className = "sora-status-badge is-pending"; } + if (progressFillEl) progressFillEl.style.width = "0%"; + if (progressTextEl) progressTextEl.textContent = "0%"; + if (elapsedEl) elapsedEl.textContent = "00:00"; + if (queueEl) queueEl.textContent = "等待中"; + if (accountEl) accountEl.textContent = "自动选择"; + if (createdEl) createdEl.textContent = "创建时间 --"; + if (hintEl) hintEl.textContent = "支持多任务并行轮询"; + if (kickerEl) kickerEl.textContent = "等待任务"; + return; + } + var previewUrl = pickSoraPreviewUrl(task.video_urls); + if (mediaEl) { + if (previewUrl) { + var renderedTaskId = mediaEl.getAttribute("data-task-id") || ""; + var renderedPreviewUrl = mediaEl.getAttribute("data-preview-url") || ""; + var videoEl = mediaEl.querySelector("video"); + if (renderedTaskId !== task.task_id || renderedPreviewUrl !== previewUrl || !videoEl) { + mediaEl.innerHTML = ''; + mediaEl.setAttribute("data-task-id", task.task_id); + mediaEl.setAttribute("data-preview-url", previewUrl); + videoEl = mediaEl.querySelector("video"); + if (videoEl) { + videoEl.addEventListener("loadeddata", function() { + delete soraVideoMediaReloadAttempts[task.task_id]; + }, { once: true }); + videoEl.addEventListener("error", function() { + var attempt = soraVideoMediaReloadAttempts[task.task_id] || 0; + if (attempt >= 1) return; + soraVideoMediaReloadAttempts[task.task_id] = attempt + 1; + refreshSoraVideoTaskSnapshot(task.task_id, { + message: "视频预览地址已刷新,正在重新加载...", + resetMediaRetry: true + }).catch(function() {}); + }, { once: true }); + } + } + } else { + mediaEl.innerHTML = '
' + escapeHtml(task.normalized_status || "task") + '

' + escapeHtml(task.prompt || "任务已创建,正在等待更多进度。") + '

'; + mediaEl.setAttribute("data-task-id", task.task_id); + mediaEl.setAttribute("data-preview-url", ""); + } + } + if (titleEl) titleEl.textContent = trimVideoPrompt(task.prompt || task.task_id, 54) || task.task_id; + if (promptEl) promptEl.textContent = task.prompt || "这个任务当前还没有返回 prompt。"; + if (statusEl) { + statusEl.textContent = task.normalized_status || "unknown"; + statusEl.className = "sora-status-badge " + (task.is_success ? "is-success" : (task.is_terminal ? "is-failed" : "is-pending")); + } + if (progressFillEl) progressFillEl.style.width = String(getSoraVideoTaskProgressPercent(task)) + "%"; + if (progressTextEl) progressTextEl.textContent = getSoraVideoTaskProgressText(task); + if (elapsedEl) elapsedEl.textContent = formatDuration(getSoraVideoTaskElapsedSeconds(task)); + if (queueEl) queueEl.textContent = getSoraVideoTaskQueueText(task); + if (accountEl) accountEl.textContent = task.used_email ? (String(task.used_account_id || "--") + " · " + task.used_email) : (task.used_account_id ? String(task.used_account_id) : "自动选择"); + if (createdEl) createdEl.textContent = "创建时间 " + formatSoraDateTime(task.remote_created_at || task.created_local_at || ""); + if (hintEl) hintEl.textContent = task.error_message || (task.is_success ? "预览默认优先使用更顺畅的中低码率流" : (task.is_terminal ? "任务已结束" : (task.polling ? "后台自动轮询中" : "点击任务卡可继续轮询"))); + if (kickerEl) { + var modeLabel = task.task_mode === SORA_KEY_SCOPE_IMAGE ? "图生视频" : "文生视频"; + var familyLabel = task.task_family === "nf2" ? "官方 App" : "旧链路"; + kickerEl.textContent = (task.is_success ? "预览就绪" : (task.is_terminal ? "任务结束" : "实时进度")) + " · " + modeLabel + " · " + familyLabel; + } +} + +function renderSoraVideoTaskList() { + var listEl = document.getElementById("sora-video-task-list"); + if (!listEl) return; + if (!soraVideoTasks.length) { + listEl.innerHTML = '
还没有任务。点击右上角黄色按钮创建后,会自动进入这里并并行轮询。
'; + return; + } + var tasks = soraVideoTasks.slice().sort(function(a, b) { + if (!!a.is_terminal !== !!b.is_terminal) return a.is_terminal ? 1 : -1; + return parseSoraDateMs(b.remote_created_at || b.created_local_at || "") - parseSoraDateMs(a.remote_created_at || a.created_local_at || ""); + }); + listEl.innerHTML = tasks.map(function(task) { + var progress = getSoraVideoTaskProgressPercent(task); + var statusClass = task.is_success ? "is-success" : (task.is_terminal ? "is-failed" : "is-pending"); + var familyLabel = task.task_family === "nf2" ? "官方 App" : "旧链路"; + return ( + '
' + + '
' + + '
' + + '

' + escapeHtml(trimVideoPrompt(task.prompt || task.task_id, 28) || task.task_id) + '

' + + '

' + escapeHtml(task.task_mode === SORA_KEY_SCOPE_IMAGE ? '图生视频' : '文生视频') + ' · ' + escapeHtml(familyLabel) + ' · task_id ' + escapeHtml(task.task_id) + '

' + + '
' + + '' + escapeHtml(task.normalized_status || 'unknown') + '' + + '
' + + '

' + escapeHtml(task.prompt || "这个任务当前还没有返回 prompt。") + '

' + + '
' + + '
进度 ' + escapeHtml(getSoraVideoTaskProgressText(task)) + '耗时 ' + escapeHtml(formatDuration(getSoraVideoTaskElapsedSeconds(task))) + '
' + + '
' + escapeHtml(getSoraVideoTaskQueueText(task)) + '账号 ' + escapeHtml(task.used_account_id ? String(task.used_account_id) : '--') + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + ); + }).join(""); + listEl.querySelectorAll("[data-video-task-select], [data-video-task-focus]").forEach(function(node) { + node.addEventListener("click", function(event) { + var target = event.currentTarget.getAttribute("data-video-task-select") || event.currentTarget.getAttribute("data-video-task-focus") || ""; + if (target) setSelectedSoraVideoTask(target); + }); + }); + listEl.querySelectorAll("[data-video-task-copy]").forEach(function(node) { + node.addEventListener("click", function(event) { + event.stopPropagation(); + var taskId = event.currentTarget.getAttribute("data-video-task-copy") || ""; + copyTextToClipboard(taskId, "task_id 已复制"); + }); + }); + listEl.querySelectorAll("[data-video-task-toggle]").forEach(function(node) { + node.addEventListener("click", function(event) { + event.stopPropagation(); + var taskId = event.currentTarget.getAttribute("data-video-task-toggle") || ""; + var task = getSoraVideoTask(taskId); + if (!task) return; + if (task.polling) stopSoraVideoTaskPolling(taskId, { message: "已暂停 " + taskId }); + else startSoraVideoTaskPolling(taskId); + }); + }); +} + +function renderSoraVideoWorkspace() { + ensureSelectedSoraVideoTask(); + renderSoraVideoOverview(); + renderSoraVideoStage(); + renderSoraVideoTaskList(); +} + +function startSoraVideoUiClock() { + if (soraVideoUiClock) return; + soraVideoUiClock = window.setInterval(function() { + if (soraVideoTasks.length) renderSoraVideoWorkspace(); + }, 1000); +} + +function stopSoraVideoTaskPolling(taskId, options) { + var timer = soraVideoPollers[taskId]; + if (timer) { + clearTimeout(timer); + delete soraVideoPollers[taskId]; + } + var task = getSoraVideoTask(taskId); + if (task) { + upsertSoraVideoTask({ + task_id: taskId, + polling: false, + error_message: options && options.message ? options.message : task.error_message + }); + } + if (options && options.toast) toast(options.toast, options.type || "info"); + if (options && options.message) { + var msgEl = document.getElementById("sora-video-msg"); + if (msgEl) msgEl.textContent = options.message; + } + renderSoraVideoWorkspace(); +} + +function stopAllSoraVideoTaskPolling(options) { + Object.keys(soraVideoPollers).forEach(function(taskId) { + stopSoraVideoTaskPolling(taskId); + }); + if (options && options.message) { + var msgEl = document.getElementById("sora-video-msg"); + if (msgEl) msgEl.textContent = options.message; + } + if (options && options.toast) toast(options.toast, options.type || "info"); +} + +function fetchSoraVideoTask(taskId) { + var accountId = getSoraAccountIdFromInput(); + function request(body) { + return api("/api/sora-api/video-gen/get", { + method: "POST", + body: JSON.stringify(body) + }); + } + return request({ task_id: taskId }).catch(function(err) { + var message = parseApiErrorMessage(err); + if (accountId && (message.indexOf("缺少 access_token") >= 0 || message.indexOf("refresh_token") >= 0)) { + return request({ + account_id: accountId, + task_id: taskId + }); + } + throw err; + }); +} + +function refreshSoraVideoTaskSnapshot(taskId, options) { + var cleanTaskId = (taskId || "").trim(); + if (!cleanTaskId) return Promise.resolve(null); + if (soraVideoSnapshotRefreshInFlight[cleanTaskId]) return Promise.resolve(getSoraVideoTask(cleanTaskId)); + soraVideoSnapshotRefreshInFlight[cleanTaskId] = true; + var current = getSoraVideoTask(cleanTaskId) || { task_id: cleanTaskId }; + return fetchSoraVideoTask(cleanTaskId).then(function(result) { + var nextTask = upsertSoraVideoTaskFromResult(result, current); + if (nextTask && nextTask.used_account_id) setCurrentSoraAccountId(nextTask.used_account_id); + if (options && options.resetMediaRetry) delete soraVideoMediaReloadAttempts[cleanTaskId]; + if (options && options.message) { + var msgEl = document.getElementById("sora-video-msg"); + if (msgEl) msgEl.textContent = options.message; + } + renderSoraVideoWorkspace(); + return nextTask; + }).catch(function(err) { + var message = parseApiErrorMessage(err); + upsertSoraVideoTask({ task_id: cleanTaskId, error_message: message }); + renderSoraVideoWorkspace(); + throw err; + }).finally(function() { + delete soraVideoSnapshotRefreshInFlight[cleanTaskId]; + }); +} + +function startSoraVideoTaskPolling(taskId) { + var cleanTaskId = (taskId || "").trim(); + if (!cleanTaskId) return; + var task = getSoraVideoTask(cleanTaskId); + if (!task || task.is_terminal) { + stopSoraVideoTaskPolling(cleanTaskId); + return; + } + stopSoraVideoTaskPolling(cleanTaskId); + upsertSoraVideoTask({ task_id: cleanTaskId, polling: true, error_message: "" }); + renderSoraVideoWorkspace(); + function tick() { + var current = getSoraVideoTask(cleanTaskId); + if (!current) return stopSoraVideoTaskPolling(cleanTaskId); + var timeoutMs = Math.max(30000, Math.round((current.timeout_seconds || 900) * 1000)); + if (getSoraVideoTaskElapsedSeconds(current) * 1000 >= timeoutMs) { + stopSoraVideoTaskPolling(cleanTaskId, { message: "任务 " + cleanTaskId + " 轮询超时" }); + return; + } + fetchSoraVideoTask(cleanTaskId).then(function(result) { + var nextTask = upsertSoraVideoTaskFromResult(result, current); + if (nextTask && nextTask.used_account_id) { + setCurrentSoraAccountId(nextTask.used_account_id); + } + renderSoraVideoWorkspace(); + if (nextTask && nextTask.is_terminal) { + stopSoraVideoTaskPolling(cleanTaskId, { + message: nextTask.is_success ? ("任务 " + cleanTaskId + " 已成功出片") : ("任务 " + cleanTaskId + " 已结束"), + toast: nextTask.is_success ? "有任务生成完成" : "", + type: nextTask.is_success ? "success" : "info" + }); + return; + } + var currentTask = getSoraVideoTask(cleanTaskId); + if (!currentTask) return; + soraVideoPollers[cleanTaskId] = window.setTimeout(tick, Math.max(1000, Math.round((currentTask.poll_interval_seconds || 5) * 1000))); + }).catch(function(err) { + var message = parseApiErrorMessage(err); + upsertSoraVideoTask({ task_id: cleanTaskId, polling: true, error_message: message }); + renderSoraVideoWorkspace(); + var currentTask = getSoraVideoTask(cleanTaskId); + if (!currentTask) return; + soraVideoPollers[cleanTaskId] = window.setTimeout(tick, Math.max(1000, Math.round((currentTask.poll_interval_seconds || 5) * 1000))); + }); + } + tick(); +} + +function refreshAllSoraVideoTasks() { + var pending = soraVideoTasks.filter(function(task) { return !task.is_terminal; }); + var completed = soraVideoTasks.filter(function(task) { return task.is_terminal; }); + if (!pending.length && !completed.length) { + var msgEl = document.getElementById("sora-video-msg"); + if (msgEl) msgEl.textContent = "当前没有可刷新的任务"; + return; + } + pending.forEach(function(task) { + startSoraVideoTaskPolling(task.task_id); + }); + completed.forEach(function(task) { + refreshSoraVideoTaskSnapshot(task.task_id, { resetMediaRetry: true }).catch(function() {}); + }); + var msgEl = document.getElementById("sora-video-msg"); + if (msgEl) msgEl.textContent = "已刷新全部任务,未完成任务继续轮询,已完成任务会重新获取预览地址"; +} + +function clearFinishedSoraVideoTasks() { + soraVideoTasks = soraVideoTasks.filter(function(task) { return !task.is_terminal; }); + ensureSelectedSoraVideoTask(); + persistSoraVideoWorkspace(); + renderSoraVideoWorkspace(); +} + +function openSoraVideoComposer() { + var overlay = document.getElementById("sora-video-compose-overlay"); + if (overlay) overlay.classList.remove("hidden"); + updateSoraVideoComposerMode(); + var promptEl = document.getElementById("sora-video-prompt"); + if (promptEl) window.setTimeout(function() { promptEl.focus(); }, 30); +} + +function closeSoraVideoComposer() { + var overlay = document.getElementById("sora-video-compose-overlay"); + if (overlay) overlay.classList.add("hidden"); + var msgEl = document.getElementById("sora-video-compose-msg"); + if (msgEl) msgEl.textContent = ""; +} + +function createSoraVideoRequestBody(payload) { + var body = { + prompt: payload.prompt, + auto_rotate: payload.autoRotate, + task_family: payload.taskFamily || SORA_TASK_FAMILY_VIDEO_GEN, + n_variants: payload.n_variants, + n_frames: payload.n_frames, + resolution: payload.resolution, + orientation: payload.orientation, + source_image_media_id: payload.source_image_media_id || "" + }; + if (payload.audio_caption) body.audio_caption = payload.audio_caption; + if (payload.audio_transcript) body.audio_transcript = payload.audio_transcript; + if (!payload.autoRotate) body.account_id = payload.account_id; + return body; +} + +function createSoraVideoTasks() { + var composeMsgEl = document.getElementById("sora-video-compose-msg"); + var globalMsgEl = document.getElementById("sora-video-msg"); + var buttonEl = document.getElementById("btn-sora-video-create"); + var payload; + if (soraVideoCreateInFlight) return; + try { + payload = getSoraVideoComposerPayload(); + } catch (err) { + var message = parseApiErrorMessage(err); + if (composeMsgEl) composeMsgEl.textContent = message; + toast(message, "info"); + return; + } + soraVideoCreateInFlight = true; + if (buttonEl) buttonEl.disabled = true; + if (composeMsgEl) composeMsgEl.textContent = "正在发起 " + payload.batchCount + " 条任务..."; + if (globalMsgEl) globalMsgEl.textContent = "正在创建任务并加入并行队列..."; + var requests = []; + for (var i = 0; i < payload.batchCount; i += 1) { + if (payload.taskMode === SORA_KEY_SCOPE_IMAGE) { + var form = new FormData(); + form.append("prompt", payload.prompt); + form.append("auto_rotate", payload.autoRotate ? "true" : "false"); + form.append("n_variants", String(payload.n_variants)); + form.append("n_frames", String(payload.n_frames)); + form.append("resolution", String(payload.resolution)); + form.append("orientation", payload.orientation); + form.append("file", payload.imageFile, payload.imageFile.name || ("image-" + String(i + 1) + ".png")); + if (!payload.autoRotate && payload.account_id) form.append("account_id", String(payload.account_id)); + requests.push(apiForm("/api/sora-api/video-gen/create-with-image", form)); + } else { + requests.push(api("/api/sora-api/video-gen/create", { + method: "POST", + body: JSON.stringify(createSoraVideoRequestBody(payload)) + })); + } + } + Promise.allSettled(requests).then(function(results) { + var successCount = 0; + var failures = []; + results.forEach(function(entry) { + if (entry.status !== "fulfilled") { + failures.push(parseApiErrorMessage(entry.reason)); + return; + } + var result = entry.value || {}; + var taskId = (result.task_id || "").trim(); + if (!result.ok || !taskId) { + failures.push(extractSoraVideoResultMessage(result) || ("HTTP " + String(result.status_code || ""))); + return; + } + var task = upsertSoraVideoTaskFromResult(result, { + task_id: taskId, + prompt: payload.prompt, + task_mode: payload.taskMode, + used_account_id: result.used_account_id || null, + used_email: result.used_email || "", + created_local_at: new Date().toISOString(), + poll_interval_seconds: payload.pollIntervalSeconds, + timeout_seconds: payload.timeoutSeconds, + auto_rotate: payload.autoRotate, + source_image_media_id: result.source_image_media_id || "", + source_image_name: payload.imageFile ? payload.imageFile.name : "" + }); + if (task && task.used_account_id) { + setCurrentSoraAccountId(task.used_account_id); + loadSoraAccountDetails(task.used_account_id, { silent: true, skipKeyList: true }).catch(function() {}); + } + if (successCount === 0 && task) setSelectedSoraVideoTask(task.task_id); + if (task) startSoraVideoTaskPolling(task.task_id); + successCount += 1; + }); + renderSoraVideoWorkspace(); + if (successCount > 0) { + if (composeMsgEl) composeMsgEl.textContent = "已创建 " + successCount + " 条任务,正在并行轮询..."; + if (globalMsgEl) globalMsgEl.textContent = "已创建 " + successCount + " 条任务,任务墙会继续显示进度和耗时"; + toast("已创建 " + successCount + " 条任务", "success"); + if (!failures.length) window.setTimeout(closeSoraVideoComposer, 250); + } else { + if (composeMsgEl) composeMsgEl.textContent = "创建失败:" + failures.join(";"); + if (globalMsgEl) globalMsgEl.textContent = "创建失败:" + failures.join(";"); + } + if (failures.length) { + toast(failures[0], "error"); + } + }).finally(function() { + soraVideoCreateInFlight = false; + if (buttonEl) buttonEl.disabled = false; + }); +} + +function importSoraVideoTask() { + var taskId = getSoraVideoTaskId(); + var msgEl = document.getElementById("sora-video-msg"); + if (!taskId) { + if (msgEl) msgEl.textContent = "请先输入 task_id"; + toast("请先输入 task_id", "info"); + return; + } + upsertSoraVideoTask({ + task_id: taskId, + prompt: "", + created_local_at: new Date().toISOString(), + poll_interval_seconds: getSoraVideoPollOptions().pollIntervalSeconds, + timeout_seconds: getSoraVideoPollOptions().timeoutSeconds, + polling: true + }); + setSelectedSoraVideoTask(taskId); + startSoraVideoTaskPolling(taskId); + if (msgEl) msgEl.textContent = "已把 " + taskId + " 加入任务墙并开始轮询"; +} + +(function initSoraVideoTool() { + renderSoraVideoAccountSummary(null); + setSoraVideoAutoRotateEnabled(localStorage.getItem(SORA_VIDEO_AUTO_ROTATE_STORAGE_KEY) !== "0"); + soraVideoTasks = loadPersistedSoraVideoTasks(); + ensureSelectedSoraVideoTask(); + renderSoraVideoWorkspace(); + startSoraVideoUiClock(); + var savedTaskId = localStorage.getItem("sora_video_last_task_id"); + if (savedTaskId) setSoraVideoTaskId(savedTaskId); + soraVideoTasks.forEach(function(task) { + if (!task.is_terminal) startSoraVideoTaskPolling(task.task_id); + }); + var selectedTask = getSoraVideoTask(soraVideoSelectedTaskId); + if (selectedTask && selectedTask.is_terminal) { + refreshSoraVideoTaskSnapshot(selectedTask.task_id, { resetMediaRetry: true }).catch(function() {}); + } + document.getElementById("sora-video-task-id").addEventListener("change", function() { + setSoraVideoTaskId(this.value || ""); + }); + document.getElementById("sora-video-auto-rotate").addEventListener("change", function() { + setSoraVideoAutoRotateEnabled(this.checked); + renderSoraVideoOverview(); + }); + document.getElementById("sora-video-task-mode").addEventListener("change", updateSoraVideoComposerMode); + document.getElementById("sora-video-image-file").addEventListener("change", updateSoraVideoImageMeta); + document.getElementById("btn-sora-video-open-composer").addEventListener("click", openSoraVideoComposer); + document.getElementById("btn-sora-video-compose-close").addEventListener("click", closeSoraVideoComposer); + document.getElementById("btn-sora-video-compose-backdrop").addEventListener("click", closeSoraVideoComposer); + document.getElementById("btn-sora-video-create").addEventListener("click", createSoraVideoTasks); + document.getElementById("btn-sora-video-import-task").addEventListener("click", importSoraVideoTask); + document.getElementById("btn-sora-video-refresh-all").addEventListener("click", refreshAllSoraVideoTasks); + document.getElementById("btn-sora-video-stop-all").addEventListener("click", function() { + stopAllSoraVideoTaskPolling({ + message: "已暂停全部任务轮询", + toast: "已暂停全部轮询", + type: "info" + }); + }); + document.getElementById("btn-sora-video-clear-finished").addEventListener("click", clearFinishedSoraVideoTasks); + updateSoraVideoComposerMode(); +})(); + +document.getElementById("btn-go-video-page").addEventListener("click", function() { + showPage("video"); +}); + +// Emails +function loadEmails() { + document.getElementById("email-api-balance").textContent = "--"; + document.getElementById("email-api-msg").textContent = ""; + api("/api/email-api/balance").then((d) => { + document.getElementById("email-api-balance").textContent = String(d.balance); + }).catch(() => { + document.getElementById("email-api-balance").textContent = "未配置或请求失败"; + }); + api("/api/emails").then((d) => { + document.getElementById("emails-tbody").innerHTML = (d.items || []) + .map( + (r) => + ` + ${r.id} + ${escapeHtml(r.email)} + + ${escapeHtml(r.password || "")} + ${r.password ? `` : ""} + + ${escapeHtml((r.uuid || "").slice(0, 12))} + ${r.registered ? '已注册' : '未注册'} + + + + + ` + ) + .join(""); + document.getElementById("emails-tbody").querySelectorAll(".btn-op-view").forEach((btn) => { + btn.addEventListener("click", () => { + const id = btn.dataset.id; + showModal(''); + var modalContent = document.querySelector(".modal-content"); + if (modalContent) modalContent.classList.add("modal-content-wide"); + api("/api/email-api/mail-list?email_id=" + encodeURIComponent(id)) + .then((d) => { + var list = d.list || []; + function renderMailDetail(mail) { + var isObj = mail && typeof mail === "object" && !Array.isArray(mail); + var subject = isObj && (mail.subject != null || mail.title != null) ? (mail.subject ?? mail.title) : ""; + var body = isObj && (mail.body != null || mail.content != null || mail.text != null || mail.Text != null) ? (mail.body ?? mail.content ?? mail.text ?? mail.Text) : ""; + var from = isObj && mail.from != null ? mail.from : ""; + var date = isObj && mail.date != null ? mail.date : ""; + var html = isObj && (mail.html != null || mail.Html != null) ? (mail.html ?? mail.Html) : ""; + var previewHtml = ""; + if (html) previewHtml = "
" + html + "
"; + else if (body) previewHtml = "
" + escapeHtml(String(body)) + "
"; + else if (isObj) previewHtml = "
" + escapeHtml(JSON.stringify(mail, null, 2)) + "
"; + else previewHtml = "
" + escapeHtml(String(mail)) + "
"; + var rawHtml = "
" + escapeHtml(JSON.stringify(mail, null, 2)) + "
"; + return '

发件人 ' + escapeHtml(String(from)) + '

主题 ' + escapeHtml(String(subject)) + (date ? '

时间 ' + escapeHtml(String(date)) : '') + '

"; + } + function bindTabSwitch() { + document.querySelectorAll(".email-view-detail .email-tab").forEach(function(tab) { + tab.onclick = function() { + document.querySelectorAll(".email-view-detail .email-tab").forEach(function(t) { t.classList.remove("active"); }); + document.querySelectorAll(".email-view-detail .email-tab-panel").forEach(function(p) { p.classList.add("hidden"); }); + this.classList.add("active"); + var pid = "email-panel-" + this.getAttribute("data-tab"); + var panel = document.getElementById(pid); + if (panel) panel.classList.remove("hidden"); + }; + }); + } + var listHtml = '"; + var detailHtml = ''; + document.getElementById("modal-body").innerHTML = '"; + bindTabSwitch(); + document.querySelectorAll(".email-view-list-item").forEach(function(item) { + item.addEventListener("click", function() { + var idx = parseInt(this.getAttribute("data-index"), 10); + var mail = list[idx]; + if (!mail) return; + document.querySelectorAll(".email-view-list-item").forEach(function(el) { el.classList.remove("active"); }); + this.classList.add("active"); + var inner = document.querySelector(".email-view-detail-inner"); + if (inner) { + inner.innerHTML = renderMailDetail(mail) + ''; + bindTabSwitch(); + } + }); + }); + }) + .catch((err) => { + if (modalContent) modalContent.classList.remove("modal-content-wide"); + document.getElementById("modal-body").innerHTML = + ''; + }); + }); + }); + document.getElementById("emails-tbody").querySelectorAll(".btn-op.danger").forEach((btn) => { + btn.addEventListener("click", () => { + confirmBox("确定删除该邮箱?", function() { + api("/api/emails/" + btn.dataset.id, { method: "DELETE" }).then(() => { toast("已删除"); loadEmails(); }); + }); + }); + }); + document.getElementById("emails-tbody").querySelectorAll(".btn-copy-email-password").forEach((btn) => { + btn.addEventListener("click", () => { + var pwd = decodeURIComponent(btn.dataset.password || ""); + copyTextToClipboard(pwd, "邮箱密码已复制"); + }); + }); + }); +} +document.getElementById("btn-add-email").addEventListener("click", () => { + showModal(` +
+ + + + + + +
+ `); + document.getElementById("email-form").addEventListener("submit", (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + api("/api/emails", { + method: "POST", + body: JSON.stringify({ + email: fd.get("email"), + password: fd.get("password"), + uuid: fd.get("uuid"), + token: fd.get("token"), + remark: fd.get("remark"), + }), + }).then(() => { hideModal(); loadEmails(); }); + }); +}); +document.getElementById("btn-batch-import-email").addEventListener("click", () => { + showModal(` +

每行一条:邮箱----密码----UUID----Token

+ + + `); + document.getElementById("email-import-submit").addEventListener("click", () => { + const lines = document.getElementById("email-import-lines").value; + api("/api/emails/batch-import", { method: "POST", body: JSON.stringify({ lines }) }).then((d) => { + hideModal(); + toast("已导入 " + d.added + " 条"); + loadEmails(); + }); + }); +}); +document.getElementById("btn-batch-export-email").addEventListener("click", () => { + api("/api/emails/export").then((d) => { + const items = d.items || []; + const lines = items.map((r) => [r.email, r.password || "", r.uuid || "", r.token || ""].join("----")); + const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "emails-" + new Date().toISOString().slice(0, 10) + ".txt"; + a.click(); + URL.revokeObjectURL(a.href); + toast("已导出 " + items.length + " 条"); + }).catch((err) => toast("导出失败: " + (err.message || "请求错误"), "error")); +}); +document.getElementById("link-to-settings").addEventListener("click", function(e) { + e.preventDefault(); + showPage("settings"); +}); +document.getElementById("btn-email-api-stock").addEventListener("click", function() { + const mailType = document.getElementById("email-api-mail-type").value; + const msg = document.getElementById("email-api-msg"); + msg.textContent = "查询中..."; + api("/api/email-api/stock?mailType=" + encodeURIComponent(mailType)).then((d) => { + msg.textContent = "库存:" + d.stock + "(" + (d.mail_type || "全部") + ")"; + }).catch((err) => { + msg.textContent = "失败:" + (err.message || "请求错误"); + }); +}); +document.getElementById("btn-email-api-fetch").addEventListener("click", function() { + const mailType = document.getElementById("email-api-mail-type").value; + const quantity = parseInt(document.getElementById("email-api-quantity").value, 10) || 1; + const msg = document.getElementById("email-api-msg"); + msg.textContent = "拉取中..."; + api("/api/email-api/fetch-mail", { + method: "POST", + body: JSON.stringify({ mail_type: mailType, quantity, import_to_emails: true }), + }).then((d) => { + msg.textContent = "拉取 " + d.count + " 条,已导入 " + d.imported + " 条"; + if (d.imported) loadEmails(); + }).catch((err) => { + msg.textContent = "失败:" + (err.message || "请求错误"); + }); +}); + +// Bank cards +function loadBankCards() { + api("/api/bank-cards").then((d) => { + document.getElementById("cards-tbody").innerHTML = (d.items || []) + .map( + (r) => + ` + + ${r.id} + ${escapeHtml(r.card_number_masked || "")} + ${r.used_count}/${r.max_use_count} + ${escapeHtml(r.remark || "")} + + ` + ) + .join(""); + document.getElementById("cards-tbody").querySelectorAll(".btn-link.danger").forEach((btn) => { + btn.addEventListener("click", () => { + confirmBox("确定删除该银行卡?", function() { + api("/api/bank-cards/" + btn.dataset.id, { method: "DELETE" }).then(() => { toast("已删除"); loadBankCards(); }); + }); + }); + }); + }); +} +document.getElementById("btn-add-card").addEventListener("click", () => { + showModal(` +
+ + + + +
+ `); + document.getElementById("card-form").addEventListener("submit", (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + api("/api/bank-cards", { + method: "POST", + body: JSON.stringify({ + card_number_masked: fd.get("card_number_masked"), + card_data: fd.get("card_number_masked"), + max_use_count: parseInt(fd.get("max_use_count") || 1, 10), + remark: fd.get("remark"), + }), + }).then(() => { hideModal(); loadBankCards(); }); + }); +}); +document.getElementById("btn-batch-import-card").addEventListener("click", () => { + showModal(` +

每行一条卡信息(掩码或后四位),使用次数从系统设置读取

+ + + `); + document.getElementById("card-import-submit").addEventListener("click", () => { + const lines = document.getElementById("card-import-lines").value; + api("/api/bank-cards/batch-import", { method: "POST", body: JSON.stringify({ lines }) }).then((d) => { + hideModal(); + toast("已导入 " + d.added + " 条"); + loadBankCards(); + }); + }); +}); +document.getElementById("btn-batch-delete-card").addEventListener("click", () => { + const ids = Array.from(document.querySelectorAll(".card-id:checked")).map((c) => parseInt(c.value, 10)); + if (!ids.length) { toast("请先勾选要删除的卡", "info"); return; } + confirmBox("确定删除已选 " + ids.length + " 条银行卡?", function() { + api("/api/bank-cards/batch-delete", { method: "POST", body: JSON.stringify({ ids }) }).then(() => { + toast("已删除"); + loadBankCards(); + }); + }); +}); + +// Phones +document.getElementById("link-to-settings-phones").addEventListener("click", function(e) { + e.preventDefault(); + showPage("settings"); +}); +function refreshSmsApiSummary() { + var balanceEl = document.getElementById("sms-api-balance"); + var countEl = document.getElementById("sms-api-openai-count"); + var msgEl = document.getElementById("sms-api-msg"); + balanceEl.textContent = "--"; + countEl.textContent = "--"; + msgEl.textContent = ""; + api("/api/sms-api/openai-availability").then(function(d) { + balanceEl.textContent = String(d.balance != null ? d.balance : 0); + countEl.textContent = String(d.total_count != null ? d.total_count : 0); + if (d.service_hint && d.service_hint.length) { + msgEl.textContent = "当前服务代号不被支持。可用代号: " + d.service_hint.join(", ") + ",请到系统设置修改「OpenAI 服务 ID」"; + } + }).catch(function() { + balanceEl.textContent = "未配置或失败"; + countEl.textContent = "--"; + }); +} +function formatExpiredAtLocal(utcStr) { + if (!utcStr) return "—"; + var s = String(utcStr).trim(); + if (s.indexOf("Z") === -1 && s.indexOf("+") === -1 && s.indexOf("-") >= 0) s = s.replace(" ", "T") + "Z"; + var d = new Date(s); + if (isNaN(d.getTime())) return utcStr; + return d.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }); +} +function loadPhones() { + refreshSmsApiSummary(); + var tbody = document.getElementById("phones-tbody"); + api("/api/phones?_=" + Date.now()).then((d) => { + var items = d.items || []; + tbody.innerHTML = items + .map( + (r) => + "" + + "" + + "" + r.id + "" + + "" + escapeHtml(r.phone || "") + "" + + "" + (r.used_count != null ? r.used_count : 0) + "/" + (r.max_use_count != null ? r.max_use_count : 1) + "" + + "" + escapeHtml(formatExpiredAtLocal(r.expired_at)) + "" + + "" + escapeHtml(r.remark || "") + "" + + "" + + " " + + " " + + "" + + "" + + "" + ) + .join(""); + tbody.querySelectorAll(".btn-op.sms-code").forEach(function(btn) { + btn.addEventListener("click", function() { + var id = btn.dataset.id; + btn.disabled = true; + btn.textContent = "查询中..."; + api("/api/phones/" + id + "/sms-code").then(function(d) { + btn.disabled = false; + btn.textContent = "收码"; + if (d.code) showModal("

短信验证码

" + escapeHtml(d.code) + "

" + (d.message || "") + "

"); + else toast(d.message || "等待短信中", "info"); + }).catch(function(e) { + btn.disabled = false; + btn.textContent = "收码"; + toast(e.message || "失败", "info"); + }); + }); + }); + tbody.querySelectorAll(".btn-op.release-phone").forEach(function(btn) { + btn.addEventListener("click", function() { + confirmBox("确定销毁该号码?将通知接码平台取消并从列表移除。", function() { + api("/api/phones/" + btn.dataset.id + "/release", { method: "POST" }).then(function() { + toast("已销毁"); + loadPhones(); + }).catch(function(e) { toast(e.message || "失败", "info"); }); + }); + }); + }); + tbody.querySelectorAll(".btn-op.danger").forEach(function(btn) { + btn.addEventListener("click", function() { + confirmBox("确定删除该手机号?", function() { + api("/api/phones/" + btn.dataset.id, { method: "DELETE" }).then(function() { + toast("已删除"); + loadPhones(); + }); + }); + }); + }); + }).catch(function(err) { + tbody.innerHTML = "加载失败:" + escapeHtml(err.message || "请求错误") + ""; + }); +} +document.getElementById("btn-add-phone").addEventListener("click", function() { + showModal( + "
" + + "" + + "" + + "" + + "" + + "
" + ); + document.getElementById("phone-form").addEventListener("submit", function(e) { + e.preventDefault(); + var fd = new FormData(e.target); + api("/api/phones", { + method: "POST", + body: JSON.stringify({ + phone: fd.get("phone"), + max_use_count: parseInt(fd.get("max_use_count") || 1, 10), + remark: fd.get("remark"), + }), + }).then(function() { hideModal(); toast("已添加"); loadPhones(); }); + }); +}); +document.getElementById("btn-batch-import-phone").addEventListener("click", function() { + showModal( + "

每行一个手机号,可绑定次数使用系统设置中的「手机号绑定数」。

" + + "" + + "" + ); + document.getElementById("phone-import-submit").addEventListener("click", function() { + var lines = document.getElementById("phone-import-lines").value; + api("/api/phones/batch-import", { method: "POST", body: JSON.stringify({ lines }) }).then(function(d) { + hideModal(); + toast("已导入 " + d.added + " 条"); + loadPhones(); + }); + }); +}); +document.getElementById("btn-batch-delete-phone").addEventListener("click", function() { + var ids = Array.from(document.querySelectorAll(".phone-id:checked")).map(function(c) { return parseInt(c.value, 10); }); + if (!ids.length) { toast("请先勾选要删除的手机号", "info"); return; } + confirmBox("确定删除已选 " + ids.length + " 个手机号?", function() { + api("/api/phones/batch-delete", { method: "POST", body: JSON.stringify({ ids }) }).then(function() { + toast("已删除"); + loadPhones(); + }); + }); +}); +document.getElementById("btn-sms-api-test").addEventListener("click", function() { + var msgEl = document.getElementById("sms-api-msg"); + msgEl.textContent = "测试中..."; + api("/api/sms-api/balance").then(function(d) { + msgEl.textContent = "接口正常,余额:" + d.balance; + }).catch(function(err) { + msgEl.textContent = "失败:" + (err.message || "请求错误"); + }); +}); +document.getElementById("btn-sms-api-refresh-openai").addEventListener("click", function() { + refreshSmsApiSummary(); +}); +document.getElementById("btn-sms-api-debug-prices").addEventListener("click", function() { + var msgEl = document.getElementById("sms-api-msg"); + msgEl.textContent = "加载中..."; + api("/api/sms-api/openai-availability?debug=1").then(function(d) { + msgEl.textContent = ""; + var raw = d.prices_raw; + var text = raw === undefined ? "(无 prices_raw)" : JSON.stringify(raw, null, 2); + var desc = "

接码平台 getPrices 接口的原始返回(当前「OpenAI 服务 ID」下的价格/库存)。若「OpenAI 可用数量」一直为 0,可据此核对返回结构或到系统设置中修改服务代号。

"; + showModal(desc + "
" + escapeHtml(text) + "
"); + }).catch(function(err) { + msgEl.textContent = "失败:" + (err.message || "请求错误"); + }); +}); +document.getElementById("btn-sms-api-services").addEventListener("click", function() { + var msgEl = document.getElementById("sms-api-msg"); + var country = parseInt(document.getElementById("sms-api-country").value, 10) || 0; + msgEl.textContent = "加载中..."; + api("/api/sms-api/services?country=" + country).then(function(d) { + msgEl.textContent = ""; + var list = d.services || []; + var text = list.length ? JSON.stringify(list, null, 2) : "(空),请检查 API 与 country"; + showModal("

接码平台服务列表(country=" + country + "),请找到 OpenAI 对应的 id 或 shortName 填到系统设置「OpenAI 服务 ID」:

" + escapeHtml(text) + "
"); + }).catch(function(err) { + msgEl.textContent = "失败:" + (err.message || "请求错误"); + }); +}); +document.getElementById("btn-sms-api-get-numbers").addEventListener("click", function() { + var msgEl = document.getElementById("sms-api-msg"); + var quantity = parseInt(document.getElementById("sms-api-get-quantity").value, 10) || 1; + var country = parseInt(document.getElementById("sms-api-country").value, 10) || 0; + msgEl.textContent = "获取中..."; + api("/api/sms-api/get-numbers", { + method: "POST", + body: JSON.stringify({ country: country, quantity: quantity }), + }).then(function(d) { + if (d.got) { + msgEl.textContent = "已获取 " + d.got + " 个号码并加入列表"; + } else { + var errMsg = (d.errors && d.errors[0]) ? ("获取失败:" + d.errors[0]) : "已获取 0 个号码并加入列表"; + if (d.errors && d.errors[0] === "BAD_SERVICE") errMsg += "(请到系统设置将「OpenAI 服务 ID」改为 dr 并保存)"; + msgEl.textContent = errMsg; + } + loadPhones(); + }).catch(function(err) { + msgEl.textContent = "失败:" + (err.message || "请求错误"); + }); +}); + +// 批量注册 - 仪表盘与日志 +function loadDashboard() { + api("/api/dashboard").then(function(d) { + document.getElementById("dash-today").textContent = d.today_registered != null ? d.today_registered : 0; + document.getElementById("dash-total").textContent = d.total_registered != null ? d.total_registered : 0; + document.getElementById("dash-phone").textContent = d.phone_bound_count != null ? d.phone_bound_count : 0; + document.getElementById("dash-plus").textContent = d.plus_count != null ? d.plus_count : 0; + document.getElementById("dash-sora-available").textContent = d.sora_available_count != null ? d.sora_available_count : 0; + document.getElementById("dash-sora-daily-capacity").textContent = d.today_generatable_videos != null ? d.today_generatable_videos : 0; + document.getElementById("dash-sora-generated-today").textContent = d.today_generated_videos != null ? d.today_generated_videos : 0; + document.getElementById("dash-success").textContent = d.success_count != null ? d.success_count : 0; + document.getElementById("dash-fail").textContent = d.fail_count != null ? d.fail_count : 0; + document.getElementById("dash-email-api").textContent = d.email_api_set ? "已设置" : "未设置"; + document.getElementById("dash-sms-api").textContent = d.sms_api_set ? "已设置" : "未设置"; + document.getElementById("dash-bank-api").textContent = d.bank_api_set ? "已设置" : "未设置"; + document.getElementById("dash-captcha-api").textContent = d.captcha_api_set ? "已设置" : "未设置"; + document.getElementById("dash-threads").textContent = d.thread_count != null ? d.thread_count : "1"; + api("/api/phone-bind/status").then(function(s) { + var stopBtn = document.getElementById("btn-stop-bind-phone"); + if (stopBtn) stopBtn.style.display = (s && s.running) ? "" : "none"; + }).catch(function() {}); + }).catch(function() { + document.getElementById("dash-today").textContent = "—"; + document.getElementById("dash-total").textContent = "—"; + document.getElementById("dash-phone").textContent = "—"; + document.getElementById("dash-plus").textContent = "—"; + document.getElementById("dash-sora-available").textContent = "—"; + document.getElementById("dash-sora-daily-capacity").textContent = "—"; + document.getElementById("dash-sora-generated-today").textContent = "—"; + document.getElementById("dash-success").textContent = "—"; + document.getElementById("dash-fail").textContent = "—"; + document.getElementById("dash-email-api").textContent = "—"; + document.getElementById("dash-sms-api").textContent = "—"; + document.getElementById("dash-bank-api").textContent = "—"; + document.getElementById("dash-captcha-api").textContent = "—"; + document.getElementById("dash-threads").textContent = "—"; + }); +} +var currentLogLimit = 20; +function loadLogs(limit) { + if (limit != null && limit !== undefined) currentLogLimit = Math.min(Math.max(Number(limit) || 20, 1), 100); + limit = currentLogLimit; + api("/api/logs?page=1&page_size=" + limit).then(function(d) { + var list = document.getElementById("log-list"); + var items = d.items || []; + var total = d.total || 0; + var titleEl = document.getElementById("log-panel-title"); + var expandEl = document.getElementById("log-expand-area"); + if (titleEl) titleEl.textContent = "最近 " + limit + " 条日志"; + list.classList.toggle("log-list-expanded", limit > 20); + list.innerHTML = items.length ? items.map(function(r) { + var levelClass = (r.level === "error") ? " log-line--error" : " log-line--info"; + return "
" + escapeHtml(r.created_at) + " " + escapeHtml(r.message) + "
"; + }).join("") : "
暂无日志
"; + if (expandEl) { + if (limit < 100 && total > 20) { + expandEl.innerHTML = ""; + expandEl.style.display = ""; + expandEl.className = "log-panel-expand"; + document.getElementById("btn-expand-logs").addEventListener("click", function() { loadLogs(100); }); + } else if (limit === 100 && total > 20) { + expandEl.innerHTML = "已显示最多 100 条 "; + expandEl.style.display = ""; + expandEl.className = "log-panel-expand log-panel-expand--done"; + document.getElementById("btn-collapse-logs").addEventListener("click", function() { loadLogs(20); }); + } else { + expandEl.innerHTML = ""; + expandEl.style.display = "none"; + expandEl.className = "log-panel-expand"; + } + } + }).catch(function() { + document.getElementById("log-list").innerHTML = "
加载失败
"; + var expandEl = document.getElementById("log-expand-area"); + if (expandEl) { expandEl.innerHTML = ""; expandEl.style.display = "none"; } + }); +} +document.getElementById("btn-start-register").addEventListener("click", function() { + if (this.disabled) return; + api("/api/register/start", { method: "POST" }).then(function(d) { + if (d && d.ok) { + toast(d.message || "已启动注册任务", "success"); + updateRegisterStatusOnce(); + loadDashboard(); + loadLogs(); + } else { + toast(d.message || "启动失败", "error"); + } + }).catch(function(err) { + toast(err.message || "请求失败", "error"); + }); +}); +document.getElementById("btn-stop-register").addEventListener("click", function() { + api("/api/register/stop", { method: "POST" }).then(function(d) { + if (d && d.ok) { + toast(d.message || "已请求停止", "info"); + updateRegisterStatusOnce(); + } else { + toast(d.message || "操作失败", "error"); + } + }).catch(function(err) { + toast(err.message || "请求失败", "error"); + }); +}); +document.getElementById("btn-start-bind-phone").addEventListener("click", function() { + var btn = this; + var stopBtn = document.getElementById("btn-stop-bind-phone"); + var bindCountEl = document.getElementById("phone-bind-max-count"); + var bindCountRaw = bindCountEl ? String(bindCountEl.value || "").trim() : ""; + var bindCount = null; + var url = "/api/phone-bind/start"; + if (bindCountRaw !== "") { + bindCount = parseInt(bindCountRaw, 10); + if (!isFinite(bindCount) || bindCount < 1) { + toast("绑定数量必须是大于 0 的整数", "error"); + return; + } + bindCount = Math.min(bindCount, 100); + if (bindCountEl) bindCountEl.value = String(bindCount); + url += "?max_count=" + encodeURIComponent(bindCount); + } + btn.disabled = true; + api(url, { method: "POST" }).then(function(d) { + if (d.ok) { + var msg = "绑定任务已启动"; + if (bindCount != null) msg += ",按 " + bindCount + " 并发执行,目标成功 " + bindCount + " 个"; + msg += ",task_id: " + (d.task_id || ""); + toast(msg, "success"); + if (stopBtn) stopBtn.style.display = ""; + loadDashboard(); + loadLogs(); + } else { + toast(d.message || "启动失败", "info"); + } + }).catch(function(err) { + toast(err.message || "请求失败", "error"); + }).finally(function() { + btn.disabled = false; + }); +}); +document.getElementById("btn-stop-bind-phone").addEventListener("click", function() { + api("/api/phone-bind/stop", { method: "POST" }).then(function(d) { + toast(d.message || "已请求停止", "info"); + }).catch(function(err) { + toast(err.message || "请求失败", "error"); + }); +}); +document.getElementById("btn-start-plus").addEventListener("click", function() { + toast("开始开通 Plus 功能开发中", "info"); +}); +document.getElementById("btn-refresh-dashboard").addEventListener("click", function() { + loadDashboard(); + loadLogs(); +}); +document.getElementById("btn-clear-logs").addEventListener("click", function() { + confirmBox("确定清空所有日志?", function() { + api("/api/logs", { method: "DELETE" }).then(function(d) { + toast(d.message || "已清空日志", "success"); + loadDashboard(); + loadLogs(); + }).catch(function(err) { + toast(err.message || "清空失败", "error"); + }); + }); +}); + +// Settings +var SETTINGS_KEYS = [ + "sms_api_url", "sms_api_key", "sms_openai_service", "sms_max_price", "thread_count", "proxy_url", "proxy_api_url", + "bank_card_api_url", "bank_card_api_key", "bank_card_api_platform", "email_api_url", "email_api_key", "email_api_default_type", + "captcha_api_url", "captcha_api_key", "oauth_client_id", "oauth_redirect_uri", + "retry_count", "card_use_limit", "phone_bind_limit" +]; +function loadSettings() { + api("/api/settings").then((d) => { + const form = document.getElementById("settings-form"); + SETTINGS_KEYS.forEach((k) => { + const el = form.querySelector(`[name="${k}"]`); + if (el) el.value = d[k] != null ? d[k] : ""; + }); + }); +} +document.getElementById("settings-form").addEventListener("submit", (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const body = {}; + SETTINGS_KEYS.forEach((k) => { body[k] = fd.get(k) || ""; }); + api("/api/settings", { method: "PUT", body: JSON.stringify(body) }) + .then(() => { + toast("已保存"); + loadSettings(); + }) + .catch((err) => { + toast(err && err.message ? err.message : "保存失败"); + }); +}); + +function escapeHtml(s) { + if (s == null) return ""; + const div = document.createElement("div"); + div.textContent = s; + return div.innerHTML; +} + +// 点击用户名弹出修改账号密码 +document.getElementById("current-user").addEventListener("click", function() { + showModal(` + + `); + document.getElementById("login-update-form").addEventListener("submit", function(e) { + e.preventDefault(); + var fd = new FormData(this); + var username = (fd.get("admin_username") || "").toString().trim(); + var password = (fd.get("admin_password") || "").toString(); + if (!username || !password) { + toast("账号与密码均不能为空", "error"); + return; + } + api("/api/settings/login", { method: "PUT", body: JSON.stringify({ admin_username: username, admin_password: password }) }) + .then(function() { + hideModal(); + toast("已修改,请重新登录"); + localStorage.removeItem("admin_token"); + window.location.reload(); + }) + .catch(function(err) { + toast(err.message || "保存失败", "error"); + }); + }); +}); + +// Default tab(登录后默认打开批量注册) +if (token) showPage("logs"); diff --git a/Register_GPT_v0/web/frontend/static/style.css b/Register_GPT_v0/web/frontend/static/style.css new file mode 100644 index 0000000..b9aef96 --- /dev/null +++ b/Register_GPT_v0/web/frontend/static/style.css @@ -0,0 +1,2429 @@ +/* 浅色柔和风格:圆角、阴影、Plus Jakarta Sans 字体 */ +:root { + --bg: #f1f5f9; + --bg-card: #ffffff; + --bg-input: #f8fafc; + --border: #e2e8f0; + --border-focus: #94a3b8; + --text: #1e293b; + --text-muted: #64748b; + --accent: #6366f1; + --accent-hover: #4f46e5; + --accent-light: #eef2ff; + --danger: #dc2626; + --danger-bg: #fef2f2; + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 1px 3px rgba(0,0,0,0.06); + --shadow-md: 0 4px 12px rgba(0,0,0,0.08); + --font: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: var(--font); + font-size: 14px; + background: var(--bg); + color: var(--text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +.hidden { display: none !important; } + +/* 登录页 */ +#login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #e0e7ff 0%, #f1f5f9 50%, #fce7f3 100%); +} +.login-box { + width: 360px; + padding: 2.25rem; + background: var(--bg-card); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + border: 1px solid var(--border); +} +.login-box h1 { + margin: 0 0 1.75rem; + font-size: 1.35rem; + font-weight: 700; + text-align: center; + color: var(--text); + letter-spacing: -0.02em; +} +.login-box input { + width: 100%; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font); + font-size: 14px; + transition: border-color 0.2s; +} +.login-box input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} +.login-box input::placeholder { color: var(--text-muted); } +.login-box button { + width: 100%; + padding: 0.75rem 1rem; + margin-top: 0.5rem; + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + color: #fff; + font-family: var(--font); + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; +} +.login-box button:hover { background: var(--accent-hover); } +.login-box .error { + margin-top: 0.75rem; + color: var(--danger); + font-size: 13px; +} + +/* 后台布局:左右结构 */ +.layout-sidebar { + display: flex; + min-height: 100vh; +} +.sidebar { + width: 220px; + flex-shrink: 0; + background: var(--bg-card); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + box-shadow: var(--shadow); + transition: width 0.2s ease; +} +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 0.75rem; + border-bottom: 1px solid var(--border); + min-height: 52px; + gap: 0.5rem; +} +.sidebar .logo { + padding: 0; + font-weight: 700; + font-size: 1rem; + color: var(--text); + letter-spacing: -0.02em; + border-bottom: none; + line-height: 1.3; + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} +.sidebar .logo-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + background: var(--accent); + color: #fff; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; +} +.sidebar .logo-text { + white-space: nowrap; + overflow: hidden; +} +.sidebar-toggle { + flex-shrink: 0; + width: 32px; + height: 32px; + padding: 0; + border: none; + background: var(--bg-input); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.2s, background 0.2s; +} +.sidebar-toggle:hover { + color: var(--accent); + background: var(--accent-light); +} +.sidebar-toggle svg { + transition: transform 0.2s ease; +} +.sidebar:not(.collapsed) .sidebar-toggle svg { + transform: rotate(180deg); +} +/* 收起时仅显示图标,侧栏加宽、图标加大且居中 */ +.sidebar.collapsed { + width: 80px; +} +/* 收起时:头部只保留 Logo 居中,展开/收起按钮移到底部单独一行,不遮挡 */ +.sidebar.collapsed .sidebar-header { + padding: 0.5rem 0; + justify-content: center; + min-height: 56px; +} +.sidebar.collapsed .sidebar-header .sidebar-toggle-in-header { + display: none; +} +.sidebar:not(.collapsed) .sidebar-toggle-in-footer { + display: none; +} +.sidebar.collapsed .logo { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + padding: 0; + margin: 0; + justify-content: center; + align-items: center; +} +.sidebar.collapsed .logo-icon { + width: 32px; + height: 32px; + font-size: 16px; +} +/* 隐藏文字完全移出布局流,避免影响图标居中 */ +.sidebar.collapsed .logo-text, +.sidebar.collapsed .nav-text, +.sidebar.collapsed .footer-text, +.sidebar.collapsed .toggle-footer-text { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + pointer-events: none; +} +.sidebar.collapsed .user-name { + padding: 0.6rem; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-input); +} +.sidebar.collapsed .user-name .user-name-text { + overflow: hidden; + width: 0; + opacity: 0; + padding: 0; + margin: 0; + white-space: nowrap; + pointer-events: none; +} +.sidebar.collapsed .user-name .user-name-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} +.sidebar.collapsed .user-name .user-name-icon svg { + display: block; +} +/* 收起时:每个菜单/底部项为相同尺寸的正方形,左右对称留白,图标在框内居中 */ +.sidebar.collapsed .nav { + align-items: center; + padding: 0.5rem 12px; + gap: 4px; +} +.sidebar.collapsed .nav a { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + padding: 0; + margin: 0 auto; + display: flex; + justify-content: center; + align-items: center; + border-radius: var(--radius-sm); +} +.sidebar.collapsed .nav a .nav-icon { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.sidebar.collapsed .nav a .nav-icon svg { + width: 22px; + height: 22px; +} +.sidebar.collapsed .sidebar-footer { + align-items: center; + gap: 4px; + padding: 0.5rem 12px; +} +.sidebar.collapsed .sidebar-link { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + padding: 0; + margin: 0 auto; + display: flex; + justify-content: center; + align-items: center; + border-radius: var(--radius-sm); +} +.sidebar.collapsed .sidebar-link .nav-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} +.sidebar.collapsed .sidebar-link .nav-icon svg { + width: 20px; + height: 20px; +} +.sidebar.collapsed .user-name { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + padding: 0; + margin: 0 auto; + display: flex; + justify-content: center; + align-items: center; + border-radius: var(--radius-sm); +} +.sidebar.collapsed .btn-logout { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + padding: 0; + margin: 0 auto; + display: block; + text-indent: -999px; + font-size: 0; + background: var(--bg-input) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4'/%3E%3Cpolyline points='16 17 21 12 16 7'/%3E%3Cline x1='21' y1='12' x2='9' y2='12'/%3E%3C/svg%3E") center no-repeat; + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +/* 收起时底部展开按钮:与其它项一致的 56x56 正方形 */ +.sidebar-toggle-in-footer { + width: 100%; + padding: 0.4rem 0.75rem; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + font-family: var(--font); + font-size: 12px; + transition: color 0.2s, background 0.2s, border-color 0.2s; +} +.sidebar-toggle-in-footer:hover { + color: var(--accent); + background: var(--accent-light); + border-color: var(--accent); +} +.sidebar-toggle-in-footer .nav-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} +.sidebar.collapsed .sidebar-toggle-in-footer { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + padding: 0; + margin: 0 auto; + display: flex; + justify-content: center; + align-items: center; + border-radius: var(--radius-sm); +} +.sidebar.collapsed .sidebar-toggle-in-footer .nav-icon svg { + width: 22px; + height: 22px; +} +.sidebar .nav { + flex: 1; + padding: 0.75rem 0.5rem; + display: flex; + flex-direction: column; + gap: 2px; +} +.sidebar .nav a { + padding: 0.6rem 1rem; + color: var(--text-muted); + text-decoration: none; + border-radius: var(--radius-sm); + font-weight: 500; + font-size: 13px; + transition: color 0.2s, background 0.2s; + display: flex; + align-items: center; + gap: 0.75rem; +} +.sidebar .nav a .nav-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: inherit; +} +.sidebar .nav a .nav-icon svg { + display: block; + width: 22px; + height: 22px; +} +.sidebar .nav a .nav-text { + white-space: nowrap; + overflow: hidden; +} +.sidebar .nav a:hover { color: var(--accent); background: var(--accent-light); } +.sidebar .nav a.active { color: var(--accent); background: var(--accent-light); } +.sidebar-footer { + padding: 1rem 0.75rem; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 0.6rem; +} +.sidebar-footer .sidebar-link { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 12px; + color: var(--text); + text-decoration: none; + padding: 0.45rem 0.75rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + transition: color 0.2s, border-color 0.2s, background 0.2s; +} +.sidebar-footer .sidebar-link .nav-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.sidebar-footer .sidebar-link .nav-icon svg { + width: 18px; + height: 18px; +} +.sidebar-footer .sidebar-link:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-light); +} +.sidebar-footer .user-name { + font-size: 13px; + font-weight: 500; + color: var(--text); + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-input); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; +} +.sidebar-footer .user-name .user-name-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} +.sidebar-footer .user-name .user-name-icon svg { + display: block; + width: 18px; + height: 18px; +} +.sidebar-footer .user-name .user-name-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar-footer .user-name-clickable { + cursor: pointer; +} +.sidebar-footer .user-name-clickable:hover { + border-color: var(--accent); + color: var(--accent); +} +.sidebar-footer .btn-logout { + padding: 0.4rem 0.75rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + font-family: var(--font); + font-size: 12px; + transition: color 0.2s, border-color 0.2s; +} +.sidebar-footer .btn-logout:hover { color: var(--text); border-color: var(--border-focus); } + +.main { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 1.5rem; overflow: hidden; min-width: 0; } +.main > .panel { flex: 1; min-height: 0; overflow: auto; display: flex; flex-direction: column; } +#panel-logs.panel { overflow: hidden; } +.panel h2 { + margin: 0 0 0.5rem; + font-size: 1.125rem; + font-weight: 600; + color: var(--text); + letter-spacing: -0.01em; +} +.panel-desc { + margin: 0 0 1rem; + font-size: 12px; + color: var(--text-muted); +} +.panel-desc a { color: var(--accent); text-decoration: none; } +.panel-desc a:hover { text-decoration: underline; } +.toolbar { + margin-bottom: 1.25rem; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} +.toolbar label { display: flex; align-items: center; gap: 0.5rem; font-weight: 500; color: var(--text); } +.toolbar select, .toolbar input[type="text"], .toolbar input[type="number"] { + padding: 0.5rem 0.75rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font); + font-size: 13px; +} +.toolbar select:focus, .toolbar input:focus { + outline: none; + border-color: var(--accent); +} +.toolbar button { + padding: 0.5rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, border-color 0.2s, color 0.2s; +} +.toolbar button:hover { background: var(--accent-light); border-color: var(--accent); color: var(--accent); } + +.table-wrap { + overflow-x: auto; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-card); + box-shadow: var(--shadow); +} +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.data-table th, .data-table td { + padding: 0.65rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} +.data-table th { + background: var(--bg-input); + color: var(--text-muted); + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.data-table tr:last-child td { border-bottom: none; } +.data-table tr:hover td { background: var(--accent-light); } +.data-table .btn-link { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + padding: 0; + font-family: var(--font); + font-size: 13px; + font-weight: 500; +} +.data-table .btn-link:hover { text-decoration: underline; } +.data-table .btn-link.danger { color: var(--danger); } + +/* 手机号操作列:销毁 / 删除 / 收码 小按钮 */ +.data-table .btn-op { + display: inline-block; + padding: 0.28rem 0.6rem; + margin: 0 0.15rem 0.15rem 0; + font-family: var(--font); + font-size: 12px; + font-weight: 500; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + transition: background 0.2s, border-color 0.2s, color 0.2s; +} +.data-table .btn-op.release-phone { + background: #f0f9ff; + border-color: #0ea5e9; + color: #0369a1; +} +.data-table .btn-op.release-phone:hover { + background: #e0f2fe; + border-color: #0284c7; + color: #075985; +} +.data-table .btn-op.danger { + background: #fef2f2; + border-color: #f87171; + color: #b91c1c; +} +.data-table .btn-op.danger:hover { + background: #fee2e2; + border-color: #ef4444; + color: #991b1b; +} +.data-table .btn-op.sms-code { + background: #f0fdf4; + border-color: #22c55e; + color: #15803d; +} +.data-table .btn-op.sms-code:hover { + background: #dcfce7; + border-color: #16a34a; + color: #166534; +} +.data-table .btn-op.btn-op-view { + background: #eef2ff; + border-color: #6366f1; + color: #4338ca; +} +.data-table .btn-op.btn-op-view:hover { + background: #e0e7ff; + border-color: #4f46e5; + color: #3730a3; +} +.data-table td .btn-op { text-decoration: none; } +.data-table td .btn-op:hover { text-decoration: none; } + +.pagination { margin-top: 1rem; font-size: 13px; color: var(--text-muted); } +.pagination button { + padding: 0.35rem 0.65rem; + margin-right: 0.25rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + cursor: pointer; + font-family: var(--font); + font-size: 13px; +} +.pagination button:hover { background: var(--accent-light); border-color: var(--accent); color: var(--accent); } + +/* 日志区域:黑色终端风格,浅色文字 */ +.log-list { + font-family: "Consolas", "Monaco", "Courier New", monospace; + font-size: 12px; + line-height: 1.5; + max-height: 400px; + overflow-y: auto; + background: #1a1a1a; + border: 1px solid #333; + border-radius: var(--radius-sm); + padding: 0.75rem 1rem; + color: #d4d4d4; +} +.log-list .log-line { + padding: 0.25rem 0; + border-bottom: 1px solid #2d2d2d; + word-break: break-all; +} +.log-list .log-line:last-child { border-bottom: none; } +.log-list .log-line .ts { + color: #858585; + margin-right: 0.5rem; + font-size: 11px; +} +.log-list .log-line--error .ts { color: #f59e0b; } +.log-list .log-line--error { color: #fbbf24; } +.log-list .log-line--info .ts { color: #6b7280; } +.log-list .log-line--info { color: #e5e7eb; } +.log-list-fixed { overflow-y: auto; } +.log-list.log-list-fixed { max-height: none; } + +/* 批量注册 - 仪表盘 */ +.dashboard-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 0.75rem; + box-shadow: var(--shadow); +} +.dashboard-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} +.dashboard-inline-field { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-height: 40px; + padding: 0 0.8rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-muted); + font-size: 13px; +} +.dashboard-inline-field span { + color: var(--text-muted); + white-space: nowrap; +} +.dashboard-inline-field input { + width: 72px; + padding: 0.3rem 0.45rem; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text); + font-family: var(--font); + font-size: 13px; +} +.dashboard-inline-field input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12); +} +.btn-dash { + padding: 0.5rem 1.25rem; + font-family: var(--font); + font-size: 14px; + font-weight: 600; + border-radius: var(--radius-sm); + border: none; + cursor: pointer; + transition: background 0.2s, transform 0.1s; +} +.btn-dash:hover { transform: translateY(-1px); } +.btn-dash.btn-primary { + background: var(--accent); + color: #fff; +} +.btn-dash.btn-primary:hover { background: var(--accent-hover); } +.btn-dash.btn-secondary { + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); +} +.btn-dash.btn-secondary:hover { background: var(--border); } +.btn-dash:disabled, +.btn-dash.btn-dash-disabled { + opacity: 0.85; + cursor: not-allowed; + transform: none; +} +.btn-dash.btn-dash-disabled { background: #6b7280; color: #fff; } +.register-heartbeat { + font-size: 11px; + color: #dc2626; + margin-left: 0.25rem; + padding: 0.25rem 0.5rem; + border: 1px solid #dc2626; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + line-height: 1; +} +.dashboard-stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 0.75rem; +} +.dashboard-stats .stat-item { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.6rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.2rem; +} +.dashboard-stats .stat-label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.dashboard-stats .stat-value { + font-size: 1.1rem; + font-weight: 700; + color: var(--text); +} +.dashboard-stats .stat-value.stat-ok { color: #15803d; } +.dashboard-stats .stat-value.stat-fail { color: var(--danger); } +.dashboard-stats .stat-value.stat-value-muted { + font-size: 12px; + font-weight: 400; + color: var(--text-muted); +} + +.status-registered { color: #15803d; font-weight: 400; font-size: 12px; } +.status-unregistered { color: var(--text-muted); font-weight: 400; font-size: 12px; } +/* 日志面板:标题栏 + 黑色日志框一体 */ +.log-panel { + margin-top: 0.5rem; + background: #1a1a1a; + border: 1px solid #333; + border-radius: var(--radius-sm); + overflow: hidden; +} +.log-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + background: #1a1a1a; + border-bottom: 1px solid #2d2d2d; + color: #d4d4d4; + font-size: 12px; + font-weight: 600; +} +.log-panel-title { + color: #d4d4d4; +} +.log-panel-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} +.log-panel-refresh { + padding: 0.25rem 0.6rem; + font-size: 12px; + color: #858585; + background: transparent; + border: 1px solid #444; + border-radius: var(--radius-sm); + cursor: pointer; + font-family: var(--font); + transition: color 0.2s, border-color 0.2s; +} +.log-panel-refresh:hover { + color: #d4d4d4; + border-color: #666; +} +.log-panel-refresh.log-panel-clear { + color: #f87171; + border-color: #dc2626; +} +.log-panel-refresh.log-panel-clear:hover { + color: #fca5a5; + border-color: #ef4444; +} +/* 日志面板内列表高度自适应,占满剩余空间 */ +#panel-logs .log-panel { + flex: 1; + min-height: 200px; + display: flex; + flex-direction: column; + overflow: hidden; +} +.log-panel .log-list { + border: none; + border-radius: 0; + flex: 1; + min-height: 120px; + overflow-y: auto; + overflow-x: hidden; +} +.log-panel .log-list.log-list-expanded { + min-height: 160px; +} +/* 日志区滚动条美化(深色主题,仅列表内一条滚动条) */ +.log-panel .log-list::-webkit-scrollbar { + width: 6px; +} +.log-panel .log-list::-webkit-scrollbar-track { + background: #1a1a1a; + border-radius: 3px; +} +.log-panel .log-list::-webkit-scrollbar-thumb { + background: #404040; + border-radius: 3px; +} +.log-panel .log-list::-webkit-scrollbar-thumb:hover { + background: #525252; +} +.log-panel .log-list::-webkit-scrollbar-thumb:active { + background: #737373; +} +.log-panel .log-list { + scrollbar-width: thin; + scrollbar-color: #404040 #1a1a1a; + scrollbar-gutter: stable; +} +.log-panel-expand { + flex-shrink: 0; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border, #333); + background: #151515; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} +.log-panel-expand:empty, +.log-panel-expand[style*="display: none"] { + display: none !important; +} +.log-panel-expand--done { + padding: 0.45rem 1rem; +} +.log-panel-expand-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: #252525; + border: 1px solid #4b5563; + color: #d1d5db; + padding: 0.4rem 0.85rem; + font-size: 0.8125rem; + cursor: pointer; + border-radius: 6px; + font-family: var(--font); + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.log-panel-expand-btn:hover { + background: #2d2d2d; + border-color: #6b7280; + color: #f3f4f6; +} +.log-panel-expand-icon { + font-size: 0.6rem; + opacity: 0.85; +} +.log-panel-expand-done { + color: #9ca3af; + font-size: 0.8125rem; +} +.log-panel-collapse-btn { + background: transparent; + border: none; + color: #6b7280; + font-size: 0.8125rem; + cursor: pointer; + padding: 0.2rem 0.4rem; + font-family: var(--font); + text-decoration: underline; + text-underline-offset: 2px; +} +.log-panel-collapse-btn:hover { + color: #9ca3af; +} + +/* 邮箱管理 - Hotmail007 拉取卡片 */ +.email-api-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1.25rem; + box-shadow: var(--shadow); +} +.email-api-card h3 { margin: 0 0 0.5rem; font-size: 0.95rem; font-weight: 600; color: var(--text); } +.email-api-desc { margin: 0 0 0.75rem; font-size: 12px; color: var(--text-muted); } +.email-api-desc a { color: var(--accent); text-decoration: none; } +.email-api-desc a:hover { text-decoration: underline; } +.email-api-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; } +.email-api-row label { display: flex; align-items: center; gap: 0.35rem; font-size: 13px; } +.email-api-row input[type="number"] { width: 70px; padding: 0.35rem 0.5rem; border: 1px solid var(--border); border-radius: var(--radius-sm); } +.email-api-row input[type="text"] { padding: 0.35rem 0.5rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-input); } +.email-api-row select { padding: 0.35rem 0.5rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-input); } +.email-api-row button { padding: 0.4rem 0.75rem; font-size: 12px; background: var(--accent); color: #fff; border: none; border-radius: var(--radius-sm); cursor: pointer; font-family: var(--font); } +.email-api-row button:hover { background: var(--accent-hover); } +.email-api-row button.btn-default { background: var(--bg-input); color: var(--text); border: 1px solid var(--border); } +.email-api-row button.btn-default:hover { background: var(--accent-light); border-color: var(--accent); color: var(--accent); } +.sora-key-list { + margin-top: 0.65rem; + border-top: 1px dashed var(--border); + padding-top: 0.65rem; + color: var(--text-muted); + font-size: 12px; + line-height: 1.6; +} +.sora-key-list .key-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.35rem; +} +.sora-key-list .key-tag { + display: inline-flex; + align-items: center; + height: 20px; + border-radius: 999px; + padding: 0 0.5rem; + background: var(--accent-light); + color: var(--accent-hover); +} +.email-api-msg { margin: 0.5rem 0 0; font-size: 12px; color: var(--text-muted); } +.key-page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.key-page-head h2 { + margin-bottom: 0.35rem; +} +.key-page-head-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} +.key-manager-grid { + display: grid; + grid-template-columns: minmax(0, 1.28fr) minmax(300px, 0.82fr); + gap: 1.25rem; + margin-bottom: 1.25rem; + align-items: start; +} +.key-manager-card { + background: var(--bg-card); + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 24px; + padding: 1.25rem; + box-shadow: var(--shadow); +} +.key-manager-card-create { + background: + radial-gradient(circle at top right, rgba(250, 204, 21, 0.18), transparent 34%), + linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.98)); +} +.key-manager-card-stats { + background: + radial-gradient(circle at top left, rgba(250, 204, 21, 0.14), transparent 34%), + linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(255, 255, 255, 0.96)); +} +.key-manager-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.key-manager-card-head h3 { + margin: 0 0 0.35rem; +} +.key-manager-card-head p { + margin: 0; + font-size: 12px; + line-height: 1.6; + color: var(--text-muted); +} +.key-manager-form { + display: grid; + gap: 1rem; +} +.key-manager-field { + display: grid; + gap: 0.45rem; +} +.key-manager-field > span { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); +} +.key-manager-field input[type="text"] { + width: 100%; + padding: 0.8rem 0.9rem; + border: 1px solid rgba(148, 163, 184, 0.26); + border-radius: 14px; + background: rgba(255, 255, 255, 0.92); + color: var(--text); + font-family: var(--font); +} +.key-scope-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; +} +.key-scope-option { + position: relative; + display: grid; + gap: 0.35rem; + min-height: 126px; + padding: 1rem; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 18px; + background: rgba(255, 255, 255, 0.84); + cursor: pointer; + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} +.key-scope-option:hover { + transform: translateY(-1px); + border-color: rgba(245, 158, 11, 0.45); +} +.key-scope-option.is-selected { + border-color: rgba(245, 158, 11, 0.65); + background: linear-gradient(180deg, rgba(255, 248, 220, 0.95), rgba(255, 255, 255, 0.98)); + box-shadow: 0 14px 28px rgba(245, 158, 11, 0.12); +} +.key-scope-option input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.key-scope-option strong { + font-size: 15px; + color: var(--text); +} +.key-scope-option small { + color: var(--text-muted); + line-height: 1.6; +} +.key-manager-inline { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.key-chip { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 0.7rem; + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} +.key-chip-pool { + background: rgba(59, 130, 246, 0.1); + color: #2563eb; +} +.key-chip-rotate { + background: rgba(16, 185, 129, 0.12); + color: #047857; +} +.key-chip-local { + background: rgba(148, 163, 184, 0.14); + color: #475569; +} +.key-chip-scope-text { + background: rgba(245, 158, 11, 0.14); + color: #b45309; +} +.key-chip-scope-image { + background: rgba(14, 165, 233, 0.12); + color: #0369a1; +} +.key-chip-scope-all { + background: rgba(99, 102, 241, 0.12); + color: #4f46e5; +} +.key-chip-inactive { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; +} +.key-manager-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} +.key-stats-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.8rem; +} +.key-stat-card { + padding: 0.95rem 1rem; + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(255, 255, 255, 0.82); +} +.key-stat-card span { + display: block; + font-size: 12px; + color: var(--text-muted); +} +.key-stat-card strong { + display: block; + margin-top: 0.25rem; + font-size: 1.5rem; + color: var(--text); +} +.key-manager-footnote { + margin: 1rem 0 0; + font-size: 12px; + line-height: 1.7; + color: var(--text-muted); +} +.key-toolbar { + align-items: center; +} +.key-toolbar label { + white-space: nowrap; +} +.key-data-table td { + vertical-align: middle; +} +.key-cell-stack { + display: grid; + gap: 0.3rem; +} +.key-cell-inline { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; +} +.key-empty { + padding: 1.2rem 0; + text-align: center; + color: var(--text-muted); +} +.key-row-actions { + display: flex; + align-items: center; + gap: 0.45rem; +} +.key-row-actions button { + padding: 0.38rem 0.7rem; + border: 1px solid var(--border); + border-radius: 10px; + background: #fff; + color: var(--text); + font-family: var(--font); + font-size: 12px; + cursor: pointer; +} +.key-row-actions button:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-light); +} +.key-row-actions button.key-btn-danger:hover { + border-color: #ef4444; + color: #b91c1c; + background: rgba(254, 226, 226, 0.95); +} +.video-page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.video-page-head h2 { + margin-bottom: 0.35rem; +} +.video-page-head-actions { + display: flex; + align-items: center; + justify-content: flex-end; +} +.video-launch-btn { + min-width: 132px; + height: 48px; + padding: 0 1.2rem; + border: none; + border-radius: 14px; + background: linear-gradient(135deg, #facc15 0%, #f59e0b 100%); + color: #1f2937; + font-family: var(--font); + font-size: 14px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 12px 30px rgba(245, 158, 11, 0.28); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} +.video-launch-btn:hover { + transform: translateY(-1px); + box-shadow: 0 16px 34px rgba(245, 158, 11, 0.32); +} +.video-stage-shell { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.72fr); + gap: 1.25rem; + align-items: start; +} +.video-stage-card { + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 28px; + background: + radial-gradient(circle at top right, rgba(250, 204, 21, 0.16), transparent 36%), + linear-gradient(160deg, #111827 0%, #1f2937 52%, #0f172a 100%); + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22); +} +.video-stage-media { + position: relative; + aspect-ratio: 16 / 9; + background: + radial-gradient(circle at top left, rgba(250, 204, 21, 0.18), transparent 34%), + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), + #0b1120; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.video-stage-media::after { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.04), transparent 30%, transparent 70%, rgba(255, 255, 255, 0.06)), + linear-gradient(0deg, rgba(15, 23, 42, 0.2), rgba(15, 23, 42, 0.2)); + pointer-events: none; +} +.video-stage-media video { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} +.video-stage-placeholder { + position: relative; + z-index: 1; + width: min(72%, 420px); + padding: 1.8rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 24px; + background: rgba(15, 23, 42, 0.58); + backdrop-filter: blur(16px); + color: rgba(255, 255, 255, 0.88); + text-align: center; +} +.video-stage-placeholder-icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 86px; + height: 42px; + margin-bottom: 0.8rem; + padding: 0 1rem; + border-radius: 999px; + background: rgba(250, 204, 21, 0.16); + color: #fef3c7; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.video-stage-placeholder p { + margin: 0; + font-size: 14px; + line-height: 1.7; +} +.video-stage-meta { + padding: 1.35rem 1.4rem 1.4rem; + color: #e5e7eb; +} +.video-stage-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.8rem; +} +.video-stage-kicker { + margin: 0 0 0.35rem; + color: rgba(250, 204, 21, 0.9); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.video-stage-title-row h3 { + margin: 0; + font-size: 1.45rem; + line-height: 1.2; + color: #fff; +} +.video-stage-prompt { + margin: 0.9rem 0 1rem; + min-height: 2.8em; + color: rgba(226, 232, 240, 0.86); + font-size: 14px; +} +.video-stage-progress-track, +.video-task-progress-track { + position: relative; + height: 10px; + border-radius: 999px; + overflow: hidden; + background: rgba(148, 163, 184, 0.18); +} +.video-stage-progress-fill, +.video-task-progress-fill { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #facc15 0%, #f59e0b 48%, #fb7185 100%); + transition: width 0.35s ease; +} +.video-stage-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + margin-top: 1rem; +} +.video-stage-stat { + padding: 0.85rem 0.9rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + background: rgba(255, 255, 255, 0.04); +} +.video-stage-stat span { + display: block; + margin-bottom: 0.2rem; + color: rgba(226, 232, 240, 0.7); + font-size: 12px; +} +.video-stage-stat strong { + color: #fff; + font-size: 14px; + font-weight: 700; + word-break: break-word; +} +.video-stage-footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.65rem; + margin-top: 0.95rem; +} +.video-stage-footnote { + color: rgba(226, 232, 240, 0.72); + font-size: 12px; +} +.video-side-column { + display: grid; + gap: 1rem; +} +.video-side-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 22px; + padding: 1.15rem; + box-shadow: var(--shadow); +} +.video-side-card-accent { + background: + linear-gradient(180deg, rgba(250, 204, 21, 0.18), rgba(250, 204, 21, 0.04)), + var(--bg-card); + border-color: rgba(245, 158, 11, 0.25); +} +.video-side-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.8rem; + margin-bottom: 0.85rem; +} +.video-side-card-head h3, +.video-task-board-head h3 { + margin: 0; + font-size: 1rem; +} +.video-side-card-head p, +.video-task-board-head p { + margin: 0.2rem 0 0; + color: var(--text-muted); + font-size: 12px; +} +.video-toggle { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-height: 38px; + padding: 0.35rem 0.75rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.68); + border: 1px solid rgba(245, 158, 11, 0.25); + color: var(--text); + font-size: 12px; + font-weight: 600; +} +.video-toggle input { + margin: 0; +} +.video-overview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} +.video-overview-item { + padding: 0.8rem 0.9rem; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 16px; + background: rgba(255, 255, 255, 0.74); +} +.video-overview-item span { + display: block; + margin-bottom: 0.15rem; + color: var(--text-muted); + font-size: 12px; +} +.video-overview-item strong { + color: var(--text); + font-size: 16px; + font-weight: 700; +} +.sora-account-summary { + display: grid; + gap: 0.65rem; + margin-top: 0.85rem; +} +.sora-account-summary-empty { + padding: 0.9rem 1rem; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-muted); + font-size: 12px; +} +.sora-account-summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; +} +.sora-account-summary-item { + padding: 0.85rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} +.sora-account-summary-label { + display: block; + margin-bottom: 0.15rem; + font-size: 12px; + color: var(--text-muted); +} +.sora-account-summary-value { + color: var(--text); + font-size: 13px; + font-weight: 600; + word-break: break-word; +} +.sora-account-flags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.sora-account-flag { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0.2rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-input); + color: var(--text); + font-size: 12px; + font-weight: 600; +} +.sora-account-flag.is-ok { + background: #ecfdf5; + border-color: #a7f3d0; + color: #065f46; +} +.sora-account-flag.is-warn { + background: #fff7ed; + border-color: #fdba74; + color: #9a3412; +} +.sora-account-flag.is-bad { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; +} +.video-advanced-card { + margin-top: 0.85rem; + border-top: 1px dashed var(--border); + padding-top: 0.85rem; +} +.video-advanced-card summary { + cursor: pointer; + color: var(--text); + font-weight: 600; +} +.video-advanced-card-body { + margin-top: 0.85rem; +} +.video-task-board { + margin-top: 1.2rem; + border: 1px solid var(--border); + border-radius: 24px; + background: var(--bg-card); + padding: 1.15rem; + box-shadow: var(--shadow); +} +.video-task-board-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} +.video-task-board-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} +.video-task-board-actions button, +.video-task-actions button { + min-height: 38px; + padding: 0.5rem 0.8rem; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--bg-input); + color: var(--text); + font-family: var(--font); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.video-task-board-actions button:hover, +.video-task-actions button:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-light); +} +.video-task-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 0.85rem; + margin-top: 1rem; +} +.video-task-empty { + grid-column: 1 / -1; + padding: 1.4rem; + border: 1px dashed var(--border); + border-radius: 18px; + background: var(--bg-input); + color: var(--text-muted); + text-align: center; +} +.video-task-card { + padding: 1rem; + border: 1px solid var(--border); + border-radius: 20px; + background: linear-gradient(180deg, #fff, #f8fafc); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} +.video-task-card:hover { + transform: translateY(-1px); + border-color: rgba(99, 102, 241, 0.35); + box-shadow: 0 16px 28px rgba(15, 23, 42, 0.08); +} +.video-task-card.is-active { + border-color: rgba(245, 158, 11, 0.42); + box-shadow: 0 18px 32px rgba(245, 158, 11, 0.12); +} +.video-task-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.65rem; +} +.video-task-card-title { + margin: 0; + color: var(--text); + font-size: 14px; + font-weight: 700; +} +.video-task-card-subtitle { + margin: 0.2rem 0 0; + color: var(--text-muted); + font-size: 12px; +} +.video-task-prompt { + margin: 0 0 0.8rem; + color: var(--text); + font-size: 13px; + line-height: 1.6; + min-height: 3.2em; +} +.video-task-progress-meta, +.video-task-meta { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + margin-top: 0.55rem; + color: var(--text-muted); + font-size: 12px; +} +.video-task-actions { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + margin-top: 0.8rem; +} +.video-task-actions .is-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.video-task-actions .is-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + color: #fff; +} +.video-compose-overlay { + position: fixed; + inset: 0; + z-index: 50; +} +.video-compose-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.46); + backdrop-filter: blur(6px); +} +.video-compose-dialog { + position: absolute; + top: 50%; + left: 50%; + width: min(720px, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + transform: translate(-50%, -50%); + overflow: auto; + border-radius: 28px; + border: 1px solid rgba(245, 158, 11, 0.24); + background: + radial-gradient(circle at top right, rgba(250, 204, 21, 0.18), transparent 32%), + #fffdf7; + box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22); + padding: 1.35rem; +} +.video-compose-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.video-compose-kicker { + margin: 0 0 0.3rem; + color: #b45309; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.video-compose-head h3 { + margin: 0; + font-size: 1.35rem; +} +.video-compose-head p { + margin: 0.35rem 0 0; + color: var(--text-muted); + font-size: 13px; +} +.video-compose-close { + width: 38px; + height: 38px; + border: none; + border-radius: 50%; + background: rgba(148, 163, 184, 0.14); + color: var(--text); + font-size: 24px; + line-height: 1; + cursor: pointer; +} +.video-compose-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} +.video-compose-grid-compact { + grid-template-columns: minmax(0, 220px); + margin-bottom: 0.9rem; +} +.video-compose-grid label { + display: flex; + flex-direction: column; + gap: 0.35rem; + color: var(--text); + font-size: 13px; +} +.video-compose-grid input, +.video-compose-grid select { + height: 42px; + padding: 0 0.85rem; + border: 1px solid var(--border); + border-radius: 12px; + background: #fff; + color: var(--text); + font-family: var(--font); +} +.video-compose-grid input:focus, +.video-compose-grid select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} +.video-compose-actions { + display: flex; + justify-content: flex-end; + margin-top: 1rem; +} +.sora-video-field { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 0.85rem; + font-size: 13px; + color: var(--text); +} +.sora-video-image-field { + padding: 0.9rem 1rem; + border: 1px dashed rgba(245, 158, 11, 0.35); + border-radius: 16px; + background: rgba(255, 251, 235, 0.72); +} +.sora-video-image-field input[type="file"] { + display: block; + width: 100%; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 12px; + background: #fff; + color: var(--text); + font-family: var(--font); +} +.sora-video-image-field small { + color: var(--text-muted); + line-height: 1.6; +} +.sora-video-card textarea, +.sora-video-card input[type="text"], +.sora-video-card input[type="number"], +.sora-video-card select { + font-family: var(--font); +} +.sora-video-card textarea { + width: 100%; + min-height: 92px; + padding: 0.75rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text); + resize: vertical; +} +.sora-video-card textarea:focus, +.sora-video-card input:focus, +.sora-video-card select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} +.sora-video-summary { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; + margin-bottom: 0.75rem; + font-size: 12px; + color: var(--text-muted); +} +.sora-status-badge { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border); + background: var(--bg-input); + color: var(--text); +} +.sora-status-badge.is-success { + background: #ecfdf5; + border-color: #a7f3d0; + color: #065f46; +} +.sora-status-badge.is-failed { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; +} +.sora-status-badge.is-pending { + background: #eff6ff; + border-color: #bfdbfe; + color: #1d4ed8; +} +.sora-video-links { + display: grid; + gap: 0.9rem; +} +.sora-video-link-item { + padding: 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} +.sora-video-link-item a { + color: var(--accent-hover); + text-decoration: none; + word-break: break-all; +} +.sora-video-link-item a:hover { + text-decoration: underline; +} +.sora-video-link-item video { + display: block; + width: 100%; + max-width: 540px; + margin-top: 0.6rem; + border-radius: var(--radius-sm); + background: #000; +} +.sora-video-raw { + margin-top: 0.85rem; +} +.sora-video-raw summary { + cursor: pointer; + color: var(--text); + font-weight: 500; +} +.sora-video-raw pre { + margin: 0.65rem 0 0; + padding: 0.85rem; + border-radius: var(--radius-sm); + background: #0f172a; + color: #e2e8f0; + overflow: auto; + font-size: 12px; + line-height: 1.55; +} +@media (max-width: 1080px) { + .key-manager-grid { + grid-template-columns: 1fr; + } + .key-page-head, + .key-manager-card-head-table { + flex-direction: column; + } + .key-scope-options { + grid-template-columns: 1fr; + } + .video-stage-shell { + grid-template-columns: 1fr; + } + .video-stage-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .video-page-head, + .video-task-board-head { + flex-direction: column; + } + .video-compose-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 720px) { + .key-page-head-actions, + .key-manager-actions { + width: 100%; + } + .key-page-head-actions .btn-default, + .key-manager-actions .video-launch-btn { + width: 100%; + } + .key-stats-grid { + grid-template-columns: 1fr; + } + .video-stage-title-row { + flex-direction: column; + } + .video-stage-stats, + .video-overview-grid, + .video-compose-grid { + grid-template-columns: 1fr; + } + .video-task-list { + grid-template-columns: 1fr; + } +} + +.form-grid { max-width: 520px; } +.form-grid label { display: block; margin-bottom: 0.75rem; font-weight: 500; color: var(--text); } +.form-grid input { + width: 100%; + padding: 0.55rem 0.75rem; + margin-top: 0.35rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font); + font-size: 14px; +} +.form-grid input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} +.form-grid hr { margin: 1.25rem 0; border: none; border-top: 1px solid var(--border); } +.form-grid button { + padding: 0.55rem 1.25rem; + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + color: #fff; + font-family: var(--font); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} +.form-grid button:hover { background: var(--accent-hover); } + +/* 系统设置:左右布局,一行 2 个卡片 */ +.settings-grid { + display: grid; + grid-template-columns: repeat(3, minmax(240px, 1fr)); + gap: 1rem; + width: 100%; + max-width: 100%; +} +@media (max-width: 900px) { + .settings-grid { + grid-template-columns: repeat(2, 1fr); + } +} +@media (max-width: 560px) { + .settings-grid { + grid-template-columns: 1fr; + } +} +.settings-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + box-shadow: var(--shadow); + min-width: 0; +} +.settings-card h3 { + margin: 0 0 0.5rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} +.settings-card-desc { + margin: 0 0 0.75rem; + font-size: 12px; + color: var(--text-muted); +} +.settings-card-desc a { color: var(--accent); text-decoration: none; } +.settings-card-desc a:hover { text-decoration: underline; } +.settings-card label { + display: block; + margin-bottom: 0.75rem; + font-weight: 500; + color: var(--text); + font-size: 13px; +} +.settings-card label:last-of-type { margin-bottom: 0; } +.settings-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 0.75rem; +} +.settings-row-2:last-of-type { margin-bottom: 0; } +.settings-row-2 label { margin-bottom: 0; } +.settings-card input { + width: 100%; + padding: 0.5rem 0.75rem; + margin-top: 0.3rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font); + font-size: 13px; +} +.settings-card input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} +.settings-card select { + width: 100%; + padding: 0.5rem 0.75rem; + margin-top: 0.3rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font); + font-size: 13px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 8L2 4h8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2rem; +} +.settings-card select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} +.settings-submit { + grid-column: 1 / -1; + padding-top: 0.5rem; +} +.settings-submit button { + padding: 0.55rem 1.5rem; + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + color: #fff; + font-family: var(--font); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} +.settings-submit button:hover { background: var(--accent-hover); } + +.modal { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + backdrop-filter: blur(4px); +} +.modal-content { + background: var(--bg-card); + border-radius: var(--radius); + border: 1px solid var(--border); + padding: 1.5rem; + max-width: 90%; + max-height: 80vh; + overflow: auto; + position: relative; + box-shadow: var(--shadow-md); +} +.modal-content.modal-content-wide { + width: 90%; + max-width: 820px; + max-height: 85vh; +} +.modal-close { + position: absolute; + top: 0.75rem; + right: 1rem; + cursor: pointer; + font-size: 1.25rem; + color: var(--text-muted); + line-height: 1; +} +.modal-close:hover { color: var(--text); } +#modal-body .modal-desc { + margin: 0 0 0.75rem; + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; +} +#modal-body .modal-desc strong { color: var(--text); } + +/* 修改登录账号弹窗 */ +.login-update-modal { + min-width: 320px; + max-width: 400px; +} +.login-update-title { + margin: 0 0 0.5rem; + font-size: 1.1rem; + font-weight: 600; + color: var(--text); + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} +.login-update-desc { + margin: 0 0 1rem; + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; +} +.login-update-modal label { + display: block; + margin-bottom: 0.75rem; + font-weight: 500; + font-size: 13px; + color: var(--text); +} +.login-update-modal label input { + display: block; + width: 100%; + margin-top: 0.35rem; + padding: 0.5rem 0.75rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font); + font-size: 13px; +} +.login-update-modal label input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} +.login-update-actions { + margin-top: 1.25rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} +.login-update-btn { + padding: 0.5rem 1.25rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius-sm); + font-family: var(--font); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} +.login-update-btn:hover { + background: var(--accent-hover); +} + +.email-view-card p { margin: 0.6rem 0; font-size: 13px; } +.email-view-card .btn-copy { + margin-left: 0.5rem; + padding: 0.2rem 0.5rem; + font-size: 12px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text); +} +.email-view-card .btn-copy:hover { border-color: var(--accent); color: var(--accent); } +.email-view-card a { color: var(--accent); } +.email-view-card .error { color: var(--danger); } +.email-view-card .email-body { + max-height: 200px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + padding: 0.5rem; + background: var(--bg-input); + border-radius: var(--radius-sm); + margin: 0.25rem 0; +} +.email-view-card .email-body-html { max-height: 280px; overflow: auto; font-size: 12px; padding: 0.5rem; background: #fff; border-radius: var(--radius-sm); border: 1px solid var(--border); } +.email-view-card .email-view-tabs { margin: 0.5rem 0; display: flex; gap: 0.25rem; } +.email-view-card .email-tab { + padding: 0.35rem 0.75rem; + font-size: 12px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-muted); +} +.email-view-card .email-tab:hover { border-color: var(--accent); color: var(--accent); } +.email-view-card .email-tab.active { background: var(--accent-light); border-color: var(--accent); color: var(--accent); } +.email-view-card .email-tab-panel { margin-top: 0.25rem; } +.email-view-card .email-tab-panel.hidden { display: none; } +.email-view-card .email-view-fallback { margin-top: 0.75rem; font-size: 12px; color: var(--text-muted); } +.email-view-card .email-view-empty { color: var(--text-muted); } + +/* 邮件查看:列表 + 详情 布局 */ +.email-view-card.email-view-layout { + display: flex; + gap: 1rem; + min-height: 320px; +} +.email-view-list { + flex-shrink: 0; + width: 200px; + border-right: 1px solid var(--border); + padding-right: 0.75rem; + display: flex; + flex-direction: column; +} +.email-view-list-title { + font-weight: 600; + font-size: 13px; + margin-bottom: 0.25rem; + color: var(--text); +} +.email-view-list-inner { + overflow: auto; + flex: 1; + min-height: 0; +} +.email-view-list-item { + padding: 0.5rem 0.4rem; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 12px; + margin-bottom: 0.25rem; + border: 1px solid transparent; +} +.email-view-list-item:hover { + background: var(--bg-input); +} +.email-view-list-item.active { + background: var(--accent-light); + border-color: var(--accent); + color: var(--accent); +} +.email-view-list-item-subject { + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.email-view-list-item-meta { + color: var(--text-muted); + font-size: 11px; + margin-top: 0.2rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.email-view-detail { + flex: 1; + min-width: 0; + overflow: auto; +} +.email-view-detail-inner { + padding-right: 0.25rem; +} +.email-view-card.email-view-layout .email-body-html { + max-height: 360px; +} + +/* 统一提示:Toast */ +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 200; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} +.toast { + padding: 0.65rem 1rem; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 500; + box-shadow: var(--shadow-md); + border: 1px solid transparent; + max-width: 320px; + pointer-events: auto; + animation: toast-in 0.25s ease; +} +@keyframes toast-in { + from { opacity: 0; transform: translateX(100%); } + to { opacity: 1; transform: translateX(0); } +} +.toast.success { + background: #ecfdf5; + color: #065f46; + border-color: #a7f3d0; +} +.toast.error { + background: #fef2f2; + color: #991b1b; + border-color: #fecaca; +} +.toast.info { + background: var(--accent-light); + color: var(--accent-hover); + border-color: var(--accent); +} +/* 确认框(在 modal 内使用) */ +.confirm-dialog { text-align: center; } +.confirm-dialog .confirm-msg { margin-bottom: 1.25rem; font-size: 14px; color: var(--text); } +.confirm-dialog .confirm-btns { display: flex; justify-content: center; gap: 0.75rem; } +.confirm-dialog .confirm-btns button { + padding: 0.5rem 1.25rem; + border-radius: var(--radius-sm); + font-family: var(--font); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; +} +.confirm-dialog .confirm-btns .btn-primary { background: var(--accent); color: #fff; } +.confirm-dialog .confirm-btns .btn-primary:hover { background: var(--accent-hover); } +.confirm-dialog .confirm-btns .btn-default { + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); +} +.confirm-dialog .confirm-btns .btn-default:hover { border-color: var(--border-focus); } diff --git a/Register_GPT_v0/web/run_web.py b/Register_GPT_v0/web/run_web.py new file mode 100644 index 0000000..e1eedfd --- /dev/null +++ b/Register_GPT_v0/web/run_web.py @@ -0,0 +1,16 @@ +""" +界面版启动入口:将 protocol 与 web/backend 加入路径后启动 uvicorn。 +在 protocol 目录执行: python web/run_web.py +或在 protocol/web 目录执行: python run_web.py +""" +import sys +from pathlib import Path + +root = Path(__file__).resolve().parent +backend = root / "backend" +if str(backend) not in sys.path: + sys.path.insert(0, str(backend)) + +if __name__ == "__main__": + import uvicorn + uvicorn.run("app.main:app", host="0.0.0.0", port=1989, reload=True) diff --git a/packages/general/ExaFree b/packages/general/ExaFree new file mode 160000 index 0000000..76c2248 --- /dev/null +++ b/packages/general/ExaFree @@ -0,0 +1 @@ +Subproject commit 76c22483fc9543029b4ff845d6f36729e22e4f37