cjovs commited on
Commit
7482820
·
verified ·
1 Parent(s): a12d893

Deploy codex-console to HF Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +18 -0
  2. .gitattributes +2 -35
  3. .gitignore +58 -0
  4. Dockerfile +27 -0
  5. LICENSE +21 -0
  6. README.md +212 -11
  7. deploy/huggingface/start.sh +48 -0
  8. pyproject.toml +43 -0
  9. requirements.txt +16 -0
  10. src/__init__.py +24 -0
  11. src/config/__init__.py +53 -0
  12. src/config/constants.py +408 -0
  13. src/config/settings.py +767 -0
  14. src/core/__init__.py +32 -0
  15. src/core/dynamic_proxy.py +118 -0
  16. src/core/http_client.py +429 -0
  17. src/core/openai/__init__.py +3 -0
  18. src/core/openai/oauth.py +370 -0
  19. src/core/openai/payment.py +261 -0
  20. src/core/openai/sentinel.py +98 -0
  21. src/core/openai/token_refresh.py +332 -0
  22. src/core/register.py +1009 -0
  23. src/core/upload/__init__.py +3 -0
  24. src/core/upload/cpa_upload.py +312 -0
  25. src/core/upload/sub2api_upload.py +224 -0
  26. src/core/upload/team_manager_upload.py +204 -0
  27. src/core/utils.py +570 -0
  28. src/database/__init__.py +20 -0
  29. src/database/crud.py +714 -0
  30. src/database/init_db.py +86 -0
  31. src/database/models.py +229 -0
  32. src/database/session.py +182 -0
  33. src/services/__init__.py +76 -0
  34. src/services/base.py +386 -0
  35. src/services/cloud_mail.py +529 -0
  36. src/services/duck_mail.py +366 -0
  37. src/services/freemail.py +324 -0
  38. src/services/imap_mail.py +217 -0
  39. src/services/moe_mail.py +556 -0
  40. src/services/outlook/__init__.py +8 -0
  41. src/services/outlook/account.py +51 -0
  42. src/services/outlook/base.py +153 -0
  43. src/services/outlook/email_parser.py +228 -0
  44. src/services/outlook/health_checker.py +312 -0
  45. src/services/outlook/providers/__init__.py +29 -0
  46. src/services/outlook/providers/base.py +180 -0
  47. src/services/outlook/providers/graph_api.py +250 -0
  48. src/services/outlook/providers/imap_new.py +231 -0
  49. src/services/outlook/providers/imap_old.py +345 -0
  50. src/services/outlook/service.py +487 -0
.dockerignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ .pytest_cache
4
+ __pycache__
5
+ *.py[cod]
6
+ .venv
7
+ venv
8
+ build
9
+ dist
10
+ tests
11
+ tmp_*.js
12
+ codex_register.spec
13
+ docker-compose.yml
14
+ build.bat
15
+ build.sh
16
+ .env
17
+ .env.*
18
+ !.env.example
.gitattributes CHANGED
@@ -1,35 +1,2 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ * text=auto eol=lf
2
+ *.bat text eol=crlf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual Environment
24
+ .venv/
25
+ venv/
26
+ ENV/
27
+ env/
28
+ uv.lock
29
+
30
+ # IDE
31
+ .idea/
32
+ .vscode/
33
+ *.swp
34
+ *.swo
35
+ *~
36
+
37
+ # Data and Logs
38
+ data/
39
+ logs/
40
+ *.db
41
+ *.sqlite
42
+ *.sqlite3
43
+
44
+ # Token files
45
+ token_*.json
46
+
47
+ # Environment
48
+ .env
49
+ .env.local
50
+ *.local
51
+
52
+ # OS
53
+ .DS_Store
54
+ Thumbs.db
55
+
56
+ # Project specific
57
+ backups/
58
+ /out/
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ ENV PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1 \
7
+ APP_HOST=0.0.0.0 \
8
+ APP_PORT=1455 \
9
+ LOG_LEVEL=info \
10
+ DEBUG=0
11
+
12
+ RUN apt-get update \
13
+ && apt-get install -y --no-install-recommends \
14
+ bash \
15
+ gcc \
16
+ python3-dev \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ COPY requirements.txt .
20
+ RUN pip install --no-cache-dir --upgrade pip \
21
+ && pip install --no-cache-dir -r requirements.txt
22
+
23
+ COPY . .
24
+
25
+ EXPOSE 7860
26
+
27
+ CMD ["bash", "/app/deploy/huggingface/start.sh"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,11 +1,212 @@
1
- ---
2
- title: Codex Console
3
- emoji: 🏢
4
- colorFrom: pink
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- license: apache-2.0
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # codex-console
2
+
3
+ 基于 [cnlimiter/codex-manager](https://github.com/cnlimiter/codex-manager) 持续修复和维护的增强版本。
4
+
5
+ 这个版本的目标很直接: 把近期 OpenAI 注册链路里那些“昨天还能跑,今天突然翻车”的坑补上,让注册、登录、拿 token、打包运行都更稳一点。
6
+
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/)
9
+
10
+ ## QQ群
11
+
12
+ - 交流群: https://qm.qq.com/q/ZTCKxawxeo
13
+
14
+ ## 致谢
15
+
16
+ 首先感谢上游项目作者 [cnlimiter](https://github.com/cnlimiter) 提供的优秀基础工程。
17
+
18
+ 本仓库是在原项目思路和结构之上进行兼容性修复、流程调整和体验优化,适合作为一个“当前可用的修复维护版”继续使用。
19
+
20
+ ## 这个分支修了什么
21
+
22
+ 为适配当前注册链路,这个分支重点补了下面几个问题:
23
+
24
+ 1. 新增 Sentinel POW 求解逻辑
25
+ OpenAI 现在会强制校验 Sentinel POW,原先直接传空值已经不行了,这里补上了实际求解流程。
26
+
27
+ 2. 注册和登录拆成两段
28
+ 现在注册完成后通常不会直接返回可用 token,而是跳转到绑定手机或后续页面。
29
+ 本分支改成“先注册成功,再单独走一次登录流程拿 token”,避免卡死在旧逻辑里。
30
+
31
+ 3. 去掉重复发送验证码
32
+ 登录流程里服务端本身会自动发送验证码邮件,旧逻辑再手动发一次,容易让新旧验证码打架。
33
+ 现在改成直接等待系统自动发来的那封验证码邮件。
34
+
35
+ 4. 修复重新登录流程的页面判断问题
36
+ 针对重新登录时页面流转变化,调整了登录入口和密码提交逻辑,减少卡在错误页面的情况。
37
+
38
+ 5. 优化终端和 Web UI 提示文案
39
+ 保留可读性的前提下,把一些提示改得更友好一点,出错时至少不至于像在挨骂。
40
+
41
+ ## 核心能力
42
+
43
+ - Web UI 管理注册任务和账号数据
44
+ - 支持批量注册、日志实时查看、基础任务管理
45
+ - 支持多种邮箱服务接码
46
+ - 支持 SQLite 和远程 PostgreSQL
47
+ - 支持打包为 Windows/Linux/macOS 可执行文件
48
+ - 更适配当前 OpenAI 注册与登录链路
49
+
50
+ ## 环境要求
51
+
52
+ - Python 3.10+
53
+ - `uv`(推荐)或 `pip`
54
+
55
+ ## 安装依赖
56
+
57
+ ```bash
58
+ # 使用 uv(推荐)
59
+ uv sync
60
+
61
+ # 或使用 pip
62
+ pip install -r requirements.txt
63
+ ```
64
+
65
+ ## 环境变量配置
66
+
67
+ 可选。复制 `.env.example` 为 `.env` 后按需修改:
68
+
69
+ ```bash
70
+ cp .env.example .env
71
+ ```
72
+
73
+ 常用变量如下:
74
+
75
+ | 变量 | 说明 | 默认值 |
76
+ | --- | --- | --- |
77
+ | `APP_HOST` | 监听主机 | `0.0.0.0` |
78
+ | `APP_PORT` | 监听端口 | `8000` |
79
+ | `APP_ACCESS_PASSWORD` | Web UI 访问密钥 | `admin123` |
80
+ | `APP_DATABASE_URL` | 数据库连接字符串 | `data/database.db` |
81
+
82
+ 优先级:
83
+
84
+ `命令行参数 > 环境变量(.env) > 数据库设置 > 默认值`
85
+
86
+ ## 启动 Web UI
87
+
88
+ ```bash
89
+ # 默认启动(127.0.0.1:8000)
90
+ python webui.py
91
+
92
+ # 指定地址和端口
93
+ python webui.py --host 0.0.0.0 --port 8080
94
+
95
+ # 调试模式(热重载)
96
+ python webui.py --debug
97
+
98
+ # 设置 Web UI 访问密钥
99
+ python webui.py --access-password mypassword
100
+
101
+ # 组合参数
102
+ python webui.py --host 0.0.0.0 --port 8080 --access-password mypassword
103
+ ```
104
+
105
+ 说明:
106
+
107
+ - `--access-password` 的优先级高于数据库中的密钥设置
108
+ - 该参数只对本次启动生效
109
+ - 打包后的 exe 也支持这个参数
110
+
111
+ 例如:
112
+
113
+ ```bash
114
+ codex-console.exe --access-password mypassword
115
+ ```
116
+
117
+ 启动后访问:
118
+
119
+ [http://127.0.0.1:8000](http://127.0.0.1:8000)
120
+
121
+ ## Docker 部署
122
+
123
+ ### 使用 docker-compose
124
+
125
+ ```bash
126
+ docker-compose up -d
127
+ ```
128
+
129
+ 你可以在 `docker-compose.yml` 中修改环境变量,比如端口和访问密码。
130
+
131
+ ### 使用 docker run
132
+
133
+ ```bash
134
+ docker run -d \
135
+ -p 1455:1455 \
136
+ -e WEBUI_HOST=0.0.0.0 \
137
+ -e WEBUI_PORT=1455 \
138
+ -e WEBUI_ACCESS_PASSWORD=your_secure_password \
139
+ -v $(pwd)/data:/app/data \
140
+ --name codex-console \
141
+ ghcr.io/<yourname>/codex-console:latest
142
+ ```
143
+
144
+ 说明:
145
+
146
+ - `WEBUI_HOST`: 监听主机,默认 `0.0.0.0`
147
+ - `WEBUI_PORT`: 监听端口,默认 `1455`
148
+ - `WEBUI_ACCESS_PASSWORD`: Web UI 访问密码
149
+ - `DEBUG`: 设为 `1` 或 `true` 可开启调试模式
150
+ - `LOG_LEVEL`: 日志级别,例如 `info`、`debug`
151
+
152
+ 注意:
153
+
154
+ `-v $(pwd)/data:/app/data` 很重要,这会把数据库和账号数据持久化到宿主机。否则容器一重启,数据也可能跟着表演消失术。
155
+
156
+ ## 使用远程 PostgreSQL
157
+
158
+ ```bash
159
+ export APP_DATABASE_URL="postgresql://user:password@host:5432/dbname"
160
+ python webui.py
161
+ ```
162
+
163
+ 也支持 `DATABASE_URL`,但优先级低于 `APP_DATABASE_URL`。
164
+
165
+ ## 打包为可执行文件
166
+
167
+ ```bash
168
+ # Windows
169
+ build.bat
170
+
171
+ # Linux/macOS
172
+ bash build.sh
173
+ ```
174
+
175
+ Windows 打包完成后,默认会在 `dist/` 目录生成类似下面的文件:
176
+
177
+ ```text
178
+ dist/codex-console-windows-X64.exe
179
+ ```
180
+
181
+ 如果打包失败,优先检查:
182
+
183
+ - Python 是否已加入 PATH
184
+ - 依赖是否安装完整
185
+ - 杀毒软件是否拦截了 PyInstaller 产物
186
+ - 终端里是否有更具体的报错日志
187
+
188
+ ## 项目定位
189
+
190
+ 这个仓库更适合作为:
191
+
192
+ - 原项目的修复增强版
193
+ - 当前注册链路的兼容维护版
194
+ - 自己二次开发的基础版本
195
+
196
+ 如果你准备公开发布,建议在仓库描述里明确写上:
197
+
198
+ `Forked and fixed from cnlimiter/codex-manager`
199
+
200
+ 这样既方便别人理解来源,也对上游作者更尊重。
201
+
202
+ ## 仓库命名
203
+
204
+ 当前仓库名:
205
+
206
+ `codex-console`
207
+
208
+ ## 免责声明
209
+
210
+ 本项目仅供学习、研究和技术交流使用,请遵守相关平台和服务条款,不要用于违规、滥用或非法用途。
211
+
212
+ 因使用本项目产生的任何风险和后果,由使用者自行承担。
deploy/huggingface/start.sh ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ APP_ROOT="/app"
5
+
6
+ effective_host="${APP_HOST:-${WEBUI_HOST:-0.0.0.0}}"
7
+ effective_port="${APP_PORT:-${WEBUI_PORT:-${PORT:-1455}}}"
8
+ effective_password="${APP_ACCESS_PASSWORD:-${WEBUI_ACCESS_PASSWORD:-${SPACE_PASSWORD:-}}}"
9
+
10
+ if [[ -n "${APP_DATA_DIR:-}" ]]; then
11
+ data_dir="${APP_DATA_DIR}"
12
+ elif [[ -d "/data" ]]; then
13
+ data_dir="/data"
14
+ else
15
+ data_dir="${APP_ROOT}/data"
16
+ fi
17
+
18
+ export APP_HOST="${effective_host}"
19
+ export APP_PORT="${effective_port}"
20
+ export APP_DATA_DIR="${data_dir}"
21
+ export APP_LOGS_DIR="${APP_LOGS_DIR:-${data_dir}/logs}"
22
+
23
+ mkdir -p "${APP_DATA_DIR}" "${APP_LOGS_DIR}"
24
+
25
+ if [[ -z "${APP_DATABASE_URL:-}" && -z "${DATABASE_URL:-}" ]]; then
26
+ export APP_DATABASE_URL="sqlite:///${APP_DATA_DIR}/database.db"
27
+ fi
28
+
29
+ if [[ -n "${effective_password}" ]]; then
30
+ export APP_ACCESS_PASSWORD="${effective_password}"
31
+ fi
32
+
33
+ echo "[hf-space] starting codex-console"
34
+ echo "[hf-space] listen: ${APP_HOST}:${APP_PORT}"
35
+ echo "[hf-space] data dir: ${APP_DATA_DIR}"
36
+ echo "[hf-space] logs dir: ${APP_LOGS_DIR}"
37
+ if [[ -n "${APP_ACCESS_PASSWORD:-}" ]]; then
38
+ echo "[hf-space] access password: configured"
39
+ else
40
+ echo "[hf-space] access password: not set"
41
+ fi
42
+
43
+ cmd=(python webui.py --host "${APP_HOST}" --port "${APP_PORT}")
44
+ if [[ -n "${APP_ACCESS_PASSWORD:-}" ]]; then
45
+ cmd+=(--access-password "${APP_ACCESS_PASSWORD}")
46
+ fi
47
+
48
+ exec "${cmd[@]}"
pyproject.toml ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "codex-console"
3
+ version = "1.0.4"
4
+ description = "OpenAI account management console"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "curl-cffi>=0.14.0",
8
+ "fastapi>=0.100.0",
9
+ "uvicorn>=0.23.0",
10
+ "jinja2>=3.1.0",
11
+ "python-multipart>=0.0.6",
12
+ "pydantic>=2.0.0",
13
+ "pydantic-settings>=2.0.0",
14
+ "sqlalchemy>=2.0.0",
15
+ "aiosqlite>=0.19.0",
16
+ "psycopg[binary]>=3.1.18",
17
+ "websockets>=16.0",
18
+ "path>=17.1.1",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest>=7.0.0",
24
+ "httpx>=0.24.0",
25
+ ]
26
+ payment = [
27
+ "playwright>=1.40.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ codex-console = "webui:main"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src"]
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "pyinstaller>=6.19.0",
43
+ ]
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ certifi>=2024.0.0
2
+ cffi>=1.16.0
3
+ curl_cffi>=0.14.0
4
+ pycparser>=1.21
5
+ pydantic>=2.0.0
6
+ pydantic-settings>=2.0.0
7
+ fastapi>=0.100.0
8
+ uvicorn[standard]>=0.23.0
9
+ jinja2>=3.1.0
10
+ python-multipart>=0.0.6
11
+ requests>=2.32.5
12
+ sqlalchemy>=2.0.0
13
+ aiosqlite>=0.19.0
14
+ psycopg[binary]>=3.1.18
15
+ # 可选:无痕打开支付页需要 playwright(pip install playwright && playwright install chromium)
16
+ # playwright>=1.40.0
src/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAI/Codex CLI 自动注册系统
3
+ """
4
+
5
+ from .config import get_settings, EmailServiceType
6
+ from .database import get_db, Account, EmailService, RegistrationTask
7
+ from .core import RegistrationEngine, RegistrationResult
8
+ from .services import EmailServiceFactory, BaseEmailService
9
+
10
+ __version__ = "2.0.0"
11
+ __author__ = "Yasal"
12
+
13
+ __all__ = [
14
+ 'get_settings',
15
+ 'EmailServiceType',
16
+ 'get_db',
17
+ 'Account',
18
+ 'EmailService',
19
+ 'RegistrationTask',
20
+ 'RegistrationEngine',
21
+ 'RegistrationResult',
22
+ 'EmailServiceFactory',
23
+ 'BaseEmailService',
24
+ ]
src/config/__init__.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 配置模块
3
+ """
4
+
5
+ from .settings import (
6
+ Settings,
7
+ get_settings,
8
+ update_settings,
9
+ get_database_url,
10
+ init_default_settings,
11
+ get_setting_definition,
12
+ get_all_setting_definitions,
13
+ SETTING_DEFINITIONS,
14
+ SettingCategory,
15
+ SettingDefinition,
16
+ )
17
+ from .constants import (
18
+ AccountStatus,
19
+ TaskStatus,
20
+ EmailServiceType,
21
+ APP_NAME,
22
+ APP_VERSION,
23
+ OTP_CODE_PATTERN,
24
+ DEFAULT_PASSWORD_LENGTH,
25
+ PASSWORD_CHARSET,
26
+ DEFAULT_USER_INFO,
27
+ generate_random_user_info,
28
+ OPENAI_API_ENDPOINTS,
29
+ )
30
+
31
+ __all__ = [
32
+ 'Settings',
33
+ 'get_settings',
34
+ 'update_settings',
35
+ 'get_database_url',
36
+ 'init_default_settings',
37
+ 'get_setting_definition',
38
+ 'get_all_setting_definitions',
39
+ 'SETTING_DEFINITIONS',
40
+ 'SettingCategory',
41
+ 'SettingDefinition',
42
+ 'AccountStatus',
43
+ 'TaskStatus',
44
+ 'EmailServiceType',
45
+ 'APP_NAME',
46
+ 'APP_VERSION',
47
+ 'OTP_CODE_PATTERN',
48
+ 'DEFAULT_PASSWORD_LENGTH',
49
+ 'PASSWORD_CHARSET',
50
+ 'DEFAULT_USER_INFO',
51
+ 'generate_random_user_info',
52
+ 'OPENAI_API_ENDPOINTS',
53
+ ]
src/config/constants.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 常量定义
3
+ """
4
+
5
+ import random
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from typing import Dict, List, Tuple
9
+
10
+
11
+ # ============================================================================
12
+ # 枚举类型
13
+ # ============================================================================
14
+
15
+ class AccountStatus(str, Enum):
16
+ """账户状态"""
17
+ ACTIVE = "active"
18
+ EXPIRED = "expired"
19
+ BANNED = "banned"
20
+ FAILED = "failed"
21
+
22
+
23
+ class TaskStatus(str, Enum):
24
+ """任务状态"""
25
+ PENDING = "pending"
26
+ RUNNING = "running"
27
+ COMPLETED = "completed"
28
+ FAILED = "failed"
29
+ CANCELLED = "cancelled"
30
+
31
+
32
+ class EmailServiceType(str, Enum):
33
+ """邮箱服务类型"""
34
+ TEMPMAIL = "tempmail"
35
+ OUTLOOK = "outlook"
36
+ MOE_MAIL = "moe_mail"
37
+ TEMP_MAIL = "temp_mail"
38
+ DUCK_MAIL = "duck_mail"
39
+ FREEMAIL = "freemail"
40
+ IMAP_MAIL = "imap_mail"
41
+ CLOUD_MAIL = "cloud_mail"
42
+
43
+
44
+ # ============================================================================
45
+ # 应用常量
46
+ # ============================================================================
47
+
48
+ APP_NAME = "OpenAI/Codex CLI 自动注册系统"
49
+ APP_VERSION = "2.0.0"
50
+ APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统"
51
+
52
+ # ============================================================================
53
+ # OpenAI OAuth 相关常量
54
+ # ============================================================================
55
+
56
+ # OAuth 参数
57
+ OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
58
+ OAUTH_AUTH_URL = "https://auth.openai.com/oauth/authorize"
59
+ OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
60
+ OAUTH_REDIRECT_URI = "http://localhost:1455/auth/callback"
61
+ OAUTH_SCOPE = "openid email profile offline_access"
62
+
63
+ # OpenAI API 端点
64
+ OPENAI_API_ENDPOINTS = {
65
+ "sentinel": "https://sentinel.openai.com/backend-api/sentinel/req",
66
+ "signup": "https://auth.openai.com/api/accounts/authorize/continue",
67
+ "register": "https://auth.openai.com/api/accounts/user/register",
68
+ "password_verify": "https://auth.openai.com/api/accounts/password/verify",
69
+ "send_otp": "https://auth.openai.com/api/accounts/email-otp/send",
70
+ "validate_otp": "https://auth.openai.com/api/accounts/email-otp/validate",
71
+ "create_account": "https://auth.openai.com/api/accounts/create_account",
72
+ "select_workspace": "https://auth.openai.com/api/accounts/workspace/select",
73
+ }
74
+
75
+ # OpenAI 页面类型(用于判断账号状态)
76
+ OPENAI_PAGE_TYPES = {
77
+ "EMAIL_OTP_VERIFICATION": "email_otp_verification", # 已注册账号,需要 OTP 验证
78
+ "PASSWORD_REGISTRATION": "create_account_password", # 新账号,需要设置密码
79
+ "LOGIN_PASSWORD": "login_password", # 登录流程,需要输入密码
80
+ }
81
+
82
+ # ============================================================================
83
+ # 邮箱服务相关常量
84
+ # ============================================================================
85
+
86
+ # Tempmail.lol API 端点
87
+ TEMPMAIL_API_ENDPOINTS = {
88
+ "create_inbox": "/inbox/create",
89
+ "get_inbox": "/inbox",
90
+ }
91
+
92
+ # 自定义域名邮箱 API 端点
93
+ CUSTOM_DOMAIN_API_ENDPOINTS = {
94
+ "get_config": "/api/config",
95
+ "create_email": "/api/emails/generate",
96
+ "list_emails": "/api/emails",
97
+ "get_email_messages": "/api/emails/{emailId}",
98
+ "delete_email": "/api/emails/{emailId}",
99
+ "get_message": "/api/emails/{emailId}/{messageId}",
100
+ }
101
+
102
+ # 邮箱服务默认配置
103
+ EMAIL_SERVICE_DEFAULTS = {
104
+ "tempmail": {
105
+ "base_url": "https://api.tempmail.lol/v2",
106
+ "timeout": 30,
107
+ "max_retries": 3,
108
+ },
109
+ "outlook": {
110
+ "imap_server": "outlook.office365.com",
111
+ "imap_port": 993,
112
+ "smtp_server": "smtp.office365.com",
113
+ "smtp_port": 587,
114
+ "timeout": 30,
115
+ },
116
+ "moe_mail": {
117
+ "base_url": "", # 需要用户配置
118
+ "api_key_header": "X-API-Key",
119
+ "timeout": 30,
120
+ "max_retries": 3,
121
+ },
122
+ "duck_mail": {
123
+ "base_url": "",
124
+ "default_domain": "",
125
+ "password_length": 12,
126
+ "timeout": 30,
127
+ "max_retries": 3,
128
+ },
129
+ "freemail": {
130
+ "base_url": "",
131
+ "admin_token": "",
132
+ "domain": "",
133
+ "timeout": 30,
134
+ "max_retries": 3,
135
+ },
136
+ "imap_mail": {
137
+ "host": "",
138
+ "port": 993,
139
+ "use_ssl": True,
140
+ "email": "",
141
+ "password": "",
142
+ "timeout": 30,
143
+ "max_retries": 3,
144
+ },
145
+ "cloud_mail": {
146
+ "base_url": "",
147
+ "admin_email": "",
148
+ "admin_password": "",
149
+ "domain": "",
150
+ "timeout": 30,
151
+ "max_retries": 3,
152
+ }
153
+ }
154
+
155
+ # ============================================================================
156
+ # 注册流程相关常量
157
+ # ============================================================================
158
+
159
+ # 验证码相关
160
+ OTP_CODE_PATTERN = r"(?<!\d)(\d{6})(?!\d)"
161
+ OTP_MAX_ATTEMPTS = 40 # 最大轮询次数
162
+
163
+ # 验证码提取正则(增强版)
164
+ # 简单匹配:任意 6 位数字
165
+ OTP_CODE_SIMPLE_PATTERN = r"(?<!\d)(\d{6})(?!\d)"
166
+ # 语义匹配:带上下文的验证码(如 "code is 123456", "验证码 123456")
167
+ OTP_CODE_SEMANTIC_PATTERN = r'(?:code\s+is|验证码[是为]?\s*[::]?\s*)(\d{6})'
168
+
169
+ # OpenAI 验证邮件发件人
170
+ OPENAI_EMAIL_SENDERS = [
171
+ "noreply@openai.com",
172
+ "no-reply@openai.com",
173
+ "@openai.com", # 精确域名匹配
174
+ ".openai.com", # 子域名匹配(如 otp@tm1.openai.com)
175
+ ]
176
+
177
+ # OpenAI 验证邮件关键词
178
+ OPENAI_VERIFICATION_KEYWORDS = [
179
+ "verify your email",
180
+ "verification code",
181
+ "验证码",
182
+ "your openai code",
183
+ "code is",
184
+ "one-time code",
185
+ ]
186
+
187
+ # 密码生成
188
+ PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
189
+ DEFAULT_PASSWORD_LENGTH = 12
190
+
191
+ # 用户信息生成(用于注册)
192
+
193
+ # 常用英文名
194
+ FIRST_NAMES = [
195
+ "James", "John", "Robert", "Michael", "William", "David", "Richard", "Joseph", "Thomas", "Charles",
196
+ "Emma", "Olivia", "Ava", "Isabella", "Sophia", "Mia", "Charlotte", "Amelia", "Harper", "Evelyn",
197
+ "Alex", "Jordan", "Taylor", "Morgan", "Casey", "Riley", "Jamie", "Avery", "Quinn", "Skyler",
198
+ "Liam", "Noah", "Ethan", "Lucas", "Mason", "Oliver", "Elijah", "Aiden", "Henry", "Sebastian",
199
+ "Grace", "Lily", "Chloe", "Zoey", "Nora", "Aria", "Hazel", "Aurora", "Stella", "Ivy"
200
+ ]
201
+
202
+ def generate_random_user_info() -> dict:
203
+ """
204
+ 生成随机用户信息
205
+
206
+ Returns:
207
+ 包含 name 和 birthdate 的字典
208
+ """
209
+ # 随机选择名字
210
+ name = random.choice(FIRST_NAMES)
211
+
212
+ # 生成随机生日(18-45岁)
213
+ current_year = datetime.now().year
214
+ birth_year = random.randint(current_year - 45, current_year - 18)
215
+ birth_month = random.randint(1, 12)
216
+ # 根据月份确定天数
217
+ if birth_month in [1, 3, 5, 7, 8, 10, 12]:
218
+ birth_day = random.randint(1, 31)
219
+ elif birth_month in [4, 6, 9, 11]:
220
+ birth_day = random.randint(1, 30)
221
+ else:
222
+ # 2月,简化处理
223
+ birth_day = random.randint(1, 28)
224
+
225
+ birthdate = f"{birth_year}-{birth_month:02d}-{birth_day:02d}"
226
+
227
+ return {
228
+ "name": name,
229
+ "birthdate": birthdate
230
+ }
231
+
232
+ # 保留默认值供兼容
233
+ DEFAULT_USER_INFO = {
234
+ "name": "Neo",
235
+ "birthdate": "2000-02-20",
236
+ }
237
+
238
+ # ============================================================================
239
+ # 代理相关常量
240
+ # ============================================================================
241
+
242
+ PROXY_TYPES = ["http", "socks5", "socks5h"]
243
+ DEFAULT_PROXY_CONFIG = {
244
+ "enabled": False,
245
+ "type": "http",
246
+ "host": "127.0.0.1",
247
+ "port": 7890,
248
+ }
249
+
250
+ # ============================================================================
251
+ # 数据库相关常量
252
+ # ============================================================================
253
+
254
+ # 数据库表名
255
+ DB_TABLE_NAMES = {
256
+ "accounts": "accounts",
257
+ "email_services": "email_services",
258
+ "registration_tasks": "registration_tasks",
259
+ "settings": "settings",
260
+ }
261
+
262
+ # 默认设置
263
+ DEFAULT_SETTINGS = [
264
+ # (key, value, description, category)
265
+ ("system.name", APP_NAME, "系统名称", "general"),
266
+ ("system.version", APP_VERSION, "系统版本", "general"),
267
+ ("logs.retention_days", "30", "日志保留天数", "general"),
268
+ ("openai.client_id", OAUTH_CLIENT_ID, "OpenAI OAuth Client ID", "openai"),
269
+ ("openai.auth_url", OAUTH_AUTH_URL, "OpenAI 认证地址", "openai"),
270
+ ("openai.token_url", OAUTH_TOKEN_URL, "OpenAI Token 地址", "openai"),
271
+ ("openai.redirect_uri", OAUTH_REDIRECT_URI, "OpenAI 回调地址", "openai"),
272
+ ("openai.scope", OAUTH_SCOPE, "OpenAI 权限范围", "openai"),
273
+ ("proxy.enabled", "false", "是否启用代理", "proxy"),
274
+ ("proxy.type", "http", "代理类型 (http/socks5)", "proxy"),
275
+ ("proxy.host", "127.0.0.1", "代理主机", "proxy"),
276
+ ("proxy.port", "7890", "代理端口", "proxy"),
277
+ ("registration.max_retries", "3", "最大重试次数", "registration"),
278
+ ("registration.timeout", "120", "超时时间(秒)", "registration"),
279
+ ("registration.default_password_length", "12", "默认密码长度", "registration"),
280
+ ("webui.host", "0.0.0.0", "Web UI 监听主机", "webui"),
281
+ ("webui.port", "8000", "Web UI 监听端口", "webui"),
282
+ ("webui.debug", "true", "调试模式", "webui"),
283
+ ]
284
+
285
+ # ============================================================================
286
+ # Web UI 相关常量
287
+ # ============================================================================
288
+
289
+ # WebSocket 事件
290
+ WEBSOCKET_EVENTS = {
291
+ "CONNECT": "connect",
292
+ "DISCONNECT": "disconnect",
293
+ "LOG": "log",
294
+ "STATUS": "status",
295
+ "ERROR": "error",
296
+ "COMPLETE": "complete",
297
+ }
298
+
299
+ # API 响应状态码
300
+ API_STATUS_CODES = {
301
+ "SUCCESS": 200,
302
+ "CREATED": 201,
303
+ "BAD_REQUEST": 400,
304
+ "UNAUTHORIZED": 401,
305
+ "FORBIDDEN": 403,
306
+ "NOT_FOUND": 404,
307
+ "CONFLICT": 409,
308
+ "INTERNAL_ERROR": 500,
309
+ }
310
+
311
+ # 分页
312
+ DEFAULT_PAGE_SIZE = 20
313
+ MAX_PAGE_SIZE = 100
314
+
315
+ # ============================================================================
316
+ # 错误消息
317
+ # ============================================================================
318
+
319
+ ERROR_MESSAGES = {
320
+ # 通用错误
321
+ "DATABASE_ERROR": "数据库操作失败",
322
+ "CONFIG_ERROR": "配置错误",
323
+ "NETWORK_ERROR": "网络连接失败",
324
+ "TIMEOUT": "操作超时",
325
+ "VALIDATION_ERROR": "参数验证失败",
326
+
327
+ # 邮箱服务错误
328
+ "EMAIL_SERVICE_UNAVAILABLE": "邮箱服务不可用",
329
+ "EMAIL_CREATION_FAILED": "创建邮箱失败",
330
+ "OTP_NOT_RECEIVED": "未收到验证码",
331
+ "OTP_INVALID": "验证码无效",
332
+
333
+ # OpenAI 相关错误
334
+ "OPENAI_AUTH_FAILED": "OpenAI 认证失败",
335
+ "OPENAI_RATE_LIMIT": "OpenAI 接口限流",
336
+ "OPENAI_CAPTCHA": "遇到验证码",
337
+
338
+ # 代理错误
339
+ "PROXY_FAILED": "代理连接失败",
340
+ "PROXY_AUTH_FAILED": "代理认证失败",
341
+
342
+ # 账户错误
343
+ "ACCOUNT_NOT_FOUND": "账户不存在",
344
+ "ACCOUNT_ALREADY_EXISTS": "账户已存在",
345
+ "ACCOUNT_INVALID": "账户无效",
346
+
347
+ # 任务错误
348
+ "TASK_NOT_FOUND": "任务不存在",
349
+ "TASK_ALREADY_RUNNING": "任务已在运行中",
350
+ "TASK_CANCELLED": "任务已取消",
351
+ }
352
+
353
+ # ============================================================================
354
+ # 正则表达式
355
+ # ============================================================================
356
+
357
+ REGEX_PATTERNS = {
358
+ "EMAIL": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
359
+ "URL": r"https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+",
360
+ "IP_ADDRESS": r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
361
+ "OTP_CODE": OTP_CODE_PATTERN,
362
+ }
363
+
364
+ # ============================================================================
365
+ # 时间常量
366
+ # ============================================================================
367
+
368
+ TIME_CONSTANTS = {
369
+ "SECOND": 1,
370
+ "MINUTE": 60,
371
+ "HOUR": 3600,
372
+ "DAY": 86400,
373
+ "WEEK": 604800,
374
+ }
375
+
376
+
377
+ # ============================================================================
378
+ # Microsoft/Outlook 相关常量
379
+ # ============================================================================
380
+
381
+ # Microsoft OAuth2 Token 端点
382
+ MICROSOFT_TOKEN_ENDPOINTS = {
383
+ # 旧版 IMAP 使用的端点
384
+ "LIVE": "https://login.live.com/oauth20_token.srf",
385
+ # 新版 IMAP 使用的端点(需要特定 scope)
386
+ "CONSUMERS": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
387
+ # Graph API 使用的端点
388
+ "COMMON": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
389
+ }
390
+
391
+ # IMAP 服务器配置
392
+ OUTLOOK_IMAP_SERVERS = {
393
+ "OLD": "outlook.office365.com", # 旧版 IMAP
394
+ "NEW": "outlook.live.com", # 新版 IMAP
395
+ }
396
+
397
+ # Microsoft OAuth2 Scopes
398
+ MICROSOFT_SCOPES = {
399
+ # 旧版 IMAP 不需要特定 scope
400
+ "IMAP_OLD": "",
401
+ # 新版 IMAP 需要的 scope
402
+ "IMAP_NEW": "https://outlook.office.com/IMAP.AccessAsUser.All offline_access",
403
+ # Graph API 需要的 scope
404
+ "GRAPH_API": "https://graph.microsoft.com/.default",
405
+ }
406
+
407
+ # Outlook 提供者默认优先级
408
+ OUTLOOK_PROVIDER_PRIORITY = ["imap_new", "imap_old", "graph_api"]
src/config/settings.py ADDED
@@ -0,0 +1,767 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 配置管理 - 完全基于数据库存储
3
+ 所有配置都从数据库读取,不再使用环境变量或 .env 文件
4
+ """
5
+
6
+ import os
7
+ from typing import Optional, Dict, Any, Type, List
8
+ from enum import Enum
9
+ from pydantic import BaseModel, field_validator
10
+ from pydantic.types import SecretStr
11
+ from dataclasses import dataclass
12
+
13
+
14
+ class SettingCategory(str, Enum):
15
+ """设置分类"""
16
+ GENERAL = "general"
17
+ DATABASE = "database"
18
+ WEBUI = "webui"
19
+ LOG = "log"
20
+ OPENAI = "openai"
21
+ PROXY = "proxy"
22
+ REGISTRATION = "registration"
23
+ EMAIL = "email"
24
+ TEMPMAIL = "tempmail"
25
+ CUSTOM_DOMAIN = "moe_mail"
26
+ SECURITY = "security"
27
+ CPA = "cpa"
28
+
29
+
30
+ @dataclass
31
+ class SettingDefinition:
32
+ """设置定义"""
33
+ db_key: str
34
+ default_value: Any
35
+ category: SettingCategory
36
+ description: str = ""
37
+ is_secret: bool = False
38
+
39
+
40
+ # 所有配置项定义(包含数据库键名、默认值、分类、描述)
41
+ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
42
+ # 应用信息
43
+ "app_name": SettingDefinition(
44
+ db_key="app.name",
45
+ default_value="OpenAI/Codex CLI 自动注册系统",
46
+ category=SettingCategory.GENERAL,
47
+ description="应用名称"
48
+ ),
49
+ "app_version": SettingDefinition(
50
+ db_key="app.version",
51
+ default_value="2.0.0",
52
+ category=SettingCategory.GENERAL,
53
+ description="应用版本"
54
+ ),
55
+ "debug": SettingDefinition(
56
+ db_key="app.debug",
57
+ default_value=False,
58
+ category=SettingCategory.GENERAL,
59
+ description="调试模式"
60
+ ),
61
+
62
+ # 数据库配置
63
+ "database_url": SettingDefinition(
64
+ db_key="database.url",
65
+ default_value="data/database.db",
66
+ category=SettingCategory.DATABASE,
67
+ description="数据库路径或连接字符串"
68
+ ),
69
+
70
+ # Web UI 配置
71
+ "webui_host": SettingDefinition(
72
+ db_key="webui.host",
73
+ default_value="0.0.0.0",
74
+ category=SettingCategory.WEBUI,
75
+ description="Web UI 监听地址"
76
+ ),
77
+ "webui_port": SettingDefinition(
78
+ db_key="webui.port",
79
+ default_value=8000,
80
+ category=SettingCategory.WEBUI,
81
+ description="Web UI 监听端口"
82
+ ),
83
+ "webui_secret_key": SettingDefinition(
84
+ db_key="webui.secret_key",
85
+ default_value="your-secret-key-change-in-production",
86
+ category=SettingCategory.WEBUI,
87
+ description="Web UI 密钥",
88
+ is_secret=True
89
+ ),
90
+ "webui_access_password": SettingDefinition(
91
+ db_key="webui.access_password",
92
+ default_value="admin123",
93
+ category=SettingCategory.WEBUI,
94
+ description="Web UI 访问密码",
95
+ is_secret=True
96
+ ),
97
+
98
+ # 日志配置
99
+ "log_level": SettingDefinition(
100
+ db_key="log.level",
101
+ default_value="INFO",
102
+ category=SettingCategory.LOG,
103
+ description="日志级别"
104
+ ),
105
+ "log_file": SettingDefinition(
106
+ db_key="log.file",
107
+ default_value="logs/app.log",
108
+ category=SettingCategory.LOG,
109
+ description="日志文件路径"
110
+ ),
111
+ "log_retention_days": SettingDefinition(
112
+ db_key="log.retention_days",
113
+ default_value=30,
114
+ category=SettingCategory.LOG,
115
+ description="日志保留天数"
116
+ ),
117
+
118
+ # OpenAI 配置
119
+ "openai_client_id": SettingDefinition(
120
+ db_key="openai.client_id",
121
+ default_value="app_EMoamEEZ73f0CkXaXp7hrann",
122
+ category=SettingCategory.OPENAI,
123
+ description="OpenAI OAuth 客户端 ID"
124
+ ),
125
+ "openai_auth_url": SettingDefinition(
126
+ db_key="openai.auth_url",
127
+ default_value="https://auth.openai.com/oauth/authorize",
128
+ category=SettingCategory.OPENAI,
129
+ description="OpenAI OAuth 授权 URL"
130
+ ),
131
+ "openai_token_url": SettingDefinition(
132
+ db_key="openai.token_url",
133
+ default_value="https://auth.openai.com/oauth/token",
134
+ category=SettingCategory.OPENAI,
135
+ description="OpenAI OAuth Token URL"
136
+ ),
137
+ "openai_redirect_uri": SettingDefinition(
138
+ db_key="openai.redirect_uri",
139
+ default_value="http://localhost:1455/auth/callback",
140
+ category=SettingCategory.OPENAI,
141
+ description="OpenAI OAuth 回调 URI"
142
+ ),
143
+ "openai_scope": SettingDefinition(
144
+ db_key="openai.scope",
145
+ default_value="openid email profile offline_access",
146
+ category=SettingCategory.OPENAI,
147
+ description="OpenAI OAuth 权限范围"
148
+ ),
149
+
150
+ # 代理配置
151
+ "proxy_enabled": SettingDefinition(
152
+ db_key="proxy.enabled",
153
+ default_value=False,
154
+ category=SettingCategory.PROXY,
155
+ description="是否启用代理"
156
+ ),
157
+ "proxy_type": SettingDefinition(
158
+ db_key="proxy.type",
159
+ default_value="http",
160
+ category=SettingCategory.PROXY,
161
+ description="代理类型 (http/socks5)"
162
+ ),
163
+ "proxy_host": SettingDefinition(
164
+ db_key="proxy.host",
165
+ default_value="127.0.0.1",
166
+ category=SettingCategory.PROXY,
167
+ description="代理服务器地址"
168
+ ),
169
+ "proxy_port": SettingDefinition(
170
+ db_key="proxy.port",
171
+ default_value=7890,
172
+ category=SettingCategory.PROXY,
173
+ description="代理服务器端口"
174
+ ),
175
+ "proxy_username": SettingDefinition(
176
+ db_key="proxy.username",
177
+ default_value="",
178
+ category=SettingCategory.PROXY,
179
+ description="代理用户名"
180
+ ),
181
+ "proxy_password": SettingDefinition(
182
+ db_key="proxy.password",
183
+ default_value="",
184
+ category=SettingCategory.PROXY,
185
+ description="代理密码",
186
+ is_secret=True
187
+ ),
188
+ "proxy_dynamic_enabled": SettingDefinition(
189
+ db_key="proxy.dynamic_enabled",
190
+ default_value=False,
191
+ category=SettingCategory.PROXY,
192
+ description="是否启用动态代理"
193
+ ),
194
+ "proxy_dynamic_api_url": SettingDefinition(
195
+ db_key="proxy.dynamic_api_url",
196
+ default_value="",
197
+ category=SettingCategory.PROXY,
198
+ description="动态代理 API 地址,返回代理 URL 字符串"
199
+ ),
200
+ "proxy_dynamic_api_key": SettingDefinition(
201
+ db_key="proxy.dynamic_api_key",
202
+ default_value="",
203
+ category=SettingCategory.PROXY,
204
+ description="动态代理 API 密钥(可选)",
205
+ is_secret=True
206
+ ),
207
+ "proxy_dynamic_api_key_header": SettingDefinition(
208
+ db_key="proxy.dynamic_api_key_header",
209
+ default_value="X-API-Key",
210
+ category=SettingCategory.PROXY,
211
+ description="动态代理 API 密钥请求头名称"
212
+ ),
213
+ "proxy_dynamic_result_field": SettingDefinition(
214
+ db_key="proxy.dynamic_result_field",
215
+ default_value="",
216
+ category=SettingCategory.PROXY,
217
+ description="从 JSON 响应中提取代理 URL 的字段路径(留空则使用响应原文)"
218
+ ),
219
+
220
+ # 注册配置
221
+ "registration_max_retries": SettingDefinition(
222
+ db_key="registration.max_retries",
223
+ default_value=3,
224
+ category=SettingCategory.REGISTRATION,
225
+ description="注册最大重试次数"
226
+ ),
227
+ "registration_timeout": SettingDefinition(
228
+ db_key="registration.timeout",
229
+ default_value=120,
230
+ category=SettingCategory.REGISTRATION,
231
+ description="注册超时时间(秒)"
232
+ ),
233
+ "registration_default_password_length": SettingDefinition(
234
+ db_key="registration.default_password_length",
235
+ default_value=12,
236
+ category=SettingCategory.REGISTRATION,
237
+ description="默认密码长度"
238
+ ),
239
+ "registration_sleep_min": SettingDefinition(
240
+ db_key="registration.sleep_min",
241
+ default_value=5,
242
+ category=SettingCategory.REGISTRATION,
243
+ description="注册间隔最小值(秒)"
244
+ ),
245
+ "registration_sleep_max": SettingDefinition(
246
+ db_key="registration.sleep_max",
247
+ default_value=30,
248
+ category=SettingCategory.REGISTRATION,
249
+ description="注册间隔最大值(秒)"
250
+ ),
251
+
252
+ # 邮箱服务配置
253
+ "email_service_priority": SettingDefinition(
254
+ db_key="email.service_priority",
255
+ default_value={"tempmail": 0, "outlook": 1, "moe_mail": 2},
256
+ category=SettingCategory.EMAIL,
257
+ description="邮箱服务优先级"
258
+ ),
259
+
260
+ # Tempmail.lol 配置
261
+ "tempmail_base_url": SettingDefinition(
262
+ db_key="tempmail.base_url",
263
+ default_value="https://api.tempmail.lol/v2",
264
+ category=SettingCategory.TEMPMAIL,
265
+ description="Tempmail API 地址"
266
+ ),
267
+ "tempmail_timeout": SettingDefinition(
268
+ db_key="tempmail.timeout",
269
+ default_value=30,
270
+ category=SettingCategory.TEMPMAIL,
271
+ description="Tempmail 超时时间(秒)"
272
+ ),
273
+ "tempmail_max_retries": SettingDefinition(
274
+ db_key="tempmail.max_retries",
275
+ default_value=3,
276
+ category=SettingCategory.TEMPMAIL,
277
+ description="Tempmail 最大重试次数"
278
+ ),
279
+
280
+ # 自定义域名邮箱配置
281
+ "custom_domain_base_url": SettingDefinition(
282
+ db_key="custom_domain.base_url",
283
+ default_value="",
284
+ category=SettingCategory.CUSTOM_DOMAIN,
285
+ description="自定义域名 API 地址"
286
+ ),
287
+ "custom_domain_api_key": SettingDefinition(
288
+ db_key="custom_domain.api_key",
289
+ default_value="",
290
+ category=SettingCategory.CUSTOM_DOMAIN,
291
+ description="自定义域名 API 密钥",
292
+ is_secret=True
293
+ ),
294
+
295
+ # 安全配置
296
+ "encryption_key": SettingDefinition(
297
+ db_key="security.encryption_key",
298
+ default_value="your-encryption-key-change-in-production",
299
+ category=SettingCategory.SECURITY,
300
+ description="加密密钥",
301
+ is_secret=True
302
+ ),
303
+
304
+ # Team Manager 配置
305
+ "tm_enabled": SettingDefinition(
306
+ db_key="tm.enabled",
307
+ default_value=False,
308
+ category=SettingCategory.GENERAL,
309
+ description="是否启用 Team Manager 上传"
310
+ ),
311
+ "tm_api_url": SettingDefinition(
312
+ db_key="tm.api_url",
313
+ default_value="",
314
+ category=SettingCategory.GENERAL,
315
+ description="Team Manager API 地址"
316
+ ),
317
+ "tm_api_key": SettingDefinition(
318
+ db_key="tm.api_key",
319
+ default_value="",
320
+ category=SettingCategory.GENERAL,
321
+ description="Team Manager API Key",
322
+ is_secret=True
323
+ ),
324
+
325
+ # CPA 上传配置
326
+ "cpa_enabled": SettingDefinition(
327
+ db_key="cpa.enabled",
328
+ default_value=False,
329
+ category=SettingCategory.CPA,
330
+ description="是否启用 CPA 上传"
331
+ ),
332
+ "cpa_api_url": SettingDefinition(
333
+ db_key="cpa.api_url",
334
+ default_value="",
335
+ category=SettingCategory.CPA,
336
+ description="CPA API 地址"
337
+ ),
338
+ "cpa_api_token": SettingDefinition(
339
+ db_key="cpa.api_token",
340
+ default_value="",
341
+ category=SettingCategory.CPA,
342
+ description="CPA API Token",
343
+ is_secret=True
344
+ ),
345
+
346
+ # 验证码配置
347
+ "email_code_timeout": SettingDefinition(
348
+ db_key="email_code.timeout",
349
+ default_value=30,
350
+ category=SettingCategory.EMAIL,
351
+ description="验证码等待超时时间(秒)"
352
+ ),
353
+ "email_code_poll_interval": SettingDefinition(
354
+ db_key="email_code.poll_interval",
355
+ default_value=3,
356
+ category=SettingCategory.EMAIL,
357
+ description="验证码轮询间隔(秒)"
358
+ ),
359
+
360
+ # Outlook 配置
361
+ "outlook_provider_priority": SettingDefinition(
362
+ db_key="outlook.provider_priority",
363
+ default_value=["imap_old", "imap_new", "graph_api"],
364
+ category=SettingCategory.EMAIL,
365
+ description="Outlook 提供者优先级"
366
+ ),
367
+ "outlook_health_failure_threshold": SettingDefinition(
368
+ db_key="outlook.health_failure_threshold",
369
+ default_value=5,
370
+ category=SettingCategory.EMAIL,
371
+ description="Outlook 提供者连续失败次数阈值"
372
+ ),
373
+ "outlook_health_disable_duration": SettingDefinition(
374
+ db_key="outlook.health_disable_duration",
375
+ default_value=60,
376
+ category=SettingCategory.EMAIL,
377
+ description="Outlook 提供者禁用时长(秒)"
378
+ ),
379
+ "outlook_default_client_id": SettingDefinition(
380
+ db_key="outlook.default_client_id",
381
+ default_value="24d9a0ed-8787-4584-883c-2fd79308940a",
382
+ category=SettingCategory.EMAIL,
383
+ description="Outlook OAuth 默认 Client ID"
384
+ ),
385
+ }
386
+
387
+ # 属性名到数据库键名的映射(用于向后兼容)
388
+ DB_SETTING_KEYS = {name: defn.db_key for name, defn in SETTING_DEFINITIONS.items()}
389
+
390
+ # 类型定义映射
391
+ SETTING_TYPES: Dict[str, Type] = {
392
+ "debug": bool,
393
+ "webui_port": int,
394
+ "log_retention_days": int,
395
+ "proxy_enabled": bool,
396
+ "proxy_port": int,
397
+ "proxy_dynamic_enabled": bool,
398
+ "registration_max_retries": int,
399
+ "registration_timeout": int,
400
+ "registration_default_password_length": int,
401
+ "registration_sleep_min": int,
402
+ "registration_sleep_max": int,
403
+ "email_service_priority": dict,
404
+ "tempmail_timeout": int,
405
+ "tempmail_max_retries": int,
406
+ "tm_enabled": bool,
407
+ "cpa_enabled": bool,
408
+ "email_code_timeout": int,
409
+ "email_code_poll_interval": int,
410
+ "outlook_provider_priority": list,
411
+ "outlook_health_failure_threshold": int,
412
+ "outlook_health_disable_duration": int,
413
+ }
414
+
415
+ # 需要作为 SecretStr 处理的字段
416
+ SECRET_FIELDS = {name for name, defn in SETTING_DEFINITIONS.items() if defn.is_secret}
417
+
418
+
419
+ def _convert_value(attr_name: str, value: str) -> Any:
420
+ """将数据库字符串值转换为正确的类型"""
421
+ if attr_name in SECRET_FIELDS:
422
+ return SecretStr(value) if value else SecretStr("")
423
+
424
+ target_type = SETTING_TYPES.get(attr_name, str)
425
+
426
+ if target_type == bool:
427
+ if isinstance(value, bool):
428
+ return value
429
+ return str(value).lower() in ("true", "1", "yes", "on")
430
+ elif target_type == int:
431
+ if isinstance(value, int):
432
+ return value
433
+ return int(value) if value else 0
434
+ elif target_type == dict:
435
+ if isinstance(value, dict):
436
+ return value
437
+ if not value:
438
+ return {}
439
+ import json
440
+ import ast
441
+ try:
442
+ return json.loads(value)
443
+ except (json.JSONDecodeError, ValueError):
444
+ try:
445
+ return ast.literal_eval(value)
446
+ except Exception:
447
+ return {}
448
+ elif target_type == list:
449
+ if isinstance(value, list):
450
+ return value
451
+ if not value:
452
+ return []
453
+ import json
454
+ import ast
455
+ try:
456
+ return json.loads(value)
457
+ except (json.JSONDecodeError, ValueError):
458
+ try:
459
+ return ast.literal_eval(value)
460
+ except Exception:
461
+ return []
462
+ else:
463
+ return value
464
+
465
+
466
+ def _normalize_database_url(url: str) -> str:
467
+ if url.startswith("postgres://"):
468
+ return "postgresql+psycopg://" + url[len("postgres://"):]
469
+ if url.startswith("postgresql://"):
470
+ return "postgresql+psycopg://" + url[len("postgresql://"):]
471
+ return url
472
+
473
+
474
+ def _value_to_string(value: Any) -> str:
475
+ """将值转换为数据库存储的字符串"""
476
+ if isinstance(value, SecretStr):
477
+ return value.get_secret_value()
478
+ elif isinstance(value, bool):
479
+ return "true" if value else "false"
480
+ elif isinstance(value, (dict, list)):
481
+ import json
482
+ return json.dumps(value)
483
+ elif value is None:
484
+ return ""
485
+ else:
486
+ return str(value)
487
+
488
+
489
+ def init_default_settings() -> None:
490
+ """
491
+ 初始化数据库中的默认设置
492
+ 如果设置项不存在,则创建并设置默认值
493
+ """
494
+ try:
495
+ from ..database.session import get_db
496
+ from ..database.crud import get_setting, set_setting
497
+
498
+ with get_db() as db:
499
+ for attr_name, defn in SETTING_DEFINITIONS.items():
500
+ existing = get_setting(db, defn.db_key)
501
+ if not existing:
502
+ default_value = defn.default_value
503
+ if attr_name == "database_url":
504
+ env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
505
+ if env_url:
506
+ default_value = _normalize_database_url(env_url)
507
+ default_value = _value_to_string(default_value)
508
+ set_setting(
509
+ db,
510
+ defn.db_key,
511
+ default_value,
512
+ category=defn.category.value,
513
+ description=defn.description
514
+ )
515
+ print(f"[Settings] 初始化默认设置: {defn.db_key} = {default_value if not defn.is_secret else '***'}")
516
+ except Exception as e:
517
+ if "未初始化" not in str(e):
518
+ print(f"[Settings] 初始化默认设置失败: {e}")
519
+
520
+
521
+ def _load_settings_from_db() -> Dict[str, Any]:
522
+ """从数据库加载所有设置"""
523
+ try:
524
+ from ..database.session import get_db
525
+ from ..database.crud import get_setting
526
+
527
+ settings_dict = {}
528
+ with get_db() as db:
529
+ for attr_name, defn in SETTING_DEFINITIONS.items():
530
+ db_setting = get_setting(db, defn.db_key)
531
+ if db_setting:
532
+ settings_dict[attr_name] = _convert_value(attr_name, db_setting.value)
533
+ else:
534
+ # 数据库中没有此设置,使用默认值
535
+ settings_dict[attr_name] = _convert_value(attr_name, _value_to_string(defn.default_value))
536
+ env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
537
+ if env_url:
538
+ settings_dict["database_url"] = _normalize_database_url(env_url)
539
+ env_host = os.environ.get("APP_HOST")
540
+ if env_host:
541
+ settings_dict["webui_host"] = env_host
542
+ env_port = os.environ.get("APP_PORT")
543
+ if env_port:
544
+ try:
545
+ settings_dict["webui_port"] = int(env_port)
546
+ except ValueError:
547
+ pass
548
+ env_password = os.environ.get("APP_ACCESS_PASSWORD")
549
+ if env_password:
550
+ settings_dict["webui_access_password"] = env_password
551
+ return settings_dict
552
+ except Exception as e:
553
+ if "未初始化" not in str(e):
554
+ print(f"[Settings] 从数据库加载设置失败: {e},使用默认值")
555
+ return {name: defn.default_value for name, defn in SETTING_DEFINITIONS.items()}
556
+
557
+
558
+ def _save_settings_to_db(**kwargs) -> None:
559
+ """保存设置到数据库"""
560
+ try:
561
+ from ..database.session import get_db
562
+ from ..database.crud import set_setting
563
+
564
+ with get_db() as db:
565
+ for attr_name, value in kwargs.items():
566
+ if attr_name in SETTING_DEFINITIONS:
567
+ defn = SETTING_DEFINITIONS[attr_name]
568
+ str_value = _value_to_string(value)
569
+ set_setting(
570
+ db,
571
+ defn.db_key,
572
+ str_value,
573
+ category=defn.category.value,
574
+ description=defn.description
575
+ )
576
+ except Exception as e:
577
+ if "未初始化" not in str(e):
578
+ print(f"[Settings] 保存设置到数据库失败: {e}")
579
+
580
+
581
+ class Settings(BaseModel):
582
+ """
583
+ 应用配置 - 完全基于数据库存储
584
+ """
585
+
586
+ # 应用信息
587
+ app_name: str = "OpenAI/Codex CLI 自动注册系统"
588
+ app_version: str = "2.0.0"
589
+ debug: bool = False
590
+
591
+ # 数据库配置
592
+ database_url: str = "data/database.db"
593
+
594
+ @field_validator('database_url', mode='before')
595
+ @classmethod
596
+ def validate_database_url(cls, v):
597
+ if isinstance(v, str):
598
+ if v.startswith(("postgres://", "postgresql://")):
599
+ return _normalize_database_url(v)
600
+ if v.startswith(("postgresql+psycopg://", "postgresql+psycopg2://")):
601
+ return v
602
+ if isinstance(v, str) and v.startswith("sqlite:///"):
603
+ return v
604
+ if isinstance(v, str) and not v.startswith(("sqlite:///", "postgresql://", "postgresql+psycopg://", "postgresql+psycopg2://", "mysql://")):
605
+ # 如果是文件路径,转换为 SQLite URL
606
+ if os.path.isabs(v) or ":/" not in v:
607
+ return f"sqlite:///{v}"
608
+ return v
609
+
610
+ # Web UI 配置
611
+ webui_host: str = "0.0.0.0"
612
+ webui_port: int = 8000
613
+ webui_secret_key: SecretStr = SecretStr("your-secret-key-change-in-production")
614
+ webui_access_password: SecretStr = SecretStr("admin123")
615
+
616
+ # 日志配置
617
+ log_level: str = "INFO"
618
+ log_file: str = "logs/app.log"
619
+ log_retention_days: int = 30
620
+
621
+ # OpenAI 配置
622
+ openai_client_id: str = "app_EMoamEEZ73f0CkXaXp7hrann"
623
+ openai_auth_url: str = "https://auth.openai.com/oauth/authorize"
624
+ openai_token_url: str = "https://auth.openai.com/oauth/token"
625
+ openai_redirect_uri: str = "http://localhost:1455/auth/callback"
626
+ openai_scope: str = "openid email profile offline_access"
627
+
628
+ # 代理配置
629
+ proxy_enabled: bool = False
630
+ proxy_type: str = "http"
631
+ proxy_host: str = "127.0.0.1"
632
+ proxy_port: int = 7890
633
+ proxy_username: Optional[str] = None
634
+ proxy_password: Optional[SecretStr] = None
635
+ proxy_dynamic_enabled: bool = False
636
+ proxy_dynamic_api_url: str = ""
637
+ proxy_dynamic_api_key: Optional[SecretStr] = None
638
+ proxy_dynamic_api_key_header: str = "X-API-Key"
639
+ proxy_dynamic_result_field: str = ""
640
+
641
+ @property
642
+ def proxy_url(self) -> Optional[str]:
643
+ """获取完整的代理 URL"""
644
+ if not self.proxy_enabled:
645
+ return None
646
+
647
+ if self.proxy_type == "http":
648
+ scheme = "http"
649
+ elif self.proxy_type == "socks5":
650
+ scheme = "socks5"
651
+ else:
652
+ return None
653
+
654
+ auth = ""
655
+ if self.proxy_username and self.proxy_password:
656
+ auth = f"{self.proxy_username}:{self.proxy_password.get_secret_value()}@"
657
+
658
+ return f"{scheme}://{auth}{self.proxy_host}:{self.proxy_port}"
659
+
660
+ # 注册配置
661
+ registration_max_retries: int = 3
662
+ registration_timeout: int = 120
663
+ registration_default_password_length: int = 12
664
+ registration_sleep_min: int = 5
665
+ registration_sleep_max: int = 30
666
+
667
+ # 邮箱服务配置
668
+ email_service_priority: Dict[str, int] = {"tempmail": 0, "outlook": 1, "moe_mail": 2}
669
+
670
+ # Tempmail.lol 配置
671
+ tempmail_base_url: str = "https://api.tempmail.lol/v2"
672
+ tempmail_timeout: int = 30
673
+ tempmail_max_retries: int = 3
674
+
675
+ # 自定义域名邮箱配置
676
+ custom_domain_base_url: str = ""
677
+ custom_domain_api_key: Optional[SecretStr] = None
678
+
679
+ # 安全配置
680
+ encryption_key: SecretStr = SecretStr("your-encryption-key-change-in-production")
681
+
682
+ # Team Manager 配置
683
+ tm_enabled: bool = False
684
+ tm_api_url: str = ""
685
+ tm_api_key: Optional[SecretStr] = None
686
+
687
+ # CPA 上传配置
688
+ cpa_enabled: bool = False
689
+ cpa_api_url: str = ""
690
+ cpa_api_token: SecretStr = SecretStr("")
691
+
692
+ # 验证码配置
693
+ email_code_timeout: int = 30
694
+ email_code_poll_interval: int = 3
695
+
696
+ # Outlook 配置
697
+ outlook_provider_priority: List[str] = ["imap_old", "imap_new", "graph_api"]
698
+ outlook_health_failure_threshold: int = 5
699
+ outlook_health_disable_duration: int = 60
700
+ outlook_default_client_id: str = "24d9a0ed-8787-4584-883c-2fd79308940a"
701
+
702
+
703
+ # 全局配置实例
704
+ _settings: Optional[Settings] = None
705
+
706
+
707
+ def get_settings() -> Settings:
708
+ """
709
+ 获取全局配置实例(单例模式)
710
+ 完全从数据库加载配置
711
+ """
712
+ global _settings
713
+ if _settings is None:
714
+ # 先初始化默认设置(如果数据库中没有的话)
715
+ init_default_settings()
716
+ # 从数据库加载所有设置
717
+ settings_dict = _load_settings_from_db()
718
+ _settings = Settings(**settings_dict)
719
+ return _settings
720
+
721
+
722
+ def update_settings(**kwargs) -> Settings:
723
+ """
724
+ 更新配置并保存到数据库
725
+ """
726
+ global _settings
727
+ if _settings is None:
728
+ _settings = get_settings()
729
+
730
+ # 创建新的配置实例
731
+ updated_data = _settings.model_dump()
732
+ updated_data.update(kwargs)
733
+ _settings = Settings(**updated_data)
734
+
735
+ # 保存到数据库
736
+ _save_settings_to_db(**kwargs)
737
+
738
+ return _settings
739
+
740
+
741
+ def get_database_url() -> str:
742
+ """
743
+ 获取数据库 URL(处理相对路径)
744
+ """
745
+ settings = get_settings()
746
+ url = settings.database_url
747
+
748
+ # 如果 URL 是相对路径,转换为绝对路径
749
+ if url.startswith("sqlite:///"):
750
+ path = url[10:] # 移除 "sqlite:///"
751
+ if not os.path.isabs(path):
752
+ # 转换为相对于项目根目录的路径
753
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
754
+ abs_path = os.path.join(project_root, path)
755
+ return f"sqlite:///{abs_path}"
756
+
757
+ return url
758
+
759
+
760
+ def get_setting_definition(attr_name: str) -> Optional[SettingDefinition]:
761
+ """获取设置项的定义信息"""
762
+ return SETTING_DEFINITIONS.get(attr_name)
763
+
764
+
765
+ def get_all_setting_definitions() -> Dict[str, SettingDefinition]:
766
+ """获取所有设置项的定义"""
767
+ return SETTING_DEFINITIONS.copy()
src/core/__init__.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 核心功能模块
3
+ """
4
+
5
+ from .openai.oauth import OAuthManager, OAuthStart, generate_oauth_url, submit_callback_url
6
+ from .http_client import (
7
+ OpenAIHTTPClient,
8
+ HTTPClient,
9
+ HTTPClientError,
10
+ RequestConfig,
11
+ create_http_client,
12
+ create_openai_client,
13
+ )
14
+ from .register import RegistrationEngine, RegistrationResult
15
+ from .utils import setup_logging, get_data_dir
16
+
17
+ __all__ = [
18
+ 'OAuthManager',
19
+ 'OAuthStart',
20
+ 'generate_oauth_url',
21
+ 'submit_callback_url',
22
+ 'OpenAIHTTPClient',
23
+ 'HTTPClient',
24
+ 'HTTPClientError',
25
+ 'RequestConfig',
26
+ 'create_http_client',
27
+ 'create_openai_client',
28
+ 'RegistrationEngine',
29
+ 'RegistrationResult',
30
+ 'setup_logging',
31
+ 'get_data_dir',
32
+ ]
src/core/dynamic_proxy.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 动态代理获取模块
3
+ 支持通过外部 API 获取动态代理 URL
4
+ """
5
+
6
+ import logging
7
+ import re
8
+ from typing import Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def fetch_dynamic_proxy(api_url: str, api_key: str = "", api_key_header: str = "X-API-Key", result_field: str = "") -> Optional[str]:
14
+ """
15
+ 从代理 API 获取代理 URL
16
+
17
+ Args:
18
+ api_url: 代理 API 地址,响应应为代理 URL 字符串或含代理 URL 的 JSON
19
+ api_key: API 密钥(可选)
20
+ api_key_header: API 密钥请求头名称
21
+ result_field: 从 JSON 响应中提取代理 URL 的字段路径,支持点号分隔(如 "data.proxy"),留空则使用响应原文
22
+
23
+ Returns:
24
+ 代理 URL 字符串(如 http://user:pass@host:port),失败返回 None
25
+ """
26
+ try:
27
+ from curl_cffi import requests as cffi_requests
28
+
29
+ headers = {}
30
+ if api_key:
31
+ headers[api_key_header] = api_key
32
+
33
+ response = cffi_requests.get(
34
+ api_url,
35
+ headers=headers,
36
+ timeout=10,
37
+ impersonate="chrome110"
38
+ )
39
+
40
+ if response.status_code != 200:
41
+ logger.warning(f"动态代理 API 返回错误状态码: {response.status_code}")
42
+ return None
43
+
44
+ text = response.text.strip()
45
+
46
+ # 尝试解析 JSON
47
+ if result_field or text.startswith("{") or text.startswith("["):
48
+ try:
49
+ import json
50
+ data = json.loads(text)
51
+ if result_field:
52
+ # 按点号路径逐层提取
53
+ for key in result_field.split("."):
54
+ if isinstance(data, dict):
55
+ data = data.get(key)
56
+ elif isinstance(data, list) and key.isdigit():
57
+ data = data[int(key)]
58
+ else:
59
+ data = None
60
+ if data is None:
61
+ break
62
+ proxy_url = str(data).strip() if data is not None else None
63
+ else:
64
+ # 无指定字段,尝试常见键名
65
+ for key in ("proxy", "url", "proxy_url", "data", "ip"):
66
+ val = data.get(key) if isinstance(data, dict) else None
67
+ if val:
68
+ proxy_url = str(val).strip()
69
+ break
70
+ else:
71
+ proxy_url = text
72
+ except (ValueError, AttributeError):
73
+ proxy_url = text
74
+ else:
75
+ proxy_url = text
76
+
77
+ if not proxy_url:
78
+ logger.warning("动态代理 API 返回空代理 URL")
79
+ return None
80
+
81
+ # 若未包含协议头,默认加 http://
82
+ if not re.match(r'^(http|socks5)://', proxy_url):
83
+ proxy_url = "http://" + proxy_url
84
+
85
+ logger.info(f"动态代理获取成功: {proxy_url[:40]}..." if len(proxy_url) > 40 else f"动态代理获取成功: {proxy_url}")
86
+ return proxy_url
87
+
88
+ except Exception as e:
89
+ logger.error(f"获取动态代理失败: {e}")
90
+ return None
91
+
92
+
93
+ def get_proxy_url_for_task() -> Optional[str]:
94
+ """
95
+ 为注册任务获取代理 URL。
96
+ 优先使用动态代理(若启用),否则使用静态代理配置。
97
+
98
+ Returns:
99
+ 代理 URL 或 None
100
+ """
101
+ from ..config.settings import get_settings
102
+ settings = get_settings()
103
+
104
+ # 优先使用动态代理
105
+ if settings.proxy_dynamic_enabled and settings.proxy_dynamic_api_url:
106
+ api_key = settings.proxy_dynamic_api_key.get_secret_value() if settings.proxy_dynamic_api_key else ""
107
+ proxy_url = fetch_dynamic_proxy(
108
+ api_url=settings.proxy_dynamic_api_url,
109
+ api_key=api_key,
110
+ api_key_header=settings.proxy_dynamic_api_key_header,
111
+ result_field=settings.proxy_dynamic_result_field,
112
+ )
113
+ if proxy_url:
114
+ return proxy_url
115
+ logger.warning("动态代理获取失败,回退到静态代理")
116
+
117
+ # 使用静态代理
118
+ return settings.proxy_url
src/core/http_client.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HTTP 客户端封装
3
+ 基于 curl_cffi 的 HTTP 请求封装,支持代理和错误处理
4
+ """
5
+
6
+ import time
7
+ import json
8
+ from typing import Optional, Dict, Any, Union, Tuple
9
+ from dataclasses import dataclass
10
+ import logging
11
+
12
+ from curl_cffi import requests as cffi_requests
13
+ from curl_cffi.requests import Session, Response
14
+
15
+ from ..config.constants import ERROR_MESSAGES
16
+ from ..config.settings import get_settings
17
+ from .openai.sentinel import SentinelPOWError, build_sentinel_pow_token
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class RequestConfig:
25
+ """HTTP 请求配置"""
26
+ timeout: int = 30
27
+ max_retries: int = 3
28
+ retry_delay: float = 1.0
29
+ impersonate: str = "chrome"
30
+ verify_ssl: bool = True
31
+ follow_redirects: bool = True
32
+
33
+
34
+ class HTTPClientError(Exception):
35
+ """HTTP 客户端异常"""
36
+ pass
37
+
38
+
39
+ class HTTPClient:
40
+ """
41
+ HTTP 客户端封装
42
+ 支持代理、重试、错误处理和会话管理
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ proxy_url: Optional[str] = None,
48
+ config: Optional[RequestConfig] = None,
49
+ session: Optional[Session] = None
50
+ ):
51
+ """
52
+ 初始化 HTTP 客户端
53
+
54
+ Args:
55
+ proxy_url: 代理 URL,如 "http://127.0.0.1:7890"
56
+ config: 请求配置
57
+ session: 可重用的会话对象
58
+ """
59
+ self.proxy_url = proxy_url
60
+ self.config = config or RequestConfig()
61
+ self._session = session
62
+
63
+ @property
64
+ def proxies(self) -> Optional[Dict[str, str]]:
65
+ """获取代理配置"""
66
+ if not self.proxy_url:
67
+ return None
68
+ return {
69
+ "http": self.proxy_url,
70
+ "https": self.proxy_url,
71
+ }
72
+
73
+ @property
74
+ def session(self) -> Session:
75
+ """获取会话对象(单例)"""
76
+ if self._session is None:
77
+ self._session = Session(
78
+ proxies=self.proxies,
79
+ impersonate=self.config.impersonate,
80
+ verify=self.config.verify_ssl,
81
+ timeout=self.config.timeout
82
+ )
83
+ return self._session
84
+
85
+ def request(
86
+ self,
87
+ method: str,
88
+ url: str,
89
+ **kwargs
90
+ ) -> Response:
91
+ """
92
+ 发送 HTTP 请求
93
+
94
+ Args:
95
+ method: HTTP 方法 (GET, POST, PUT, DELETE, etc.)
96
+ url: 请求 URL
97
+ **kwargs: 其他请求参数
98
+
99
+ Returns:
100
+ Response 对象
101
+
102
+ Raises:
103
+ HTTPClientError: 请求失败
104
+ """
105
+ # 设置默认参数
106
+ kwargs.setdefault("timeout", self.config.timeout)
107
+ kwargs.setdefault("allow_redirects", self.config.follow_redirects)
108
+
109
+ # 添加代理配置
110
+ if self.proxies and "proxies" not in kwargs:
111
+ kwargs["proxies"] = self.proxies
112
+
113
+ last_exception = None
114
+ for attempt in range(self.config.max_retries):
115
+ try:
116
+ response = self.session.request(method, url, **kwargs)
117
+
118
+ # 检查响应状态码
119
+ if response.status_code >= 400:
120
+ logger.warning(
121
+ f"HTTP {response.status_code} for {method} {url}"
122
+ f" (attempt {attempt + 1}/{self.config.max_retries})"
123
+ )
124
+
125
+ # 如果是服务器错误,重试
126
+ if response.status_code >= 500 and attempt < self.config.max_retries - 1:
127
+ time.sleep(self.config.retry_delay * (attempt + 1))
128
+ continue
129
+
130
+ return response
131
+
132
+ except (cffi_requests.RequestsError, ConnectionError, TimeoutError) as e:
133
+ last_exception = e
134
+ logger.warning(
135
+ f"请求失败: {method} {url} (attempt {attempt + 1}/{self.config.max_retries}): {e}"
136
+ )
137
+
138
+ if attempt < self.config.max_retries - 1:
139
+ time.sleep(self.config.retry_delay * (attempt + 1))
140
+ else:
141
+ break
142
+
143
+ raise HTTPClientError(
144
+ f"请求失败,最大重试次数已达: {method} {url} - {last_exception}"
145
+ )
146
+
147
+ def get(self, url: str, **kwargs) -> Response:
148
+ """发送 GET 请求"""
149
+ return self.request("GET", url, **kwargs)
150
+
151
+ def post(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response:
152
+ """发送 POST 请求"""
153
+ return self.request("POST", url, data=data, json=json, **kwargs)
154
+
155
+ def put(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response:
156
+ """发送 PUT 请求"""
157
+ return self.request("PUT", url, data=data, json=json, **kwargs)
158
+
159
+ def delete(self, url: str, **kwargs) -> Response:
160
+ """发送 DELETE 请求"""
161
+ return self.request("DELETE", url, **kwargs)
162
+
163
+ def head(self, url: str, **kwargs) -> Response:
164
+ """发送 HEAD 请求"""
165
+ return self.request("HEAD", url, **kwargs)
166
+
167
+ def options(self, url: str, **kwargs) -> Response:
168
+ """发送 OPTIONS 请求"""
169
+ return self.request("OPTIONS", url, **kwargs)
170
+
171
+ def patch(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response:
172
+ """发送 PATCH 请求"""
173
+ return self.request("PATCH", url, data=data, json=json, **kwargs)
174
+
175
+ def download_file(self, url: str, filepath: str, chunk_size: int = 8192) -> None:
176
+ """
177
+ 下载文件
178
+
179
+ Args:
180
+ url: 文件 URL
181
+ filepath: 保存路径
182
+ chunk_size: 块大小
183
+
184
+ Raises:
185
+ HTTPClientError: 下载失败
186
+ """
187
+ try:
188
+ response = self.get(url, stream=True)
189
+ response.raise_for_status()
190
+
191
+ with open(filepath, 'wb') as f:
192
+ for chunk in response.iter_content(chunk_size=chunk_size):
193
+ if chunk:
194
+ f.write(chunk)
195
+
196
+ except Exception as e:
197
+ raise HTTPClientError(f"下载文件失败: {url} - {e}")
198
+
199
+ def check_proxy(self, test_url: str = "https://httpbin.org/ip") -> bool:
200
+ """
201
+ 检查代理是否可用
202
+
203
+ Args:
204
+ test_url: 测试 URL
205
+
206
+ Returns:
207
+ bool: 代理是否可用
208
+ """
209
+ if not self.proxy_url:
210
+ return False
211
+
212
+ try:
213
+ response = self.get(test_url, timeout=10)
214
+ return response.status_code == 200
215
+ except Exception:
216
+ return False
217
+
218
+ def close(self):
219
+ """关闭会话"""
220
+ if self._session:
221
+ self._session.close()
222
+ self._session = None
223
+
224
+ def __enter__(self):
225
+ return self
226
+
227
+ def __exit__(self, exc_type, exc_val, exc_tb):
228
+ self.close()
229
+
230
+
231
+ class OpenAIHTTPClient(HTTPClient):
232
+ """
233
+ OpenAI 专用 HTTP 客户端
234
+ 包含 OpenAI API 特定的请求方法
235
+ """
236
+
237
+ def __init__(
238
+ self,
239
+ proxy_url: Optional[str] = None,
240
+ config: Optional[RequestConfig] = None
241
+ ):
242
+ """
243
+ 初始化 OpenAI HTTP 客户端
244
+
245
+ Args:
246
+ proxy_url: 代理 URL
247
+ config: 请求配置
248
+ """
249
+ super().__init__(proxy_url, config)
250
+
251
+ # OpenAI 特定的默认配置
252
+ if config is None:
253
+ self.config.timeout = 30
254
+ self.config.max_retries = 3
255
+
256
+ # 默认请求头
257
+ self.default_headers = {
258
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
259
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
260
+ "Accept": "application/json",
261
+ "Accept-Language": "en-US,en;q=0.9",
262
+ "Accept-Encoding": "gzip, deflate, br",
263
+ "Connection": "keep-alive",
264
+ "Sec-Fetch-Dest": "empty",
265
+ "Sec-Fetch-Mode": "cors",
266
+ "Sec-Fetch-Site": "same-site",
267
+ }
268
+
269
+ def check_ip_location(self) -> Tuple[bool, Optional[str]]:
270
+ """
271
+ 检查 IP 地理位置
272
+
273
+ Returns:
274
+ Tuple[是否支持, 位置信息]
275
+ """
276
+ try:
277
+ response = self.get("https://cloudflare.com/cdn-cgi/trace", timeout=10)
278
+ trace_text = response.text
279
+
280
+ # 解析位置信息
281
+ import re
282
+ loc_match = re.search(r"loc=([A-Z]+)", trace_text)
283
+ loc = loc_match.group(1) if loc_match else None
284
+
285
+ # 检查是否支持
286
+ if loc in ["CN", "HK", "MO", "TW"]:
287
+ return False, loc
288
+ return True, loc
289
+
290
+ except Exception as e:
291
+ logger.error(f"检查 IP 地理位置失败: {e}")
292
+ return False, None
293
+
294
+ def send_openai_request(
295
+ self,
296
+ endpoint: str,
297
+ method: str = "POST",
298
+ data: Optional[Dict[str, Any]] = None,
299
+ json_data: Optional[Dict[str, Any]] = None,
300
+ headers: Optional[Dict[str, str]] = None,
301
+ **kwargs
302
+ ) -> Dict[str, Any]:
303
+ """
304
+ 发送 OpenAI API 请求
305
+
306
+ Args:
307
+ endpoint: API 端点
308
+ method: HTTP 方法
309
+ data: 表单数据
310
+ json_data: JSON 数据
311
+ headers: 请求头
312
+ **kwargs: 其他参数
313
+
314
+ Returns:
315
+ 响应 JSON 数据
316
+
317
+ Raises:
318
+ HTTPClientError: 请求失败
319
+ """
320
+ # 合并请求头
321
+ request_headers = self.default_headers.copy()
322
+ if headers:
323
+ request_headers.update(headers)
324
+
325
+ # 设置 Content-Type
326
+ if json_data is not None and "Content-Type" not in request_headers:
327
+ request_headers["Content-Type"] = "application/json"
328
+ elif data is not None and "Content-Type" not in request_headers:
329
+ request_headers["Content-Type"] = "application/x-www-form-urlencoded"
330
+
331
+ try:
332
+ response = self.request(
333
+ method,
334
+ endpoint,
335
+ data=data,
336
+ json=json_data,
337
+ headers=request_headers,
338
+ **kwargs
339
+ )
340
+
341
+ # 检查响应状态码
342
+ response.raise_for_status()
343
+
344
+ # 尝试解析 JSON
345
+ try:
346
+ return response.json()
347
+ except json.JSONDecodeError:
348
+ return {"raw_response": response.text}
349
+
350
+ except cffi_requests.RequestsError as e:
351
+ raise HTTPClientError(f"OpenAI 请求失败: {endpoint} - {e}")
352
+
353
+ def check_sentinel(self, did: str, proxies: Optional[Dict] = None) -> Optional[str]:
354
+ """
355
+ 检查 Sentinel 拦截
356
+
357
+ Args:
358
+ did: Device ID
359
+ proxies: 代理配置
360
+
361
+ Returns:
362
+ Sentinel token 或 None
363
+ """
364
+ from ..config.constants import OPENAI_API_ENDPOINTS
365
+
366
+ try:
367
+ pow_token = build_sentinel_pow_token(self.default_headers.get("User-Agent", ""))
368
+ sen_req_body = json.dumps({
369
+ "p": pow_token,
370
+ "id": did,
371
+ "flow": "authorize_continue",
372
+ }, separators=(",", ":"))
373
+
374
+ response = self.post(
375
+ OPENAI_API_ENDPOINTS["sentinel"],
376
+ headers={
377
+ "origin": "https://sentinel.openai.com",
378
+ "referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6",
379
+ "content-type": "text/plain;charset=UTF-8",
380
+ },
381
+ data=sen_req_body,
382
+ )
383
+
384
+ if response.status_code == 200:
385
+ return response.json().get("token")
386
+ else:
387
+ logger.warning(f"Sentinel 检查失败: {response.status_code}")
388
+ return None
389
+
390
+ except SentinelPOWError as e:
391
+ logger.error(f"Sentinel POW 求解失败: {e}")
392
+ return None
393
+ except Exception as e:
394
+ logger.error(f"Sentinel 检查异常: {e}")
395
+ return None
396
+
397
+
398
+ def create_http_client(
399
+ proxy_url: Optional[str] = None,
400
+ config: Optional[RequestConfig] = None
401
+ ) -> HTTPClient:
402
+ """
403
+ 创建 HTTP 客户端工厂函数
404
+
405
+ Args:
406
+ proxy_url: 代理 URL
407
+ config: 请求配置
408
+
409
+ Returns:
410
+ HTTPClient 实例
411
+ """
412
+ return HTTPClient(proxy_url, config)
413
+
414
+
415
+ def create_openai_client(
416
+ proxy_url: Optional[str] = None,
417
+ config: Optional[RequestConfig] = None
418
+ ) -> OpenAIHTTPClient:
419
+ """
420
+ 创建 OpenAI HTTP 客户端工厂函数
421
+
422
+ Args:
423
+ proxy_url: 代理 URL
424
+ config: 请求配置
425
+
426
+ Returns:
427
+ OpenAIHTTPClient 实例
428
+ """
429
+ return OpenAIHTTPClient(proxy_url, config)
src/core/openai/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # @Time : 2026/3/18 19:55
src/core/openai/oauth.py ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAI OAuth 授权模块
3
+ 从 main.py 中提取的 OAuth 相关函数
4
+ """
5
+
6
+ import base64
7
+ import hashlib
8
+ import json
9
+ import secrets
10
+ import time
11
+ import urllib.parse
12
+ from dataclasses import dataclass
13
+ from typing import Any, Dict, Optional
14
+
15
+ from curl_cffi import requests as cffi_requests
16
+
17
+ from ...config.constants import (
18
+ OAUTH_CLIENT_ID,
19
+ OAUTH_AUTH_URL,
20
+ OAUTH_TOKEN_URL,
21
+ OAUTH_REDIRECT_URI,
22
+ OAUTH_SCOPE,
23
+ )
24
+
25
+
26
+ def _b64url_no_pad(raw: bytes) -> str:
27
+ """Base64 URL 编码(无填充)"""
28
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
29
+
30
+
31
+ def _sha256_b64url_no_pad(s: str) -> str:
32
+ """SHA256 哈希后 Base64 URL 编码"""
33
+ return _b64url_no_pad(hashlib.sha256(s.encode("ascii")).digest())
34
+
35
+
36
+ def _random_state(nbytes: int = 16) -> str:
37
+ """生成随机 state"""
38
+ return secrets.token_urlsafe(nbytes)
39
+
40
+
41
+ def _pkce_verifier() -> str:
42
+ """生成 PKCE code_verifier"""
43
+ return secrets.token_urlsafe(64)
44
+
45
+
46
+ def _parse_callback_url(callback_url: str) -> Dict[str, str]:
47
+ """解析回调 URL"""
48
+ candidate = callback_url.strip()
49
+ if not candidate:
50
+ return {"code": "", "state": "", "error": "", "error_description": ""}
51
+
52
+ if "://" not in candidate:
53
+ if candidate.startswith("?"):
54
+ candidate = f"http://localhost{candidate}"
55
+ elif any(ch in candidate for ch in "/?#") or ":" in candidate:
56
+ candidate = f"http://{candidate}"
57
+ elif "=" in candidate:
58
+ candidate = f"http://localhost/?{candidate}"
59
+
60
+ parsed = urllib.parse.urlparse(candidate)
61
+ query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
62
+ fragment = urllib.parse.parse_qs(parsed.fragment, keep_blank_values=True)
63
+
64
+ for key, values in fragment.items():
65
+ if key not in query or not query[key] or not (query[key][0] or "").strip():
66
+ query[key] = values
67
+
68
+ def get1(k: str) -> str:
69
+ v = query.get(k, [""])
70
+ return (v[0] or "").strip()
71
+
72
+ code = get1("code")
73
+ state = get1("state")
74
+ error = get1("error")
75
+ error_description = get1("error_description")
76
+
77
+ if code and not state and "#" in code:
78
+ code, state = code.split("#", 1)
79
+
80
+ if not error and error_description:
81
+ error, error_description = error_description, ""
82
+
83
+ return {
84
+ "code": code,
85
+ "state": state,
86
+ "error": error,
87
+ "error_description": error_description,
88
+ }
89
+
90
+
91
+ def _jwt_claims_no_verify(id_token: str) -> Dict[str, Any]:
92
+ """解析 JWT ID Token(不验证签名)"""
93
+ if not id_token or id_token.count(".") < 2:
94
+ return {}
95
+ payload_b64 = id_token.split(".")[1]
96
+ pad = "=" * ((4 - (len(payload_b64) % 4)) % 4)
97
+ try:
98
+ payload = base64.urlsafe_b64decode((payload_b64 + pad).encode("ascii"))
99
+ return json.loads(payload.decode("utf-8"))
100
+ except Exception:
101
+ return {}
102
+
103
+
104
+ def _decode_jwt_segment(seg: str) -> Dict[str, Any]:
105
+ """解码 JWT 片段"""
106
+ raw = (seg or "").strip()
107
+ if not raw:
108
+ return {}
109
+ pad = "=" * ((4 - (len(raw) % 4)) % 4)
110
+ try:
111
+ decoded = base64.urlsafe_b64decode((raw + pad).encode("ascii"))
112
+ return json.loads(decoded.decode("utf-8"))
113
+ except Exception:
114
+ return {}
115
+
116
+
117
+ def _to_int(v: Any) -> int:
118
+ """转换为整数"""
119
+ try:
120
+ return int(v)
121
+ except (TypeError, ValueError):
122
+ return 0
123
+
124
+
125
+ def _post_form(
126
+ url: str,
127
+ data: Dict[str, str],
128
+ timeout: int = 30,
129
+ proxy_url: Optional[str] = None
130
+ ) -> Dict[str, Any]:
131
+ """
132
+ 发送 POST 表单请求
133
+
134
+ Args:
135
+ url: 请求 URL
136
+ data: 表单数据
137
+ timeout: 超时时间
138
+ proxy_url: 代理 URL
139
+
140
+ Returns:
141
+ 响应 JSON 数据
142
+ """
143
+ # 构建代理配置
144
+ proxies = None
145
+ if proxy_url:
146
+ proxies = {
147
+ "http": proxy_url,
148
+ "https": proxy_url,
149
+ }
150
+
151
+ headers = {
152
+ "Content-Type": "application/x-www-form-urlencoded",
153
+ "Accept": "application/json",
154
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
155
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
156
+ }
157
+
158
+ try:
159
+ # 使用 curl_cffi 发送请求,支持代理和浏览器指纹
160
+ response = cffi_requests.post(
161
+ url,
162
+ data=data,
163
+ headers=headers,
164
+ timeout=timeout,
165
+ proxies=proxies,
166
+ impersonate="chrome"
167
+ )
168
+
169
+ if response.status_code != 200:
170
+ raise RuntimeError(
171
+ f"token exchange failed: {response.status_code}: {response.text}"
172
+ )
173
+
174
+ return response.json()
175
+
176
+ except cffi_requests.RequestsError as e:
177
+ raise RuntimeError(f"token exchange failed: network error: {e}") from e
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class OAuthStart:
182
+ """OAuth 开始信息"""
183
+ auth_url: str
184
+ state: str
185
+ code_verifier: str
186
+ redirect_uri: str
187
+
188
+
189
+ def generate_oauth_url(
190
+ *,
191
+ redirect_uri: str = OAUTH_REDIRECT_URI,
192
+ scope: str = OAUTH_SCOPE,
193
+ client_id: str = OAUTH_CLIENT_ID
194
+ ) -> OAuthStart:
195
+ """
196
+ 生成 OAuth 授权 URL
197
+
198
+ Args:
199
+ redirect_uri: 回调地址
200
+ scope: 权限范围
201
+ client_id: OpenAI Client ID
202
+
203
+ Returns:
204
+ OAuthStart 对象,包含授权 URL 和必要参数
205
+ """
206
+ state = _random_state()
207
+ code_verifier = _pkce_verifier()
208
+ code_challenge = _sha256_b64url_no_pad(code_verifier)
209
+
210
+ params = {
211
+ "client_id": client_id,
212
+ "response_type": "code",
213
+ "redirect_uri": redirect_uri,
214
+ "scope": scope,
215
+ "state": state,
216
+ "code_challenge": code_challenge,
217
+ "code_challenge_method": "S256",
218
+ "prompt": "login",
219
+ "id_token_add_organizations": "true",
220
+ "codex_cli_simplified_flow": "true",
221
+ }
222
+ auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}"
223
+ return OAuthStart(
224
+ auth_url=auth_url,
225
+ state=state,
226
+ code_verifier=code_verifier,
227
+ redirect_uri=redirect_uri,
228
+ )
229
+
230
+
231
+ def submit_callback_url(
232
+ *,
233
+ callback_url: str,
234
+ expected_state: str,
235
+ code_verifier: str,
236
+ redirect_uri: str = OAUTH_REDIRECT_URI,
237
+ client_id: str = OAUTH_CLIENT_ID,
238
+ token_url: str = OAUTH_TOKEN_URL,
239
+ proxy_url: Optional[str] = None
240
+ ) -> str:
241
+ """
242
+ 处理 OAuth 回调 URL,获取访问令牌
243
+
244
+ Args:
245
+ callback_url: 回调 URL
246
+ expected_state: 预期的 state 值
247
+ code_verifier: PKCE code_verifier
248
+ redirect_uri: 回调地址
249
+ client_id: OpenAI Client ID
250
+ token_url: Token 交换地址
251
+ proxy_url: 代理 URL
252
+
253
+ Returns:
254
+ 包含访问令牌等信息的 JSON 字符串
255
+
256
+ Raises:
257
+ RuntimeError: OAuth 错误
258
+ ValueError: 缺少必要参数或 state 不匹配
259
+ """
260
+ cb = _parse_callback_url(callback_url)
261
+ if cb["error"]:
262
+ desc = cb["error_description"]
263
+ raise RuntimeError(f"oauth error: {cb['error']}: {desc}".strip())
264
+
265
+ if not cb["code"]:
266
+ raise ValueError("callback url missing ?code=")
267
+ if not cb["state"]:
268
+ raise ValueError("callback url missing ?state=")
269
+ if cb["state"] != expected_state:
270
+ raise ValueError("state mismatch")
271
+
272
+ token_resp = _post_form(
273
+ token_url,
274
+ {
275
+ "grant_type": "authorization_code",
276
+ "client_id": client_id,
277
+ "code": cb["code"],
278
+ "redirect_uri": redirect_uri,
279
+ "code_verifier": code_verifier,
280
+ },
281
+ proxy_url=proxy_url
282
+ )
283
+
284
+ access_token = (token_resp.get("access_token") or "").strip()
285
+ refresh_token = (token_resp.get("refresh_token") or "").strip()
286
+ id_token = (token_resp.get("id_token") or "").strip()
287
+ expires_in = _to_int(token_resp.get("expires_in"))
288
+
289
+ claims = _jwt_claims_no_verify(id_token)
290
+ email = str(claims.get("email") or "").strip()
291
+ auth_claims = claims.get("https://api.openai.com/auth") or {}
292
+ account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
293
+
294
+ now = int(time.time())
295
+ expired_rfc3339 = time.strftime(
296
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + max(expires_in, 0))
297
+ )
298
+ now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
299
+
300
+ config = {
301
+ "id_token": id_token,
302
+ "access_token": access_token,
303
+ "refresh_token": refresh_token,
304
+ "account_id": account_id,
305
+ "last_refresh": now_rfc3339,
306
+ "email": email,
307
+ "type": "codex",
308
+ "expired": expired_rfc3339,
309
+ }
310
+
311
+ return json.dumps(config, ensure_ascii=False, separators=(",", ":"))
312
+
313
+
314
+ class OAuthManager:
315
+ """OAuth 管理器"""
316
+
317
+ def __init__(
318
+ self,
319
+ client_id: str = OAUTH_CLIENT_ID,
320
+ auth_url: str = OAUTH_AUTH_URL,
321
+ token_url: str = OAUTH_TOKEN_URL,
322
+ redirect_uri: str = OAUTH_REDIRECT_URI,
323
+ scope: str = OAUTH_SCOPE,
324
+ proxy_url: Optional[str] = None
325
+ ):
326
+ self.client_id = client_id
327
+ self.auth_url = auth_url
328
+ self.token_url = token_url
329
+ self.redirect_uri = redirect_uri
330
+ self.scope = scope
331
+ self.proxy_url = proxy_url
332
+
333
+ def start_oauth(self) -> OAuthStart:
334
+ """开始 OAuth 流程"""
335
+ return generate_oauth_url(
336
+ redirect_uri=self.redirect_uri,
337
+ scope=self.scope,
338
+ client_id=self.client_id
339
+ )
340
+
341
+ def handle_callback(
342
+ self,
343
+ callback_url: str,
344
+ expected_state: str,
345
+ code_verifier: str
346
+ ) -> Dict[str, Any]:
347
+ """处理 OAuth 回调"""
348
+ result_json = submit_callback_url(
349
+ callback_url=callback_url,
350
+ expected_state=expected_state,
351
+ code_verifier=code_verifier,
352
+ redirect_uri=self.redirect_uri,
353
+ client_id=self.client_id,
354
+ token_url=self.token_url,
355
+ proxy_url=self.proxy_url
356
+ )
357
+ return json.loads(result_json)
358
+
359
+ def extract_account_info(self, id_token: str) -> Dict[str, Any]:
360
+ """从 ID Token 中提取账户信息"""
361
+ claims = _jwt_claims_no_verify(id_token)
362
+ email = str(claims.get("email") or "").strip()
363
+ auth_claims = claims.get("https://api.openai.com/auth") or {}
364
+ account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
365
+
366
+ return {
367
+ "email": email,
368
+ "account_id": account_id,
369
+ "claims": claims
370
+ }
src/core/openai/payment.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 支付核心逻辑 — 生成 Plus/Team 支付链接、无痕打开浏览器、检测订阅状态
3
+ """
4
+
5
+ import logging
6
+ import subprocess
7
+ import sys
8
+ from typing import Optional
9
+
10
+ from curl_cffi import requests as cffi_requests
11
+
12
+ from ...database.models import Account
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ PAYMENT_CHECKOUT_URL = "https://chatgpt.com/backend-api/payments/checkout"
17
+ TEAM_CHECKOUT_BASE_URL = "https://chatgpt.com/checkout/openai_llc/"
18
+
19
+
20
+ def _build_proxies(proxy: Optional[str]) -> Optional[dict]:
21
+ if proxy:
22
+ return {"http": proxy, "https": proxy}
23
+ return None
24
+
25
+
26
+ _COUNTRY_CURRENCY_MAP = {
27
+ "SG": "SGD",
28
+ "US": "USD",
29
+ "TR": "TRY",
30
+ "JP": "JPY",
31
+ "HK": "HKD",
32
+ "GB": "GBP",
33
+ "EU": "EUR",
34
+ "AU": "AUD",
35
+ "CA": "CAD",
36
+ "IN": "INR",
37
+ "BR": "BRL",
38
+ "MX": "MXN",
39
+ }
40
+
41
+
42
+ def _extract_oai_did(cookies_str: str) -> Optional[str]:
43
+ """从 cookie 字符串中提取 oai-device-id"""
44
+ for part in cookies_str.split(";"):
45
+ part = part.strip()
46
+ if part.startswith("oai-did="):
47
+ return part[len("oai-did="):].strip()
48
+ return None
49
+
50
+
51
+ def _parse_cookie_str(cookies_str: str, domain: str) -> list:
52
+ """将 'key=val; key2=val2' 格式解析为 Playwright cookie 列表"""
53
+ cookies = []
54
+ for part in cookies_str.split(";"):
55
+ part = part.strip()
56
+ if "=" not in part:
57
+ continue
58
+ name, _, value = part.partition("=")
59
+ cookies.append({
60
+ "name": name.strip(),
61
+ "value": value.strip(),
62
+ "domain": domain,
63
+ "path": "/",
64
+ })
65
+ return cookies
66
+
67
+
68
+ def _open_url_system_browser(url: str) -> bool:
69
+ """回退方案:调用系统浏览器以无痕模式打开"""
70
+ platform = sys.platform
71
+ try:
72
+ if platform == "win32":
73
+ for browser, flag in [("chrome", "--incognito"), ("msedge", "--inprivate")]:
74
+ try:
75
+ subprocess.Popen(f'start {browser} {flag} "{url}"', shell=True)
76
+ return True
77
+ except Exception:
78
+ continue
79
+ elif platform == "darwin":
80
+ subprocess.Popen(["open", "-a", "Google Chrome", "--args", "--incognito", url])
81
+ return True
82
+ else:
83
+ for binary in ["google-chrome", "chromium-browser", "chromium"]:
84
+ try:
85
+ subprocess.Popen([binary, "--incognito", url])
86
+ return True
87
+ except FileNotFoundError:
88
+ continue
89
+ except Exception as e:
90
+ logger.warning(f"系统浏览器无痕打开失败: {e}")
91
+ return False
92
+
93
+
94
+ def generate_plus_link(
95
+ account: Account,
96
+ proxy: Optional[str] = None,
97
+ country: str = "SG",
98
+ ) -> str:
99
+ """生成 Plus 支付链接(后端携带账号 cookie 发请求)"""
100
+ if not account.access_token:
101
+ raise ValueError("账号缺少 access_token")
102
+
103
+ currency = _COUNTRY_CURRENCY_MAP.get(country, "USD")
104
+ headers = {
105
+ "Authorization": f"Bearer {account.access_token}",
106
+ "Content-Type": "application/json",
107
+ "oai-language": "zh-CN",
108
+ }
109
+ if account.cookies:
110
+ headers["cookie"] = account.cookies
111
+ oai_did = _extract_oai_did(account.cookies)
112
+ if oai_did:
113
+ headers["oai-device-id"] = oai_did
114
+
115
+ payload = {
116
+ "plan_name": "chatgptplusplan",
117
+ "billing_details": {"country": country, "currency": currency},
118
+ "promo_campaign": {
119
+ "promo_campaign_id": "plus-1-month-free",
120
+ "is_coupon_from_query_param": False,
121
+ },
122
+ "checkout_ui_mode": "custom",
123
+ }
124
+
125
+ resp = cffi_requests.post(
126
+ PAYMENT_CHECKOUT_URL,
127
+ headers=headers,
128
+ json=payload,
129
+ proxies=_build_proxies(proxy),
130
+ timeout=30,
131
+ impersonate="chrome110",
132
+ )
133
+ resp.raise_for_status()
134
+ data = resp.json()
135
+ if "checkout_session_id" in data:
136
+ return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"]
137
+ raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
138
+
139
+
140
+ def generate_team_link(
141
+ account: Account,
142
+ workspace_name: str = "MyTeam",
143
+ price_interval: str = "month",
144
+ seat_quantity: int = 5,
145
+ proxy: Optional[str] = None,
146
+ country: str = "SG",
147
+ ) -> str:
148
+ """生成 Team 支付链接(后端携带账号 cookie 发请求)"""
149
+ if not account.access_token:
150
+ raise ValueError("账号缺少 access_token")
151
+
152
+ currency = _COUNTRY_CURRENCY_MAP.get(country, "USD")
153
+ headers = {
154
+ "Authorization": f"Bearer {account.access_token}",
155
+ "Content-Type": "application/json",
156
+ "oai-language": "zh-CN",
157
+ }
158
+ if account.cookies:
159
+ headers["cookie"] = account.cookies
160
+ oai_did = _extract_oai_did(account.cookies)
161
+ if oai_did:
162
+ headers["oai-device-id"] = oai_did
163
+
164
+ payload = {
165
+ "plan_name": "chatgptteamplan",
166
+ "team_plan_data": {
167
+ "workspace_name": workspace_name,
168
+ "price_interval": price_interval,
169
+ "seat_quantity": seat_quantity,
170
+ },
171
+ "billing_details": {"country": country, "currency": currency},
172
+ "promo_campaign": {
173
+ "promo_campaign_id": "team-1-month-free",
174
+ "is_coupon_from_query_param": True,
175
+ },
176
+ "cancel_url": "https://chatgpt.com/#pricing",
177
+ "checkout_ui_mode": "custom",
178
+ }
179
+
180
+ resp = cffi_requests.post(
181
+ PAYMENT_CHECKOUT_URL,
182
+ headers=headers,
183
+ json=payload,
184
+ proxies=_build_proxies(proxy),
185
+ timeout=30,
186
+ impersonate="chrome110",
187
+ )
188
+ resp.raise_for_status()
189
+ data = resp.json()
190
+ if "checkout_session_id" in data:
191
+ return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"]
192
+ raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
193
+
194
+
195
+ def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool:
196
+ """用 Playwright 以无痕模式打开 URL,可注入 cookie"""
197
+ import threading
198
+ try:
199
+ from playwright.sync_api import sync_playwright
200
+ except ImportError:
201
+ logger.warning("playwright 未安装,回退到系统浏览器")
202
+ return _open_url_system_browser(url)
203
+
204
+ def _launch():
205
+ try:
206
+ with sync_playwright() as p:
207
+ browser = p.chromium.launch(headless=False, args=["--incognito"])
208
+ ctx = browser.new_context()
209
+ if cookies_str:
210
+ ctx.add_cookies(_parse_cookie_str(cookies_str, "chatgpt.com"))
211
+ page = ctx.new_page()
212
+ page.goto(url)
213
+ # 保持窗口打开直到用户关闭
214
+ page.wait_for_timeout(300_000) # 最多等待 5 分钟
215
+ except Exception as e:
216
+ logger.warning(f"Playwright 无痕打开失败: {e}")
217
+
218
+ threading.Thread(target=_launch, daemon=True).start()
219
+ return True
220
+
221
+
222
+ def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str:
223
+ """
224
+ 检测账号当前订阅状态。
225
+
226
+ Returns:
227
+ 'free' / 'plus' / 'team'
228
+ """
229
+ if not account.access_token:
230
+ raise ValueError("账号缺少 access_token")
231
+
232
+ headers = {
233
+ "Authorization": f"Bearer {account.access_token}",
234
+ "Content-Type": "application/json",
235
+ }
236
+
237
+ resp = cffi_requests.get(
238
+ "https://chatgpt.com/backend-api/me",
239
+ headers=headers,
240
+ proxies=_build_proxies(proxy),
241
+ timeout=20,
242
+ impersonate="chrome110",
243
+ )
244
+ resp.raise_for_status()
245
+ data = resp.json()
246
+
247
+ # 解析订阅类型
248
+ plan = data.get("plan_type") or ""
249
+ if "team" in plan.lower():
250
+ return "team"
251
+ if "plus" in plan.lower():
252
+ return "plus"
253
+
254
+ # 尝试从 orgs 或 workspace 信息判断
255
+ orgs = data.get("orgs", {}).get("data", [])
256
+ for org in orgs:
257
+ settings_ = org.get("settings", {})
258
+ if settings_.get("workspace_plan_type") in ("team", "enterprise"):
259
+ return "team"
260
+
261
+ return "free"
src/core/openai/sentinel.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers for OpenAI Sentinel proof-of-work tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import random
9
+ import time
10
+ import uuid
11
+ from datetime import datetime, timedelta, timezone
12
+ from typing import Sequence
13
+
14
+
15
+ DEFAULT_SENTINEL_DIFF = "0fffff"
16
+ DEFAULT_MAX_ITERATIONS = 500_000
17
+ _SCREEN_SIGNATURES = (3000, 3120, 4000, 4160)
18
+ _LANGUAGE_SIGNATURE = "en-US,es-US,en,es"
19
+ _NAVIGATOR_KEYS = ("location", "ontransitionend", "onprogress")
20
+ _WINDOW_KEYS = ("window", "document", "navigator")
21
+
22
+
23
+ class SentinelPOWError(RuntimeError):
24
+ """Raised when a Sentinel proof-of-work token cannot be solved."""
25
+
26
+
27
+ def _format_browser_time() -> str:
28
+ """Match the browser-style timestamp used by public Sentinel solvers."""
29
+ browser_now = datetime.now(timezone(timedelta(hours=-5)))
30
+ return browser_now.strftime("%a %b %d %Y %H:%M:%S") + " GMT-0500 (Eastern Standard Time)"
31
+
32
+
33
+ def build_sentinel_config(user_agent: str) -> list:
34
+ """Build a browser-like fingerprint payload for the Sentinel PoW solver."""
35
+ perf_ms = time.perf_counter() * 1000
36
+ epoch_ms = (time.time() * 1000) - perf_ms
37
+ return [
38
+ random.choice(_SCREEN_SIGNATURES),
39
+ _format_browser_time(),
40
+ 4294705152,
41
+ 0,
42
+ user_agent,
43
+ "",
44
+ "",
45
+ "en-US",
46
+ _LANGUAGE_SIGNATURE,
47
+ 0,
48
+ random.choice(_NAVIGATOR_KEYS),
49
+ "location",
50
+ random.choice(_WINDOW_KEYS),
51
+ perf_ms,
52
+ str(uuid.uuid4()),
53
+ "",
54
+ 8,
55
+ epoch_ms,
56
+ ]
57
+
58
+
59
+ def _encode_pow_payload(config: Sequence[object], nonce: int) -> bytes:
60
+ prefix = (json.dumps(config[:3], separators=(",", ":"), ensure_ascii=False)[:-1] + ",").encode("utf-8")
61
+ middle = (
62
+ "," + json.dumps(config[4:9], separators=(",", ":"), ensure_ascii=False)[1:-1] + ","
63
+ ).encode("utf-8")
64
+ suffix = ("," + json.dumps(config[10:], separators=(",", ":"), ensure_ascii=False)[1:]).encode("utf-8")
65
+ body = prefix + str(nonce).encode("ascii") + middle + str(nonce >> 1).encode("ascii") + suffix
66
+ return base64.b64encode(body)
67
+
68
+
69
+ def solve_sentinel_pow(
70
+ seed: str,
71
+ difficulty: str,
72
+ config: Sequence[object],
73
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
74
+ ) -> str:
75
+ """Solve the Sentinel PoW challenge and return the base64 payload."""
76
+ seed_bytes = seed.encode("utf-8")
77
+ target = bytes.fromhex(difficulty)
78
+ prefix_length = len(target)
79
+
80
+ for nonce in range(max_iterations):
81
+ encoded = _encode_pow_payload(config, nonce)
82
+ digest = hashlib.sha3_512(seed_bytes + encoded).digest()
83
+ if digest[:prefix_length] <= target:
84
+ return encoded.decode("ascii")
85
+
86
+ raise SentinelPOWError(f"failed to solve sentinel pow after {max_iterations} attempts")
87
+
88
+
89
+ def build_sentinel_pow_token(
90
+ user_agent: str,
91
+ difficulty: str = DEFAULT_SENTINEL_DIFF,
92
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
93
+ ) -> str:
94
+ """Build the `p` token required by the Sentinel request endpoint."""
95
+ config = build_sentinel_config(user_agent)
96
+ seed = format(random.random())
97
+ solution = solve_sentinel_pow(seed, difficulty, config, max_iterations=max_iterations)
98
+ return f"gAAAAAC{solution}"
src/core/openai/token_refresh.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Token 刷新模块
3
+ 支持 Session Token 和 OAuth Refresh Token 两种刷新方式
4
+ """
5
+
6
+ import logging
7
+ import json
8
+ import time
9
+ from typing import Optional, Dict, Any, Tuple
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timedelta
12
+
13
+ from curl_cffi import requests as cffi_requests
14
+
15
+ from ...config.settings import get_settings
16
+ from ...database.session import get_db
17
+ from ...database import crud
18
+ from ...database.models import Account
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class TokenRefreshResult:
25
+ """Token 刷新结果"""
26
+ success: bool
27
+ access_token: str = ""
28
+ refresh_token: str = ""
29
+ expires_at: Optional[datetime] = None
30
+ error_message: str = ""
31
+
32
+
33
+ class TokenRefreshManager:
34
+ """
35
+ Token 刷新管理器
36
+ 支持两种刷新方式:
37
+ 1. Session Token 刷新(优先)
38
+ 2. OAuth Refresh Token 刷新
39
+ """
40
+
41
+ # OpenAI OAuth 端点
42
+ SESSION_URL = "https://chatgpt.com/api/auth/session"
43
+ TOKEN_URL = "https://auth.openai.com/oauth/token"
44
+
45
+ def __init__(self, proxy_url: Optional[str] = None):
46
+ """
47
+ 初始化 Token 刷新管理器
48
+
49
+ Args:
50
+ proxy_url: 代理 URL
51
+ """
52
+ self.proxy_url = proxy_url
53
+ self.settings = get_settings()
54
+
55
+ def _create_session(self) -> cffi_requests.Session:
56
+ """创建 HTTP 会话"""
57
+ session = cffi_requests.Session(impersonate="chrome120", proxy=self.proxy_url)
58
+ return session
59
+
60
+ def refresh_by_session_token(self, session_token: str) -> TokenRefreshResult:
61
+ """
62
+ 使用 Session Token 刷新
63
+
64
+ Args:
65
+ session_token: 会话令牌
66
+
67
+ Returns:
68
+ TokenRefreshResult: 刷新结果
69
+ """
70
+ result = TokenRefreshResult(success=False)
71
+
72
+ try:
73
+ session = self._create_session()
74
+
75
+ # 设置会话 Cookie
76
+ session.cookies.set(
77
+ "__Secure-next-auth.session-token",
78
+ session_token,
79
+ domain=".chatgpt.com",
80
+ path="/"
81
+ )
82
+
83
+ # 请求会话端点
84
+ response = session.get(
85
+ self.SESSION_URL,
86
+ headers={
87
+ "accept": "application/json",
88
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
89
+ },
90
+ timeout=30
91
+ )
92
+
93
+ if response.status_code != 200:
94
+ result.error_message = f"Session token 刷新失败: HTTP {response.status_code}"
95
+ logger.warning(result.error_message)
96
+ return result
97
+
98
+ data = response.json()
99
+
100
+ # 提取 access_token
101
+ access_token = data.get("accessToken")
102
+ if not access_token:
103
+ result.error_message = "Session token 刷新失败: 未找到 accessToken"
104
+ logger.warning(result.error_message)
105
+ return result
106
+
107
+ # 提取过期时间
108
+ expires_at = None
109
+ expires_str = data.get("expires")
110
+ if expires_str:
111
+ try:
112
+ expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
113
+ except:
114
+ pass
115
+
116
+ result.success = True
117
+ result.access_token = access_token
118
+ result.expires_at = expires_at
119
+
120
+ logger.info(f"Session token 刷新成功,过期时间: {expires_at}")
121
+ return result
122
+
123
+ except Exception as e:
124
+ result.error_message = f"Session token 刷新异常: {str(e)}"
125
+ logger.error(result.error_message)
126
+ return result
127
+
128
+ def refresh_by_oauth_token(
129
+ self,
130
+ refresh_token: str,
131
+ client_id: Optional[str] = None
132
+ ) -> TokenRefreshResult:
133
+ """
134
+ 使用 OAuth Refresh Token 刷新
135
+
136
+ Args:
137
+ refresh_token: OAuth 刷新令牌
138
+ client_id: OAuth Client ID
139
+
140
+ Returns:
141
+ TokenRefreshResult: 刷新结果
142
+ """
143
+ result = TokenRefreshResult(success=False)
144
+
145
+ try:
146
+ session = self._create_session()
147
+
148
+ # 使用配置的 client_id 或默认值
149
+ client_id = client_id or self.settings.openai_client_id
150
+
151
+ # 构建请求体
152
+ token_data = {
153
+ "client_id": client_id,
154
+ "grant_type": "refresh_token",
155
+ "refresh_token": refresh_token,
156
+ "redirect_uri": self.settings.openai_redirect_uri
157
+ }
158
+
159
+ response = session.post(
160
+ self.TOKEN_URL,
161
+ headers={
162
+ "content-type": "application/x-www-form-urlencoded",
163
+ "accept": "application/json"
164
+ },
165
+ data=token_data,
166
+ timeout=30
167
+ )
168
+
169
+ if response.status_code != 200:
170
+ result.error_message = f"OAuth token 刷新失败: HTTP {response.status_code}"
171
+ logger.warning(f"{result.error_message}, 响应: {response.text[:200]}")
172
+ return result
173
+
174
+ data = response.json()
175
+
176
+ # 提取令牌
177
+ access_token = data.get("access_token")
178
+ new_refresh_token = data.get("refresh_token", refresh_token)
179
+ expires_in = data.get("expires_in", 3600)
180
+
181
+ if not access_token:
182
+ result.error_message = "OAuth token 刷新失败: 未找到 access_token"
183
+ logger.warning(result.error_message)
184
+ return result
185
+
186
+ # 计算过期时间
187
+ expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
188
+
189
+ result.success = True
190
+ result.access_token = access_token
191
+ result.refresh_token = new_refresh_token
192
+ result.expires_at = expires_at
193
+
194
+ logger.info(f"OAuth token 刷新成功,过期时间: {expires_at}")
195
+ return result
196
+
197
+ except Exception as e:
198
+ result.error_message = f"OAuth token 刷新异常: {str(e)}"
199
+ logger.error(result.error_message)
200
+ return result
201
+
202
+ def refresh_account(self, account: Account) -> TokenRefreshResult:
203
+ """
204
+ 刷新账号的 Token
205
+
206
+ 优先级:
207
+ 1. Session Token 刷新
208
+ 2. OAuth Refresh Token 刷新
209
+
210
+ Args:
211
+ account: 账号对象
212
+
213
+ Returns:
214
+ TokenRefreshResult: 刷新结果
215
+ """
216
+ # 优先尝试 Session Token
217
+ if account.session_token:
218
+ logger.info(f"尝试使用 Session Token 刷新账号 {account.email}")
219
+ result = self.refresh_by_session_token(account.session_token)
220
+ if result.success:
221
+ return result
222
+ logger.warning(f"Session Token 刷新失败,尝试 OAuth 刷新")
223
+
224
+ # 尝试 OAuth Refresh Token
225
+ if account.refresh_token:
226
+ logger.info(f"尝试使用 OAuth Refresh Token 刷新账号 {account.email}")
227
+ result = self.refresh_by_oauth_token(
228
+ refresh_token=account.refresh_token,
229
+ client_id=account.client_id
230
+ )
231
+ return result
232
+
233
+ # 无可用刷新方式
234
+ return TokenRefreshResult(
235
+ success=False,
236
+ error_message="账号没有可用的刷新方式(缺少 session_token 和 refresh_token)"
237
+ )
238
+
239
+ def validate_token(self, access_token: str) -> Tuple[bool, Optional[str]]:
240
+ """
241
+ 验证 Access Token 是否有效
242
+
243
+ Args:
244
+ access_token: 访问令牌
245
+
246
+ Returns:
247
+ Tuple[bool, Optional[str]]: (是否有效, 错误信息)
248
+ """
249
+ try:
250
+ session = self._create_session()
251
+
252
+ # 调用 OpenAI API 验证 token
253
+ response = session.get(
254
+ "https://chatgpt.com/backend-api/me",
255
+ headers={
256
+ "authorization": f"Bearer {access_token}",
257
+ "accept": "application/json"
258
+ },
259
+ timeout=30
260
+ )
261
+
262
+ if response.status_code == 200:
263
+ return True, None
264
+ elif response.status_code == 401:
265
+ return False, "Token 无效或已过期"
266
+ elif response.status_code == 403:
267
+ return False, "账号可能被封禁"
268
+ else:
269
+ return False, f"验证失败: HTTP {response.status_code}"
270
+
271
+ except Exception as e:
272
+ return False, f"验证异常: {str(e)}"
273
+
274
+
275
+ def refresh_account_token(account_id: int, proxy_url: Optional[str] = None) -> TokenRefreshResult:
276
+ """
277
+ 刷新指定账号的 Token 并更新数据库
278
+
279
+ Args:
280
+ account_id: 账号 ID
281
+ proxy_url: 代理 URL
282
+
283
+ Returns:
284
+ TokenRefreshResult: 刷新结果
285
+ """
286
+ with get_db() as db:
287
+ account = crud.get_account_by_id(db, account_id)
288
+ if not account:
289
+ return TokenRefreshResult(success=False, error_message="账号不存在")
290
+
291
+ manager = TokenRefreshManager(proxy_url=proxy_url)
292
+ result = manager.refresh_account(account)
293
+
294
+ if result.success:
295
+ # 更新数据库
296
+ update_data = {
297
+ "access_token": result.access_token,
298
+ "last_refresh": datetime.utcnow()
299
+ }
300
+
301
+ if result.refresh_token:
302
+ update_data["refresh_token"] = result.refresh_token
303
+
304
+ if result.expires_at:
305
+ update_data["expires_at"] = result.expires_at
306
+
307
+ crud.update_account(db, account_id, **update_data)
308
+
309
+ return result
310
+
311
+
312
+ def validate_account_token(account_id: int, proxy_url: Optional[str] = None) -> Tuple[bool, Optional[str]]:
313
+ """
314
+ 验证指定账号的 Token 是否有效
315
+
316
+ Args:
317
+ account_id: 账号 ID
318
+ proxy_url: ���理 URL
319
+
320
+ Returns:
321
+ Tuple[bool, Optional[str]]: (是否有效, 错误信息)
322
+ """
323
+ with get_db() as db:
324
+ account = crud.get_account_by_id(db, account_id)
325
+ if not account:
326
+ return False, "账号不存在"
327
+
328
+ if not account.access_token:
329
+ return False, "账号没有 access_token"
330
+
331
+ manager = TokenRefreshManager(proxy_url=proxy_url)
332
+ return manager.validate_token(account.access_token)
src/core/register.py ADDED
@@ -0,0 +1,1009 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 注册流程引擎
3
+ 从 main.py 中提取并重构的注册流程
4
+ """
5
+
6
+ import re
7
+ import json
8
+ import time
9
+ import logging
10
+ import secrets
11
+ import string
12
+ from typing import Optional, Dict, Any, Tuple, Callable
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+
16
+ from curl_cffi import requests as cffi_requests
17
+
18
+ from .openai.oauth import OAuthManager, OAuthStart
19
+ from .http_client import OpenAIHTTPClient, HTTPClientError
20
+ from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType
21
+ from ..database import crud
22
+ from ..database.session import get_db
23
+ from ..config.constants import (
24
+ OPENAI_API_ENDPOINTS,
25
+ OPENAI_PAGE_TYPES,
26
+ generate_random_user_info,
27
+ OTP_CODE_PATTERN,
28
+ DEFAULT_PASSWORD_LENGTH,
29
+ PASSWORD_CHARSET,
30
+ AccountStatus,
31
+ TaskStatus,
32
+ )
33
+ from ..config.settings import get_settings
34
+
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ @dataclass
40
+ class RegistrationResult:
41
+ """注册结果"""
42
+ success: bool
43
+ email: str = ""
44
+ password: str = "" # 注册密码
45
+ account_id: str = ""
46
+ workspace_id: str = ""
47
+ access_token: str = ""
48
+ refresh_token: str = ""
49
+ id_token: str = ""
50
+ session_token: str = "" # 会话令牌
51
+ error_message: str = ""
52
+ logs: list = None
53
+ metadata: dict = None
54
+ source: str = "register" # 'register' 或 'login',区分账号来源
55
+
56
+ def to_dict(self) -> Dict[str, Any]:
57
+ """转换为字典"""
58
+ return {
59
+ "success": self.success,
60
+ "email": self.email,
61
+ "password": self.password,
62
+ "account_id": self.account_id,
63
+ "workspace_id": self.workspace_id,
64
+ "access_token": self.access_token[:20] + "..." if self.access_token else "",
65
+ "refresh_token": self.refresh_token[:20] + "..." if self.refresh_token else "",
66
+ "id_token": self.id_token[:20] + "..." if self.id_token else "",
67
+ "session_token": self.session_token[:20] + "..." if self.session_token else "",
68
+ "error_message": self.error_message,
69
+ "logs": self.logs or [],
70
+ "metadata": self.metadata or {},
71
+ "source": self.source,
72
+ }
73
+
74
+
75
+ @dataclass
76
+ class SignupFormResult:
77
+ """提交注册表单的结果"""
78
+ success: bool
79
+ page_type: str = "" # 响应中的 page.type 字段
80
+ is_existing_account: bool = False # 是否为已注册账号
81
+ response_data: Dict[str, Any] = None # 完整的响应数据
82
+ error_message: str = ""
83
+
84
+
85
+ class RegistrationEngine:
86
+ """
87
+ 注册引擎
88
+ 负责协调邮箱服务、OAuth 流程和 OpenAI API 调用
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ email_service: BaseEmailService,
94
+ proxy_url: Optional[str] = None,
95
+ callback_logger: Optional[Callable[[str], None]] = None,
96
+ task_uuid: Optional[str] = None
97
+ ):
98
+ """
99
+ 初始化注册引擎
100
+
101
+ Args:
102
+ email_service: 邮箱服务实例
103
+ proxy_url: 代理 URL
104
+ callback_logger: 日志回调函数
105
+ task_uuid: 任务 UUID(用于数据库记录)
106
+ """
107
+ self.email_service = email_service
108
+ self.proxy_url = proxy_url
109
+ self.callback_logger = callback_logger or (lambda msg: logger.info(msg))
110
+ self.task_uuid = task_uuid
111
+
112
+ # 创建 HTTP 客户端
113
+ self.http_client = OpenAIHTTPClient(proxy_url=proxy_url)
114
+
115
+ # 创建 OAuth 管理器
116
+ settings = get_settings()
117
+ self.oauth_manager = OAuthManager(
118
+ client_id=settings.openai_client_id,
119
+ auth_url=settings.openai_auth_url,
120
+ token_url=settings.openai_token_url,
121
+ redirect_uri=settings.openai_redirect_uri,
122
+ scope=settings.openai_scope,
123
+ proxy_url=proxy_url # 传递代理配置
124
+ )
125
+
126
+ # 状态变量
127
+ self.email: Optional[str] = None
128
+ self.password: Optional[str] = None # 注册密码
129
+ self.email_info: Optional[Dict[str, Any]] = None
130
+ self.oauth_start: Optional[OAuthStart] = None
131
+ self.session: Optional[cffi_requests.Session] = None
132
+ self.session_token: Optional[str] = None # 会话令牌
133
+ self.logs: list = []
134
+ self._otp_sent_at: Optional[float] = None # OTP 发送时间戳
135
+ self._is_existing_account: bool = False # 是否为已注册账号(用于自动登录)
136
+ self._token_acquisition_requires_login: bool = False # 新注册账号需要二次登录拿 token
137
+
138
+ def _log(self, message: str, level: str = "info"):
139
+ """记录日志"""
140
+ timestamp = datetime.now().strftime("%H:%M:%S")
141
+ log_message = f"[{timestamp}] {message}"
142
+
143
+ # 添加到日志列表
144
+ self.logs.append(log_message)
145
+
146
+ # 调用回调函数
147
+ if self.callback_logger:
148
+ self.callback_logger(log_message)
149
+
150
+ # 记录到数据库(如果有关联任务)
151
+ if self.task_uuid:
152
+ try:
153
+ with get_db() as db:
154
+ crud.append_task_log(db, self.task_uuid, log_message)
155
+ except Exception as e:
156
+ logger.warning(f"记录任务日志失败: {e}")
157
+
158
+ # 根据级别记录到日志系统
159
+ if level == "error":
160
+ logger.error(message)
161
+ elif level == "warning":
162
+ logger.warning(message)
163
+ else:
164
+ logger.info(message)
165
+
166
+ def _generate_password(self, length: int = DEFAULT_PASSWORD_LENGTH) -> str:
167
+ """生成随机密码"""
168
+ return ''.join(secrets.choice(PASSWORD_CHARSET) for _ in range(length))
169
+
170
+ def _check_ip_location(self) -> Tuple[bool, Optional[str]]:
171
+ """检查 IP 地理位置"""
172
+ try:
173
+ return self.http_client.check_ip_location()
174
+ except Exception as e:
175
+ self._log(f"检查 IP 地理位置失败: {e}", "error")
176
+ return False, None
177
+
178
+ def _create_email(self) -> bool:
179
+ """创建邮箱"""
180
+ try:
181
+ self._log(f"正在创建 {self.email_service.service_type.value} 邮箱,先给新账号整个收件箱...")
182
+ self.email_info = self.email_service.create_email()
183
+
184
+ if not self.email_info or "email" not in self.email_info:
185
+ self._log("创建邮箱失败: 返回信息不完整", "error")
186
+ return False
187
+
188
+ self.email = self.email_info["email"]
189
+ self._log(f"邮箱已就位,地址新鲜出炉: {self.email}")
190
+ return True
191
+
192
+ except Exception as e:
193
+ self._log(f"创建邮箱失败: {e}", "error")
194
+ return False
195
+
196
+ def _start_oauth(self) -> bool:
197
+ """开始 OAuth 流程"""
198
+ try:
199
+ self._log("开始 OAuth 授权流程,去门口刷个脸...")
200
+ self.oauth_start = self.oauth_manager.start_oauth()
201
+ self._log(f"OAuth URL 已备好,通道已经打开: {self.oauth_start.auth_url[:80]}...")
202
+ return True
203
+ except Exception as e:
204
+ self._log(f"生成 OAuth URL 失败: {e}", "error")
205
+ return False
206
+
207
+ def _init_session(self) -> bool:
208
+ """初始化会话"""
209
+ try:
210
+ self.session = self.http_client.session
211
+ return True
212
+ except Exception as e:
213
+ self._log(f"初始化会话失败: {e}", "error")
214
+ return False
215
+
216
+ def _get_device_id(self) -> Optional[str]:
217
+ """获取 Device ID"""
218
+ if not self.oauth_start:
219
+ return None
220
+
221
+ max_attempts = 3
222
+ for attempt in range(1, max_attempts + 1):
223
+ try:
224
+ if not self.session:
225
+ self.session = self.http_client.session
226
+
227
+ response = self.session.get(
228
+ self.oauth_start.auth_url,
229
+ timeout=20
230
+ )
231
+ did = self.session.cookies.get("oai-did")
232
+
233
+ if did:
234
+ self._log(f"Device ID: {did}")
235
+ return did
236
+
237
+ self._log(
238
+ f"获取 Device ID 失败: 未返回 oai-did Cookie (HTTP {response.status_code}, 第 {attempt}/{max_attempts} 次)",
239
+ "warning" if attempt < max_attempts else "error"
240
+ )
241
+ except Exception as e:
242
+ self._log(
243
+ f"获取 Device ID 失败: {e} (第 {attempt}/{max_attempts} 次)",
244
+ "warning" if attempt < max_attempts else "error"
245
+ )
246
+
247
+ if attempt < max_attempts:
248
+ time.sleep(attempt)
249
+ self.http_client.close()
250
+ self.session = self.http_client.session
251
+
252
+ return None
253
+
254
+ def _check_sentinel(self, did: str) -> Optional[str]:
255
+ """检查 Sentinel 拦截"""
256
+ try:
257
+ sen_token = self.http_client.check_sentinel(did)
258
+ if sen_token:
259
+ self._log(f"Sentinel token 获取成功")
260
+ return sen_token
261
+ self._log("Sentinel 检查失败: 未获取到 token", "warning")
262
+ return None
263
+
264
+ except Exception as e:
265
+ self._log(f"Sentinel 检查异常: {e}", "warning")
266
+ return None
267
+
268
+ def _submit_auth_start(
269
+ self,
270
+ did: str,
271
+ sen_token: Optional[str],
272
+ *,
273
+ screen_hint: str,
274
+ referer: str,
275
+ log_label: str,
276
+ record_existing_account: bool = True,
277
+ ) -> SignupFormResult:
278
+ """
279
+ 提交授权入口表单
280
+
281
+ Returns:
282
+ SignupFormResult: 提交结果,包含账号状态判断
283
+ """
284
+ try:
285
+ request_body = json.dumps({
286
+ "username": {
287
+ "value": self.email,
288
+ "kind": "email",
289
+ },
290
+ "screen_hint": screen_hint,
291
+ })
292
+
293
+ headers = {
294
+ "referer": referer,
295
+ "accept": "application/json",
296
+ "content-type": "application/json",
297
+ }
298
+
299
+ if sen_token:
300
+ sentinel = json.dumps({
301
+ "p": "",
302
+ "t": "",
303
+ "c": sen_token,
304
+ "id": did,
305
+ "flow": "authorize_continue",
306
+ })
307
+ headers["openai-sentinel-token"] = sentinel
308
+
309
+ response = self.session.post(
310
+ OPENAI_API_ENDPOINTS["signup"],
311
+ headers=headers,
312
+ data=request_body,
313
+ )
314
+
315
+ self._log(f"{log_label}状态: {response.status_code}")
316
+
317
+ if response.status_code != 200:
318
+ return SignupFormResult(
319
+ success=False,
320
+ error_message=f"HTTP {response.status_code}: {response.text[:200]}"
321
+ )
322
+
323
+ # 解析响应判断账号状态
324
+ try:
325
+ response_data = response.json()
326
+ page_type = response_data.get("page", {}).get("type", "")
327
+ self._log(f"响应页面类型: {page_type}")
328
+
329
+ is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]
330
+
331
+ if is_existing:
332
+ self._otp_sent_at = time.time()
333
+ if record_existing_account:
334
+ self._log(f"检测到已注册账号,将自动切换到登录流程")
335
+ self._is_existing_account = True
336
+ else:
337
+ self._log("登录流程已触发,等待系统自动发送的验证码")
338
+
339
+ return SignupFormResult(
340
+ success=True,
341
+ page_type=page_type,
342
+ is_existing_account=is_existing,
343
+ response_data=response_data
344
+ )
345
+
346
+ except Exception as parse_error:
347
+ self._log(f"解析响应失败: {parse_error}", "warning")
348
+ # 无法解析,默认成功
349
+ return SignupFormResult(success=True)
350
+
351
+ except Exception as e:
352
+ self._log(f"{log_label}失败: {e}", "error")
353
+ return SignupFormResult(success=False, error_message=str(e))
354
+
355
+ def _submit_signup_form(
356
+ self,
357
+ did: str,
358
+ sen_token: Optional[str],
359
+ *,
360
+ record_existing_account: bool = True,
361
+ ) -> SignupFormResult:
362
+ """提交注册入口表单。"""
363
+ return self._submit_auth_start(
364
+ did,
365
+ sen_token,
366
+ screen_hint="signup",
367
+ referer="https://auth.openai.com/create-account",
368
+ log_label="提交注册表单",
369
+ record_existing_account=record_existing_account,
370
+ )
371
+
372
+ def _submit_login_start(self, did: str, sen_token: Optional[str]) -> SignupFormResult:
373
+ """提交登录入口表单。"""
374
+ return self._submit_auth_start(
375
+ did,
376
+ sen_token,
377
+ screen_hint="login",
378
+ referer="https://auth.openai.com/log-in",
379
+ log_label="提交登录入口",
380
+ record_existing_account=False,
381
+ )
382
+
383
+ def _submit_login_password(self) -> SignupFormResult:
384
+ """提交登录密码,进入邮箱验证码页面。"""
385
+ try:
386
+ response = self.session.post(
387
+ OPENAI_API_ENDPOINTS["password_verify"],
388
+ headers={
389
+ "referer": "https://auth.openai.com/log-in/password",
390
+ "accept": "application/json",
391
+ "content-type": "application/json",
392
+ },
393
+ data=json.dumps({"password": self.password}),
394
+ )
395
+
396
+ self._log(f"提交登录密码状态: {response.status_code}")
397
+
398
+ if response.status_code != 200:
399
+ return SignupFormResult(
400
+ success=False,
401
+ error_message=f"HTTP {response.status_code}: {response.text[:200]}"
402
+ )
403
+
404
+ response_data = response.json()
405
+ page_type = response_data.get("page", {}).get("type", "")
406
+ self._log(f"登录密码响应页面类型: {page_type}")
407
+
408
+ is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]
409
+ if is_existing:
410
+ self._otp_sent_at = time.time()
411
+ self._log("登录密码校验通过,等待系统自动发送的验证码")
412
+
413
+ return SignupFormResult(
414
+ success=True,
415
+ page_type=page_type,
416
+ is_existing_account=is_existing,
417
+ response_data=response_data,
418
+ )
419
+
420
+ except Exception as e:
421
+ self._log(f"提交登录密码失败: {e}", "error")
422
+ return SignupFormResult(success=False, error_message=str(e))
423
+
424
+ def _reset_auth_flow(self) -> None:
425
+ """重置会话,准备重新发起 OAuth 流程。"""
426
+ self.http_client.close()
427
+ self.session = None
428
+ self.oauth_start = None
429
+ self.session_token = None
430
+ self._otp_sent_at = None
431
+
432
+ def _prepare_authorize_flow(self, label: str) -> Tuple[Optional[str], Optional[str]]:
433
+ """初始化当前阶段的授权流程,返回 device id 和 sentinel token。"""
434
+ self._log(f"{label}: 先把会话热热身...")
435
+ if not self._init_session():
436
+ return None, None
437
+
438
+ self._log(f"{label}: OAuth 流程准备开跑,系好鞋带...")
439
+ if not self._start_oauth():
440
+ return None, None
441
+
442
+ self._log(f"{label}: 领取 Device ID 通行证...")
443
+ did = self._get_device_id()
444
+ if not did:
445
+ return None, None
446
+
447
+ self._log(f"{label}: 解一道 Sentinel POW 小题,答对才给进...")
448
+ sen_token = self._check_sentinel(did)
449
+ if not sen_token:
450
+ return did, None
451
+
452
+ self._log(f"{label}: Sentinel 点头放行,继续前进")
453
+ return did, sen_token
454
+
455
+ def _complete_token_exchange(self, result: RegistrationResult) -> bool:
456
+ """在登录态已建立后,继续完成 workspace 和 OAuth token 获取。"""
457
+ self._log("等待登录验证码到场,最后这位嘉宾还在路上...")
458
+ code = self._get_verification_code()
459
+ if not code:
460
+ result.error_message = "获取验证码失败"
461
+ return False
462
+
463
+ self._log("核对登录验证码,验明正身一下...")
464
+ if not self._validate_verification_code(code):
465
+ result.error_message = "验证码校验失败"
466
+ return False
467
+
468
+ self._log("摸一下 Workspace ID,看看该坐哪桌...")
469
+ workspace_id = self._get_workspace_id()
470
+ if not workspace_id:
471
+ result.error_message = "获取 Workspace ID 失败"
472
+ return False
473
+
474
+ result.workspace_id = workspace_id
475
+
476
+ self._log("选择 Workspace,安排个靠谱座位...")
477
+ continue_url = self._select_workspace(workspace_id)
478
+ if not continue_url:
479
+ result.error_message = "选择 Workspace 失败"
480
+ return False
481
+
482
+ self._log("顺着重定向面包屑往前走,别跟丢了...")
483
+ callback_url = self._follow_redirects(continue_url)
484
+ if not callback_url:
485
+ result.error_message = "跟随重定向链失败"
486
+ return False
487
+
488
+ self._log("处理 OAuth 回调,准备把 token 请出来...")
489
+ token_info = self._handle_oauth_callback(callback_url)
490
+ if not token_info:
491
+ result.error_message = "处理 OAuth 回调失败"
492
+ return False
493
+
494
+ result.account_id = token_info.get("account_id", "")
495
+ result.access_token = token_info.get("access_token", "")
496
+ result.refresh_token = token_info.get("refresh_token", "")
497
+ result.id_token = token_info.get("id_token", "")
498
+ result.password = self.password or ""
499
+ result.source = "login" if self._is_existing_account else "register"
500
+
501
+ session_cookie = self.session.cookies.get("__Secure-next-auth.session-token")
502
+ if session_cookie:
503
+ self.session_token = session_cookie
504
+ result.session_token = session_cookie
505
+ self._log("Session Token 也捞到了,今天这网没白连")
506
+
507
+ return True
508
+
509
+ def _restart_login_flow(self) -> Tuple[bool, str]:
510
+ """新注册账号完成建号后,重新发起一次登录流程拿 token。"""
511
+ self._token_acquisition_requires_login = True
512
+ self._log("注册这边忙完了,再走一趟登录把 token 请出来,收个尾...")
513
+ self._reset_auth_flow()
514
+
515
+ did, sen_token = self._prepare_authorize_flow("重新登录")
516
+ if not did:
517
+ return False, "重新登录时获取 Device ID 失败"
518
+ if not sen_token:
519
+ return False, "重新登录时 Sentinel POW 验证失败"
520
+
521
+ login_start_result = self._submit_login_start(did, sen_token)
522
+ if not login_start_result.success:
523
+ return False, f"重新登录提交邮箱失败: {login_start_result.error_message}"
524
+ if login_start_result.page_type != OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]:
525
+ return False, f"重新登录未进入密码页面: {login_start_result.page_type or 'unknown'}"
526
+
527
+ password_result = self._submit_login_password()
528
+ if not password_result.success:
529
+ return False, f"重新登录提交密码失败: {password_result.error_message}"
530
+ if not password_result.is_existing_account:
531
+ return False, f"重新登录未进入验证码页面: {password_result.page_type or 'unknown'}"
532
+ return True, ""
533
+
534
+ def _register_password(self) -> Tuple[bool, Optional[str]]:
535
+ """注册密码"""
536
+ try:
537
+ # 生成密码
538
+ password = self._generate_password()
539
+ self.password = password # 保存密码到实例变量
540
+ self._log(f"生成密码: {password}")
541
+
542
+ # 提交密码注册
543
+ register_body = json.dumps({
544
+ "password": password,
545
+ "username": self.email
546
+ })
547
+
548
+ response = self.session.post(
549
+ OPENAI_API_ENDPOINTS["register"],
550
+ headers={
551
+ "referer": "https://auth.openai.com/create-account/password",
552
+ "accept": "application/json",
553
+ "content-type": "application/json",
554
+ },
555
+ data=register_body,
556
+ )
557
+
558
+ self._log(f"提交密码状态: {response.status_code}")
559
+
560
+ if response.status_code != 200:
561
+ error_text = response.text[:500]
562
+ self._log(f"密码注册失败: {error_text}", "warning")
563
+
564
+ # 解析错误信息,判断是否是邮箱已注册
565
+ try:
566
+ error_json = response.json()
567
+ error_msg = error_json.get("error", {}).get("message", "")
568
+ error_code = error_json.get("error", {}).get("code", "")
569
+
570
+ # 检测邮箱已注册的情况
571
+ if "already" in error_msg.lower() or "exists" in error_msg.lower() or error_code == "user_exists":
572
+ self._log(f"邮箱 {self.email} 可能已在 OpenAI 注册过", "error")
573
+ # 标记此邮箱为已注册状态
574
+ self._mark_email_as_registered()
575
+ except Exception:
576
+ pass
577
+
578
+ return False, None
579
+
580
+ return True, password
581
+
582
+ except Exception as e:
583
+ self._log(f"密码注册失败: {e}", "error")
584
+ return False, None
585
+
586
+ def _mark_email_as_registered(self):
587
+ """标记邮箱为已注册状态(用于防止重复尝试)"""
588
+ try:
589
+ with get_db() as db:
590
+ # 检查是否已存在该邮箱的记录
591
+ existing = crud.get_account_by_email(db, self.email)
592
+ if not existing:
593
+ # 创建一个失败记录,标记该邮箱已注册过
594
+ crud.create_account(
595
+ db,
596
+ email=self.email,
597
+ password="", # 空密码表示未成功注册
598
+ email_service=self.email_service.service_type.value,
599
+ email_service_id=self.email_info.get("service_id") if self.email_info else None,
600
+ status="failed",
601
+ extra_data={"register_failed_reason": "email_already_registered_on_openai"}
602
+ )
603
+ self._log(f"已在数据库中标记邮箱 {self.email} 为已注册状态")
604
+ except Exception as e:
605
+ logger.warning(f"标记邮箱状态失败: {e}")
606
+
607
+ def _send_verification_code(self) -> bool:
608
+ """发送验证码"""
609
+ try:
610
+ # 记录发送时间戳
611
+ self._otp_sent_at = time.time()
612
+
613
+ response = self.session.get(
614
+ OPENAI_API_ENDPOINTS["send_otp"],
615
+ headers={
616
+ "referer": "https://auth.openai.com/create-account/password",
617
+ "accept": "application/json",
618
+ },
619
+ )
620
+
621
+ self._log(f"验证码发送状态: {response.status_code}")
622
+ return response.status_code == 200
623
+
624
+ except Exception as e:
625
+ self._log(f"发送验证码失败: {e}", "error")
626
+ return False
627
+
628
+ def _get_verification_code(self) -> Optional[str]:
629
+ """获取验证码"""
630
+ try:
631
+ self._log(f"正在等待邮箱 {self.email} 的验证码...")
632
+
633
+ email_id = self.email_info.get("service_id") if self.email_info else None
634
+ code = self.email_service.get_verification_code(
635
+ email=self.email,
636
+ email_id=email_id,
637
+ timeout=30,
638
+ pattern=OTP_CODE_PATTERN,
639
+ otp_sent_at=self._otp_sent_at,
640
+ )
641
+
642
+ if code:
643
+ self._log(f"成功获取验证码: {code}")
644
+ return code
645
+ else:
646
+ self._log("等待验证码超时", "error")
647
+ return None
648
+
649
+ except Exception as e:
650
+ self._log(f"获取验证码失败: {e}", "error")
651
+ return None
652
+
653
+ def _validate_verification_code(self, code: str) -> bool:
654
+ """验证验证码"""
655
+ try:
656
+ code_body = f'{{"code":"{code}"}}'
657
+
658
+ response = self.session.post(
659
+ OPENAI_API_ENDPOINTS["validate_otp"],
660
+ headers={
661
+ "referer": "https://auth.openai.com/email-verification",
662
+ "accept": "application/json",
663
+ "content-type": "application/json",
664
+ },
665
+ data=code_body,
666
+ )
667
+
668
+ self._log(f"验证码校验状态: {response.status_code}")
669
+ return response.status_code == 200
670
+
671
+ except Exception as e:
672
+ self._log(f"验证验证码失败: {e}", "error")
673
+ return False
674
+
675
+ def _create_user_account(self) -> bool:
676
+ """创建用户账户"""
677
+ try:
678
+ user_info = generate_random_user_info()
679
+ self._log(f"生成用户信息: {user_info['name']}, 生日: {user_info['birthdate']}")
680
+ create_account_body = json.dumps(user_info)
681
+
682
+ response = self.session.post(
683
+ OPENAI_API_ENDPOINTS["create_account"],
684
+ headers={
685
+ "referer": "https://auth.openai.com/about-you",
686
+ "accept": "application/json",
687
+ "content-type": "application/json",
688
+ },
689
+ data=create_account_body,
690
+ )
691
+
692
+ self._log(f"账户创建状态: {response.status_code}")
693
+
694
+ if response.status_code != 200:
695
+ self._log(f"账户创建失败: {response.text[:200]}", "warning")
696
+ return False
697
+
698
+ return True
699
+
700
+ except Exception as e:
701
+ self._log(f"创建账户失败: {e}", "error")
702
+ return False
703
+
704
+ def _get_workspace_id(self) -> Optional[str]:
705
+ """获取 Workspace ID"""
706
+ try:
707
+ auth_cookie = self.session.cookies.get("oai-client-auth-session")
708
+ if not auth_cookie:
709
+ self._log("未能获取到授权 Cookie", "error")
710
+ return None
711
+
712
+ # 解码 JWT
713
+ import base64
714
+ import json as json_module
715
+
716
+ try:
717
+ segments = auth_cookie.split(".")
718
+ if len(segments) < 1:
719
+ self._log("授权 Cookie 格式错误", "error")
720
+ return None
721
+
722
+ # 解码第一个 segment
723
+ payload = segments[0]
724
+ pad = "=" * ((4 - (len(payload) % 4)) % 4)
725
+ decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii"))
726
+ auth_json = json_module.loads(decoded.decode("utf-8"))
727
+
728
+ workspaces = auth_json.get("workspaces") or []
729
+ if not workspaces:
730
+ self._log("授权 Cookie 里没有 workspace 信息", "error")
731
+ return None
732
+
733
+ workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
734
+ if not workspace_id:
735
+ self._log("无法解析 workspace_id", "error")
736
+ return None
737
+
738
+ self._log(f"Workspace ID: {workspace_id}")
739
+ return workspace_id
740
+
741
+ except Exception as e:
742
+ self._log(f"解析授权 Cookie 失败: {e}", "error")
743
+ return None
744
+
745
+ except Exception as e:
746
+ self._log(f"获取 Workspace ID 失败: {e}", "error")
747
+ return None
748
+
749
+ def _select_workspace(self, workspace_id: str) -> Optional[str]:
750
+ """选择 Workspace"""
751
+ try:
752
+ select_body = f'{{"workspace_id":"{workspace_id}"}}'
753
+
754
+ response = self.session.post(
755
+ OPENAI_API_ENDPOINTS["select_workspace"],
756
+ headers={
757
+ "referer": "https://auth.openai.com/sign-in-with-chatgpt/codex/consent",
758
+ "content-type": "application/json",
759
+ },
760
+ data=select_body,
761
+ )
762
+
763
+ if response.status_code != 200:
764
+ self._log(f"选择 workspace 失败: {response.status_code}", "error")
765
+ self._log(f"响应: {response.text[:200]}", "warning")
766
+ return None
767
+
768
+ continue_url = str((response.json() or {}).get("continue_url") or "").strip()
769
+ if not continue_url:
770
+ self._log("workspace/select 响应里缺少 continue_url", "error")
771
+ return None
772
+
773
+ self._log(f"Continue URL: {continue_url[:100]}...")
774
+ return continue_url
775
+
776
+ except Exception as e:
777
+ self._log(f"选择 Workspace 失败: {e}", "error")
778
+ return None
779
+
780
+ def _follow_redirects(self, start_url: str) -> Optional[str]:
781
+ """跟随重定向链,寻找回调 URL"""
782
+ try:
783
+ current_url = start_url
784
+ max_redirects = 6
785
+
786
+ for i in range(max_redirects):
787
+ self._log(f"重定向 {i+1}/{max_redirects}: {current_url[:100]}...")
788
+
789
+ response = self.session.get(
790
+ current_url,
791
+ allow_redirects=False,
792
+ timeout=15
793
+ )
794
+
795
+ location = response.headers.get("Location") or ""
796
+
797
+ # 如果不是重定向状态码,停止
798
+ if response.status_code not in [301, 302, 303, 307, 308]:
799
+ self._log(f"非重定向状态码: {response.status_code}")
800
+ break
801
+
802
+ if not location:
803
+ self._log("重定向响应缺少 Location 头")
804
+ break
805
+
806
+ # 构建下一个 URL
807
+ import urllib.parse
808
+ next_url = urllib.parse.urljoin(current_url, location)
809
+
810
+ # 检查是否包含回调参数
811
+ if "code=" in next_url and "state=" in next_url:
812
+ self._log(f"找到回调 URL: {next_url[:100]}...")
813
+ return next_url
814
+
815
+ current_url = next_url
816
+
817
+ self._log("未能在重定向链中找到回调 URL", "error")
818
+ return None
819
+
820
+ except Exception as e:
821
+ self._log(f"跟随重定向失败: {e}", "error")
822
+ return None
823
+
824
+ def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]:
825
+ """处理 OAuth 回调"""
826
+ try:
827
+ if not self.oauth_start:
828
+ self._log("OAuth 流程未初始化", "error")
829
+ return None
830
+
831
+ self._log("处理 OAuth 回调,最后一哆嗦,稳住别抖...")
832
+ token_info = self.oauth_manager.handle_callback(
833
+ callback_url=callback_url,
834
+ expected_state=self.oauth_start.state,
835
+ code_verifier=self.oauth_start.code_verifier
836
+ )
837
+
838
+ self._log("OAuth 授权成功,通关文牒到手")
839
+ return token_info
840
+
841
+ except Exception as e:
842
+ self._log(f"处理 OAuth 回调失败: {e}", "error")
843
+ return None
844
+
845
+ def run(self) -> RegistrationResult:
846
+ """
847
+ 执行完整的注册流程
848
+
849
+ 支持已注册账号自动登录:
850
+ - 如果检测到邮箱已注册,自动切换到登录流程
851
+ - 已注册账号跳过:设置密码、发送验证码、创建用户账户
852
+ - 共用步骤:获取验证码、验证验证码、Workspace 和 OAuth 回调
853
+
854
+ Returns:
855
+ RegistrationResult: 注册结果
856
+ """
857
+ result = RegistrationResult(success=False, logs=self.logs)
858
+
859
+ try:
860
+ self._is_existing_account = False
861
+ self._token_acquisition_requires_login = False
862
+ self._otp_sent_at = None
863
+
864
+ self._log("=" * 60)
865
+ self._log("注册流程启动,开始替你敲门")
866
+ self._log("=" * 60)
867
+
868
+ # 1. 检查 IP 地理位置
869
+ self._log("1. 先看看这条网络从哪儿来,别一开局就站错片场...")
870
+ ip_ok, location = self._check_ip_location()
871
+ if not ip_ok:
872
+ result.error_message = f"IP 地理位置不支持: {location}"
873
+ self._log(f"IP 检查失败: {location}", "error")
874
+ return result
875
+
876
+ self._log(f"IP 位置: {location}")
877
+
878
+ # 2. 创建邮箱
879
+ self._log("2. 开个新邮箱,准备收信...")
880
+ if not self._create_email():
881
+ result.error_message = "创建邮箱失败"
882
+ return result
883
+
884
+ result.email = self.email
885
+
886
+ # 3. 准备首轮授权流程
887
+ did, sen_token = self._prepare_authorize_flow("首次授权")
888
+ if not did:
889
+ result.error_message = "获取 Device ID 失败"
890
+ return result
891
+ if not sen_token:
892
+ result.error_message = "Sentinel POW 验证失败"
893
+ return result
894
+
895
+ # 4. 提交注册入口邮箱
896
+ self._log("4. 递上邮箱,看看 OpenAI 这球怎么接...")
897
+ signup_result = self._submit_signup_form(did, sen_token)
898
+ if not signup_result.success:
899
+ result.error_message = f"提交注册表单失败: {signup_result.error_message}"
900
+ return result
901
+
902
+ if self._is_existing_account:
903
+ self._log("检测到这是老朋友账号,直接切去登录拿 token,不走弯路")
904
+ else:
905
+ self._log("5. 设置密码,别让小偷偷笑...")
906
+ password_ok, _ = self._register_password()
907
+ if not password_ok:
908
+ result.error_message = "注册密码失败"
909
+ return result
910
+
911
+ self._log("6. 催一下注册验证码出门,邮差该冲刺了...")
912
+ if not self._send_verification_code():
913
+ result.error_message = "发送验证码失败"
914
+ return result
915
+
916
+ self._log("7. 等验证码飞来,邮箱请注意查收...")
917
+ code = self._get_verification_code()
918
+ if not code:
919
+ result.error_message = "获取验证码失败"
920
+ return result
921
+
922
+ self._log("8. 对一下验证码,看看是不是本人...")
923
+ if not self._validate_verification_code(code):
924
+ result.error_message = "验证验证码失败"
925
+ return result
926
+
927
+ self._log("9. 给账号办个正式户口,名字写档案里...")
928
+ if not self._create_user_account():
929
+ result.error_message = "创建用户账户失败"
930
+ return result
931
+
932
+ login_ready, login_error = self._restart_login_flow()
933
+ if not login_ready:
934
+ result.error_message = login_error
935
+ return result
936
+
937
+ if not self._complete_token_exchange(result):
938
+ return result
939
+
940
+ # 10. 完成
941
+ self._log("=" * 60)
942
+ if self._is_existing_account:
943
+ self._log("登录成功,老朋友顺利回家")
944
+ else:
945
+ self._log("注册成功,账号已经稳稳落地,可以开香槟了")
946
+ self._log(f"邮箱: {result.email}")
947
+ self._log(f"Account ID: {result.account_id}")
948
+ self._log(f"Workspace ID: {result.workspace_id}")
949
+ self._log("=" * 60)
950
+
951
+ result.success = True
952
+ result.metadata = {
953
+ "email_service": self.email_service.service_type.value,
954
+ "proxy_used": self.proxy_url,
955
+ "registered_at": datetime.now().isoformat(),
956
+ "is_existing_account": self._is_existing_account,
957
+ "token_acquired_via_relogin": self._token_acquisition_requires_login,
958
+ }
959
+
960
+ return result
961
+
962
+ except Exception as e:
963
+ self._log(f"注册过程中发生未预期错误: {e}", "error")
964
+ result.error_message = str(e)
965
+ return result
966
+
967
+ def save_to_database(self, result: RegistrationResult) -> bool:
968
+ """
969
+ 保存注册结果到数据库
970
+
971
+ Args:
972
+ result: 注册结果
973
+
974
+ Returns:
975
+ 是否保存成功
976
+ """
977
+ if not result.success:
978
+ return False
979
+
980
+ try:
981
+ # 获取默认 client_id
982
+ settings = get_settings()
983
+
984
+ with get_db() as db:
985
+ # 保存账户信息
986
+ account = crud.create_account(
987
+ db,
988
+ email=result.email,
989
+ password=result.password,
990
+ client_id=settings.openai_client_id,
991
+ session_token=result.session_token,
992
+ email_service=self.email_service.service_type.value,
993
+ email_service_id=self.email_info.get("service_id") if self.email_info else None,
994
+ account_id=result.account_id,
995
+ workspace_id=result.workspace_id,
996
+ access_token=result.access_token,
997
+ refresh_token=result.refresh_token,
998
+ id_token=result.id_token,
999
+ proxy_used=self.proxy_url,
1000
+ extra_data=result.metadata,
1001
+ source=result.source
1002
+ )
1003
+
1004
+ self._log(f"账户已存进数据库,落袋为安,ID: {account.id}")
1005
+ return True
1006
+
1007
+ except Exception as e:
1008
+ self._log(f"保存到数据库失败: {e}", "error")
1009
+ return False
src/core/upload/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # @Time : 2026/3/18 19:54
src/core/upload/cpa_upload.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CPA (Codex Protocol API) 上传功能
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ from typing import List, Dict, Any, Tuple, Optional
8
+ from datetime import datetime
9
+ from urllib.parse import quote
10
+
11
+ from curl_cffi import requests as cffi_requests
12
+ from curl_cffi import CurlMime
13
+
14
+ from ...database.session import get_db
15
+ from ...database.models import Account
16
+ from ...config.settings import get_settings
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _normalize_cpa_auth_files_url(api_url: str) -> str:
22
+ """将用户填写的 CPA 地址规范化为 auth-files 接口地址。"""
23
+ normalized = (api_url or "").strip().rstrip("/")
24
+ lower_url = normalized.lower()
25
+
26
+ if not normalized:
27
+ return ""
28
+
29
+ if lower_url.endswith("/auth-files"):
30
+ return normalized
31
+
32
+ if lower_url.endswith("/v0/management") or lower_url.endswith("/management"):
33
+ return f"{normalized}/auth-files"
34
+
35
+ if lower_url.endswith("/v0"):
36
+ return f"{normalized}/management/auth-files"
37
+
38
+ return f"{normalized}/v0/management/auth-files"
39
+
40
+
41
+ def _build_cpa_headers(api_token: str, content_type: Optional[str] = None) -> dict:
42
+ headers = {
43
+ "Authorization": f"Bearer {api_token}",
44
+ }
45
+ if content_type:
46
+ headers["Content-Type"] = content_type
47
+ return headers
48
+
49
+
50
+ def _extract_cpa_error(response) -> str:
51
+ error_msg = f"上传失败: HTTP {response.status_code}"
52
+ try:
53
+ error_detail = response.json()
54
+ if isinstance(error_detail, dict):
55
+ error_msg = error_detail.get("message", error_msg)
56
+ except Exception:
57
+ error_msg = f"{error_msg} - {response.text[:200]}"
58
+ return error_msg
59
+
60
+
61
+ def _post_cpa_auth_file_multipart(upload_url: str, filename: str, file_content: bytes, api_token: str):
62
+ mime = CurlMime()
63
+ mime.addpart(
64
+ name="file",
65
+ data=file_content,
66
+ filename=filename,
67
+ content_type="application/json",
68
+ )
69
+
70
+ return cffi_requests.post(
71
+ upload_url,
72
+ multipart=mime,
73
+ headers=_build_cpa_headers(api_token),
74
+ proxies=None,
75
+ timeout=30,
76
+ impersonate="chrome110",
77
+ )
78
+
79
+
80
+ def _post_cpa_auth_file_raw_json(upload_url: str, filename: str, file_content: bytes, api_token: str):
81
+ raw_upload_url = f"{upload_url}?name={quote(filename)}"
82
+ return cffi_requests.post(
83
+ raw_upload_url,
84
+ data=file_content,
85
+ headers=_build_cpa_headers(api_token, content_type="application/json"),
86
+ proxies=None,
87
+ timeout=30,
88
+ impersonate="chrome110",
89
+ )
90
+
91
+
92
+ def generate_token_json(account: Account) -> dict:
93
+ """
94
+ 生成 CPA 格式的 Token JSON
95
+
96
+ Args:
97
+ account: 账号模型实例
98
+
99
+ Returns:
100
+ CPA 格式的 Token 字典
101
+ """
102
+ return {
103
+ "type": "codex",
104
+ "email": account.email,
105
+ "expired": account.expires_at.strftime("%Y-%m-%dT%H:%M:%S+08:00") if account.expires_at else "",
106
+ "id_token": account.id_token or "",
107
+ "account_id": account.account_id or "",
108
+ "access_token": account.access_token or "",
109
+ "last_refresh": account.last_refresh.strftime("%Y-%m-%dT%H:%M:%S+08:00") if account.last_refresh else "",
110
+ "refresh_token": account.refresh_token or "",
111
+ }
112
+
113
+
114
+ def upload_to_cpa(
115
+ token_data: dict,
116
+ proxy: str = None,
117
+ api_url: str = None,
118
+ api_token: str = None,
119
+ ) -> Tuple[bool, str]:
120
+ """
121
+ 上传单个账号到 CPA 管理平台(不走代理)
122
+
123
+ Args:
124
+ token_data: Token JSON 数据
125
+ proxy: 保留参数,不使用(CPA 上传始终直连)
126
+ api_url: 指定 CPA API URL(优先于全局配置)
127
+ api_token: 指定 CPA API Token(优先于全局配置)
128
+
129
+ Returns:
130
+ (成功标志, 消息或错误信息)
131
+ """
132
+ settings = get_settings()
133
+
134
+ # 优先使用传入的参数,否则退回全局配置
135
+ effective_url = api_url or settings.cpa_api_url
136
+ effective_token = api_token or (settings.cpa_api_token.get_secret_value() if settings.cpa_api_token else "")
137
+
138
+ # 仅当未指定服务时才检查全局启用开关
139
+ if not api_url and not settings.cpa_enabled:
140
+ return False, "CPA 上传未启用"
141
+
142
+ if not effective_url:
143
+ return False, "CPA API URL 未配置"
144
+
145
+ if not effective_token:
146
+ return False, "CPA API Token 未配置"
147
+
148
+ upload_url = _normalize_cpa_auth_files_url(effective_url)
149
+
150
+ filename = f"{token_data['email']}.json"
151
+ file_content = json.dumps(token_data, ensure_ascii=False, indent=2).encode("utf-8")
152
+
153
+ try:
154
+ response = _post_cpa_auth_file_multipart(
155
+ upload_url,
156
+ filename,
157
+ file_content,
158
+ effective_token,
159
+ )
160
+
161
+ if response.status_code in (200, 201):
162
+ return True, "上传成功"
163
+
164
+ if response.status_code in (404, 405, 415):
165
+ logger.warning("CPA multipart 上传失败,尝试原始 JSON 回退: %s", response.status_code)
166
+ fallback_response = _post_cpa_auth_file_raw_json(
167
+ upload_url,
168
+ filename,
169
+ file_content,
170
+ effective_token,
171
+ )
172
+ if fallback_response.status_code in (200, 201):
173
+ return True, "上传成功"
174
+ response = fallback_response
175
+
176
+ return False, _extract_cpa_error(response)
177
+
178
+ except Exception as e:
179
+ logger.error(f"CPA 上传异常: {e}")
180
+ return False, f"上传异常: {str(e)}"
181
+
182
+
183
+ def batch_upload_to_cpa(
184
+ account_ids: List[int],
185
+ proxy: str = None,
186
+ api_url: str = None,
187
+ api_token: str = None,
188
+ ) -> dict:
189
+ """
190
+ 批量上传账号到 CPA 管理平台
191
+
192
+ Args:
193
+ account_ids: 账号 ID 列表
194
+ proxy: 可选的代理 URL
195
+ api_url: 指定 CPA API URL(优先于全局配置)
196
+ api_token: 指定 CPA API Token(优先于全局配置)
197
+
198
+ Returns:
199
+ 包含成功/失败统计和详情的字典
200
+ """
201
+ results = {
202
+ "success_count": 0,
203
+ "failed_count": 0,
204
+ "skipped_count": 0,
205
+ "details": []
206
+ }
207
+
208
+ with get_db() as db:
209
+ for account_id in account_ids:
210
+ account = db.query(Account).filter(Account.id == account_id).first()
211
+
212
+ if not account:
213
+ results["failed_count"] += 1
214
+ results["details"].append({
215
+ "id": account_id,
216
+ "email": None,
217
+ "success": False,
218
+ "error": "账号不存在"
219
+ })
220
+ continue
221
+
222
+ # 检查是否已有 Token
223
+ if not account.access_token:
224
+ results["skipped_count"] += 1
225
+ results["details"].append({
226
+ "id": account_id,
227
+ "email": account.email,
228
+ "success": False,
229
+ "error": "缺少 Token"
230
+ })
231
+ continue
232
+
233
+ # 生成 Token JSON
234
+ token_data = generate_token_json(account)
235
+
236
+ # 上传
237
+ success, message = upload_to_cpa(token_data, proxy, api_url=api_url, api_token=api_token)
238
+
239
+ if success:
240
+ # 更新数据库状态
241
+ account.cpa_uploaded = True
242
+ account.cpa_uploaded_at = datetime.utcnow()
243
+ db.commit()
244
+
245
+ results["success_count"] += 1
246
+ results["details"].append({
247
+ "id": account_id,
248
+ "email": account.email,
249
+ "success": True,
250
+ "message": message
251
+ })
252
+ else:
253
+ results["failed_count"] += 1
254
+ results["details"].append({
255
+ "id": account_id,
256
+ "email": account.email,
257
+ "success": False,
258
+ "error": message
259
+ })
260
+
261
+ return results
262
+
263
+
264
+ def test_cpa_connection(api_url: str, api_token: str, proxy: str = None) -> Tuple[bool, str]:
265
+ """
266
+ 测试 CPA 连接(不走代理)
267
+
268
+ Args:
269
+ api_url: CPA API URL
270
+ api_token: CPA API Token
271
+ proxy: 保留参数,不使用(CPA 始终直连)
272
+
273
+ Returns:
274
+ (成功标志, 消息)
275
+ """
276
+ if not api_url:
277
+ return False, "API URL 不能为空"
278
+
279
+ if not api_token:
280
+ return False, "API Token 不能为空"
281
+
282
+ test_url = _normalize_cpa_auth_files_url(api_url)
283
+ headers = _build_cpa_headers(api_token)
284
+
285
+ try:
286
+ response = cffi_requests.get(
287
+ test_url,
288
+ headers=headers,
289
+ proxies=None,
290
+ timeout=10,
291
+ impersonate="chrome110",
292
+ )
293
+
294
+ if response.status_code == 200:
295
+ return True, "CPA 连接测试成功"
296
+ if response.status_code == 401:
297
+ return False, "连接成功,但 API Token 无效"
298
+ if response.status_code == 403:
299
+ return False, "连接成功,但服务端未启用远程管理或当前 Token 无权限"
300
+ if response.status_code == 404:
301
+ return False, "未找到 CPA auth-files 接口,请检查 API URL 是否填写为根地址、/v0/management 或完整 auth-files 地址"
302
+ if response.status_code == 503:
303
+ return False, "连接成功,但服务端认证管理器不可用"
304
+
305
+ return False, f"服务器返回异常状态码: {response.status_code}"
306
+
307
+ except cffi_requests.exceptions.ConnectionError as e:
308
+ return False, f"无法连接到服务器: {str(e)}"
309
+ except cffi_requests.exceptions.Timeout:
310
+ return False, "连接超时,请检查网络配置"
311
+ except Exception as e:
312
+ return False, f"连接测试失败: {str(e)}"
src/core/upload/sub2api_upload.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sub2API 账号上传功能
3
+ 将账号以 sub2api-data 格式批量导入到 Sub2API 平台
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ from datetime import datetime, timezone
9
+ from typing import List, Tuple, Optional
10
+
11
+ from curl_cffi import requests as cffi_requests
12
+
13
+ from ...database.session import get_db
14
+ from ...database.models import Account
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def upload_to_sub2api(
20
+ accounts: List[Account],
21
+ api_url: str,
22
+ api_key: str,
23
+ concurrency: int = 3,
24
+ priority: int = 50,
25
+ ) -> Tuple[bool, str]:
26
+ """
27
+ 上传账号列表到 Sub2API 平台(不走代理)
28
+
29
+ Args:
30
+ accounts: 账号模型实例列表
31
+ api_url: Sub2API 地址,如 http://host
32
+ api_key: Admin API Key(x-api-key header)
33
+ concurrency: 账号并发数,默认 3
34
+ priority: 账号优先级,默认 50
35
+
36
+ Returns:
37
+ (成功标志, 消息)
38
+ """
39
+ if not accounts:
40
+ return False, "无可上传的账号"
41
+
42
+ if not api_url:
43
+ return False, "Sub2API URL 未配置"
44
+
45
+ if not api_key:
46
+ return False, "Sub2API API Key 未配置"
47
+
48
+ exported_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
49
+
50
+ account_items = []
51
+ for acc in accounts:
52
+ if not acc.access_token:
53
+ continue
54
+ expires_at = int(acc.expires_at.timestamp()) if acc.expires_at else 0
55
+ account_items.append({
56
+ "name": acc.email,
57
+ "platform": "openai",
58
+ "type": "oauth",
59
+ "credentials": {
60
+ "access_token": acc.access_token,
61
+ "chatgpt_account_id": acc.account_id or "",
62
+ "chatgpt_user_id": "",
63
+ "client_id": acc.client_id or "",
64
+ "expires_at": expires_at,
65
+ "expires_in": 863999,
66
+ "model_mapping": {
67
+ "gpt-5.1": "gpt-5.1",
68
+ "gpt-5.1-codex": "gpt-5.1-codex",
69
+ "gpt-5.1-codex-max": "gpt-5.1-codex-max",
70
+ "gpt-5.1-codex-mini": "gpt-5.1-codex-mini",
71
+ "gpt-5.2": "gpt-5.2",
72
+ "gpt-5.2-codex": "gpt-5.2-codex",
73
+ "gpt-5.3": "gpt-5.3",
74
+ "gpt-5.3-codex": "gpt-5.3-codex",
75
+ "gpt-5.4": "gpt-5.4"
76
+ },
77
+ "organization_id": acc.workspace_id or "",
78
+ "refresh_token": acc.refresh_token or "",
79
+ },
80
+ "extra": {},
81
+ "concurrency": concurrency,
82
+ "priority": priority,
83
+ "rate_multiplier": 1,
84
+ "auto_pause_on_expired": True,
85
+ })
86
+
87
+ if not account_items:
88
+ return False, "所有账号均缺少 access_token,无法上传"
89
+
90
+ payload = {
91
+ "data": {
92
+ "type": "sub2api-data",
93
+ "version": 1,
94
+ "exported_at": exported_at,
95
+ "proxies": [],
96
+ "accounts": account_items,
97
+ },
98
+ "skip_default_group_bind": True,
99
+ }
100
+
101
+ url = api_url.rstrip("/") + "/api/v1/admin/accounts/data"
102
+ headers = {
103
+ "Content-Type": "application/json",
104
+ "x-api-key": api_key,
105
+ "Idempotency-Key": f"import-{exported_at}",
106
+ }
107
+
108
+ try:
109
+ response = cffi_requests.post(
110
+ url,
111
+ json=payload,
112
+ headers=headers,
113
+ proxies=None,
114
+ timeout=30,
115
+ impersonate="chrome110",
116
+ )
117
+
118
+ if response.status_code in (200, 201):
119
+ return True, f"成功上传 {len(account_items)} 个账号"
120
+
121
+ error_msg = f"上传失败: HTTP {response.status_code}"
122
+ try:
123
+ detail = response.json()
124
+ if isinstance(detail, dict):
125
+ error_msg = detail.get("message", error_msg)
126
+ except Exception:
127
+ error_msg = f"{error_msg} - {response.text[:200]}"
128
+ return False, error_msg
129
+
130
+ except Exception as e:
131
+ logger.error(f"Sub2API 上传异常: {e}")
132
+ return False, f"上传异常: {str(e)}"
133
+
134
+
135
+ def batch_upload_to_sub2api(
136
+ account_ids: List[int],
137
+ api_url: str,
138
+ api_key: str,
139
+ concurrency: int = 3,
140
+ priority: int = 50,
141
+ ) -> dict:
142
+ """
143
+ 批量上传指定 ID 的账号到 Sub2API 平台
144
+
145
+ Returns:
146
+ 包含成功/失败/跳过统计和详情的字典
147
+ """
148
+ results = {
149
+ "success_count": 0,
150
+ "failed_count": 0,
151
+ "skipped_count": 0,
152
+ "details": []
153
+ }
154
+
155
+ with get_db() as db:
156
+ accounts = []
157
+ for account_id in account_ids:
158
+ acc = db.query(Account).filter(Account.id == account_id).first()
159
+ if not acc:
160
+ results["failed_count"] += 1
161
+ results["details"].append({"id": account_id, "email": None, "success": False, "error": "账号不存在"})
162
+ continue
163
+ if not acc.access_token:
164
+ results["skipped_count"] += 1
165
+ results["details"].append({"id": account_id, "email": acc.email, "success": False, "error": "缺少 access_token"})
166
+ continue
167
+ accounts.append(acc)
168
+
169
+ if not accounts:
170
+ return results
171
+
172
+ success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority)
173
+
174
+ if success:
175
+ for acc in accounts:
176
+ results["success_count"] += 1
177
+ results["details"].append({"id": acc.id, "email": acc.email, "success": True, "message": message})
178
+ else:
179
+ for acc in accounts:
180
+ results["failed_count"] += 1
181
+ results["details"].append({"id": acc.id, "email": acc.email, "success": False, "error": message})
182
+
183
+ return results
184
+
185
+
186
+ def test_sub2api_connection(api_url: str, api_key: str) -> Tuple[bool, str]:
187
+ """
188
+ 测试 Sub2API 连接(GET /api/v1/admin/accounts/data 探活)
189
+
190
+ Returns:
191
+ (成功标志, 消息)
192
+ """
193
+ if not api_url:
194
+ return False, "API URL 不能为空"
195
+ if not api_key:
196
+ return False, "API Key 不能为空"
197
+
198
+ url = api_url.rstrip("/") + "/api/v1/admin/accounts/data"
199
+ headers = {"x-api-key": api_key}
200
+
201
+ try:
202
+ response = cffi_requests.get(
203
+ url,
204
+ headers=headers,
205
+ proxies=None,
206
+ timeout=10,
207
+ impersonate="chrome110",
208
+ )
209
+
210
+ if response.status_code in (200, 201, 204, 405):
211
+ return True, "Sub2API 连接测试成功"
212
+ if response.status_code == 401:
213
+ return False, "连接成功,但 API Key 无效"
214
+ if response.status_code == 403:
215
+ return False, "连接成功,但权限不足"
216
+
217
+ return False, f"服务器返回异常状态码: {response.status_code}"
218
+
219
+ except cffi_requests.exceptions.ConnectionError as e:
220
+ return False, f"无法连接到服务器: {str(e)}"
221
+ except cffi_requests.exceptions.Timeout:
222
+ return False, "连接超时,请检查网络配置"
223
+ except Exception as e:
224
+ return False, f"连接测试失败: {str(e)}"
src/core/upload/team_manager_upload.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Team Manager 上传功能
3
+ 参照 CPA 上传模式,直连不走代理
4
+ """
5
+
6
+ import logging
7
+ from typing import List, Tuple
8
+
9
+ from curl_cffi import requests as cffi_requests
10
+
11
+ from ...database.models import Account
12
+ from ...database.session import get_db
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def upload_to_team_manager(
18
+ account: Account,
19
+ api_url: str,
20
+ api_key: str,
21
+ ) -> Tuple[bool, str]:
22
+ """
23
+ 上传单账号到 Team Manager(直连,不走代理)
24
+
25
+ Returns:
26
+ (成功标志, 消息)
27
+ """
28
+ if not api_url:
29
+ return False, "Team Manager API URL 未配置"
30
+ if not api_key:
31
+ return False, "Team Manager API Key 未配置"
32
+ if not account.access_token:
33
+ return False, "账号缺少 access_token"
34
+
35
+ url = api_url.rstrip("/") + "/admin/teams/import"
36
+ headers = {
37
+ "X-API-Key": api_key,
38
+ "Content-Type": "application/json",
39
+ }
40
+ payload = {
41
+ "import_type": "single",
42
+ "email": account.email,
43
+ "access_token": account.access_token or "",
44
+ "session_token": account.session_token or "",
45
+ "refresh_token": account.refresh_token or "",
46
+ "client_id": account.client_id or "",
47
+ "account_id": account.account_id or "",
48
+ }
49
+
50
+ try:
51
+ resp = cffi_requests.post(
52
+ url,
53
+ headers=headers,
54
+ json=payload,
55
+ proxies=None,
56
+ timeout=30
57
+ )
58
+ if resp.status_code in (200, 201):
59
+ return True, "上传成功"
60
+ error_msg = f"上传失败: HTTP {resp.status_code}"
61
+ try:
62
+ detail = resp.json()
63
+ if isinstance(detail, dict):
64
+ error_msg = detail.get("message", error_msg)
65
+ except Exception:
66
+ error_msg = f"{error_msg} - {resp.text[:200]}"
67
+ return False, error_msg
68
+ except Exception as e:
69
+ logger.error(f"Team Manager 上传异常: {e}")
70
+ return False, f"上传异常: {str(e)}"
71
+
72
+
73
+ def batch_upload_to_team_manager(
74
+ account_ids: List[int],
75
+ api_url: str,
76
+ api_key: str,
77
+ ) -> dict:
78
+ """
79
+ 批量上传账号到 Team Manager(使用 batch 模式,一次请求提交所有账号)
80
+
81
+ Returns:
82
+ 包含成功/失败统计和详情的字典
83
+ """
84
+ results = {
85
+ "success_count": 0,
86
+ "failed_count": 0,
87
+ "skipped_count": 0,
88
+ "details": [],
89
+ }
90
+
91
+ with get_db() as db:
92
+ lines = []
93
+ valid_accounts = []
94
+ for account_id in account_ids:
95
+ account = db.query(Account).filter(Account.id == account_id).first()
96
+ if not account:
97
+ results["failed_count"] += 1
98
+ results["details"].append(
99
+ {"id": account_id, "email": None, "success": False, "error": "账号不存在"}
100
+ )
101
+ continue
102
+ if not account.access_token:
103
+ results["skipped_count"] += 1
104
+ results["details"].append(
105
+ {"id": account_id, "email": account.email, "success": False, "error": "缺少 Token"}
106
+ )
107
+ continue
108
+ # 格式:邮箱,AT,RT,ST,ClientID
109
+ lines.append(",".join([
110
+ account.email or "",
111
+ account.access_token or "",
112
+ account.refresh_token or "",
113
+ account.session_token or "",
114
+ account.client_id or "",
115
+ ]))
116
+ valid_accounts.append(account)
117
+
118
+ if not valid_accounts:
119
+ return results
120
+
121
+ url = api_url.rstrip("/") + "/admin/teams/import"
122
+ headers = {
123
+ "X-API-Key": api_key,
124
+ "Content-Type": "application/json",
125
+ }
126
+ payload = {
127
+ "import_type": "batch",
128
+ "content": "\n".join(lines),
129
+ }
130
+
131
+ try:
132
+ resp = cffi_requests.post(
133
+ url,
134
+ headers=headers,
135
+ json=payload,
136
+ proxies=None,
137
+ timeout=60,
138
+ impersonate="chrome110",
139
+ )
140
+ if resp.status_code in (200, 201):
141
+ for account in valid_accounts:
142
+ results["success_count"] += 1
143
+ results["details"].append(
144
+ {"id": account.id, "email": account.email, "success": True, "message": "批量上传成功"}
145
+ )
146
+ else:
147
+ error_msg = f"批量上传失败: HTTP {resp.status_code}"
148
+ try:
149
+ detail = resp.json()
150
+ if isinstance(detail, dict):
151
+ error_msg = detail.get("message", error_msg)
152
+ except Exception:
153
+ error_msg = f"{error_msg} - {resp.text[:200]}"
154
+ for account in valid_accounts:
155
+ results["failed_count"] += 1
156
+ results["details"].append(
157
+ {"id": account.id, "email": account.email, "success": False, "error": error_msg}
158
+ )
159
+ except Exception as e:
160
+ logger.error(f"Team Manager 批量上传异常: {e}")
161
+ error_msg = f"上传异常: {str(e)}"
162
+ for account in valid_accounts:
163
+ results["failed_count"] += 1
164
+ results["details"].append(
165
+ {"id": account.id, "email": account.email, "success": False, "error": error_msg}
166
+ )
167
+
168
+ return results
169
+
170
+
171
+ def test_team_manager_connection(api_url: str, api_key: str) -> Tuple[bool, str]:
172
+ """
173
+ 测试 Team Manager 连接(直连)
174
+
175
+ Returns:
176
+ (成功标志, 消息)
177
+ """
178
+ if not api_url:
179
+ return False, "API URL 不能为空"
180
+ if not api_key:
181
+ return False, "API Key 不能为空"
182
+
183
+ url = api_url.rstrip("/") + "/admin/teams/import"
184
+ headers = {"X-API-Key": api_key}
185
+
186
+ try:
187
+ resp = cffi_requests.options(
188
+ url,
189
+ headers=headers,
190
+ proxies=None,
191
+ timeout=10,
192
+ impersonate="chrome110",
193
+ )
194
+ if resp.status_code in (200, 204, 401, 403, 405):
195
+ if resp.status_code == 401:
196
+ return False, "连接成功,但 API Key 无效"
197
+ return True, "Team Manager 连接测试成功"
198
+ return False, f"服务器返回异常状态码: {resp.status_code}"
199
+ except cffi_requests.exceptions.ConnectionError as e:
200
+ return False, f"无法连接到服务器: {str(e)}"
201
+ except cffi_requests.exceptions.Timeout:
202
+ return False, "连接超时,请检查网络配置"
203
+ except Exception as e:
204
+ return False, f"连接测试失败: {str(e)}"
src/core/utils.py ADDED
@@ -0,0 +1,570 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 通用工具函数
3
+ """
4
+
5
+ import os
6
+ import sys
7
+ import json
8
+ import time
9
+ import random
10
+ import string
11
+ import secrets
12
+ import hashlib
13
+ import logging
14
+ import base64
15
+ import re
16
+ import uuid
17
+ from datetime import datetime, timedelta
18
+ from typing import Any, Dict, List, Optional, Union, Callable
19
+ from pathlib import Path
20
+
21
+ from ..config.constants import PASSWORD_CHARSET, DEFAULT_PASSWORD_LENGTH
22
+ from ..config.settings import get_settings
23
+
24
+
25
+ def setup_logging(
26
+ log_level: str = "INFO",
27
+ log_file: Optional[str] = None,
28
+ log_format: str = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
29
+ ) -> logging.Logger:
30
+ """
31
+ 配置日志系统
32
+
33
+ Args:
34
+ log_level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
35
+ log_file: 日志文件路径,如果不指定则只输出到控制台
36
+ log_format: 日志格式
37
+
38
+ Returns:
39
+ 根日志记录器
40
+ """
41
+ # 设置日志级别
42
+ numeric_level = getattr(logging, log_level.upper(), None)
43
+ if not isinstance(numeric_level, int):
44
+ numeric_level = logging.INFO
45
+
46
+ # 配置根日志记录器
47
+ root_logger = logging.getLogger()
48
+ root_logger.setLevel(numeric_level)
49
+
50
+ # 清除现有的处理器
51
+ root_logger.handlers.clear()
52
+
53
+ # 创建格式化器
54
+ formatter = logging.Formatter(log_format)
55
+
56
+ # 控制台处理器
57
+ console_handler = logging.StreamHandler(sys.stdout)
58
+ console_handler.setFormatter(formatter)
59
+ console_handler.setLevel(numeric_level)
60
+ root_logger.addHandler(console_handler)
61
+
62
+ # 文件处理器(如果指定了日志文件)
63
+ if log_file:
64
+ # 确保日志目录存在
65
+ log_dir = os.path.dirname(log_file)
66
+ if log_dir:
67
+ os.makedirs(log_dir, exist_ok=True)
68
+
69
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
70
+ file_handler.setFormatter(formatter)
71
+ file_handler.setLevel(numeric_level)
72
+ root_logger.addHandler(file_handler)
73
+
74
+ return root_logger
75
+
76
+
77
+ def generate_password(length: int = DEFAULT_PASSWORD_LENGTH) -> str:
78
+ """
79
+ 生成随机密码
80
+
81
+ Args:
82
+ length: 密码长度
83
+
84
+ Returns:
85
+ 随机密码字符串
86
+ """
87
+ if length < 4:
88
+ length = 4
89
+
90
+ # 确保密码包含至少一个大写字母、一个小写字母和一个数字
91
+ password = [
92
+ secrets.choice(string.ascii_lowercase),
93
+ secrets.choice(string.ascii_uppercase),
94
+ secrets.choice(string.digits),
95
+ ]
96
+
97
+ # 添加剩余字符
98
+ password.extend(secrets.choice(PASSWORD_CHARSET) for _ in range(length - 3))
99
+
100
+ # 随机打乱
101
+ secrets.SystemRandom().shuffle(password)
102
+
103
+ return ''.join(password)
104
+
105
+
106
+ def generate_random_string(length: int = 8) -> str:
107
+ """
108
+ 生成随机字符串(仅字母)
109
+
110
+ Args:
111
+ length: 字符串长度
112
+
113
+ Returns:
114
+ 随机字符串
115
+ """
116
+ chars = string.ascii_letters
117
+ return ''.join(secrets.choice(chars) for _ in range(length))
118
+
119
+
120
+ def generate_uuid() -> str:
121
+ """生成 UUID 字符串"""
122
+ return str(uuid.uuid4())
123
+
124
+
125
+ def get_timestamp() -> int:
126
+ """获取当前时间戳(秒)"""
127
+ return int(time.time())
128
+
129
+
130
+ def format_datetime(dt: Optional[datetime] = None, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
131
+ """
132
+ 格式化日期时间
133
+
134
+ Args:
135
+ dt: 日期时间对象,如果为 None 则使用当前时间
136
+ fmt: 格式字符串
137
+
138
+ Returns:
139
+ 格式化后的字符串
140
+ """
141
+ if dt is None:
142
+ dt = datetime.now()
143
+ return dt.strftime(fmt)
144
+
145
+
146
+ def parse_datetime(dt_str: str, fmt: str = "%Y-%m-%d %H:%M:%S") -> Optional[datetime]:
147
+ """
148
+ 解析日期时间字符串
149
+
150
+ Args:
151
+ dt_str: 日期时间字符串
152
+ fmt: 格式字符串
153
+
154
+ Returns:
155
+ 日期时间对象,如果解析失败返回 None
156
+ """
157
+ try:
158
+ return datetime.strptime(dt_str, fmt)
159
+ except (ValueError, TypeError):
160
+ return None
161
+
162
+
163
+ def human_readable_size(size_bytes: int) -> str:
164
+ """
165
+ 将字节大小转换为人类可读的格式
166
+
167
+ Args:
168
+ size_bytes: 字节大小
169
+
170
+ Returns:
171
+ 人类可读的字符串
172
+ """
173
+ if size_bytes < 0:
174
+ return "0 B"
175
+
176
+ units = ["B", "KB", "MB", "GB", "TB", "PB"]
177
+ unit_index = 0
178
+
179
+ while size_bytes >= 1024 and unit_index < len(units) - 1:
180
+ size_bytes /= 1024
181
+ unit_index += 1
182
+
183
+ return f"{size_bytes:.2f} {units[unit_index]}"
184
+
185
+
186
+ def retry_with_backoff(
187
+ func: Callable,
188
+ max_retries: int = 3,
189
+ base_delay: float = 1.0,
190
+ max_delay: float = 30.0,
191
+ backoff_factor: float = 2.0,
192
+ exceptions: tuple = (Exception,)
193
+ ) -> Any:
194
+ """
195
+ 带有指数退避的重试装饰器/函数
196
+
197
+ Args:
198
+ func: 要重试的函数
199
+ max_retries: 最大重试次数
200
+ base_delay: 基础延迟(秒)
201
+ max_delay: 最大延迟(秒)
202
+ backoff_factor: 退避因子
203
+ exceptions: 要捕获的异常类型
204
+
205
+ Returns:
206
+ 函数的返回值
207
+
208
+ Raises:
209
+ 最后一次尝试的异常
210
+ """
211
+ last_exception = None
212
+
213
+ for attempt in range(max_retries + 1):
214
+ try:
215
+ return func()
216
+ except exceptions as e:
217
+ last_exception = e
218
+
219
+ # 如果是最后一次尝试,直接抛出异常
220
+ if attempt == max_retries:
221
+ break
222
+
223
+ # 计算延迟时间
224
+ delay = min(base_delay * (backoff_factor ** attempt), max_delay)
225
+
226
+ # 添加随机抖动
227
+ delay *= (0.5 + random.random())
228
+
229
+ # 记录日志
230
+ logger = logging.getLogger(__name__)
231
+ logger.warning(
232
+ f"尝试 {func.__name__} 失败 (attempt {attempt + 1}/{max_retries + 1}): {e}. "
233
+ f"等待 {delay:.2f} 秒后重试..."
234
+ )
235
+
236
+ time.sleep(delay)
237
+
238
+ # 所有重试都失败,抛出最后一个异常
239
+ raise last_exception
240
+
241
+
242
+ class RetryDecorator:
243
+ """重试装饰器类"""
244
+
245
+ def __init__(
246
+ self,
247
+ max_retries: int = 3,
248
+ base_delay: float = 1.0,
249
+ max_delay: float = 30.0,
250
+ backoff_factor: float = 2.0,
251
+ exceptions: tuple = (Exception,)
252
+ ):
253
+ self.max_retries = max_retries
254
+ self.base_delay = base_delay
255
+ self.max_delay = max_delay
256
+ self.backoff_factor = backoff_factor
257
+ self.exceptions = exceptions
258
+
259
+ def __call__(self, func: Callable) -> Callable:
260
+ """装饰器调用"""
261
+ def wrapper(*args, **kwargs):
262
+ def func_to_retry():
263
+ return func(*args, **kwargs)
264
+
265
+ return retry_with_backoff(
266
+ func_to_retry,
267
+ max_retries=self.max_retries,
268
+ base_delay=self.base_delay,
269
+ max_delay=self.max_delay,
270
+ backoff_factor=self.backoff_factor,
271
+ exceptions=self.exceptions
272
+ )
273
+
274
+ return wrapper
275
+
276
+
277
+ def validate_email(email: str) -> bool:
278
+ """
279
+ 验证邮箱地址格式
280
+
281
+ Args:
282
+ email: 邮箱地址
283
+
284
+ Returns:
285
+ 是否有效
286
+ """
287
+ pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
288
+ return bool(re.match(pattern, email))
289
+
290
+
291
+ def validate_url(url: str) -> bool:
292
+ """
293
+ 验证 URL 格式
294
+
295
+ Args:
296
+ url: URL
297
+
298
+ Returns:
299
+ 是否有效
300
+ """
301
+ pattern = r"^https?://[^\s/$.?#].[^\s]*$"
302
+ return bool(re.match(pattern, url))
303
+
304
+
305
+ def sanitize_filename(filename: str) -> str:
306
+ """
307
+ 清理文件名,移除不安全的字符
308
+
309
+ Args:
310
+ filename: 原始文件名
311
+
312
+ Returns:
313
+ 清理后的文件名
314
+ """
315
+ # 移除危险字符
316
+ filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
317
+ # 移除控制字符
318
+ filename = ''.join(char for char in filename if ord(char) >= 32)
319
+ # 限制长度
320
+ if len(filename) > 255:
321
+ name, ext = os.path.splitext(filename)
322
+ filename = name[:255 - len(ext)] + ext
323
+ return filename
324
+
325
+
326
+ def read_json_file(filepath: str) -> Optional[Dict[str, Any]]:
327
+ """
328
+ 读取 JSON 文件
329
+
330
+ Args:
331
+ filepath: 文件路径
332
+
333
+ Returns:
334
+ JSON 数据,如果读取失败返回 None
335
+ """
336
+ try:
337
+ with open(filepath, 'r', encoding='utf-8') as f:
338
+ return json.load(f)
339
+ except (FileNotFoundError, json.JSONDecodeError, IOError) as e:
340
+ logging.getLogger(__name__).warning(f"读取 JSON 文件失败: {filepath} - {e}")
341
+ return None
342
+
343
+
344
+ def write_json_file(filepath: str, data: Dict[str, Any], indent: int = 2) -> bool:
345
+ """
346
+ 写入 JSON 文件
347
+
348
+ Args:
349
+ filepath: 文件路径
350
+ data: 要写入的数据
351
+ indent: 缩进空格数
352
+
353
+ Returns:
354
+ 是否成功
355
+ """
356
+ try:
357
+ # 确保目录存在
358
+ os.makedirs(os.path.dirname(filepath), exist_ok=True)
359
+
360
+ with open(filepath, 'w', encoding='utf-8') as f:
361
+ json.dump(data, f, ensure_ascii=False, indent=indent)
362
+
363
+ return True
364
+ except (IOError, TypeError) as e:
365
+ logging.getLogger(__name__).error(f"写入 JSON 文件失败: {filepath} - {e}")
366
+ return False
367
+
368
+
369
+ def get_project_root() -> Path:
370
+ """
371
+ 获取项目根目录
372
+
373
+ Returns:
374
+ 项目根目录 Path 对象
375
+ """
376
+ # 当前文件所在目录
377
+ current_dir = Path(__file__).parent
378
+
379
+ # 向上查找直到找到项目根目录(包含 pyproject.toml 或 setup.py)
380
+ for parent in [current_dir] + list(current_dir.parents):
381
+ if (parent / "pyproject.toml").exists() or (parent / "setup.py").exists():
382
+ return parent
383
+
384
+ # 如果找不到,返回当前目录的父目录
385
+ return current_dir.parent
386
+
387
+
388
+ def get_data_dir() -> Path:
389
+ """
390
+ 获取数据目录
391
+
392
+ Returns:
393
+ 数据目录 Path 对象
394
+ """
395
+ settings = get_settings()
396
+ if not settings.database_url.startswith("sqlite"):
397
+ data_dir = Path(os.environ.get("APP_DATA_DIR", "data"))
398
+ data_dir.mkdir(parents=True, exist_ok=True)
399
+ return data_dir
400
+ data_dir = Path(settings.database_url).parent
401
+
402
+ # 如果 database_url 是 SQLite URL,提取路径
403
+ if settings.database_url.startswith("sqlite:///"):
404
+ db_path = settings.database_url[10:] # 移除 "sqlite:///"
405
+ data_dir = Path(db_path).parent
406
+
407
+ # 确保目录存在
408
+ data_dir.mkdir(parents=True, exist_ok=True)
409
+
410
+ return data_dir
411
+
412
+
413
+ def get_logs_dir() -> Path:
414
+ """
415
+ 获取日志目录
416
+
417
+ Returns:
418
+ 日志目录 Path 对象
419
+ """
420
+ settings = get_settings()
421
+ log_file = Path(settings.log_file)
422
+ log_dir = log_file.parent
423
+
424
+ # 确保目录存在
425
+ log_dir.mkdir(parents=True, exist_ok=True)
426
+
427
+ return log_dir
428
+
429
+
430
+ def format_duration(seconds: int) -> str:
431
+ """
432
+ 格式化持续时间
433
+
434
+ Args:
435
+ seconds: 秒数
436
+
437
+ Returns:
438
+ 格式化的持续时间字符串
439
+ """
440
+ if seconds < 60:
441
+ return f"{seconds}秒"
442
+
443
+ minutes, seconds = divmod(seconds, 60)
444
+ if minutes < 60:
445
+ return f"{minutes}分{seconds}秒"
446
+
447
+ hours, minutes = divmod(minutes, 60)
448
+ if hours < 24:
449
+ return f"{hours}小时{minutes}分"
450
+
451
+ days, hours = divmod(hours, 24)
452
+ return f"{days}天{hours}小时"
453
+
454
+
455
+ def mask_sensitive_data(data: Union[str, Dict, List], mask_char: str = "*") -> Union[str, Dict, List]:
456
+ """
457
+ 掩码敏感数据
458
+
459
+ Args:
460
+ data: 要掩码的数据
461
+ mask_char: 掩码字符
462
+
463
+ Returns:
464
+ 掩码后的数据
465
+ """
466
+ if isinstance(data, str):
467
+ # 如果是邮箱,掩码中间部分
468
+ if "@" in data:
469
+ local, domain = data.split("@", 1)
470
+ if len(local) > 2:
471
+ masked_local = local[0] + mask_char * (len(local) - 2) + local[-1]
472
+ else:
473
+ masked_local = mask_char * len(local)
474
+ return f"{masked_local}@{domain}"
475
+
476
+ # 如果是 token 或密钥,掩码大部分内容
477
+ if len(data) > 10:
478
+ return data[:4] + mask_char * (len(data) - 8) + data[-4:]
479
+ return mask_char * len(data)
480
+
481
+ elif isinstance(data, dict):
482
+ masked_dict = {}
483
+ for key, value in data.items():
484
+ # 敏感字段名
485
+ sensitive_keys = ["password", "token", "secret", "key", "auth", "credential"]
486
+ if any(sensitive in key.lower() for sensitive in sensitive_keys):
487
+ masked_dict[key] = mask_sensitive_data(value, mask_char)
488
+ else:
489
+ masked_dict[key] = value
490
+ return masked_dict
491
+
492
+ elif isinstance(data, list):
493
+ return [mask_sensitive_data(item, mask_char) for item in data]
494
+
495
+ return data
496
+
497
+
498
+ def calculate_md5(data: Union[str, bytes]) -> str:
499
+ """
500
+ 计算 MD5 哈希
501
+
502
+ Args:
503
+ data: 要哈希的数据
504
+
505
+ Returns:
506
+ MD5 哈希字符串
507
+ """
508
+ if isinstance(data, str):
509
+ data = data.encode('utf-8')
510
+
511
+ return hashlib.md5(data).hexdigest()
512
+
513
+
514
+ def calculate_sha256(data: Union[str, bytes]) -> str:
515
+ """
516
+ 计算 SHA256 哈希
517
+
518
+ Args:
519
+ data: 要哈希的数据
520
+
521
+ Returns:
522
+ SHA256 哈希字符串
523
+ """
524
+ if isinstance(data, str):
525
+ data = data.encode('utf-8')
526
+
527
+ return hashlib.sha256(data).hexdigest()
528
+
529
+
530
+ def base64_encode(data: Union[str, bytes]) -> str:
531
+ """Base64 编码"""
532
+ if isinstance(data, str):
533
+ data = data.encode('utf-8')
534
+
535
+ return base64.b64encode(data).decode('utf-8')
536
+
537
+
538
+ def base64_decode(data: str) -> str:
539
+ """Base64 解码"""
540
+ try:
541
+ decoded = base64.b64decode(data)
542
+ return decoded.decode('utf-8')
543
+ except (base64.binascii.Error, UnicodeDecodeError):
544
+ return ""
545
+
546
+
547
+ class Timer:
548
+ """计时器上下文管理器"""
549
+
550
+ def __init__(self, name: str = "操作"):
551
+ self.name = name
552
+ self.start_time = None
553
+ self.elapsed = None
554
+
555
+ def __enter__(self):
556
+ self.start_time = time.time()
557
+ return self
558
+
559
+ def __exit__(self, exc_type, exc_val, exc_tb):
560
+ self.elapsed = time.time() - self.start_time
561
+ logger = logging.getLogger(__name__)
562
+ logger.debug(f"{self.name} 耗时: {self.elapsed:.2f} 秒")
563
+
564
+ def get_elapsed(self) -> float:
565
+ """获取经过的时间(秒)"""
566
+ if self.elapsed is not None:
567
+ return self.elapsed
568
+ if self.start_time is not None:
569
+ return time.time() - self.start_time
570
+ return 0.0
src/database/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 数据库模块
3
+ """
4
+
5
+ from .models import Base, Account, EmailService, RegistrationTask, Setting
6
+ from .session import get_db, init_database, get_session_manager, DatabaseSessionManager
7
+ from . import crud
8
+
9
+ __all__ = [
10
+ 'Base',
11
+ 'Account',
12
+ 'EmailService',
13
+ 'RegistrationTask',
14
+ 'Setting',
15
+ 'get_db',
16
+ 'init_database',
17
+ 'get_session_manager',
18
+ 'DatabaseSessionManager',
19
+ 'crud',
20
+ ]
src/database/crud.py ADDED
@@ -0,0 +1,714 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 数据库 CRUD 操作
3
+ """
4
+
5
+ from typing import List, Optional, Dict, Any, Union
6
+ from datetime import datetime, timedelta
7
+ from sqlalchemy.orm import Session
8
+ from sqlalchemy import and_, or_, desc, asc, func
9
+
10
+ from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService
11
+
12
+
13
+ # ============================================================================
14
+ # 账户 CRUD
15
+ # ============================================================================
16
+
17
+ def create_account(
18
+ db: Session,
19
+ email: str,
20
+ email_service: str,
21
+ password: Optional[str] = None,
22
+ client_id: Optional[str] = None,
23
+ session_token: Optional[str] = None,
24
+ email_service_id: Optional[str] = None,
25
+ account_id: Optional[str] = None,
26
+ workspace_id: Optional[str] = None,
27
+ access_token: Optional[str] = None,
28
+ refresh_token: Optional[str] = None,
29
+ id_token: Optional[str] = None,
30
+ proxy_used: Optional[str] = None,
31
+ expires_at: Optional['datetime'] = None,
32
+ extra_data: Optional[Dict[str, Any]] = None,
33
+ status: Optional[str] = None,
34
+ source: Optional[str] = None
35
+ ) -> Account:
36
+ """创建新账户"""
37
+ db_account = Account(
38
+ email=email,
39
+ password=password,
40
+ client_id=client_id,
41
+ session_token=session_token,
42
+ email_service=email_service,
43
+ email_service_id=email_service_id,
44
+ account_id=account_id,
45
+ workspace_id=workspace_id,
46
+ access_token=access_token,
47
+ refresh_token=refresh_token,
48
+ id_token=id_token,
49
+ proxy_used=proxy_used,
50
+ expires_at=expires_at,
51
+ extra_data=extra_data or {},
52
+ status=status or 'active',
53
+ source=source or 'register',
54
+ registered_at=datetime.utcnow()
55
+ )
56
+ db.add(db_account)
57
+ db.commit()
58
+ db.refresh(db_account)
59
+ return db_account
60
+
61
+
62
+ def get_account_by_id(db: Session, account_id: int) -> Optional[Account]:
63
+ """根据 ID 获取账户"""
64
+ return db.query(Account).filter(Account.id == account_id).first()
65
+
66
+
67
+ def get_account_by_email(db: Session, email: str) -> Optional[Account]:
68
+ """根据邮箱获取账户"""
69
+ return db.query(Account).filter(Account.email == email).first()
70
+
71
+
72
+ def get_accounts(
73
+ db: Session,
74
+ skip: int = 0,
75
+ limit: int = 100,
76
+ email_service: Optional[str] = None,
77
+ status: Optional[str] = None,
78
+ search: Optional[str] = None
79
+ ) -> List[Account]:
80
+ """获取账户列表(支持分页、筛选)"""
81
+ query = db.query(Account)
82
+
83
+ if email_service:
84
+ query = query.filter(Account.email_service == email_service)
85
+
86
+ if status:
87
+ query = query.filter(Account.status == status)
88
+
89
+ if search:
90
+ search_filter = or_(
91
+ Account.email.ilike(f"%{search}%"),
92
+ Account.account_id.ilike(f"%{search}%"),
93
+ Account.workspace_id.ilike(f"%{search}%")
94
+ )
95
+ query = query.filter(search_filter)
96
+
97
+ query = query.order_by(desc(Account.created_at)).offset(skip).limit(limit)
98
+ return query.all()
99
+
100
+
101
+ def update_account(
102
+ db: Session,
103
+ account_id: int,
104
+ **kwargs
105
+ ) -> Optional[Account]:
106
+ """更新账户信息"""
107
+ db_account = get_account_by_id(db, account_id)
108
+ if not db_account:
109
+ return None
110
+
111
+ for key, value in kwargs.items():
112
+ if hasattr(db_account, key) and value is not None:
113
+ setattr(db_account, key, value)
114
+
115
+ db.commit()
116
+ db.refresh(db_account)
117
+ return db_account
118
+
119
+
120
+ def delete_account(db: Session, account_id: int) -> bool:
121
+ """删除账户"""
122
+ db_account = get_account_by_id(db, account_id)
123
+ if not db_account:
124
+ return False
125
+
126
+ db.delete(db_account)
127
+ db.commit()
128
+ return True
129
+
130
+
131
+ def delete_accounts_batch(db: Session, account_ids: List[int]) -> int:
132
+ """批量删除账户"""
133
+ result = db.query(Account).filter(Account.id.in_(account_ids)).delete(synchronize_session=False)
134
+ db.commit()
135
+ return result
136
+
137
+
138
+ def get_accounts_count(
139
+ db: Session,
140
+ email_service: Optional[str] = None,
141
+ status: Optional[str] = None
142
+ ) -> int:
143
+ """获取账户数量"""
144
+ query = db.query(func.count(Account.id))
145
+
146
+ if email_service:
147
+ query = query.filter(Account.email_service == email_service)
148
+
149
+ if status:
150
+ query = query.filter(Account.status == status)
151
+
152
+ return query.scalar()
153
+
154
+
155
+ # ============================================================================
156
+ # 邮箱服务 CRUD
157
+ # ============================================================================
158
+
159
+ def create_email_service(
160
+ db: Session,
161
+ service_type: str,
162
+ name: str,
163
+ config: Dict[str, Any],
164
+ enabled: bool = True,
165
+ priority: int = 0
166
+ ) -> EmailService:
167
+ """创建邮箱服务配置"""
168
+ db_service = EmailService(
169
+ service_type=service_type,
170
+ name=name,
171
+ config=config,
172
+ enabled=enabled,
173
+ priority=priority
174
+ )
175
+ db.add(db_service)
176
+ db.commit()
177
+ db.refresh(db_service)
178
+ return db_service
179
+
180
+
181
+ def get_email_service_by_id(db: Session, service_id: int) -> Optional[EmailService]:
182
+ """根据 ID 获取邮箱服务"""
183
+ return db.query(EmailService).filter(EmailService.id == service_id).first()
184
+
185
+
186
+ def get_email_services(
187
+ db: Session,
188
+ service_type: Optional[str] = None,
189
+ enabled: Optional[bool] = None,
190
+ skip: int = 0,
191
+ limit: int = 100
192
+ ) -> List[EmailService]:
193
+ """获取邮箱服务列表"""
194
+ query = db.query(EmailService)
195
+
196
+ if service_type:
197
+ query = query.filter(EmailService.service_type == service_type)
198
+
199
+ if enabled is not None:
200
+ query = query.filter(EmailService.enabled == enabled)
201
+
202
+ query = query.order_by(
203
+ asc(EmailService.priority),
204
+ desc(EmailService.last_used)
205
+ ).offset(skip).limit(limit)
206
+
207
+ return query.all()
208
+
209
+
210
+ def update_email_service(
211
+ db: Session,
212
+ service_id: int,
213
+ **kwargs
214
+ ) -> Optional[EmailService]:
215
+ """更新邮箱服务配置"""
216
+ db_service = get_email_service_by_id(db, service_id)
217
+ if not db_service:
218
+ return None
219
+
220
+ for key, value in kwargs.items():
221
+ if hasattr(db_service, key) and value is not None:
222
+ setattr(db_service, key, value)
223
+
224
+ db.commit()
225
+ db.refresh(db_service)
226
+ return db_service
227
+
228
+
229
+ def delete_email_service(db: Session, service_id: int) -> bool:
230
+ """删除邮箱服务配置"""
231
+ db_service = get_email_service_by_id(db, service_id)
232
+ if not db_service:
233
+ return False
234
+
235
+ db.delete(db_service)
236
+ db.commit()
237
+ return True
238
+
239
+
240
+ # ============================================================================
241
+ # 注册任务 CRUD
242
+ # ============================================================================
243
+
244
+ def create_registration_task(
245
+ db: Session,
246
+ task_uuid: str,
247
+ email_service_id: Optional[int] = None,
248
+ proxy: Optional[str] = None
249
+ ) -> RegistrationTask:
250
+ """创建注册任务"""
251
+ db_task = RegistrationTask(
252
+ task_uuid=task_uuid,
253
+ email_service_id=email_service_id,
254
+ proxy=proxy,
255
+ status='pending'
256
+ )
257
+ db.add(db_task)
258
+ db.commit()
259
+ db.refresh(db_task)
260
+ return db_task
261
+
262
+
263
+ def get_registration_task_by_uuid(db: Session, task_uuid: str) -> Optional[RegistrationTask]:
264
+ """根据 UUID 获取注册任务"""
265
+ return db.query(RegistrationTask).filter(RegistrationTask.task_uuid == task_uuid).first()
266
+
267
+
268
+ def get_registration_tasks(
269
+ db: Session,
270
+ status: Optional[str] = None,
271
+ skip: int = 0,
272
+ limit: int = 100
273
+ ) -> List[RegistrationTask]:
274
+ """获取注册任务列表"""
275
+ query = db.query(RegistrationTask)
276
+
277
+ if status:
278
+ query = query.filter(RegistrationTask.status == status)
279
+
280
+ query = query.order_by(desc(RegistrationTask.created_at)).offset(skip).limit(limit)
281
+ return query.all()
282
+
283
+
284
+ def update_registration_task(
285
+ db: Session,
286
+ task_uuid: str,
287
+ **kwargs
288
+ ) -> Optional[RegistrationTask]:
289
+ """更新注册任务状态"""
290
+ db_task = get_registration_task_by_uuid(db, task_uuid)
291
+ if not db_task:
292
+ return None
293
+
294
+ for key, value in kwargs.items():
295
+ if hasattr(db_task, key):
296
+ setattr(db_task, key, value)
297
+
298
+ db.commit()
299
+ db.refresh(db_task)
300
+ return db_task
301
+
302
+
303
+ def append_task_log(db: Session, task_uuid: str, log_message: str) -> bool:
304
+ """追加任务日志"""
305
+ db_task = get_registration_task_by_uuid(db, task_uuid)
306
+ if not db_task:
307
+ return False
308
+
309
+ if db_task.logs:
310
+ db_task.logs += f"\n{log_message}"
311
+ else:
312
+ db_task.logs = log_message
313
+
314
+ db.commit()
315
+ return True
316
+
317
+
318
+ def delete_registration_task(db: Session, task_uuid: str) -> bool:
319
+ """删除注册任务"""
320
+ db_task = get_registration_task_by_uuid(db, task_uuid)
321
+ if not db_task:
322
+ return False
323
+
324
+ db.delete(db_task)
325
+ db.commit()
326
+ return True
327
+
328
+
329
+ # 为 API 路由添加别名
330
+ get_account = get_account_by_id
331
+ get_registration_task = get_registration_task_by_uuid
332
+
333
+
334
+ # ============================================================================
335
+ # 设置 CRUD
336
+ # ============================================================================
337
+
338
+ def get_setting(db: Session, key: str) -> Optional[Setting]:
339
+ """获取设置"""
340
+ return db.query(Setting).filter(Setting.key == key).first()
341
+
342
+
343
+ def get_settings_by_category(db: Session, category: str) -> List[Setting]:
344
+ """根据分类获取设置"""
345
+ return db.query(Setting).filter(Setting.category == category).all()
346
+
347
+
348
+ def set_setting(
349
+ db: Session,
350
+ key: str,
351
+ value: str,
352
+ description: Optional[str] = None,
353
+ category: str = 'general'
354
+ ) -> Setting:
355
+ """设置或更新配置项"""
356
+ db_setting = get_setting(db, key)
357
+ if db_setting:
358
+ db_setting.value = value
359
+ db_setting.description = description or db_setting.description
360
+ db_setting.category = category
361
+ db_setting.updated_at = datetime.utcnow()
362
+ else:
363
+ db_setting = Setting(
364
+ key=key,
365
+ value=value,
366
+ description=description,
367
+ category=category
368
+ )
369
+ db.add(db_setting)
370
+
371
+ db.commit()
372
+ db.refresh(db_setting)
373
+ return db_setting
374
+
375
+
376
+ def delete_setting(db: Session, key: str) -> bool:
377
+ """删除设置"""
378
+ db_setting = get_setting(db, key)
379
+ if not db_setting:
380
+ return False
381
+
382
+ db.delete(db_setting)
383
+ db.commit()
384
+ return True
385
+
386
+
387
+ # ============================================================================
388
+ # 代理 CRUD
389
+ # ============================================================================
390
+
391
+ def create_proxy(
392
+ db: Session,
393
+ name: str,
394
+ type: str,
395
+ host: str,
396
+ port: int,
397
+ username: Optional[str] = None,
398
+ password: Optional[str] = None,
399
+ enabled: bool = True,
400
+ priority: int = 0
401
+ ) -> Proxy:
402
+ """创建代理配置"""
403
+ db_proxy = Proxy(
404
+ name=name,
405
+ type=type,
406
+ host=host,
407
+ port=port,
408
+ username=username,
409
+ password=password,
410
+ enabled=enabled,
411
+ priority=priority
412
+ )
413
+ db.add(db_proxy)
414
+ db.commit()
415
+ db.refresh(db_proxy)
416
+ return db_proxy
417
+
418
+
419
+ def get_proxy_by_id(db: Session, proxy_id: int) -> Optional[Proxy]:
420
+ """根据 ID 获取代理"""
421
+ return db.query(Proxy).filter(Proxy.id == proxy_id).first()
422
+
423
+
424
+ def get_proxies(
425
+ db: Session,
426
+ enabled: Optional[bool] = None,
427
+ skip: int = 0,
428
+ limit: int = 100
429
+ ) -> List[Proxy]:
430
+ """获取代理列表"""
431
+ query = db.query(Proxy)
432
+
433
+ if enabled is not None:
434
+ query = query.filter(Proxy.enabled == enabled)
435
+
436
+ query = query.order_by(desc(Proxy.created_at)).offset(skip).limit(limit)
437
+ return query.all()
438
+
439
+
440
+ def get_enabled_proxies(db: Session) -> List[Proxy]:
441
+ """获取所有启用的代理"""
442
+ return db.query(Proxy).filter(Proxy.enabled == True).all()
443
+
444
+
445
+ def update_proxy(
446
+ db: Session,
447
+ proxy_id: int,
448
+ **kwargs
449
+ ) -> Optional[Proxy]:
450
+ """更新代理配置"""
451
+ db_proxy = get_proxy_by_id(db, proxy_id)
452
+ if not db_proxy:
453
+ return None
454
+
455
+ for key, value in kwargs.items():
456
+ if hasattr(db_proxy, key):
457
+ setattr(db_proxy, key, value)
458
+
459
+ db.commit()
460
+ db.refresh(db_proxy)
461
+ return db_proxy
462
+
463
+
464
+ def delete_proxy(db: Session, proxy_id: int) -> bool:
465
+ """删除代理配置"""
466
+ db_proxy = get_proxy_by_id(db, proxy_id)
467
+ if not db_proxy:
468
+ return False
469
+
470
+ db.delete(db_proxy)
471
+ db.commit()
472
+ return True
473
+
474
+
475
+ def update_proxy_last_used(db: Session, proxy_id: int) -> bool:
476
+ """更新代理最后使用时间"""
477
+ db_proxy = get_proxy_by_id(db, proxy_id)
478
+ if not db_proxy:
479
+ return False
480
+
481
+ db_proxy.last_used = datetime.utcnow()
482
+ db.commit()
483
+ return True
484
+
485
+
486
+ def get_random_proxy(db: Session) -> Optional[Proxy]:
487
+ """随机获取一个启用的代理,优先返回 is_default=True 的代理"""
488
+ import random
489
+ # 优先返回默认代理
490
+ default_proxy = db.query(Proxy).filter(Proxy.enabled == True, Proxy.is_default == True).first()
491
+ if default_proxy:
492
+ return default_proxy
493
+ proxies = get_enabled_proxies(db)
494
+ if not proxies:
495
+ return None
496
+ return random.choice(proxies)
497
+
498
+
499
+ def set_proxy_default(db: Session, proxy_id: int) -> Optional[Proxy]:
500
+ """将指定代理设为默认,同时清除其他代理的默认标记"""
501
+ # 清除所有默认标记
502
+ db.query(Proxy).filter(Proxy.is_default == True).update({"is_default": False})
503
+ # 设置新的默认代理
504
+ proxy = db.query(Proxy).filter(Proxy.id == proxy_id).first()
505
+ if proxy:
506
+ proxy.is_default = True
507
+ db.commit()
508
+ db.refresh(proxy)
509
+ return proxy
510
+
511
+
512
+ def get_proxies_count(db: Session, enabled: Optional[bool] = None) -> int:
513
+ """获取代理数量"""
514
+ query = db.query(func.count(Proxy.id))
515
+ if enabled is not None:
516
+ query = query.filter(Proxy.enabled == enabled)
517
+ return query.scalar()
518
+
519
+
520
+ # ============================================================================
521
+ # CPA 服务 CRUD
522
+ # ============================================================================
523
+
524
+ def create_cpa_service(
525
+ db: Session,
526
+ name: str,
527
+ api_url: str,
528
+ api_token: str,
529
+ enabled: bool = True,
530
+ priority: int = 0
531
+ ) -> CpaService:
532
+ """创建 CPA 服务配置"""
533
+ db_service = CpaService(
534
+ name=name,
535
+ api_url=api_url,
536
+ api_token=api_token,
537
+ enabled=enabled,
538
+ priority=priority
539
+ )
540
+ db.add(db_service)
541
+ db.commit()
542
+ db.refresh(db_service)
543
+ return db_service
544
+
545
+
546
+ def get_cpa_service_by_id(db: Session, service_id: int) -> Optional[CpaService]:
547
+ """根据 ID 获取 CPA 服务"""
548
+ return db.query(CpaService).filter(CpaService.id == service_id).first()
549
+
550
+
551
+ def get_cpa_services(
552
+ db: Session,
553
+ enabled: Optional[bool] = None
554
+ ) -> List[CpaService]:
555
+ """获取 CPA 服务列表"""
556
+ query = db.query(CpaService)
557
+ if enabled is not None:
558
+ query = query.filter(CpaService.enabled == enabled)
559
+ return query.order_by(asc(CpaService.priority), asc(CpaService.id)).all()
560
+
561
+
562
+ def update_cpa_service(
563
+ db: Session,
564
+ service_id: int,
565
+ **kwargs
566
+ ) -> Optional[CpaService]:
567
+ """更新 CPA 服务配置"""
568
+ db_service = get_cpa_service_by_id(db, service_id)
569
+ if not db_service:
570
+ return None
571
+ for key, value in kwargs.items():
572
+ if hasattr(db_service, key):
573
+ setattr(db_service, key, value)
574
+ db.commit()
575
+ db.refresh(db_service)
576
+ return db_service
577
+
578
+
579
+ def delete_cpa_service(db: Session, service_id: int) -> bool:
580
+ """删除 CPA 服务配置"""
581
+ db_service = get_cpa_service_by_id(db, service_id)
582
+ if not db_service:
583
+ return False
584
+ db.delete(db_service)
585
+ db.commit()
586
+ return True
587
+
588
+
589
+ # ============================================================================
590
+ # Sub2API 服务 CRUD
591
+ # ============================================================================
592
+
593
+ def create_sub2api_service(
594
+ db: Session,
595
+ name: str,
596
+ api_url: str,
597
+ api_key: str,
598
+ enabled: bool = True,
599
+ priority: int = 0
600
+ ) -> Sub2ApiService:
601
+ """创建 Sub2API 服务配置"""
602
+ svc = Sub2ApiService(
603
+ name=name,
604
+ api_url=api_url,
605
+ api_key=api_key,
606
+ enabled=enabled,
607
+ priority=priority,
608
+ )
609
+ db.add(svc)
610
+ db.commit()
611
+ db.refresh(svc)
612
+ return svc
613
+
614
+
615
+ def get_sub2api_service_by_id(db: Session, service_id: int) -> Optional[Sub2ApiService]:
616
+ """按 ID 获取 Sub2API 服务"""
617
+ return db.query(Sub2ApiService).filter(Sub2ApiService.id == service_id).first()
618
+
619
+
620
+ def get_sub2api_services(
621
+ db: Session,
622
+ enabled: Optional[bool] = None
623
+ ) -> List[Sub2ApiService]:
624
+ """获取 Sub2API 服务列表"""
625
+ query = db.query(Sub2ApiService)
626
+ if enabled is not None:
627
+ query = query.filter(Sub2ApiService.enabled == enabled)
628
+ return query.order_by(asc(Sub2ApiService.priority), asc(Sub2ApiService.id)).all()
629
+
630
+
631
+ def update_sub2api_service(db: Session, service_id: int, **kwargs) -> Optional[Sub2ApiService]:
632
+ """更新 Sub2API 服务配置"""
633
+ svc = get_sub2api_service_by_id(db, service_id)
634
+ if not svc:
635
+ return None
636
+ for key, value in kwargs.items():
637
+ setattr(svc, key, value)
638
+ db.commit()
639
+ db.refresh(svc)
640
+ return svc
641
+
642
+
643
+ def delete_sub2api_service(db: Session, service_id: int) -> bool:
644
+ """删除 Sub2API 服务配置"""
645
+ svc = get_sub2api_service_by_id(db, service_id)
646
+ if not svc:
647
+ return False
648
+ db.delete(svc)
649
+ db.commit()
650
+ return True
651
+
652
+
653
+ # ============================================================================
654
+ # Team Manager 服务 CRUD
655
+ # ============================================================================
656
+
657
+ def create_tm_service(
658
+ db: Session,
659
+ name: str,
660
+ api_url: str,
661
+ api_key: str,
662
+ enabled: bool = True,
663
+ priority: int = 0,
664
+ ):
665
+ """创建 Team Manager 服务配置"""
666
+ from .models import TeamManagerService
667
+ svc = TeamManagerService(
668
+ name=name,
669
+ api_url=api_url,
670
+ api_key=api_key,
671
+ enabled=enabled,
672
+ priority=priority,
673
+ )
674
+ db.add(svc)
675
+ db.commit()
676
+ db.refresh(svc)
677
+ return svc
678
+
679
+
680
+ def get_tm_service_by_id(db: Session, service_id: int):
681
+ """按 ID 获取 Team Manager 服务"""
682
+ from .models import TeamManagerService
683
+ return db.query(TeamManagerService).filter(TeamManagerService.id == service_id).first()
684
+
685
+
686
+ def get_tm_services(db: Session, enabled=None):
687
+ """获取 Team Manager 服务列表"""
688
+ from .models import TeamManagerService
689
+ q = db.query(TeamManagerService)
690
+ if enabled is not None:
691
+ q = q.filter(TeamManagerService.enabled == enabled)
692
+ return q.order_by(TeamManagerService.priority.asc(), TeamManagerService.id.asc()).all()
693
+
694
+
695
+ def update_tm_service(db: Session, service_id: int, **kwargs):
696
+ """更新 Team Manager 服务配置"""
697
+ svc = get_tm_service_by_id(db, service_id)
698
+ if not svc:
699
+ return None
700
+ for k, v in kwargs.items():
701
+ setattr(svc, k, v)
702
+ db.commit()
703
+ db.refresh(svc)
704
+ return svc
705
+
706
+
707
+ def delete_tm_service(db: Session, service_id: int) -> bool:
708
+ """删除 Team Manager 服务配置"""
709
+ svc = get_tm_service_by_id(db, service_id)
710
+ if not svc:
711
+ return False
712
+ db.delete(svc)
713
+ db.commit()
714
+ return True
src/database/init_db.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 数据库初始化和初始化数据
3
+ """
4
+
5
+ from .session import init_database
6
+ from .models import Base
7
+
8
+
9
+ def initialize_database(database_url: str = None):
10
+ """
11
+ 初始化数据库
12
+ 创建所有表并设置默认配置
13
+ """
14
+ # 初始化数据库连接和表
15
+ db_manager = init_database(database_url)
16
+
17
+ # 创建表
18
+ db_manager.create_tables()
19
+
20
+ # 初始化默认设置(从 settings 模块导入以避免循环导入)
21
+ from ..config.settings import init_default_settings
22
+ init_default_settings()
23
+
24
+ return db_manager
25
+
26
+
27
+ def reset_database(database_url: str = None):
28
+ """
29
+ 重置数据库(删除所有表并重新创建)
30
+ 警告:会丢失所有数据!
31
+ """
32
+ db_manager = init_database(database_url)
33
+
34
+ # 删除所有表
35
+ db_manager.drop_tables()
36
+ print("已删除所有表")
37
+
38
+ # 重新创建所有表
39
+ db_manager.create_tables()
40
+ print("已重新创建所有表")
41
+
42
+ # 初始化默认设置
43
+ from ..config.settings import init_default_settings
44
+ init_default_settings()
45
+
46
+ print("数据库重置完成")
47
+ return db_manager
48
+
49
+
50
+ def check_database_connection(database_url: str = None) -> bool:
51
+ """
52
+ 检查数据库连接是否正常
53
+ """
54
+ try:
55
+ db_manager = init_database(database_url)
56
+ with db_manager.get_db() as db:
57
+ # 尝试执行一个简单的查询
58
+ db.execute("SELECT 1")
59
+ print("数据库连接正常")
60
+ return True
61
+ except Exception as e:
62
+ print(f"数据库连接失败: {e}")
63
+ return False
64
+
65
+
66
+ if __name__ == "__main__":
67
+ # 当直接运行此脚本时,初始化数据库
68
+ import argparse
69
+
70
+ parser = argparse.ArgumentParser(description="数据库初始化脚本")
71
+ parser.add_argument("--reset", action="store_true", help="重置数据库(删除所有数据)")
72
+ parser.add_argument("--check", action="store_true", help="检查数据库连接")
73
+ parser.add_argument("--url", help="数据库连接字符串")
74
+
75
+ args = parser.parse_args()
76
+
77
+ if args.check:
78
+ check_database_connection(args.url)
79
+ elif args.reset:
80
+ confirm = input("警告:这将删除所有数据!确认重置?(y/N): ")
81
+ if confirm.lower() == 'y':
82
+ reset_database(args.url)
83
+ else:
84
+ print("操作已取消")
85
+ else:
86
+ initialize_database(args.url)
src/database/models.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLAlchemy ORM 模型定义
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Optional, Dict, Any
7
+ import json
8
+ from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
9
+ from sqlalchemy.ext.declarative import declarative_base
10
+ from sqlalchemy.types import TypeDecorator
11
+ from sqlalchemy.orm import relationship
12
+
13
+ Base = declarative_base()
14
+
15
+
16
+ class JSONEncodedDict(TypeDecorator):
17
+ """JSON 编码字典类型"""
18
+ impl = Text
19
+
20
+ def process_bind_param(self, value: Optional[Dict[str, Any]], dialect):
21
+ if value is None:
22
+ return None
23
+ return json.dumps(value, ensure_ascii=False)
24
+
25
+ def process_result_value(self, value: Optional[str], dialect):
26
+ if value is None:
27
+ return None
28
+ return json.loads(value)
29
+
30
+
31
+ class Account(Base):
32
+ """已注册账号表"""
33
+ __tablename__ = 'accounts'
34
+
35
+ id = Column(Integer, primary_key=True, autoincrement=True)
36
+ email = Column(String(255), nullable=False, unique=True, index=True)
37
+ password = Column(String(255)) # 注册密码(明文存储)
38
+ access_token = Column(Text)
39
+ refresh_token = Column(Text)
40
+ id_token = Column(Text)
41
+ session_token = Column(Text) # 会话令牌(优先刷新方式)
42
+ client_id = Column(String(255)) # OAuth Client ID
43
+ account_id = Column(String(255))
44
+ workspace_id = Column(String(255))
45
+ email_service = Column(String(50), nullable=False) # 'tempmail', 'outlook', 'moe_mail'
46
+ email_service_id = Column(String(255)) # 邮箱服务中的ID
47
+ proxy_used = Column(String(255))
48
+ registered_at = Column(DateTime, default=datetime.utcnow)
49
+ last_refresh = Column(DateTime) # 最后刷新时间
50
+ expires_at = Column(DateTime) # Token 过期时间
51
+ status = Column(String(20), default='active') # 'active', 'expired', 'banned', 'failed'
52
+ extra_data = Column(JSONEncodedDict) # 额外信息存储
53
+ cpa_uploaded = Column(Boolean, default=False) # 是否已上传到 CPA
54
+ cpa_uploaded_at = Column(DateTime) # 上传时间
55
+ source = Column(String(20), default='register') # 'register' 或 'login',区分账号来源
56
+ subscription_type = Column(String(20)) # None / 'plus' / 'team'
57
+ subscription_at = Column(DateTime) # 订阅开通时间
58
+ cookies = Column(Text) # 完整 cookie 字符串,用于支付请求
59
+ created_at = Column(DateTime, default=datetime.utcnow)
60
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
61
+
62
+ def to_dict(self) -> Dict[str, Any]:
63
+ """转换为字典"""
64
+ return {
65
+ 'id': self.id,
66
+ 'email': self.email,
67
+ 'password': self.password,
68
+ 'client_id': self.client_id,
69
+ 'email_service': self.email_service,
70
+ 'account_id': self.account_id,
71
+ 'workspace_id': self.workspace_id,
72
+ 'registered_at': self.registered_at.isoformat() if self.registered_at else None,
73
+ 'last_refresh': self.last_refresh.isoformat() if self.last_refresh else None,
74
+ 'expires_at': self.expires_at.isoformat() if self.expires_at else None,
75
+ 'status': self.status,
76
+ 'proxy_used': self.proxy_used,
77
+ 'cpa_uploaded': self.cpa_uploaded,
78
+ 'cpa_uploaded_at': self.cpa_uploaded_at.isoformat() if self.cpa_uploaded_at else None,
79
+ 'source': self.source,
80
+ 'subscription_type': self.subscription_type,
81
+ 'subscription_at': self.subscription_at.isoformat() if self.subscription_at else None,
82
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
83
+ 'updated_at': self.updated_at.isoformat() if self.updated_at else None
84
+ }
85
+
86
+
87
+ class EmailService(Base):
88
+ """邮箱服务配置表"""
89
+ __tablename__ = 'email_services'
90
+
91
+ id = Column(Integer, primary_key=True, autoincrement=True)
92
+ service_type = Column(String(50), nullable=False) # 'outlook', 'moe_mail'
93
+ name = Column(String(100), nullable=False)
94
+ config = Column(JSONEncodedDict, nullable=False) # 服务配置(加密存储)
95
+ enabled = Column(Boolean, default=True)
96
+ priority = Column(Integer, default=0) # 使用优先级
97
+ last_used = Column(DateTime)
98
+ created_at = Column(DateTime, default=datetime.utcnow)
99
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
100
+
101
+
102
+ class RegistrationTask(Base):
103
+ """注册任务表"""
104
+ __tablename__ = 'registration_tasks'
105
+
106
+ id = Column(Integer, primary_key=True, autoincrement=True)
107
+ task_uuid = Column(String(36), unique=True, nullable=False, index=True) # 任务唯一标识
108
+ status = Column(String(20), default='pending') # 'pending', 'running', 'completed', 'failed', 'cancelled'
109
+ email_service_id = Column(Integer, ForeignKey('email_services.id'), index=True) # 使用的邮箱服务
110
+ proxy = Column(String(255)) # 使用的代理
111
+ logs = Column(Text) # 注册过程日志
112
+ result = Column(JSONEncodedDict) # 注册���果
113
+ error_message = Column(Text)
114
+ created_at = Column(DateTime, default=datetime.utcnow)
115
+ started_at = Column(DateTime)
116
+ completed_at = Column(DateTime)
117
+
118
+ # 关系
119
+ email_service = relationship('EmailService')
120
+
121
+
122
+ class Setting(Base):
123
+ """系统设置表"""
124
+ __tablename__ = 'settings'
125
+
126
+ key = Column(String(100), primary_key=True)
127
+ value = Column(Text)
128
+ description = Column(Text)
129
+ category = Column(String(50), default='general') # 'general', 'email', 'proxy', 'openai'
130
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
131
+
132
+
133
+ class CpaService(Base):
134
+ """CPA 服务配置表"""
135
+ __tablename__ = 'cpa_services'
136
+
137
+ id = Column(Integer, primary_key=True, autoincrement=True)
138
+ name = Column(String(100), nullable=False) # 服务名称
139
+ api_url = Column(String(500), nullable=False) # API URL
140
+ api_token = Column(Text, nullable=False) # API Token
141
+ enabled = Column(Boolean, default=True)
142
+ priority = Column(Integer, default=0) # 优先级
143
+ created_at = Column(DateTime, default=datetime.utcnow)
144
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
145
+
146
+
147
+ class Sub2ApiService(Base):
148
+ """Sub2API 服务配置表"""
149
+ __tablename__ = 'sub2api_services'
150
+
151
+ id = Column(Integer, primary_key=True, autoincrement=True)
152
+ name = Column(String(100), nullable=False) # 服务名称
153
+ api_url = Column(String(500), nullable=False) # API URL (host)
154
+ api_key = Column(Text, nullable=False) # x-api-key
155
+ enabled = Column(Boolean, default=True)
156
+ priority = Column(Integer, default=0) # 优先级
157
+ created_at = Column(DateTime, default=datetime.utcnow)
158
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
159
+
160
+
161
+ class TeamManagerService(Base):
162
+ """Team Manager 服务配置表"""
163
+ __tablename__ = 'tm_services'
164
+
165
+ id = Column(Integer, primary_key=True, autoincrement=True)
166
+ name = Column(String(100), nullable=False) # 服务名称
167
+ api_url = Column(String(500), nullable=False) # API URL
168
+ api_key = Column(Text, nullable=False) # X-API-Key
169
+ enabled = Column(Boolean, default=True)
170
+ priority = Column(Integer, default=0) # 优先级
171
+ created_at = Column(DateTime, default=datetime.utcnow)
172
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
173
+
174
+
175
+ class Proxy(Base):
176
+ """代理列表表"""
177
+ __tablename__ = 'proxies'
178
+
179
+ id = Column(Integer, primary_key=True, autoincrement=True)
180
+ name = Column(String(100), nullable=False) # 代理名称
181
+ type = Column(String(20), nullable=False, default='http') # http, socks5
182
+ host = Column(String(255), nullable=False)
183
+ port = Column(Integer, nullable=False)
184
+ username = Column(String(100))
185
+ password = Column(String(255))
186
+ enabled = Column(Boolean, default=True)
187
+ is_default = Column(Boolean, default=False) # 是否为默认代理
188
+ priority = Column(Integer, default=0) # 优先级(保留字段)
189
+ last_used = Column(DateTime) # 最后使用时间
190
+ created_at = Column(DateTime, default=datetime.utcnow)
191
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
192
+
193
+ def to_dict(self, include_password: bool = False) -> Dict[str, Any]:
194
+ """转换为字典"""
195
+ result = {
196
+ 'id': self.id,
197
+ 'name': self.name,
198
+ 'type': self.type,
199
+ 'host': self.host,
200
+ 'port': self.port,
201
+ 'username': self.username,
202
+ 'enabled': self.enabled,
203
+ 'is_default': self.is_default or False,
204
+ 'priority': self.priority,
205
+ 'last_used': self.last_used.isoformat() if self.last_used else None,
206
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
207
+ 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
208
+ }
209
+ if include_password:
210
+ result['password'] = self.password
211
+ else:
212
+ result['has_password'] = bool(self.password)
213
+ return result
214
+
215
+ @property
216
+ def proxy_url(self) -> str:
217
+ """获取完整的代理 URL"""
218
+ if self.type == "http":
219
+ scheme = "http"
220
+ elif self.type == "socks5":
221
+ scheme = "socks5"
222
+ else:
223
+ scheme = self.type
224
+
225
+ auth = ""
226
+ if self.username and self.password:
227
+ auth = f"{self.username}:{self.password}@"
228
+
229
+ return f"{scheme}://{auth}{self.host}:{self.port}"
src/database/session.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 数据库会话管理
3
+ """
4
+
5
+ from contextlib import contextmanager
6
+ from typing import Generator
7
+ from sqlalchemy import create_engine, text
8
+ from sqlalchemy.orm import sessionmaker, Session
9
+ from sqlalchemy.exc import SQLAlchemyError
10
+ import os
11
+ import logging
12
+
13
+ from .models import Base
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _build_sqlalchemy_url(database_url: str) -> str:
19
+ if database_url.startswith("postgresql://"):
20
+ return "postgresql+psycopg://" + database_url[len("postgresql://"):]
21
+ if database_url.startswith("postgres://"):
22
+ return "postgresql+psycopg://" + database_url[len("postgres://"):]
23
+ return database_url
24
+
25
+
26
+ class DatabaseSessionManager:
27
+ """数据库会话管理器"""
28
+
29
+ def __init__(self, database_url: str = None):
30
+ if database_url is None:
31
+ env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
32
+ if env_url:
33
+ database_url = env_url
34
+ else:
35
+ # 优先使用 APP_DATA_DIR 环境变量(PyInstaller 打包后由 webui.py 设置)
36
+ data_dir = os.environ.get('APP_DATA_DIR') or os.path.join(
37
+ os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
38
+ 'data'
39
+ )
40
+ db_path = os.path.join(data_dir, 'database.db')
41
+ # 确保目录存在
42
+ os.makedirs(data_dir, exist_ok=True)
43
+ database_url = f"sqlite:///{db_path}"
44
+
45
+ self.database_url = _build_sqlalchemy_url(database_url)
46
+ self.engine = create_engine(
47
+ self.database_url,
48
+ connect_args={"check_same_thread": False} if self.database_url.startswith("sqlite") else {},
49
+ echo=False, # 设置为 True 可以查看所有 SQL 语句
50
+ pool_pre_ping=True # 连接池预检查
51
+ )
52
+ self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
53
+
54
+ def get_db(self) -> Generator[Session, None, None]:
55
+ """
56
+ 获取数据库会话的上下文管理器
57
+ 使用示例:
58
+ with get_db() as db:
59
+ # 使用 db 进行数据库操作
60
+ pass
61
+ """
62
+ db = self.SessionLocal()
63
+ try:
64
+ yield db
65
+ finally:
66
+ db.close()
67
+
68
+ @contextmanager
69
+ def session_scope(self) -> Generator[Session, None, None]:
70
+ """
71
+ 事务作用域上下文管理器
72
+ 使用示例:
73
+ with session_scope() as session:
74
+ # 数据库操作
75
+ pass
76
+ """
77
+ session = self.SessionLocal()
78
+ try:
79
+ yield session
80
+ session.commit()
81
+ except Exception as e:
82
+ session.rollback()
83
+ raise e
84
+ finally:
85
+ session.close()
86
+
87
+ def create_tables(self):
88
+ """创建所有表"""
89
+ Base.metadata.create_all(bind=self.engine)
90
+
91
+ def drop_tables(self):
92
+ """删除所有表(谨慎使用)"""
93
+ Base.metadata.drop_all(bind=self.engine)
94
+
95
+ def migrate_tables(self):
96
+ """
97
+ 数据库迁移 - 添加缺失的列
98
+ 用于在不删除数据的情况下更新表结构
99
+ """
100
+ if not self.database_url.startswith("sqlite"):
101
+ logger.info("非 SQLite 数据库,跳过自动迁移")
102
+ return
103
+
104
+ # 需要检查和添加的新列
105
+ migrations = [
106
+ # (表名, 列名, 列类型)
107
+ ("accounts", "cpa_uploaded", "BOOLEAN DEFAULT 0"),
108
+ ("accounts", "cpa_uploaded_at", "DATETIME"),
109
+ ("accounts", "source", "VARCHAR(20) DEFAULT 'register'"),
110
+ ("accounts", "subscription_type", "VARCHAR(20)"),
111
+ ("accounts", "subscription_at", "DATETIME"),
112
+ ("accounts", "cookies", "TEXT"),
113
+ ("proxies", "is_default", "BOOLEAN DEFAULT 0"),
114
+ ]
115
+
116
+ # 确保新表存在(create_tables 已处理,此处兜底)
117
+ Base.metadata.create_all(bind=self.engine)
118
+
119
+ with self.engine.connect() as conn:
120
+ # 数据迁移:将旧的 custom_domain 记录统一为 moe_mail
121
+ try:
122
+ conn.execute(text("UPDATE email_services SET service_type='moe_mail' WHERE service_type='custom_domain'"))
123
+ conn.execute(text("UPDATE accounts SET email_service='moe_mail' WHERE email_service='custom_domain'"))
124
+ conn.commit()
125
+ except Exception as e:
126
+ logger.warning(f"迁移 custom_domain -> moe_mail 时出错: {e}")
127
+
128
+ for table_name, column_name, column_type in migrations:
129
+ try:
130
+ # 检查列是否存在
131
+ result = conn.execute(text(
132
+ f"SELECT * FROM pragma_table_info('{table_name}') WHERE name='{column_name}'"
133
+ ))
134
+ if result.fetchone() is None:
135
+ # 列不存在,添加它
136
+ logger.info(f"添加列 {table_name}.{column_name}")
137
+ conn.execute(text(
138
+ f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}"
139
+ ))
140
+ conn.commit()
141
+ logger.info(f"成功添加列 {table_name}.{column_name}")
142
+ except Exception as e:
143
+ logger.warning(f"迁移列 {table_name}.{column_name} 时出错: {e}")
144
+
145
+
146
+ # 全局数据库会话管理器实例
147
+ _db_manager: DatabaseSessionManager = None
148
+
149
+
150
+ def init_database(database_url: str = None) -> DatabaseSessionManager:
151
+ """
152
+ 初始化数据库会话管理器
153
+ """
154
+ global _db_manager
155
+ if _db_manager is None:
156
+ _db_manager = DatabaseSessionManager(database_url)
157
+ _db_manager.create_tables()
158
+ # 执行数据库迁移
159
+ _db_manager.migrate_tables()
160
+ return _db_manager
161
+
162
+
163
+ def get_session_manager() -> DatabaseSessionManager:
164
+ """
165
+ 获取数据库会话管理器
166
+ """
167
+ if _db_manager is None:
168
+ raise RuntimeError("数据库未初始化,请先调用 init_database()")
169
+ return _db_manager
170
+
171
+
172
+ @contextmanager
173
+ def get_db() -> Generator[Session, None, None]:
174
+ """
175
+ 获取数据库会话的快捷函数
176
+ """
177
+ manager = get_session_manager()
178
+ db = manager.SessionLocal()
179
+ try:
180
+ yield db
181
+ finally:
182
+ db.close()
src/services/__init__.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 邮箱服务模块
3
+ """
4
+
5
+ from .base import (
6
+ BaseEmailService,
7
+ EmailServiceError,
8
+ EmailServiceStatus,
9
+ EmailServiceFactory,
10
+ create_email_service,
11
+ EmailServiceType
12
+ )
13
+ from .tempmail import TempmailService
14
+ from .outlook import OutlookService
15
+ from .moe_mail import MeoMailEmailService
16
+ from .temp_mail import TempMailService
17
+ from .duck_mail import DuckMailService
18
+ from .freemail import FreemailService
19
+ from .imap_mail import ImapMailService
20
+ from .cloud_mail import CloudMailService
21
+
22
+ # 注册服务
23
+ EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
24
+ EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService)
25
+ EmailServiceFactory.register(EmailServiceType.MOE_MAIL, MeoMailEmailService)
26
+ EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService)
27
+ EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService)
28
+ EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService)
29
+ EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService)
30
+ EmailServiceFactory.register(EmailServiceType.CLOUD_MAIL, CloudMailService)
31
+
32
+ # 导出 Outlook 模块的额外内容
33
+ from .outlook.base import (
34
+ ProviderType,
35
+ EmailMessage,
36
+ TokenInfo,
37
+ ProviderHealth,
38
+ ProviderStatus,
39
+ )
40
+ from .outlook.account import OutlookAccount
41
+ from .outlook.providers import (
42
+ OutlookProvider,
43
+ IMAPOldProvider,
44
+ IMAPNewProvider,
45
+ GraphAPIProvider,
46
+ )
47
+
48
+ __all__ = [
49
+ # 基类
50
+ 'BaseEmailService',
51
+ 'EmailServiceError',
52
+ 'EmailServiceStatus',
53
+ 'EmailServiceFactory',
54
+ 'create_email_service',
55
+ 'EmailServiceType',
56
+ # 服务类
57
+ 'TempmailService',
58
+ 'OutlookService',
59
+ 'MeoMailEmailService',
60
+ 'TempMailService',
61
+ 'DuckMailService',
62
+ 'FreemailService',
63
+ 'ImapMailService',
64
+ 'CloudMailService',
65
+ # Outlook 模块
66
+ 'ProviderType',
67
+ 'EmailMessage',
68
+ 'TokenInfo',
69
+ 'ProviderHealth',
70
+ 'ProviderStatus',
71
+ 'OutlookAccount',
72
+ 'OutlookProvider',
73
+ 'IMAPOldProvider',
74
+ 'IMAPNewProvider',
75
+ 'GraphAPIProvider',
76
+ ]
src/services/base.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 邮箱服务抽象基类
3
+ 所有邮箱服务实现的基类
4
+ """
5
+
6
+ import abc
7
+ import logging
8
+ from typing import Optional, Dict, Any, List
9
+ from enum import Enum
10
+
11
+ from ..config.constants import EmailServiceType
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class EmailServiceError(Exception):
18
+ """邮箱服务异常"""
19
+ pass
20
+
21
+
22
+ class EmailServiceStatus(Enum):
23
+ """邮箱服务状态"""
24
+ HEALTHY = "healthy"
25
+ DEGRADED = "degraded"
26
+ UNAVAILABLE = "unavailable"
27
+
28
+
29
+ class BaseEmailService(abc.ABC):
30
+ """
31
+ 邮箱服务抽象基类
32
+
33
+ 所有邮箱服务必须实现此接口
34
+ """
35
+
36
+ def __init__(self, service_type: EmailServiceType, name: str = None):
37
+ """
38
+ 初始化邮箱服务
39
+
40
+ Args:
41
+ service_type: 服务类型
42
+ name: 服务名称
43
+ """
44
+ self.service_type = service_type
45
+ self.name = name or f"{service_type.value}_service"
46
+ self._status = EmailServiceStatus.HEALTHY
47
+ self._last_error = None
48
+
49
+ @property
50
+ def status(self) -> EmailServiceStatus:
51
+ """获取服务状态"""
52
+ return self._status
53
+
54
+ @property
55
+ def last_error(self) -> Optional[str]:
56
+ """获取最后一次错误信息"""
57
+ return self._last_error
58
+
59
+ @abc.abstractmethod
60
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
61
+ """
62
+ 创建新邮箱地址
63
+
64
+ Args:
65
+ config: 配置参数,如邮箱前缀、域名等
66
+
67
+ Returns:
68
+ 包含邮箱信息的字典,至少包含:
69
+ - email: 邮箱地址
70
+ - service_id: 邮箱服务中的 ID
71
+ - token/credentials: 访问凭证(如果需要)
72
+
73
+ Raises:
74
+ EmailServiceError: 创建失败
75
+ """
76
+ pass
77
+
78
+ @abc.abstractmethod
79
+ def get_verification_code(
80
+ self,
81
+ email: str,
82
+ email_id: str = None,
83
+ timeout: int = 120,
84
+ pattern: str = r"(?<!\d)(\d{6})(?!\d)",
85
+ otp_sent_at: Optional[float] = None,
86
+ ) -> Optional[str]:
87
+ """
88
+ 获取验证码
89
+
90
+ Args:
91
+ email: 邮箱地址
92
+ email_id: 邮箱服务中的 ID(如果需要)
93
+ timeout: 超时时间(秒)
94
+ pattern: 验证码正则表达式
95
+ otp_sent_at: OTP 发送时间戳,用于过滤旧邮件
96
+
97
+ Returns:
98
+ 验证码字符串,如果超时或未找到返回 None
99
+
100
+ Raises:
101
+ EmailServiceError: 服务错误
102
+ """
103
+ pass
104
+
105
+ @abc.abstractmethod
106
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
107
+ """
108
+ 列出所有邮箱(如果服务支持)
109
+
110
+ Args:
111
+ **kwargs: 其他参数
112
+
113
+ Returns:
114
+ 邮箱列表
115
+
116
+ Raises:
117
+ EmailServiceError: 服务错误
118
+ """
119
+ pass
120
+
121
+ @abc.abstractmethod
122
+ def delete_email(self, email_id: str) -> bool:
123
+ """
124
+ 删除邮箱
125
+
126
+ Args:
127
+ email_id: 邮箱服务中的 ID
128
+
129
+ Returns:
130
+ 是否删除成功
131
+
132
+ Raises:
133
+ EmailServiceError: 服务错误
134
+ """
135
+ pass
136
+
137
+ @abc.abstractmethod
138
+ def check_health(self) -> bool:
139
+ """
140
+ 检查服务健康状态
141
+
142
+ Returns:
143
+ 服务是否健康
144
+
145
+ Note:
146
+ 此方法不应抛出异常,应捕获异常并返回 False
147
+ """
148
+ pass
149
+
150
+ def get_email_info(self, email_id: str) -> Optional[Dict[str, Any]]:
151
+ """
152
+ 获取邮箱信息(可选实现)
153
+
154
+ Args:
155
+ email_id: 邮箱服务中的 ID
156
+
157
+ Returns:
158
+ 邮箱信息字典,如果不存在返回 None
159
+ """
160
+ # 默认实现:遍历列表查找
161
+ for email_info in self.list_emails():
162
+ if email_info.get("id") == email_id:
163
+ return email_info
164
+ return None
165
+
166
+ def wait_for_email(
167
+ self,
168
+ email: str,
169
+ email_id: str = None,
170
+ timeout: int = 120,
171
+ check_interval: int = 3,
172
+ expected_sender: str = None,
173
+ expected_subject: str = None
174
+ ) -> Optional[Dict[str, Any]]:
175
+ """
176
+ 等待并获取邮件(可选实现)
177
+
178
+ Args:
179
+ email: 邮箱地址
180
+ email_id: 邮箱服务中的 ID
181
+ timeout: 超时时间(秒)
182
+ check_interval: 检查间隔(秒)
183
+ expected_sender: 期望的发件人(包含检查)
184
+ expected_subject: 期望的主题(包含检查)
185
+
186
+ Returns:
187
+ 邮件信息字典,如果超时返回 None
188
+ """
189
+ import time
190
+ from datetime import datetime
191
+
192
+ start_time = time.time()
193
+ last_email_id = None
194
+
195
+ while time.time() - start_time < timeout:
196
+ try:
197
+ emails = self.list_emails()
198
+ for email_info in emails:
199
+ email_data = email_info.get("email", {})
200
+ current_email_id = email_info.get("id")
201
+
202
+ # 检查是否是新的邮件
203
+ if last_email_id and current_email_id == last_email_id:
204
+ continue
205
+
206
+ # 检查邮箱地址
207
+ if email_data.get("address") != email:
208
+ continue
209
+
210
+ # 获取邮件列表
211
+ messages = self.get_email_messages(email_id or current_email_id)
212
+ for message in messages:
213
+ # 检查发件人
214
+ if expected_sender and expected_sender not in message.get("from", ""):
215
+ continue
216
+
217
+ # 检查主题
218
+ if expected_subject and expected_subject not in message.get("subject", ""):
219
+ continue
220
+
221
+ # 返回邮件信息
222
+ return {
223
+ "id": message.get("id"),
224
+ "from": message.get("from"),
225
+ "subject": message.get("subject"),
226
+ "content": message.get("content"),
227
+ "received_at": message.get("received_at"),
228
+ "email_info": email_info
229
+ }
230
+
231
+ # 更新最后检查的邮件 ID
232
+ if messages:
233
+ last_email_id = current_email_id
234
+
235
+ except Exception as e:
236
+ logger.warning(f"等待邮件时出错: {e}")
237
+
238
+ time.sleep(check_interval)
239
+
240
+ return None
241
+
242
+ def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]:
243
+ """
244
+ 获取邮箱中的邮件列表(可选实现)
245
+
246
+ Args:
247
+ email_id: 邮箱服务中的 ID
248
+ **kwargs: 其他参数
249
+
250
+ Returns:
251
+ 邮件列表
252
+
253
+ Note:
254
+ 这是可选方法,某些服务可能不支持
255
+ """
256
+ raise NotImplementedError("此邮箱服务不支持获取邮件列表")
257
+
258
+ def get_message_content(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]:
259
+ """
260
+ 获取邮件内容(可选实现)
261
+
262
+ Args:
263
+ email_id: 邮箱服务中的 ID
264
+ message_id: 邮件 ID
265
+
266
+ Returns:
267
+ 邮件内容字典
268
+
269
+ Note:
270
+ 这是可选方法,某些服务可能不支持
271
+ """
272
+ raise NotImplementedError("此邮箱服务不支持获取邮件内容")
273
+
274
+ def update_status(self, success: bool, error: Exception = None):
275
+ """
276
+ 更新服务状态
277
+
278
+ Args:
279
+ success: 操作是否成功
280
+ error: 错误信息
281
+ """
282
+ if success:
283
+ self._status = EmailServiceStatus.HEALTHY
284
+ self._last_error = None
285
+ else:
286
+ self._status = EmailServiceStatus.DEGRADED
287
+ if error:
288
+ self._last_error = str(error)
289
+
290
+ def __str__(self) -> str:
291
+ """字符串表示"""
292
+ return f"{self.name} ({self.service_type.value})"
293
+
294
+
295
+ class EmailServiceFactory:
296
+ """邮箱服务工厂"""
297
+
298
+ _registry: Dict[EmailServiceType, type] = {}
299
+
300
+ @classmethod
301
+ def register(cls, service_type: EmailServiceType, service_class: type):
302
+ """
303
+ 注册邮箱服务类
304
+
305
+ Args:
306
+ service_type: 服务类型
307
+ service_class: 服务类
308
+ """
309
+ if not issubclass(service_class, BaseEmailService):
310
+ raise TypeError(f"{service_class} 必须是 BaseEmailService 的子类")
311
+ cls._registry[service_type] = service_class
312
+ logger.info(f"注册邮箱服务: {service_type.value} -> {service_class.__name__}")
313
+
314
+ @classmethod
315
+ def create(
316
+ cls,
317
+ service_type: EmailServiceType,
318
+ config: Dict[str, Any],
319
+ name: str = None
320
+ ) -> BaseEmailService:
321
+ """
322
+ 创建邮箱服务实例
323
+
324
+ Args:
325
+ service_type: 服务类型
326
+ config: 服务配置
327
+ name: 服务名称
328
+
329
+ Returns:
330
+ 邮箱服务实例
331
+
332
+ Raises:
333
+ ValueError: 服务类型未注册或配置无效
334
+ """
335
+ if service_type not in cls._registry:
336
+ raise ValueError(f"未注册的服务类型: {service_type.value}")
337
+
338
+ service_class = cls._registry[service_type]
339
+ try:
340
+ instance = service_class(config, name)
341
+ return instance
342
+ except Exception as e:
343
+ raise ValueError(f"创建邮箱服务失败: {e}")
344
+
345
+ @classmethod
346
+ def get_available_services(cls) -> List[EmailServiceType]:
347
+ """
348
+ 获取所有已注册的服务类型
349
+
350
+ Returns:
351
+ 已注册的服务类型列表
352
+ """
353
+ return list(cls._registry.keys())
354
+
355
+ @classmethod
356
+ def get_service_class(cls, service_type: EmailServiceType) -> Optional[type]:
357
+ """
358
+ 获取服务类
359
+
360
+ Args:
361
+ service_type: 服务类型
362
+
363
+ Returns:
364
+ 服务类,如果未注册返回 None
365
+ """
366
+ return cls._registry.get(service_type)
367
+
368
+
369
+ # 简化的工厂函数
370
+ def create_email_service(
371
+ service_type: EmailServiceType,
372
+ config: Dict[str, Any],
373
+ name: str = None
374
+ ) -> BaseEmailService:
375
+ """
376
+ 创建邮箱服务(简化工厂函数)
377
+
378
+ Args:
379
+ service_type: 服务类型
380
+ config: 服务配置
381
+ name: 服务名称
382
+
383
+ Returns:
384
+ 邮箱服务实例
385
+ """
386
+ return EmailServiceFactory.create(service_type, config, name)
src/services/cloud_mail.py ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cloud Mail 邮箱服务实现
3
+ 基于 Cloudflare Workers 的邮箱服务 (https://doc.skymail.ink)
4
+ """
5
+
6
+ import re
7
+ import sys
8
+ import time
9
+ import logging
10
+ import random
11
+ import string
12
+ import requests
13
+ from typing import Optional, Dict, Any, List
14
+ from datetime import datetime
15
+
16
+ from .base import BaseEmailService, EmailServiceError, EmailServiceType
17
+ from ..config.constants import OTP_CODE_PATTERN
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class CloudMailService(BaseEmailService):
23
+ """
24
+ Cloud Mail 邮箱服务
25
+ 基于 Cloudflare Workers 的自部署邮箱服务
26
+ """
27
+
28
+ # 类变量:所有实例共享token(按base_url区分)
29
+ _shared_tokens: Dict[str, tuple] = {} # {base_url: (token, expires_at)}
30
+ _token_lock = None # 延迟初始化
31
+ _seen_ids_lock = None # seen_email_ids 的锁
32
+ _shared_seen_email_ids: Dict[str, set] = {} # 所有实例共享已处理的邮件ID(按邮箱地址区分)
33
+
34
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
35
+ """
36
+ 初始化 Cloud Mail 服务
37
+
38
+ Args:
39
+ config: 配置字典,支持以下键:
40
+ - base_url: API 基础地址 (必需)
41
+ - admin_email: 管理员邮箱 (必需)
42
+ - admin_password: 管理员密码 (必需)
43
+ - domain: 邮箱域名 (可选,用于生成邮箱地址)
44
+ - subdomain: 子域名 (可选),会插入到 @ 和域名之间,例如 subdomain="test" 会生成 xxx@test.example.com
45
+ - timeout: 请求超时时间,默认 30
46
+ - max_retries: 最大重试次数,默认 3
47
+ - proxy_url: 代理地址 (可选)
48
+ name: 服务名称
49
+ """
50
+ super().__init__(EmailServiceType.CLOUD_MAIL, name)
51
+
52
+ required_keys = ["base_url", "admin_email", "admin_password"]
53
+ missing_keys = [key for key in required_keys if not (config or {}).get(key)]
54
+ if missing_keys:
55
+ raise ValueError(f"缺少必需配置: {missing_keys}")
56
+
57
+ default_config = {
58
+ "timeout": 30,
59
+ "max_retries": 3,
60
+ "proxy_url": None,
61
+ }
62
+ self.config = {**default_config, **(config or {})}
63
+ self.config["base_url"] = self.config["base_url"].rstrip("/")
64
+
65
+ # 创建 requests session
66
+ self.session = requests.Session()
67
+ self.session.headers.update({
68
+ "Accept": "application/json",
69
+ "Content-Type": "application/json",
70
+ })
71
+
72
+ # 初始化类级别的锁(线程安全)
73
+ if CloudMailService._token_lock is None:
74
+ import threading
75
+ CloudMailService._token_lock = threading.Lock()
76
+ CloudMailService._seen_ids_lock = threading.Lock()
77
+
78
+ # 缓存邮箱信息(实例级别)
79
+ self._created_emails: Dict[str, Dict[str, Any]] = {}
80
+
81
+ def _generate_token(self) -> str:
82
+ """
83
+ 生成身份令牌
84
+
85
+ Returns:
86
+ token 字符串
87
+
88
+ Raises:
89
+ EmailServiceError: 生成失败
90
+ """
91
+ url = f"{self.config['base_url']}/api/public/genToken"
92
+ payload = {
93
+ "email": self.config["admin_email"],
94
+ "password": self.config["admin_password"]
95
+ }
96
+
97
+ try:
98
+ response = self.session.post(
99
+ url,
100
+ json=payload,
101
+ timeout=self.config["timeout"]
102
+ )
103
+
104
+ if response.status_code >= 400:
105
+ error_msg = f"生成 token 失败: {response.status_code}"
106
+ try:
107
+ error_data = response.json()
108
+ error_msg = f"{error_msg} - {error_data}"
109
+ except Exception:
110
+ error_msg = f"{error_msg} - {response.text[:200]}"
111
+ raise EmailServiceError(error_msg)
112
+
113
+ data = response.json()
114
+ if data.get("code") != 200:
115
+ raise EmailServiceError(f"生成 token 失败: {data.get('message', 'Unknown error')}")
116
+
117
+ token = data.get("data", {}).get("token")
118
+ if not token:
119
+ raise EmailServiceError("生成 token 失败: 未返回 token")
120
+
121
+ return token
122
+
123
+ except requests.RequestException as e:
124
+ self.update_status(False, e)
125
+ raise EmailServiceError(f"生成 token 失败: {e}")
126
+ except Exception as e:
127
+ self.update_status(False, e)
128
+ if isinstance(e, EmailServiceError):
129
+ raise
130
+ raise EmailServiceError(f"生成 token 失败: {e}")
131
+
132
+ def _get_token(self, force_refresh: bool = False) -> str:
133
+ """
134
+ 获取有效的 token(带缓存,所有实例共享)
135
+
136
+ Args:
137
+ force_refresh: 是否强制刷新
138
+
139
+ Returns:
140
+ token 字符串
141
+ """
142
+ base_url = self.config["base_url"]
143
+
144
+ with CloudMailService._token_lock:
145
+ # 检查共享缓存���token 有效期设为 1 小时)
146
+ if not force_refresh and base_url in CloudMailService._shared_tokens:
147
+ token, expires_at = CloudMailService._shared_tokens[base_url]
148
+ if time.time() < expires_at:
149
+ return token
150
+
151
+ # 生成新 token
152
+ token = self._generate_token()
153
+ expires_at = time.time() + 3600 # 1 小时后过期
154
+ CloudMailService._shared_tokens[base_url] = (token, expires_at)
155
+ return token
156
+
157
+ def _get_headers(self, token: Optional[str] = None) -> Dict[str, str]:
158
+ """构造请求头"""
159
+ if token is None:
160
+ token = self._get_token()
161
+
162
+ return {
163
+ "Authorization": token,
164
+ "Content-Type": "application/json",
165
+ "Accept": "application/json",
166
+ }
167
+
168
+ def _make_request(
169
+ self,
170
+ method: str,
171
+ path: str,
172
+ retry_on_auth_error: bool = True,
173
+ **kwargs
174
+ ) -> Any:
175
+ """
176
+ 发送请求并返回 JSON 数据
177
+
178
+ Args:
179
+ method: HTTP 方法
180
+ path: 请求路径(以 / 开头)
181
+ retry_on_auth_error: 认证失败时是否重试
182
+ **kwargs: 传递给 requests 的额外参数
183
+
184
+ Returns:
185
+ 响应 JSON 数据
186
+
187
+ Raises:
188
+ EmailServiceError: 请求失败
189
+ """
190
+ url = f"{self.config['base_url']}{path}"
191
+ kwargs.setdefault("headers", {})
192
+ kwargs["headers"].update(self._get_headers())
193
+ kwargs.setdefault("timeout", self.config["timeout"])
194
+
195
+ try:
196
+ response = self.session.request(method, url, **kwargs)
197
+
198
+ if response.status_code >= 400:
199
+ # 如果是认证错误且允许重试,刷新 token 后重试一次
200
+ if response.status_code == 401 and retry_on_auth_error:
201
+ logger.warning("Cloud Mail 认证失败,尝试刷新 token")
202
+ kwargs["headers"].update(self._get_headers(self._get_token(force_refresh=True)))
203
+ response = self.session.request(method, url, **kwargs)
204
+
205
+ if response.status_code >= 400:
206
+ error_msg = f"请求失败: {response.status_code}"
207
+ try:
208
+ error_data = response.json()
209
+ error_msg = f"{error_msg} - {error_data}"
210
+ except Exception:
211
+ error_msg = f"{error_msg} - {response.text[:200]}"
212
+ self.update_status(False, EmailServiceError(error_msg))
213
+ raise EmailServiceError(error_msg)
214
+
215
+ try:
216
+ return response.json()
217
+ except Exception:
218
+ return {"raw_response": response.text}
219
+
220
+ except requests.RequestException as e:
221
+ self.update_status(False, e)
222
+ raise EmailServiceError(f"请求失败: {method} {path} - {e}")
223
+ except Exception as e:
224
+ self.update_status(False, e)
225
+ if isinstance(e, EmailServiceError):
226
+ raise
227
+ raise EmailServiceError(f"请求失败: {method} {path} - {e}")
228
+
229
+ def _generate_email_address(self, prefix: Optional[str] = None, domain: Optional[str] = None, subdomain: Optional[str] = None) -> str:
230
+ """
231
+ 生成邮箱地址
232
+
233
+ Args:
234
+ prefix: 邮箱前缀,如果不提供则随机生成
235
+ domain: 指定域名,如果不提供则从配置中选择
236
+ subdomain: 子域名,可选参数,会插入到 @ 和域名之间
237
+
238
+ Returns:
239
+ 完整的邮箱地址
240
+ """
241
+ if not prefix:
242
+ # 生成随机前缀:首字母 + 9位随机字符(共10位)
243
+ first = random.choice(string.ascii_lowercase)
244
+ rest = "".join(random.choices(string.ascii_lowercase + string.digits, k=9))
245
+ prefix = f"{first}{rest}"
246
+
247
+ # 如果没有指定域名,从配置中获取
248
+ if not domain:
249
+ domain_config = self.config.get("domain")
250
+ if not domain_config:
251
+ raise EmailServiceError("未配置邮箱域名,无法生成邮箱地址")
252
+
253
+ # 支持多个域名(列表)或单个域名(字符串)
254
+ if isinstance(domain_config, list):
255
+ if not domain_config:
256
+ raise EmailServiceError("域名列表为空")
257
+ # 随机选择一个域名
258
+ domain = random.choice(domain_config)
259
+ else:
260
+ domain = domain_config
261
+
262
+ # 如果提供了子域,插入到域名前面
263
+ if subdomain:
264
+ domain = f"{subdomain}.{domain}"
265
+
266
+ return f"{prefix}@{domain}"
267
+
268
+ def _generate_password(self, length: int = 12) -> str:
269
+ """生成随机密码"""
270
+ alphabet = string.ascii_letters + string.digits
271
+ return "".join(random.choices(alphabet, k=length))
272
+
273
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
274
+ """
275
+ 创建新邮箱地址
276
+
277
+ Args:
278
+ config: 配置参数:
279
+ - name: 邮箱前缀(可选)
280
+ - password: 邮箱密码(可选,不提供则自动生成)
281
+ - domain: 邮箱域名(可选,覆盖默认域名)
282
+ - subdomain: 子域名(可选),会插入到 @ 和域名之间,例如 subdomain="test" 会生成 xxx@test.example.com
283
+
284
+ Returns:
285
+ 包含邮箱信息的字典:
286
+ - email: 邮箱地址
287
+ - service_id: 邮箱地址(用作标识)
288
+ - password: 邮箱密码
289
+ """
290
+ req_config = config or {}
291
+
292
+ # 生成邮箱地址
293
+ prefix = req_config.get("name")
294
+ specified_domain = req_config.get("domain")
295
+ subdomain = req_config.get("subdomain") or self.config.get("subdomain")
296
+
297
+ if specified_domain:
298
+ email_address = self._generate_email_address(prefix, specified_domain, subdomain)
299
+ else:
300
+ email_address = self._generate_email_address(prefix, subdomain=subdomain)
301
+
302
+ # 生成或使用提供的密码
303
+ password = req_config.get("password") or self._generate_password()
304
+
305
+ # 直接生成邮箱信息(catch-all 域名无需预先创建)
306
+ email_info = {
307
+ "email": email_address,
308
+ "service_id": email_address,
309
+ "id": email_address,
310
+ "password": password,
311
+ "created_at": time.time(),
312
+ }
313
+
314
+ # 缓存邮箱信息
315
+ self._created_emails[email_address] = email_info
316
+ self.update_status(True)
317
+
318
+ logger.info(f"生成 CloudMail 邮箱: {email_address}")
319
+ return email_info
320
+
321
+ def get_verification_code(
322
+ self,
323
+ email: str,
324
+ email_id: str = None,
325
+ timeout: int = 120,
326
+ pattern: str = OTP_CODE_PATTERN,
327
+ otp_sent_at: Optional[float] = None,
328
+ ) -> Optional[str]:
329
+ """
330
+ 从 Cloud Mail 邮箱获取验证码
331
+
332
+ Args:
333
+ email: 邮箱地址
334
+ email_id: 未使用,保留接口兼容
335
+ timeout: 超时时间(秒)
336
+ pattern: 验证码正则
337
+ otp_sent_at: OTP 发送时间戳
338
+
339
+ Returns:
340
+ 验证码字符串,超时返回 None
341
+ """
342
+ start_time = time.time()
343
+
344
+ # 每次调用时,记录本次查询开始前已存在的邮件ID
345
+ # 这样可以支持同一个邮箱多次接收验证码(注册+OAuth)
346
+ initial_seen_ids = set()
347
+ with CloudMailService._seen_ids_lock:
348
+ if email not in CloudMailService._shared_seen_email_ids:
349
+ CloudMailService._shared_seen_email_ids[email] = set()
350
+ else:
351
+ # 记录本次查询开始前的已处理邮件
352
+ initial_seen_ids = CloudMailService._shared_seen_email_ids[email].copy()
353
+
354
+ # 本次查询中新处理的邮件ID(仅在本次查询中有效)
355
+ current_seen_ids = set()
356
+
357
+ check_count = 0
358
+
359
+ while time.time() - start_time < timeout:
360
+ try:
361
+ check_count += 1
362
+
363
+ # 查询邮件列表
364
+ url_path = "/api/public/emailList"
365
+ payload = {
366
+ "toEmail": email,
367
+ "timeSort": "desc" # 最新的邮件优先
368
+ }
369
+
370
+ result = self._make_request("POST", url_path, json=payload)
371
+
372
+ if result.get("code") != 200:
373
+ time.sleep(3)
374
+ continue
375
+
376
+ emails = result.get("data", [])
377
+ if not isinstance(emails, list):
378
+ time.sleep(3)
379
+ continue
380
+
381
+ for email_item in emails:
382
+ email_id = email_item.get("emailId")
383
+
384
+ if not email_id:
385
+ continue
386
+
387
+ # 跳过本次查询开始前已存在的邮件
388
+ if email_id in initial_seen_ids:
389
+ continue
390
+
391
+ # 跳过本次查询中已处理的邮件(防止同一轮查询重复处理)
392
+ if email_id in current_seen_ids:
393
+ continue
394
+
395
+ # 标记为本次已处理
396
+ current_seen_ids.add(email_id)
397
+
398
+ # 同时更新全局已处理列表(防止其他并发任务重复处理)
399
+ with CloudMailService._seen_ids_lock:
400
+ CloudMailService._shared_seen_email_ids[email].add(email_id)
401
+
402
+ sender_email = str(email_item.get("sendEmail", "")).lower()
403
+ sender_name = str(email_item.get("sendName", "")).lower()
404
+ subject = str(email_item.get("subject", ""))
405
+ to_email = email_item.get("toEmail", "")
406
+
407
+ # 检查收件人是否匹配
408
+ if to_email != email:
409
+ continue
410
+
411
+ if "openai" not in sender_email and "openai" not in sender_name:
412
+ continue
413
+
414
+ # 从主题提取
415
+ match = re.search(pattern, subject)
416
+ if match:
417
+ code = match.group(1)
418
+ self.update_status(True)
419
+ return code
420
+
421
+ # 从内容提取
422
+ content = str(email_item.get("content", ""))
423
+ if content:
424
+ clean_content = re.sub(r"<[^>]+>", " ", content)
425
+ email_pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
426
+ clean_content = re.sub(email_pattern, "", clean_content)
427
+
428
+ match = re.search(pattern, clean_content)
429
+ if match:
430
+ code = match.group(1)
431
+ self.update_status(True)
432
+ return code
433
+
434
+ except Exception as e:
435
+ # 如果是认证错误,强制刷新token
436
+ if "401" in str(e) or "认证" in str(e):
437
+ try:
438
+ self._get_token(force_refresh=True)
439
+ except Exception:
440
+ pass
441
+ logger.error(f"检查邮件时出错: {e}", exc_info=True)
442
+
443
+ time.sleep(3)
444
+
445
+ # 超时
446
+ logger.warning(f"等待验证码超时: {email}")
447
+ return None
448
+
449
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
450
+ """
451
+ 列出已创建的邮箱(从缓存中获取)
452
+
453
+ Returns:
454
+ 邮箱列表
455
+ """
456
+ return list(self._created_emails.values())
457
+
458
+ def delete_email(self, email_id: str) -> bool:
459
+ """
460
+ 删除邮箱(Cloud Mail API 不支持删除用户,仅从缓存中移除)
461
+
462
+ Args:
463
+ email_id: 邮箱地址
464
+
465
+ Returns:
466
+ 是否删除成功
467
+ """
468
+ if email_id in self._created_emails:
469
+ del self._created_emails[email_id]
470
+ return True
471
+
472
+ return False
473
+
474
+ def check_health(self) -> bool:
475
+ """检查服务健康状态"""
476
+ try:
477
+ # 尝试生成 token
478
+ self._get_token(force_refresh=True)
479
+ self.update_status(True)
480
+ return True
481
+ except Exception as e:
482
+ logger.warning(f"Cloud Mail 健康检查失败: {e}")
483
+ self.update_status(False, e)
484
+ return False
485
+
486
+ def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]:
487
+ """
488
+ 获取邮箱中的邮件列表
489
+
490
+ Args:
491
+ email_id: 邮箱地址
492
+ **kwargs: 额外参数(如 timeSort)
493
+
494
+ Returns:
495
+ 邮件列表
496
+ """
497
+ try:
498
+ url_path = "/api/public/emailList"
499
+ payload = {
500
+ "toEmail": email_id,
501
+ "timeSort": kwargs.get("timeSort", "desc")
502
+ }
503
+
504
+ result = self._make_request("POST", url_path, json=payload)
505
+
506
+ if result.get("code") != 200:
507
+ logger.warning(f"获取邮件列表失败: {result.get('message')}")
508
+ return []
509
+
510
+ self.update_status(True)
511
+ return result.get("data", [])
512
+
513
+ except Exception as e:
514
+ logger.error(f"获取 Cloud Mail 邮件列表失败: {email_id} - {e}")
515
+ self.update_status(False, e)
516
+ return []
517
+
518
+ def get_service_info(self) -> Dict[str, Any]:
519
+ """获取服务信息"""
520
+ return {
521
+ "service_type": self.service_type.value,
522
+ "name": self.name,
523
+ "base_url": self.config["base_url"],
524
+ "admin_email": self.config["admin_email"],
525
+ "domain": self.config.get("domain"),
526
+ "subdomain": self.config.get("subdomain"),
527
+ "cached_emails_count": len(self._created_emails),
528
+ "status": self.status.value,
529
+ }
src/services/duck_mail.py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DuckMail 邮箱服务实现
3
+ 兼容 DuckMail 的 accounts/token/messages 接口模型
4
+ """
5
+
6
+ import logging
7
+ import random
8
+ import re
9
+ import string
10
+ import time
11
+ from datetime import datetime, timezone
12
+ from html import unescape
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from .base import BaseEmailService, EmailServiceError, EmailServiceType
16
+ from ..config.constants import OTP_CODE_PATTERN
17
+ from ..core.http_client import HTTPClient, RequestConfig
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class DuckMailService(BaseEmailService):
24
+ """DuckMail 邮箱服务"""
25
+
26
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
27
+ super().__init__(EmailServiceType.DUCK_MAIL, name)
28
+
29
+ required_keys = ["base_url", "default_domain"]
30
+ missing_keys = [key for key in required_keys if not (config or {}).get(key)]
31
+ if missing_keys:
32
+ raise ValueError(f"缺少必需配置: {missing_keys}")
33
+
34
+ default_config = {
35
+ "api_key": "",
36
+ "password_length": 12,
37
+ "expires_in": None,
38
+ "timeout": 30,
39
+ "max_retries": 3,
40
+ "proxy_url": None,
41
+ }
42
+ self.config = {**default_config, **(config or {})}
43
+ self.config["base_url"] = str(self.config["base_url"]).rstrip("/")
44
+ self.config["default_domain"] = str(self.config["default_domain"]).strip().lstrip("@")
45
+
46
+ http_config = RequestConfig(
47
+ timeout=self.config["timeout"],
48
+ max_retries=self.config["max_retries"],
49
+ )
50
+ self.http_client = HTTPClient(
51
+ proxy_url=self.config.get("proxy_url"),
52
+ config=http_config,
53
+ )
54
+
55
+ self._accounts_by_id: Dict[str, Dict[str, Any]] = {}
56
+ self._accounts_by_email: Dict[str, Dict[str, Any]] = {}
57
+
58
+ def _build_headers(
59
+ self,
60
+ token: Optional[str] = None,
61
+ use_api_key: bool = False,
62
+ extra_headers: Optional[Dict[str, str]] = None,
63
+ ) -> Dict[str, str]:
64
+ headers = {
65
+ "Accept": "application/json",
66
+ "Content-Type": "application/json",
67
+ }
68
+
69
+ auth_token = token
70
+ if not auth_token and use_api_key and self.config.get("api_key"):
71
+ auth_token = self.config["api_key"]
72
+
73
+ if auth_token:
74
+ headers["Authorization"] = f"Bearer {auth_token}"
75
+
76
+ if extra_headers:
77
+ headers.update(extra_headers)
78
+
79
+ return headers
80
+
81
+ def _make_request(
82
+ self,
83
+ method: str,
84
+ path: str,
85
+ token: Optional[str] = None,
86
+ use_api_key: bool = False,
87
+ **kwargs,
88
+ ) -> Dict[str, Any]:
89
+ url = f"{self.config['base_url']}{path}"
90
+ kwargs["headers"] = self._build_headers(
91
+ token=token,
92
+ use_api_key=use_api_key,
93
+ extra_headers=kwargs.get("headers"),
94
+ )
95
+
96
+ try:
97
+ response = self.http_client.request(method, url, **kwargs)
98
+ if response.status_code >= 400:
99
+ error_message = f"API 请求失败: {response.status_code}"
100
+ try:
101
+ error_payload = response.json()
102
+ error_message = f"{error_message} - {error_payload}"
103
+ except Exception:
104
+ error_message = f"{error_message} - {response.text[:200]}"
105
+ raise EmailServiceError(error_message)
106
+
107
+ try:
108
+ return response.json()
109
+ except Exception:
110
+ return {"raw_response": response.text}
111
+ except Exception as e:
112
+ self.update_status(False, e)
113
+ if isinstance(e, EmailServiceError):
114
+ raise
115
+ raise EmailServiceError(f"请求失败: {method} {path} - {e}")
116
+
117
+ def _generate_local_part(self) -> str:
118
+ first = random.choice(string.ascii_lowercase)
119
+ rest = "".join(random.choices(string.ascii_lowercase + string.digits, k=7))
120
+ return f"{first}{rest}"
121
+
122
+ def _generate_password(self) -> str:
123
+ length = max(6, int(self.config.get("password_length") or 12))
124
+ alphabet = string.ascii_letters + string.digits
125
+ return "".join(random.choices(alphabet, k=length))
126
+
127
+ def _cache_account(self, account_info: Dict[str, Any]) -> None:
128
+ account_id = str(account_info.get("account_id") or account_info.get("service_id") or "").strip()
129
+ email = str(account_info.get("email") or "").strip().lower()
130
+
131
+ if account_id:
132
+ self._accounts_by_id[account_id] = account_info
133
+ if email:
134
+ self._accounts_by_email[email] = account_info
135
+
136
+ def _get_account_info(self, email: Optional[str] = None, email_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
137
+ if email_id:
138
+ cached = self._accounts_by_id.get(str(email_id))
139
+ if cached:
140
+ return cached
141
+
142
+ if email:
143
+ cached = self._accounts_by_email.get(str(email).strip().lower())
144
+ if cached:
145
+ return cached
146
+
147
+ return None
148
+
149
+ def _strip_html(self, html_content: Any) -> str:
150
+ if isinstance(html_content, list):
151
+ html_content = "\n".join(str(item) for item in html_content if item)
152
+ text = str(html_content or "")
153
+ return unescape(re.sub(r"<[^>]+>", " ", text))
154
+
155
+ def _parse_message_time(self, value: Optional[str]) -> Optional[float]:
156
+ if not value:
157
+ return None
158
+ try:
159
+ normalized = value.replace("Z", "+00:00")
160
+ return datetime.fromisoformat(normalized).astimezone(timezone.utc).timestamp()
161
+ except Exception:
162
+ return None
163
+
164
+ def _message_search_text(self, summary: Dict[str, Any], detail: Dict[str, Any]) -> str:
165
+ sender = summary.get("from") or detail.get("from") or {}
166
+ if isinstance(sender, dict):
167
+ sender_text = " ".join(
168
+ str(sender.get(key) or "") for key in ("name", "address")
169
+ ).strip()
170
+ else:
171
+ sender_text = str(sender)
172
+
173
+ subject = str(summary.get("subject") or detail.get("subject") or "")
174
+ text_body = str(detail.get("text") or "")
175
+ html_body = self._strip_html(detail.get("html"))
176
+ return "\n".join(part for part in [sender_text, subject, text_body, html_body] if part).strip()
177
+
178
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
179
+ request_config = config or {}
180
+ local_part = str(request_config.get("name") or self._generate_local_part()).strip()
181
+ domain = str(request_config.get("default_domain") or request_config.get("domain") or self.config["default_domain"]).strip().lstrip("@")
182
+ address = f"{local_part}@{domain}"
183
+ password = self._generate_password()
184
+
185
+ payload: Dict[str, Any] = {
186
+ "address": address,
187
+ "password": password,
188
+ }
189
+
190
+ expires_in = request_config.get("expiresIn", request_config.get("expires_in", self.config.get("expires_in")))
191
+ if expires_in is not None:
192
+ payload["expiresIn"] = expires_in
193
+
194
+ account_response = self._make_request(
195
+ "POST",
196
+ "/accounts",
197
+ json=payload,
198
+ use_api_key=bool(self.config.get("api_key")),
199
+ )
200
+ token_response = self._make_request(
201
+ "POST",
202
+ "/token",
203
+ json={
204
+ "address": account_response.get("address", address),
205
+ "password": password,
206
+ },
207
+ )
208
+
209
+ account_id = str(account_response.get("id") or token_response.get("id") or "").strip()
210
+ resolved_address = str(account_response.get("address") or address).strip()
211
+ token = str(token_response.get("token") or "").strip()
212
+
213
+ if not account_id or not resolved_address or not token:
214
+ raise EmailServiceError("DuckMail 返回数据不完整")
215
+
216
+ email_info = {
217
+ "email": resolved_address,
218
+ "service_id": account_id,
219
+ "id": account_id,
220
+ "account_id": account_id,
221
+ "token": token,
222
+ "password": password,
223
+ "created_at": time.time(),
224
+ "raw_account": account_response,
225
+ }
226
+
227
+ self._cache_account(email_info)
228
+ self.update_status(True)
229
+ return email_info
230
+
231
+ def get_verification_code(
232
+ self,
233
+ email: str,
234
+ email_id: str = None,
235
+ timeout: int = 120,
236
+ pattern: str = OTP_CODE_PATTERN,
237
+ otp_sent_at: Optional[float] = None,
238
+ ) -> Optional[str]:
239
+ account_info = self._get_account_info(email=email, email_id=email_id)
240
+ if not account_info:
241
+ logger.warning(f"DuckMail 未找到邮箱缓存: {email}, {email_id}")
242
+ return None
243
+
244
+ token = account_info.get("token")
245
+ if not token:
246
+ logger.warning(f"DuckMail 邮箱缺少访问 token: {email}")
247
+ return None
248
+
249
+ start_time = time.time()
250
+ seen_message_ids = set()
251
+
252
+ while time.time() - start_time < timeout:
253
+ try:
254
+ response = self._make_request(
255
+ "GET",
256
+ "/messages",
257
+ token=token,
258
+ params={"page": 1},
259
+ )
260
+ messages = response.get("hydra:member", [])
261
+
262
+ for message in messages:
263
+ message_id = str(message.get("id") or "").strip()
264
+ if not message_id or message_id in seen_message_ids:
265
+ continue
266
+
267
+ created_at = self._parse_message_time(message.get("createdAt"))
268
+ if otp_sent_at and created_at and created_at + 1 < otp_sent_at:
269
+ continue
270
+
271
+ seen_message_ids.add(message_id)
272
+ detail = self._make_request(
273
+ "GET",
274
+ f"/messages/{message_id}",
275
+ token=token,
276
+ )
277
+
278
+ content = self._message_search_text(message, detail)
279
+ if "openai" not in content.lower():
280
+ continue
281
+
282
+ match = re.search(pattern, content)
283
+ if match:
284
+ self.update_status(True)
285
+ return match.group(1)
286
+ except Exception as e:
287
+ logger.debug(f"DuckMail 轮询验证码失败: {e}")
288
+
289
+ time.sleep(3)
290
+
291
+ return None
292
+
293
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
294
+ return list(self._accounts_by_email.values())
295
+
296
+ def delete_email(self, email_id: str) -> bool:
297
+ account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id)
298
+ if not account_info:
299
+ return False
300
+
301
+ token = account_info.get("token")
302
+ account_id = account_info.get("account_id") or account_info.get("service_id")
303
+ if not token or not account_id:
304
+ return False
305
+
306
+ try:
307
+ self._make_request(
308
+ "DELETE",
309
+ f"/accounts/{account_id}",
310
+ token=token,
311
+ )
312
+ self._accounts_by_id.pop(str(account_id), None)
313
+ self._accounts_by_email.pop(str(account_info.get("email") or "").lower(), None)
314
+ self.update_status(True)
315
+ return True
316
+ except Exception as e:
317
+ logger.warning(f"DuckMail 删除邮箱失败: {e}")
318
+ self.update_status(False, e)
319
+ return False
320
+
321
+ def check_health(self) -> bool:
322
+ try:
323
+ self._make_request(
324
+ "GET",
325
+ "/domains",
326
+ params={"page": 1},
327
+ use_api_key=bool(self.config.get("api_key")),
328
+ )
329
+ self.update_status(True)
330
+ return True
331
+ except Exception as e:
332
+ logger.warning(f"DuckMail 健康检查失败: {e}")
333
+ self.update_status(False, e)
334
+ return False
335
+
336
+ def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]:
337
+ account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id)
338
+ if not account_info or not account_info.get("token"):
339
+ return []
340
+ response = self._make_request(
341
+ "GET",
342
+ "/messages",
343
+ token=account_info["token"],
344
+ params={"page": kwargs.get("page", 1)},
345
+ )
346
+ return response.get("hydra:member", [])
347
+
348
+ def get_message_detail(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]:
349
+ account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id)
350
+ if not account_info or not account_info.get("token"):
351
+ return None
352
+ return self._make_request(
353
+ "GET",
354
+ f"/messages/{message_id}",
355
+ token=account_info["token"],
356
+ )
357
+
358
+ def get_service_info(self) -> Dict[str, Any]:
359
+ return {
360
+ "service_type": self.service_type.value,
361
+ "name": self.name,
362
+ "base_url": self.config["base_url"],
363
+ "default_domain": self.config["default_domain"],
364
+ "cached_accounts": len(self._accounts_by_email),
365
+ "status": self.status.value,
366
+ }
src/services/freemail.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Freemail 邮箱服务实现
3
+ 基于自部署 Cloudflare Worker 临时邮箱服务 (https://github.com/idinging/freemail)
4
+ """
5
+
6
+ import re
7
+ import time
8
+ import logging
9
+ import random
10
+ import string
11
+ from typing import Optional, Dict, Any, List
12
+
13
+ from .base import BaseEmailService, EmailServiceError, EmailServiceType
14
+ from ..core.http_client import HTTPClient, RequestConfig
15
+ from ..config.constants import OTP_CODE_PATTERN
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class FreemailService(BaseEmailService):
21
+ """
22
+ Freemail 邮箱服务
23
+ 基于自部署 Cloudflare Worker 的临时邮箱
24
+ """
25
+
26
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
27
+ """
28
+ 初始化 Freemail 服务
29
+
30
+ Args:
31
+ config: 配置字典,支持以下键:
32
+ - base_url: Worker 域名地址 (必需)
33
+ - admin_token: Admin Token,对应 JWT_TOKEN (必需)
34
+ - domain: 邮箱域名,如 example.com
35
+ - timeout: 请求超时时间,默认 30
36
+ - max_retries: 最大重试次数,默认 3
37
+ name: 服务名称
38
+ """
39
+ super().__init__(EmailServiceType.FREEMAIL, name)
40
+
41
+ required_keys = ["base_url", "admin_token"]
42
+ missing_keys = [key for key in required_keys if not (config or {}).get(key)]
43
+ if missing_keys:
44
+ raise ValueError(f"缺少必需配置: {missing_keys}")
45
+
46
+ default_config = {
47
+ "timeout": 30,
48
+ "max_retries": 3,
49
+ }
50
+ self.config = {**default_config, **(config or {})}
51
+ self.config["base_url"] = self.config["base_url"].rstrip("/")
52
+
53
+ http_config = RequestConfig(
54
+ timeout=self.config["timeout"],
55
+ max_retries=self.config["max_retries"],
56
+ )
57
+ self.http_client = HTTPClient(proxy_url=None, config=http_config)
58
+
59
+ # 缓存 domain 列表
60
+ self._domains = []
61
+
62
+ def _get_headers(self) -> Dict[str, str]:
63
+ """构造 admin 请求头"""
64
+ return {
65
+ "Authorization": f"Bearer {self.config['admin_token']}",
66
+ "Content-Type": "application/json",
67
+ "Accept": "application/json",
68
+ }
69
+
70
+ def _make_request(self, method: str, path: str, **kwargs) -> Any:
71
+ """
72
+ 发送请求并返回 JSON 数据
73
+
74
+ Args:
75
+ method: HTTP 方法
76
+ path: 请求路径(以 / 开头)
77
+ **kwargs: 传递给 http_client.request 的额外参数
78
+
79
+ Returns:
80
+ 响应 JSON 数据
81
+
82
+ Raises:
83
+ EmailServiceError: 请求失败
84
+ """
85
+ url = f"{self.config['base_url']}{path}"
86
+ kwargs.setdefault("headers", {})
87
+ kwargs["headers"].update(self._get_headers())
88
+
89
+ try:
90
+ response = self.http_client.request(method, url, **kwargs)
91
+
92
+ if response.status_code >= 400:
93
+ error_msg = f"请求失败: {response.status_code}"
94
+ try:
95
+ error_data = response.json()
96
+ error_msg = f"{error_msg} - {error_data}"
97
+ except Exception:
98
+ error_msg = f"{error_msg} - {response.text[:200]}"
99
+ self.update_status(False, EmailServiceError(error_msg))
100
+ raise EmailServiceError(error_msg)
101
+
102
+ try:
103
+ return response.json()
104
+ except Exception:
105
+ return {"raw_response": response.text}
106
+
107
+ except Exception as e:
108
+ self.update_status(False, e)
109
+ if isinstance(e, EmailServiceError):
110
+ raise
111
+ raise EmailServiceError(f"请求失败: {method} {path} - {e}")
112
+
113
+ def _ensure_domains(self):
114
+ """获取并缓存可用域名列表"""
115
+ if not self._domains:
116
+ try:
117
+ domains = self._make_request("GET", "/api/domains")
118
+ if isinstance(domains, list):
119
+ self._domains = domains
120
+ except Exception as e:
121
+ logger.warning(f"获取 Freemail 域名列表失败: {e}")
122
+
123
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
124
+ """
125
+ 通过 API 创建临时邮箱
126
+
127
+ Returns:
128
+ 包含邮箱信息的字典:
129
+ - email: 邮箱地址
130
+ - service_id: 同 email(用作标识)
131
+ """
132
+ self._ensure_domains()
133
+
134
+ req_config = config or {}
135
+ domain_index = 0
136
+ target_domain = req_config.get("domain") or self.config.get("domain")
137
+
138
+ if target_domain and self._domains:
139
+ for i, d in enumerate(self._domains):
140
+ if d == target_domain:
141
+ domain_index = i
142
+ break
143
+
144
+ prefix = req_config.get("name")
145
+ try:
146
+ if prefix:
147
+ body = {
148
+ "local": prefix,
149
+ "domainIndex": domain_index
150
+ }
151
+ resp = self._make_request("POST", "/api/create", json=body)
152
+ else:
153
+ params = {"domainIndex": domain_index}
154
+ length = req_config.get("length")
155
+ if length:
156
+ params["length"] = length
157
+ resp = self._make_request("GET", "/api/generate", params=params)
158
+
159
+ email = resp.get("email")
160
+ if not email:
161
+ raise EmailServiceError(f"创建邮箱失败,未返回邮箱地址: {resp}")
162
+
163
+ email_info = {
164
+ "email": email,
165
+ "service_id": email,
166
+ "id": email,
167
+ "created_at": time.time(),
168
+ }
169
+
170
+ logger.info(f"成功创建 Freemail 邮箱: {email}")
171
+ self.update_status(True)
172
+ return email_info
173
+
174
+ except Exception as e:
175
+ self.update_status(False, e)
176
+ if isinstance(e, EmailServiceError):
177
+ raise
178
+ raise EmailServiceError(f"创建邮箱失败: {e}")
179
+
180
+ def get_verification_code(
181
+ self,
182
+ email: str,
183
+ email_id: str = None,
184
+ timeout: int = 120,
185
+ pattern: str = OTP_CODE_PATTERN,
186
+ otp_sent_at: Optional[float] = None,
187
+ ) -> Optional[str]:
188
+ """
189
+ 从 Freemail 邮箱获取验证码
190
+
191
+ Args:
192
+ email: 邮箱地址
193
+ email_id: 未使用,保留接口兼容
194
+ timeout: 超时时间(秒)
195
+ pattern: 验证码正则
196
+ otp_sent_at: OTP 发送时间戳(暂未使用)
197
+
198
+ Returns:
199
+ 验证码字符串,超时返回 None
200
+ """
201
+ logger.info(f"正在从 Freemail 邮箱 {email} 获取验证码...")
202
+
203
+ start_time = time.time()
204
+ seen_mail_ids: set = set()
205
+
206
+ while time.time() - start_time < timeout:
207
+ try:
208
+ mails = self._make_request("GET", "/api/emails", params={"mailbox": email, "limit": 20})
209
+ if not isinstance(mails, list):
210
+ time.sleep(3)
211
+ continue
212
+
213
+ for mail in mails:
214
+ mail_id = mail.get("id")
215
+ if not mail_id or mail_id in seen_mail_ids:
216
+ continue
217
+
218
+ seen_mail_ids.add(mail_id)
219
+
220
+ sender = str(mail.get("sender", "")).lower()
221
+ subject = str(mail.get("subject", ""))
222
+ preview = str(mail.get("preview", ""))
223
+
224
+ content = f"{sender}\n{subject}\n{preview}"
225
+
226
+ if "openai" not in content.lower():
227
+ continue
228
+
229
+ # 尝试直接使用 Freemail 提取的验证码
230
+ v_code = mail.get("verification_code")
231
+ if v_code:
232
+ logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {v_code}")
233
+ self.update_status(True)
234
+ return v_code
235
+
236
+ # 如果没有直接提供,通过正则匹配 preview
237
+ match = re.search(pattern, content)
238
+ if match:
239
+ code = match.group(1)
240
+ logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
241
+ self.update_status(True)
242
+ return code
243
+
244
+ # 如果依然未找到,获取邮件详情进行匹配
245
+ try:
246
+ detail = self._make_request("GET", f"/api/email/{mail_id}")
247
+ full_content = str(detail.get("content", "")) + "\n" + str(detail.get("html_content", ""))
248
+ match = re.search(pattern, full_content)
249
+ if match:
250
+ code = match.group(1)
251
+ logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
252
+ self.update_status(True)
253
+ return code
254
+ except Exception as e:
255
+ logger.debug(f"获取 Freemail 邮件详情失败: {e}")
256
+
257
+ except Exception as e:
258
+ logger.debug(f"检查 Freemail 邮件时出错: {e}")
259
+
260
+ time.sleep(3)
261
+
262
+ logger.warning(f"等待 Freemail 验证码超时: {email}")
263
+ return None
264
+
265
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
266
+ """
267
+ 列出邮箱
268
+
269
+ Args:
270
+ **kwargs: 额外查询参数
271
+
272
+ Returns:
273
+ 邮箱列表
274
+ """
275
+ try:
276
+ params = {
277
+ "limit": kwargs.get("limit", 100),
278
+ "offset": kwargs.get("offset", 0)
279
+ }
280
+ resp = self._make_request("GET", "/api/mailboxes", params=params)
281
+
282
+ emails = []
283
+ if isinstance(resp, list):
284
+ for mail in resp:
285
+ address = mail.get("address")
286
+ if address:
287
+ emails.append({
288
+ "id": address,
289
+ "service_id": address,
290
+ "email": address,
291
+ "created_at": mail.get("created_at"),
292
+ "raw_data": mail
293
+ })
294
+ self.update_status(True)
295
+ return emails
296
+ except Exception as e:
297
+ logger.warning(f"列出 Freemail 邮箱失败: {e}")
298
+ self.update_status(False, e)
299
+ return []
300
+
301
+ def delete_email(self, email_id: str) -> bool:
302
+ """
303
+ 删除邮箱
304
+ """
305
+ try:
306
+ self._make_request("DELETE", "/api/mailboxes", params={"address": email_id})
307
+ logger.info(f"已删除 Freemail 邮箱: {email_id}")
308
+ self.update_status(True)
309
+ return True
310
+ except Exception as e:
311
+ logger.warning(f"删除 Freemail 邮箱失败: {e}")
312
+ self.update_status(False, e)
313
+ return False
314
+
315
+ def check_health(self) -> bool:
316
+ """检查服务健康状态"""
317
+ try:
318
+ self._make_request("GET", "/api/domains")
319
+ self.update_status(True)
320
+ return True
321
+ except Exception as e:
322
+ logger.warning(f"Freemail 健康检查失败: {e}")
323
+ self.update_status(False, e)
324
+ return False
src/services/imap_mail.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ IMAP 邮箱服务
3
+ 支持 Gmail / QQ / 163 / Yahoo / Outlook 等标准 IMAP 协议邮件服务商。
4
+ 仅用于接收验证码,强制直连(imaplib 不支持代理)。
5
+ """
6
+
7
+ import imaplib
8
+ import email
9
+ import re
10
+ import time
11
+ import logging
12
+ from email.header import decode_header
13
+ from typing import Any, Dict, Optional
14
+
15
+ from .base import BaseEmailService, EmailServiceError
16
+ from ..config.constants import (
17
+ EmailServiceType,
18
+ OPENAI_EMAIL_SENDERS,
19
+ OTP_CODE_SEMANTIC_PATTERN,
20
+ OTP_CODE_PATTERN,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ImapMailService(BaseEmailService):
27
+ """标准 IMAP 邮箱服务(仅接收验证码,强制直连)"""
28
+
29
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
30
+ super().__init__(EmailServiceType.IMAP_MAIL, name)
31
+
32
+ cfg = config or {}
33
+ required_keys = ["host", "email", "password"]
34
+ missing_keys = [k for k in required_keys if not cfg.get(k)]
35
+ if missing_keys:
36
+ raise ValueError(f"缺少必需配置: {missing_keys}")
37
+
38
+ self.host: str = str(cfg["host"]).strip()
39
+ self.port: int = int(cfg.get("port", 993))
40
+ self.use_ssl: bool = bool(cfg.get("use_ssl", True))
41
+ self.email_addr: str = str(cfg["email"]).strip()
42
+ self.password: str = str(cfg["password"])
43
+ self.timeout: int = int(cfg.get("timeout", 30))
44
+ self.max_retries: int = int(cfg.get("max_retries", 3))
45
+
46
+ def _connect(self) -> imaplib.IMAP4:
47
+ """建立 IMAP 连接并登录,返回 mail 对象"""
48
+ if self.use_ssl:
49
+ mail = imaplib.IMAP4_SSL(self.host, self.port)
50
+ else:
51
+ mail = imaplib.IMAP4(self.host, self.port)
52
+ mail.starttls()
53
+ mail.login(self.email_addr, self.password)
54
+ return mail
55
+
56
+ def _decode_str(self, value) -> str:
57
+ """解码邮件头部字段"""
58
+ if value is None:
59
+ return ""
60
+ parts = decode_header(value)
61
+ decoded = []
62
+ for part, charset in parts:
63
+ if isinstance(part, bytes):
64
+ decoded.append(part.decode(charset or "utf-8", errors="replace"))
65
+ else:
66
+ decoded.append(str(part))
67
+ return " ".join(decoded)
68
+
69
+ def _get_text_body(self, msg) -> str:
70
+ """提取邮件纯文本内容"""
71
+ body = ""
72
+ if msg.is_multipart():
73
+ for part in msg.walk():
74
+ if part.get_content_type() == "text/plain":
75
+ charset = part.get_content_charset() or "utf-8"
76
+ payload = part.get_payload(decode=True)
77
+ if payload:
78
+ body += payload.decode(charset, errors="replace")
79
+ else:
80
+ charset = msg.get_content_charset() or "utf-8"
81
+ payload = msg.get_payload(decode=True)
82
+ if payload:
83
+ body = payload.decode(charset, errors="replace")
84
+ return body
85
+
86
+ def _is_openai_sender(self, from_addr: str) -> bool:
87
+ """判断发件人是否为 OpenAI"""
88
+ from_lower = from_addr.lower()
89
+ for sender in OPENAI_EMAIL_SENDERS:
90
+ if sender.startswith("@") or sender.startswith("."):
91
+ if sender in from_lower:
92
+ return True
93
+ else:
94
+ if sender in from_lower:
95
+ return True
96
+ return False
97
+
98
+ def _extract_otp(self, text: str) -> Optional[str]:
99
+ """从文本中提取 6 位验证码,优先语义匹配,回退简单匹配"""
100
+ match = re.search(OTP_CODE_SEMANTIC_PATTERN, text, re.IGNORECASE)
101
+ if match:
102
+ return match.group(1)
103
+ match = re.search(OTP_CODE_PATTERN, text)
104
+ if match:
105
+ return match.group(1)
106
+ return None
107
+
108
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
109
+ """IMAP 模式不创建新邮箱,直接返回配置中的固定地址"""
110
+ self.update_status(True)
111
+ return {
112
+ "email": self.email_addr,
113
+ "service_id": self.email_addr,
114
+ "id": self.email_addr,
115
+ }
116
+
117
+ def get_verification_code(
118
+ self,
119
+ email: str,
120
+ email_id: str = None,
121
+ timeout: int = 60,
122
+ pattern: str = None,
123
+ otp_sent_at: Optional[float] = None,
124
+ ) -> Optional[str]:
125
+ """轮询 IMAP 收件箱,获取 OpenAI 验证码"""
126
+ start_time = time.time()
127
+ seen_ids: set = set()
128
+ mail = None
129
+
130
+ try:
131
+ mail = self._connect()
132
+ mail.select("INBOX")
133
+
134
+ while time.time() - start_time < timeout:
135
+ try:
136
+ # 搜索所有未读邮件
137
+ status, data = mail.search(None, "UNSEEN")
138
+ if status != "OK" or not data or not data[0]:
139
+ time.sleep(3)
140
+ continue
141
+
142
+ msg_ids = data[0].split()
143
+ for msg_id in reversed(msg_ids): # 最新的优先
144
+ id_str = msg_id.decode()
145
+ if id_str in seen_ids:
146
+ continue
147
+ seen_ids.add(id_str)
148
+
149
+ # 获取邮件
150
+ status, msg_data = mail.fetch(msg_id, "(RFC822)")
151
+ if status != "OK" or not msg_data:
152
+ continue
153
+
154
+ raw = msg_data[0][1]
155
+ msg = email.message_from_bytes(raw)
156
+
157
+ # 检查发件人
158
+ from_addr = self._decode_str(msg.get("From", ""))
159
+ if not self._is_openai_sender(from_addr):
160
+ continue
161
+
162
+ # 提取验证码
163
+ body = self._get_text_body(msg)
164
+ code = self._extract_otp(body)
165
+ if code:
166
+ # 标记已读
167
+ mail.store(msg_id, "+FLAGS", "\\Seen")
168
+ self.update_status(True)
169
+ logger.info(f"IMAP 获取验证码成功: {code}")
170
+ return code
171
+
172
+ except imaplib.IMAP4.error as e:
173
+ logger.debug(f"IMAP 搜索邮件失败: {e}")
174
+ # 尝试重新连接
175
+ try:
176
+ mail.select("INBOX")
177
+ except Exception:
178
+ pass
179
+
180
+ time.sleep(3)
181
+
182
+ except Exception as e:
183
+ logger.warning(f"IMAP 连接/轮询失败: {e}")
184
+ self.update_status(False, str(e))
185
+ finally:
186
+ if mail:
187
+ try:
188
+ mail.logout()
189
+ except Exception:
190
+ pass
191
+
192
+ return None
193
+
194
+ def check_health(self) -> bool:
195
+ """尝试 IMAP 登录并选择收件箱"""
196
+ mail = None
197
+ try:
198
+ mail = self._connect()
199
+ status, _ = mail.select("INBOX")
200
+ return status == "OK"
201
+ except Exception as e:
202
+ logger.warning(f"IMAP 健康检查失败: {e}")
203
+ return False
204
+ finally:
205
+ if mail:
206
+ try:
207
+ mail.logout()
208
+ except Exception:
209
+ pass
210
+
211
+ def list_emails(self, **kwargs) -> list:
212
+ """IMAP 单账号模式,返回固定地址"""
213
+ return [{"email": self.email_addr, "id": self.email_addr}]
214
+
215
+ def delete_email(self, email_id: str) -> bool:
216
+ """IMAP 模式无需删除逻辑"""
217
+ return True
src/services/moe_mail.py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 自定义域名邮箱服务实现
3
+ 基于 email.md 中的 REST API 接口
4
+ """
5
+
6
+ import re
7
+ import time
8
+ import json
9
+ import logging
10
+ from typing import Optional, Dict, Any, List
11
+ from urllib.parse import urljoin
12
+
13
+ from .base import BaseEmailService, EmailServiceError, EmailServiceType
14
+ from ..core.http_client import HTTPClient, RequestConfig
15
+ from ..config.constants import OTP_CODE_PATTERN
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class MeoMailEmailService(BaseEmailService):
22
+ """
23
+ 自定义域名邮箱服务
24
+ 基于 REST API 接口
25
+ """
26
+
27
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
28
+ """
29
+ 初始化自定义域名邮箱服务
30
+
31
+ Args:
32
+ config: 配置字典,支持以下键:
33
+ - base_url: API 基础地址 (必需)
34
+ - api_key: API 密钥 (必需)
35
+ - api_key_header: API 密钥请求头名称 (默认: X-API-Key)
36
+ - timeout: 请求超时时间 (默认: 30)
37
+ - max_retries: 最大重试次数 (默认: 3)
38
+ - proxy_url: 代理 URL
39
+ - default_domain: 默认域名
40
+ - default_expiry: 默认过期时间(毫秒)
41
+ name: 服务名称
42
+ """
43
+ super().__init__(EmailServiceType.MOE_MAIL, name)
44
+
45
+ # 必需配置检查
46
+ required_keys = ["base_url", "api_key"]
47
+ missing_keys = [key for key in required_keys if key not in (config or {})]
48
+
49
+ if missing_keys:
50
+ raise ValueError(f"缺少必需配置: {missing_keys}")
51
+
52
+ # 默认配置
53
+ default_config = {
54
+ "base_url": "",
55
+ "api_key": "",
56
+ "api_key_header": "X-API-Key",
57
+ "timeout": 30,
58
+ "max_retries": 3,
59
+ "proxy_url": None,
60
+ "default_domain": None,
61
+ "default_expiry": 3600000, # 1小时
62
+ }
63
+
64
+ self.config = {**default_config, **(config or {})}
65
+
66
+ # 创建 HTTP 客户端
67
+ http_config = RequestConfig(
68
+ timeout=self.config["timeout"],
69
+ max_retries=self.config["max_retries"],
70
+ )
71
+ self.http_client = HTTPClient(
72
+ proxy_url=self.config.get("proxy_url"),
73
+ config=http_config
74
+ )
75
+
76
+ # 状态变量
77
+ self._emails_cache: Dict[str, Dict[str, Any]] = {}
78
+ self._last_config_check: float = 0
79
+ self._cached_config: Optional[Dict[str, Any]] = None
80
+
81
+ def _get_headers(self) -> Dict[str, str]:
82
+ """获取 API 请求头"""
83
+ headers = {
84
+ "Accept": "application/json",
85
+ "Content-Type": "application/json",
86
+ }
87
+
88
+ # 添加 API 密钥
89
+ api_key_header = self.config.get("api_key_header", "X-API-Key")
90
+ headers[api_key_header] = self.config["api_key"]
91
+
92
+ return headers
93
+
94
+ def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
95
+ """
96
+ 发送 API 请求
97
+
98
+ Args:
99
+ method: HTTP 方法
100
+ endpoint: API 端点
101
+ **kwargs: 请求参数
102
+
103
+ Returns:
104
+ 响应 JSON 数据
105
+
106
+ Raises:
107
+ EmailServiceError: 请求失败
108
+ """
109
+ url = urljoin(self.config["base_url"], endpoint)
110
+
111
+ # 添加默认请求头
112
+ kwargs.setdefault("headers", {})
113
+ kwargs["headers"].update(self._get_headers())
114
+
115
+ try:
116
+ # POST 请求禁用自动重定向,手动处理以保持 POST 方法(避免 HTTP→HTTPS 重定向时被转为 GET)
117
+ if method.upper() == "POST":
118
+ kwargs["allow_redirects"] = False
119
+ response = self.http_client.request(method, url, **kwargs)
120
+ # 处理重定向
121
+ max_redirects = 5
122
+ redirect_count = 0
123
+ while response.status_code in (301, 302, 303, 307, 308) and redirect_count < max_redirects:
124
+ location = response.headers.get("Location", "")
125
+ if not location:
126
+ break
127
+ import urllib.parse as _urlparse
128
+ redirect_url = _urlparse.urljoin(url, location)
129
+ # 307/308 保持 POST,其余(301/302/303)转为 GET
130
+ if response.status_code in (307, 308):
131
+ redirect_method = method
132
+ redirect_kwargs = kwargs
133
+ else:
134
+ redirect_method = "GET"
135
+ # GET 不传 body
136
+ redirect_kwargs = {k: v for k, v in kwargs.items() if k not in ("json", "data")}
137
+ response = self.http_client.request(redirect_method, redirect_url, **redirect_kwargs)
138
+ url = redirect_url
139
+ redirect_count += 1
140
+ else:
141
+ response = self.http_client.request(method, url, **kwargs)
142
+
143
+ if response.status_code >= 400:
144
+ error_msg = f"API 请求失败: {response.status_code}"
145
+ try:
146
+ error_data = response.json()
147
+ error_msg = f"{error_msg} - {error_data}"
148
+ except:
149
+ error_msg = f"{error_msg} - {response.text[:200]}"
150
+
151
+ self.update_status(False, EmailServiceError(error_msg))
152
+ raise EmailServiceError(error_msg)
153
+
154
+ # 解析响应
155
+ try:
156
+ return response.json()
157
+ except json.JSONDecodeError:
158
+ return {"raw_response": response.text}
159
+
160
+ except Exception as e:
161
+ self.update_status(False, e)
162
+ if isinstance(e, EmailServiceError):
163
+ raise
164
+ raise EmailServiceError(f"API 请求失败: {method} {endpoint} - {e}")
165
+
166
+ def get_config(self, force_refresh: bool = False) -> Dict[str, Any]:
167
+ """
168
+ 获取系统配置
169
+
170
+ Args:
171
+ force_refresh: 是否强制刷新缓存
172
+
173
+ Returns:
174
+ 配置信息
175
+ """
176
+ # 检查缓存
177
+ if not force_refresh and self._cached_config and time.time() - self._last_config_check < 300:
178
+ return self._cached_config
179
+
180
+ try:
181
+ response = self._make_request("GET", "/api/config")
182
+ self._cached_config = response
183
+ self._last_config_check = time.time()
184
+ self.update_status(True)
185
+ return response
186
+ except Exception as e:
187
+ logger.warning(f"获取配置失败: {e}")
188
+ return {}
189
+
190
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
191
+ """
192
+ 创建临时邮箱
193
+
194
+ Args:
195
+ config: 配置参数:
196
+ - name: 邮箱前缀(可选)
197
+ - expiryTime: 有效期(毫秒)(可选)
198
+ - domain: 邮箱域名(可选)
199
+
200
+ Returns:
201
+ 包含邮箱信息的字典:
202
+ - email: 邮箱地址
203
+ - service_id: 邮箱 ID
204
+ - id: 邮箱 ID(同 service_id)
205
+ - expiry: 过期时间信息
206
+ """
207
+ # 获取默认配置
208
+ sys_config = self.get_config()
209
+ default_domain = self.config.get("default_domain")
210
+ if not default_domain and sys_config.get("emailDomains"):
211
+ # 使用系统配置的第一个域名
212
+ domains = sys_config["emailDomains"].split(",")
213
+ default_domain = domains[0].strip() if domains else None
214
+
215
+ # 构建请求参数
216
+ request_config = config or {}
217
+ create_data = {
218
+ "name": request_config.get("name", ""),
219
+ "expiryTime": request_config.get("expiryTime", self.config.get("default_expiry", 3600000)),
220
+ "domain": request_config.get("domain", default_domain),
221
+ }
222
+
223
+ # 移除空值
224
+ create_data = {k: v for k, v in create_data.items() if v is not None and v != ""}
225
+
226
+ try:
227
+ response = self._make_request("POST", "/api/emails/generate", json=create_data)
228
+
229
+ email = response.get("email", "").strip()
230
+ email_id = response.get("id", "").strip()
231
+
232
+ if not email or not email_id:
233
+ raise EmailServiceError("API 返回数据不完整")
234
+
235
+ email_info = {
236
+ "email": email,
237
+ "service_id": email_id,
238
+ "id": email_id,
239
+ "created_at": time.time(),
240
+ "expiry": create_data.get("expiryTime"),
241
+ "domain": create_data.get("domain"),
242
+ "raw_response": response,
243
+ }
244
+
245
+ # 缓存邮箱信息
246
+ self._emails_cache[email_id] = email_info
247
+
248
+ logger.info(f"成功创建自定义域名邮箱: {email} (ID: {email_id})")
249
+ self.update_status(True)
250
+ return email_info
251
+
252
+ except Exception as e:
253
+ self.update_status(False, e)
254
+ if isinstance(e, EmailServiceError):
255
+ raise
256
+ raise EmailServiceError(f"创建邮箱失败: {e}")
257
+
258
+ def get_verification_code(
259
+ self,
260
+ email: str,
261
+ email_id: str = None,
262
+ timeout: int = 120,
263
+ pattern: str = OTP_CODE_PATTERN,
264
+ otp_sent_at: Optional[float] = None,
265
+ ) -> Optional[str]:
266
+ """
267
+ 从自定义域名邮箱获取验证码
268
+
269
+ Args:
270
+ email: 邮箱地址
271
+ email_id: 邮箱 ID(如果不提供,从缓存中查找)
272
+ timeout: 超时时间(秒)
273
+ pattern: 验证码正则表达式
274
+ otp_sent_at: OTP 发送时间戳(自定义域名服务暂不使用此参数)
275
+
276
+ Returns:
277
+ 验证码字符串,如果超时或未找到返回 None
278
+ """
279
+ # 查找邮箱 ID
280
+ target_email_id = email_id
281
+ if not target_email_id:
282
+ # 从缓存中查找
283
+ for eid, info in self._emails_cache.items():
284
+ if info.get("email") == email:
285
+ target_email_id = eid
286
+ break
287
+
288
+ if not target_email_id:
289
+ logger.warning(f"未找到邮箱 {email} 的 ID,无法获取验证码")
290
+ return None
291
+
292
+ logger.info(f"正在从自定义域名邮箱 {email} 获取验证码...")
293
+
294
+ start_time = time.time()
295
+ seen_message_ids = set()
296
+
297
+ while time.time() - start_time < timeout:
298
+ try:
299
+ # 获取邮件列表
300
+ response = self._make_request("GET", f"/api/emails/{target_email_id}")
301
+
302
+ messages = response.get("messages", [])
303
+ if not isinstance(messages, list):
304
+ time.sleep(3)
305
+ continue
306
+
307
+ for message in messages:
308
+ message_id = message.get("id")
309
+ if not message_id or message_id in seen_message_ids:
310
+ continue
311
+
312
+ seen_message_ids.add(message_id)
313
+
314
+ # 检查是否是目标邮件
315
+ sender = str(message.get("from_address", "")).lower()
316
+ subject = str(message.get("subject", ""))
317
+
318
+ # 获取邮件内容
319
+ message_content = self._get_message_content(target_email_id, message_id)
320
+ if not message_content:
321
+ continue
322
+
323
+ content = f"{sender} {subject} {message_content}"
324
+
325
+ # 检查是否是 OpenAI 邮件
326
+ if "openai" not in sender and "openai" not in content.lower():
327
+ continue
328
+
329
+ # 提取验证码 过滤掉邮箱
330
+ email_pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
331
+ match = re.search(pattern, re.sub(email_pattern, "", content))
332
+ if match:
333
+ code = match.group(1)
334
+ logger.info(f"从自定义域名邮箱 {email} 找到验证码: {code}")
335
+ self.update_status(True)
336
+ return code
337
+
338
+ except Exception as e:
339
+ logger.debug(f"检查邮件时出错: {e}")
340
+
341
+ # 等待一段时间再检查
342
+ time.sleep(3)
343
+
344
+ logger.warning(f"等待验证码超时: {email}")
345
+ return None
346
+
347
+ def _get_message_content(self, email_id: str, message_id: str) -> Optional[str]:
348
+ """获取邮件内容"""
349
+ try:
350
+ response = self._make_request("GET", f"/api/emails/{email_id}/{message_id}")
351
+ message = response.get("message", {})
352
+
353
+ # 优先使用纯文本内容,其次使用 HTML 内容
354
+ content = message.get("content", "")
355
+ if not content:
356
+ html = message.get("html", "")
357
+ if html:
358
+ # 简单去除 HTML 标签
359
+ content = re.sub(r"<[^>]+>", " ", html)
360
+
361
+ return content
362
+ except Exception as e:
363
+ logger.debug(f"获取邮件内容失败: {e}")
364
+ return None
365
+
366
+ def list_emails(self, cursor: str = None, **kwargs) -> List[Dict[str, Any]]:
367
+ """
368
+ 列出所有邮箱
369
+
370
+ Args:
371
+ cursor: 分页游标
372
+ **kwargs: 其他参数
373
+
374
+ Returns:
375
+ 邮箱列表
376
+ """
377
+ params = {}
378
+ if cursor:
379
+ params["cursor"] = cursor
380
+
381
+ try:
382
+ response = self._make_request("GET", "/api/emails", params=params)
383
+ emails = response.get("emails", [])
384
+
385
+ # 更新缓存
386
+ for email_info in emails:
387
+ email_id = email_info.get("id")
388
+ if email_id:
389
+ self._emails_cache[email_id] = email_info
390
+
391
+ self.update_status(True)
392
+ return emails
393
+ except Exception as e:
394
+ logger.warning(f"列出邮箱失败: {e}")
395
+ self.update_status(False, e)
396
+ return []
397
+
398
+ def delete_email(self, email_id: str) -> bool:
399
+ """
400
+ 删除邮箱
401
+
402
+ Args:
403
+ email_id: 邮箱 ID
404
+
405
+ Returns:
406
+ 是否删除成功
407
+ """
408
+ try:
409
+ response = self._make_request("DELETE", f"/api/emails/{email_id}")
410
+ success = response.get("success", False)
411
+
412
+ if success:
413
+ # 从缓存中移除
414
+ self._emails_cache.pop(email_id, None)
415
+ logger.info(f"成功删除邮箱: {email_id}")
416
+ else:
417
+ logger.warning(f"删除邮箱失败: {email_id}")
418
+
419
+ self.update_status(success)
420
+ return success
421
+
422
+ except Exception as e:
423
+ logger.error(f"删除邮箱失败: {email_id} - {e}")
424
+ self.update_status(False, e)
425
+ return False
426
+
427
+ def check_health(self) -> bool:
428
+ """检查自定义域名邮箱服务是否可用"""
429
+ try:
430
+ # 尝试获取配置
431
+ config = self.get_config(force_refresh=True)
432
+ if config:
433
+ logger.debug(f"自定义域名邮箱服务健康检查通过,配置: {config.get('defaultRole', 'N/A')}")
434
+ self.update_status(True)
435
+ return True
436
+ else:
437
+ logger.warning("自定义域名邮箱服务健康检查失败:获取配置为空")
438
+ self.update_status(False, EmailServiceError("获取配置为空"))
439
+ return False
440
+ except Exception as e:
441
+ logger.warning(f"自定义域名邮箱服务健康检查失败: {e}")
442
+ self.update_status(False, e)
443
+ return False
444
+
445
+ def get_email_messages(self, email_id: str, cursor: str = None) -> List[Dict[str, Any]]:
446
+ """
447
+ 获取邮箱中的邮件列表
448
+
449
+ Args:
450
+ email_id: 邮箱 ID
451
+ cursor: 分页游标
452
+
453
+ Returns:
454
+ 邮件列表
455
+ """
456
+ params = {}
457
+ if cursor:
458
+ params["cursor"] = cursor
459
+
460
+ try:
461
+ response = self._make_request("GET", f"/api/emails/{email_id}", params=params)
462
+ messages = response.get("messages", [])
463
+ self.update_status(True)
464
+ return messages
465
+ except Exception as e:
466
+ logger.error(f"获取邮件列表失败: {email_id} - {e}")
467
+ self.update_status(False, e)
468
+ return []
469
+
470
+ def get_message_detail(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]:
471
+ """
472
+ 获取邮件详情
473
+
474
+ Args:
475
+ email_id: 邮箱 ID
476
+ message_id: 邮件 ID
477
+
478
+ Returns:
479
+ 邮件详情
480
+ """
481
+ try:
482
+ response = self._make_request("GET", f"/api/emails/{email_id}/{message_id}")
483
+ message = response.get("message")
484
+ self.update_status(True)
485
+ return message
486
+ except Exception as e:
487
+ logger.error(f"获取邮件详情失败: {email_id}/{message_id} - {e}")
488
+ self.update_status(False, e)
489
+ return None
490
+
491
+ def create_email_share(self, email_id: str, expires_in: int = 86400000) -> Optional[Dict[str, Any]]:
492
+ """
493
+ 创建邮箱分享链接
494
+
495
+ Args:
496
+ email_id: 邮箱 ID
497
+ expires_in: 有效期(毫秒)
498
+
499
+ Returns:
500
+ 分享信息
501
+ """
502
+ try:
503
+ response = self._make_request(
504
+ "POST",
505
+ f"/api/emails/{email_id}/share",
506
+ json={"expiresIn": expires_in}
507
+ )
508
+ self.update_status(True)
509
+ return response
510
+ except Exception as e:
511
+ logger.error(f"创建邮箱分享链接失败: {email_id} - {e}")
512
+ self.update_status(False, e)
513
+ return None
514
+
515
+ def create_message_share(
516
+ self,
517
+ email_id: str,
518
+ message_id: str,
519
+ expires_in: int = 86400000
520
+ ) -> Optional[Dict[str, Any]]:
521
+ """
522
+ 创建邮件分享链接
523
+
524
+ Args:
525
+ email_id: 邮箱 ID
526
+ message_id: 邮件 ID
527
+ expires_in: 有效期(毫秒)
528
+
529
+ Returns:
530
+ 分享信息
531
+ """
532
+ try:
533
+ response = self._make_request(
534
+ "POST",
535
+ f"/api/emails/{email_id}/messages/{message_id}/share",
536
+ json={"expiresIn": expires_in}
537
+ )
538
+ self.update_status(True)
539
+ return response
540
+ except Exception as e:
541
+ logger.error(f"创建邮件分享链接失败: {email_id}/{message_id} - {e}")
542
+ self.update_status(False, e)
543
+ return None
544
+
545
+ def get_service_info(self) -> Dict[str, Any]:
546
+ """获取服务信息"""
547
+ config = self.get_config()
548
+ return {
549
+ "service_type": self.service_type.value,
550
+ "name": self.name,
551
+ "base_url": self.config["base_url"],
552
+ "default_domain": self.config.get("default_domain"),
553
+ "system_config": config,
554
+ "cached_emails_count": len(self._emails_cache),
555
+ "status": self.status.value,
556
+ }
src/services/outlook/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Outlook 邮箱服务模块
3
+ 支持多种 IMAP/API 连接方式,自动故障切换
4
+ """
5
+
6
+ from .service import OutlookService
7
+
8
+ __all__ = ['OutlookService']
src/services/outlook/account.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Outlook 账户数据类
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Any, Optional
7
+
8
+
9
+ @dataclass
10
+ class OutlookAccount:
11
+ """Outlook 账户信息"""
12
+ email: str
13
+ password: str = ""
14
+ client_id: str = ""
15
+ refresh_token: str = ""
16
+
17
+ @classmethod
18
+ def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount":
19
+ """从配置创建账户"""
20
+ return cls(
21
+ email=config.get("email", ""),
22
+ password=config.get("password", ""),
23
+ client_id=config.get("client_id", ""),
24
+ refresh_token=config.get("refresh_token", "")
25
+ )
26
+
27
+ def has_oauth(self) -> bool:
28
+ """是否支持 OAuth2"""
29
+ return bool(self.client_id and self.refresh_token)
30
+
31
+ def validate(self) -> bool:
32
+ """验证账户信息是否有效"""
33
+ return bool(self.email and self.password) or self.has_oauth()
34
+
35
+ def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
36
+ """转换为字典"""
37
+ result = {
38
+ "email": self.email,
39
+ "has_oauth": self.has_oauth(),
40
+ }
41
+ if include_sensitive:
42
+ result.update({
43
+ "password": self.password,
44
+ "client_id": self.client_id,
45
+ "refresh_token": self.refresh_token[:20] + "..." if self.refresh_token else "",
46
+ })
47
+ return result
48
+
49
+ def __str__(self) -> str:
50
+ """字符串表示"""
51
+ return f"OutlookAccount({self.email})"
src/services/outlook/base.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Outlook 服务基础定义
3
+ 包含枚举类型和数据类
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ from typing import Optional, Dict, Any, List
10
+
11
+
12
+ class ProviderType(str, Enum):
13
+ """Outlook 提供者类型"""
14
+ IMAP_OLD = "imap_old" # 旧版 IMAP (outlook.office365.com)
15
+ IMAP_NEW = "imap_new" # 新版 IMAP (outlook.live.com)
16
+ GRAPH_API = "graph_api" # Microsoft Graph API
17
+
18
+
19
+ class TokenEndpoint(str, Enum):
20
+ """Token 端点"""
21
+ LIVE = "https://login.live.com/oauth20_token.srf"
22
+ CONSUMERS = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
23
+ COMMON = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
24
+
25
+
26
+ class IMAPServer(str, Enum):
27
+ """IMAP 服务器"""
28
+ OLD = "outlook.office365.com"
29
+ NEW = "outlook.live.com"
30
+
31
+
32
+ class ProviderStatus(str, Enum):
33
+ """提供者状态"""
34
+ HEALTHY = "healthy" # 健康
35
+ DEGRADED = "degraded" # 降级
36
+ DISABLED = "disabled" # 禁用
37
+
38
+
39
+ @dataclass
40
+ class EmailMessage:
41
+ """邮件消息数据类"""
42
+ id: str # 消息 ID
43
+ subject: str # 主题
44
+ sender: str # 发件人
45
+ recipients: List[str] = field(default_factory=list) # 收件人列表
46
+ body: str = "" # 正文内容
47
+ body_preview: str = "" # 正文预览
48
+ received_at: Optional[datetime] = None # 接收时间
49
+ received_timestamp: int = 0 # 接收时间戳
50
+ is_read: bool = False # 是否已读
51
+ has_attachments: bool = False # 是否有附件
52
+ raw_data: Optional[bytes] = None # 原始数据(用于调试)
53
+
54
+ def to_dict(self) -> Dict[str, Any]:
55
+ """转换为字典"""
56
+ return {
57
+ "id": self.id,
58
+ "subject": self.subject,
59
+ "sender": self.sender,
60
+ "recipients": self.recipients,
61
+ "body": self.body,
62
+ "body_preview": self.body_preview,
63
+ "received_at": self.received_at.isoformat() if self.received_at else None,
64
+ "received_timestamp": self.received_timestamp,
65
+ "is_read": self.is_read,
66
+ "has_attachments": self.has_attachments,
67
+ }
68
+
69
+
70
+ @dataclass
71
+ class TokenInfo:
72
+ """Token 信息数据类"""
73
+ access_token: str
74
+ expires_at: float # 过期时间戳
75
+ token_type: str = "Bearer"
76
+ scope: str = ""
77
+ refresh_token: Optional[str] = None
78
+
79
+ def is_expired(self, buffer_seconds: int = 120) -> bool:
80
+ """检查 Token 是否已过期"""
81
+ import time
82
+ return time.time() >= (self.expires_at - buffer_seconds)
83
+
84
+ @classmethod
85
+ def from_response(cls, data: Dict[str, Any], scope: str = "") -> "TokenInfo":
86
+ """从 API 响应创建"""
87
+ import time
88
+ return cls(
89
+ access_token=data.get("access_token", ""),
90
+ expires_at=time.time() + data.get("expires_in", 3600),
91
+ token_type=data.get("token_type", "Bearer"),
92
+ scope=scope or data.get("scope", ""),
93
+ refresh_token=data.get("refresh_token"),
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class ProviderHealth:
99
+ """提供者健康状态"""
100
+ provider_type: ProviderType
101
+ status: ProviderStatus = ProviderStatus.HEALTHY
102
+ failure_count: int = 0 # 连续失败次数
103
+ last_success: Optional[datetime] = None # 最后成功时间
104
+ last_failure: Optional[datetime] = None # 最后失败时间
105
+ last_error: str = "" # 最后错误信息
106
+ disabled_until: Optional[datetime] = None # 禁用截止时间
107
+
108
+ def record_success(self):
109
+ """记录成功"""
110
+ self.status = ProviderStatus.HEALTHY
111
+ self.failure_count = 0
112
+ self.last_success = datetime.now()
113
+ self.disabled_until = None
114
+
115
+ def record_failure(self, error: str):
116
+ """记录失败"""
117
+ self.failure_count += 1
118
+ self.last_failure = datetime.now()
119
+ self.last_error = error
120
+
121
+ def should_disable(self, threshold: int = 3) -> bool:
122
+ """判断是否应该禁用"""
123
+ return self.failure_count >= threshold
124
+
125
+ def is_disabled(self) -> bool:
126
+ """检查是否被禁用"""
127
+ if self.disabled_until and datetime.now() < self.disabled_until:
128
+ return True
129
+ return False
130
+
131
+ def disable(self, duration_seconds: int = 300):
132
+ """禁用提供者"""
133
+ from datetime import timedelta
134
+ self.status = ProviderStatus.DISABLED
135
+ self.disabled_until = datetime.now() + timedelta(seconds=duration_seconds)
136
+
137
+ def enable(self):
138
+ """启用提供者"""
139
+ self.status = ProviderStatus.HEALTHY
140
+ self.disabled_until = None
141
+ self.failure_count = 0
142
+
143
+ def to_dict(self) -> Dict[str, Any]:
144
+ """转换为字典"""
145
+ return {
146
+ "provider_type": self.provider_type.value,
147
+ "status": self.status.value,
148
+ "failure_count": self.failure_count,
149
+ "last_success": self.last_success.isoformat() if self.last_success else None,
150
+ "last_failure": self.last_failure.isoformat() if self.last_failure else None,
151
+ "last_error": self.last_error,
152
+ "disabled_until": self.disabled_until.isoformat() if self.disabled_until else None,
153
+ }
src/services/outlook/email_parser.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 邮件解析和验证码提取
3
+ """
4
+
5
+ import logging
6
+ import re
7
+ from typing import Optional, List, Dict, Any
8
+
9
+ from ...config.constants import (
10
+ OTP_CODE_SIMPLE_PATTERN,
11
+ OTP_CODE_SEMANTIC_PATTERN,
12
+ OPENAI_EMAIL_SENDERS,
13
+ OPENAI_VERIFICATION_KEYWORDS,
14
+ )
15
+ from .base import EmailMessage
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class EmailParser:
22
+ """
23
+ 邮件解析器
24
+ 用于识别 OpenAI 验证邮件并提取验证码
25
+ """
26
+
27
+ def __init__(self):
28
+ # 编译正则表达式
29
+ self._simple_pattern = re.compile(OTP_CODE_SIMPLE_PATTERN)
30
+ self._semantic_pattern = re.compile(OTP_CODE_SEMANTIC_PATTERN, re.IGNORECASE)
31
+
32
+ def is_openai_verification_email(
33
+ self,
34
+ email: EmailMessage,
35
+ target_email: Optional[str] = None,
36
+ ) -> bool:
37
+ """
38
+ 判断是否为 OpenAI 验证邮件
39
+
40
+ Args:
41
+ email: 邮件对象
42
+ target_email: 目标邮箱地址(用于验证收件人)
43
+
44
+ Returns:
45
+ 是否为 OpenAI 验证邮件
46
+ """
47
+ sender = email.sender.lower()
48
+
49
+ # 1. 发件人必须是 OpenAI
50
+ if not any(s in sender for s in OPENAI_EMAIL_SENDERS):
51
+ logger.debug(f"邮件发件人非 OpenAI: {sender}")
52
+ return False
53
+
54
+ # 2. 主题或正文包含验证关键词
55
+ subject = email.subject.lower()
56
+ body = email.body.lower()
57
+ combined = f"{subject} {body}"
58
+
59
+ if not any(kw in combined for kw in OPENAI_VERIFICATION_KEYWORDS):
60
+ logger.debug(f"邮件未包含验证关键词: {subject[:50]}")
61
+ return False
62
+
63
+ # 3. 收件人检查已移除:别名邮件的 IMAP 头中收件人可能不匹配,只靠发件人+关键词判断
64
+ logger.debug(f"识别为 OpenAI 验证邮件: {subject[:50]}")
65
+ return True
66
+
67
+ def extract_verification_code(
68
+ self,
69
+ email: EmailMessage,
70
+ ) -> Optional[str]:
71
+ """
72
+ 从邮件中提取验证码
73
+
74
+ 优先级:
75
+ 1. 从主题提取(6位数字)
76
+ 2. 从正文用语义正则提取(如 "code is 123456")
77
+ 3. 兜底:任意 6 位数字
78
+
79
+ Args:
80
+ email: 邮件对象
81
+
82
+ Returns:
83
+ 验证码字符串,如果未找到返回 None
84
+ """
85
+ # 1. 主题优先
86
+ code = self._extract_from_subject(email.subject)
87
+ if code:
88
+ logger.debug(f"从主题提取验证码: {code}")
89
+ return code
90
+
91
+ # 2. 正文语义匹配
92
+ code = self._extract_semantic(email.body)
93
+ if code:
94
+ logger.debug(f"从正文语义提取验证码: {code}")
95
+ return code
96
+
97
+ # 3. 兜底:正文任意 6 位数字
98
+ code = self._extract_simple(email.body)
99
+ if code:
100
+ logger.debug(f"从正文兜底提取验证码: {code}")
101
+ return code
102
+
103
+ return None
104
+
105
+ def _extract_from_subject(self, subject: str) -> Optional[str]:
106
+ """从主题提取验证码"""
107
+ match = self._simple_pattern.search(subject)
108
+ if match:
109
+ return match.group(1)
110
+ return None
111
+
112
+ def _extract_semantic(self, body: str) -> Optional[str]:
113
+ """语义匹配提取验证码"""
114
+ match = self._semantic_pattern.search(body)
115
+ if match:
116
+ return match.group(1)
117
+ return None
118
+
119
+ def _extract_simple(self, body: str) -> Optional[str]:
120
+ """简单匹配提取验证码"""
121
+ match = self._simple_pattern.search(body)
122
+ if match:
123
+ return match.group(1)
124
+ return None
125
+
126
+ def find_verification_code_in_emails(
127
+ self,
128
+ emails: List[EmailMessage],
129
+ target_email: Optional[str] = None,
130
+ min_timestamp: int = 0,
131
+ used_codes: Optional[set] = None,
132
+ ) -> Optional[str]:
133
+ """
134
+ 从邮件列表中查找验证码
135
+
136
+ Args:
137
+ emails: 邮件列表
138
+ target_email: 目标邮箱地址
139
+ min_timestamp: 最小时间戳(用于过滤旧邮件)
140
+ used_codes: 已使用的验证码集合(用于去重)
141
+
142
+ Returns:
143
+ 验证码字符串,如果未找到返回 None
144
+ """
145
+ used_codes = used_codes or set()
146
+
147
+ for email in emails:
148
+ # 时间戳过滤
149
+ if min_timestamp > 0 and email.received_timestamp > 0:
150
+ if email.received_timestamp < min_timestamp:
151
+ logger.debug(f"跳过旧邮件: {email.subject[:50]}")
152
+ continue
153
+
154
+ # 检查是否是 OpenAI 验证邮件
155
+ if not self.is_openai_verification_email(email, target_email):
156
+ continue
157
+
158
+ # 提取验证码
159
+ code = self.extract_verification_code(email)
160
+ if code:
161
+ # 去重检查
162
+ if code in used_codes:
163
+ logger.debug(f"跳过已使用的验证码: {code}")
164
+ continue
165
+
166
+ logger.info(
167
+ f"[{target_email or 'unknown'}] 找到验证码: {code}, "
168
+ f"邮件主题: {email.subject[:30]}"
169
+ )
170
+ return code
171
+
172
+ return None
173
+
174
+ def filter_emails_by_sender(
175
+ self,
176
+ emails: List[EmailMessage],
177
+ sender_patterns: List[str],
178
+ ) -> List[EmailMessage]:
179
+ """
180
+ 按发件人过滤邮件
181
+
182
+ Args:
183
+ emails: 邮件列表
184
+ sender_patterns: 发件人匹配模式列表
185
+
186
+ Returns:
187
+ 过滤后的邮件列表
188
+ """
189
+ filtered = []
190
+ for email in emails:
191
+ sender = email.sender.lower()
192
+ if any(pattern.lower() in sender for pattern in sender_patterns):
193
+ filtered.append(email)
194
+ return filtered
195
+
196
+ def filter_emails_by_subject(
197
+ self,
198
+ emails: List[EmailMessage],
199
+ keywords: List[str],
200
+ ) -> List[EmailMessage]:
201
+ """
202
+ 按主题关键词过滤邮件
203
+
204
+ Args:
205
+ emails: 邮件列表
206
+ keywords: 关键词列表
207
+
208
+ Returns:
209
+ 过滤后的邮件列表
210
+ """
211
+ filtered = []
212
+ for email in emails:
213
+ subject = email.subject.lower()
214
+ if any(kw.lower() in subject for kw in keywords):
215
+ filtered.append(email)
216
+ return filtered
217
+
218
+
219
+ # 全局解析器实例
220
+ _parser: Optional[EmailParser] = None
221
+
222
+
223
+ def get_email_parser() -> EmailParser:
224
+ """获取全局邮件解析器实例"""
225
+ global _parser
226
+ if _parser is None:
227
+ _parser = EmailParser()
228
+ return _parser
src/services/outlook/health_checker.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 健康检查和故障切换管理
3
+ """
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ from datetime import datetime, timedelta
9
+ from typing import Dict, List, Optional, Any
10
+
11
+ from .base import ProviderType, ProviderHealth, ProviderStatus
12
+ from .providers.base import OutlookProvider
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class HealthChecker:
19
+ """
20
+ 健康检查管理器
21
+ 跟踪各提供者的健康状态,管理故障切换
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ failure_threshold: int = 3,
27
+ disable_duration: int = 300,
28
+ recovery_check_interval: int = 60,
29
+ ):
30
+ """
31
+ 初始化健康检查器
32
+
33
+ Args:
34
+ failure_threshold: 连续失败次数阈值,超过后禁用
35
+ disable_duration: 禁用时长(秒)
36
+ recovery_check_interval: 恢复检查间隔(秒)
37
+ """
38
+ self.failure_threshold = failure_threshold
39
+ self.disable_duration = disable_duration
40
+ self.recovery_check_interval = recovery_check_interval
41
+
42
+ # 提供者健康状态: ProviderType -> ProviderHealth
43
+ self._health_status: Dict[ProviderType, ProviderHealth] = {}
44
+ self._lock = threading.Lock()
45
+
46
+ # 初始化所有提供者的健康状态
47
+ for provider_type in ProviderType:
48
+ self._health_status[provider_type] = ProviderHealth(
49
+ provider_type=provider_type
50
+ )
51
+
52
+ def get_health(self, provider_type: ProviderType) -> ProviderHealth:
53
+ """获取提供者的健康状态"""
54
+ with self._lock:
55
+ return self._health_status.get(provider_type, ProviderHealth(provider_type=provider_type))
56
+
57
+ def record_success(self, provider_type: ProviderType):
58
+ """记录成功操作"""
59
+ with self._lock:
60
+ health = self._health_status.get(provider_type)
61
+ if health:
62
+ health.record_success()
63
+ logger.debug(f"{provider_type.value} 记录成功")
64
+
65
+ def record_failure(self, provider_type: ProviderType, error: str):
66
+ """记录失败操作"""
67
+ with self._lock:
68
+ health = self._health_status.get(provider_type)
69
+ if health:
70
+ health.record_failure(error)
71
+
72
+ # 检查是否需要禁用
73
+ if health.should_disable(self.failure_threshold):
74
+ health.disable(self.disable_duration)
75
+ logger.warning(
76
+ f"{provider_type.value} 已禁用 {self.disable_duration} 秒,"
77
+ f"原因: {error}"
78
+ )
79
+
80
+ def is_available(self, provider_type: ProviderType) -> bool:
81
+ """
82
+ 检查提供者是否可用
83
+
84
+ Args:
85
+ provider_type: 提供者类型
86
+
87
+ Returns:
88
+ 是否可用
89
+ """
90
+ health = self.get_health(provider_type)
91
+
92
+ # 检查是否被禁用
93
+ if health.is_disabled():
94
+ remaining = (health.disabled_until - datetime.now()).total_seconds()
95
+ logger.debug(
96
+ f"{provider_type.value} 已被禁用,剩余 {int(remaining)} 秒"
97
+ )
98
+ return False
99
+
100
+ return health.status != ProviderStatus.DISABLED
101
+
102
+ def get_available_providers(
103
+ self,
104
+ priority_order: Optional[List[ProviderType]] = None,
105
+ ) -> List[ProviderType]:
106
+ """
107
+ 获取可用的提供者列表
108
+
109
+ Args:
110
+ priority_order: 优先级顺序,默认为 [IMAP_NEW, IMAP_OLD, GRAPH_API]
111
+
112
+ Returns:
113
+ 可用的提供者列表
114
+ """
115
+ if priority_order is None:
116
+ priority_order = [
117
+ ProviderType.IMAP_NEW,
118
+ ProviderType.IMAP_OLD,
119
+ ProviderType.GRAPH_API,
120
+ ]
121
+
122
+ available = []
123
+ for provider_type in priority_order:
124
+ if self.is_available(provider_type):
125
+ available.append(provider_type)
126
+
127
+ return available
128
+
129
+ def get_next_available_provider(
130
+ self,
131
+ priority_order: Optional[List[ProviderType]] = None,
132
+ ) -> Optional[ProviderType]:
133
+ """
134
+ 获取下一个可用的提供者
135
+
136
+ Args:
137
+ priority_order: 优先级顺序
138
+
139
+ Returns:
140
+ 可用的提供者类型,如果没有返回 None
141
+ """
142
+ available = self.get_available_providers(priority_order)
143
+ return available[0] if available else None
144
+
145
+ def force_disable(self, provider_type: ProviderType, duration: Optional[int] = None):
146
+ """
147
+ 强制禁用提供者
148
+
149
+ Args:
150
+ provider_type: 提供者类型
151
+ duration: 禁用时长(秒),默认使用配置值
152
+ """
153
+ with self._lock:
154
+ health = self._health_status.get(provider_type)
155
+ if health:
156
+ health.disable(duration or self.disable_duration)
157
+ logger.warning(f"{provider_type.value} 已强制禁用")
158
+
159
+ def force_enable(self, provider_type: ProviderType):
160
+ """
161
+ 强制启用提供者
162
+
163
+ Args:
164
+ provider_type: 提供者类型
165
+ """
166
+ with self._lock:
167
+ health = self._health_status.get(provider_type)
168
+ if health:
169
+ health.enable()
170
+ logger.info(f"{provider_type.value} 已启用")
171
+
172
+ def get_all_health_status(self) -> Dict[str, Any]:
173
+ """
174
+ 获取所有提供者的健康状态
175
+
176
+ Returns:
177
+ 健康状态字典
178
+ """
179
+ with self._lock:
180
+ return {
181
+ provider_type.value: health.to_dict()
182
+ for provider_type, health in self._health_status.items()
183
+ }
184
+
185
+ def check_and_recover(self):
186
+ """
187
+ 检查并恢复被禁用的提供者
188
+
189
+ 如果禁用时间已过,自动恢复提供者
190
+ """
191
+ with self._lock:
192
+ for provider_type, health in self._health_status.items():
193
+ if health.is_disabled():
194
+ # 检查是否可以恢复
195
+ if health.disabled_until and datetime.now() >= health.disabled_until:
196
+ health.enable()
197
+ logger.info(f"{provider_type.value} 已自动恢复")
198
+
199
+ def reset_all(self):
200
+ """重置所有提供者的健康状态"""
201
+ with self._lock:
202
+ for provider_type in ProviderType:
203
+ self._health_status[provider_type] = ProviderHealth(
204
+ provider_type=provider_type
205
+ )
206
+ logger.info("已重置所有提供者的健康状态")
207
+
208
+
209
+ class FailoverManager:
210
+ """
211
+ 故障切换管理器
212
+ 管理提供者之间的自动切换
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ health_checker: HealthChecker,
218
+ priority_order: Optional[List[ProviderType]] = None,
219
+ ):
220
+ """
221
+ 初始化故障切换管理器
222
+
223
+ Args:
224
+ health_checker: 健康检查器
225
+ priority_order: 提供者优先级顺序
226
+ """
227
+ self.health_checker = health_checker
228
+ self.priority_order = priority_order or [
229
+ ProviderType.IMAP_NEW,
230
+ ProviderType.IMAP_OLD,
231
+ ProviderType.GRAPH_API,
232
+ ]
233
+
234
+ # 当前使用的提供者索引
235
+ self._current_index = 0
236
+ self._lock = threading.Lock()
237
+
238
+ def get_current_provider(self) -> Optional[ProviderType]:
239
+ """
240
+ 获取当前提供者
241
+
242
+ Returns:
243
+ 当前提供者类型,如果没有可用的返回 None
244
+ """
245
+ available = self.health_checker.get_available_providers(self.priority_order)
246
+ if not available:
247
+ return None
248
+
249
+ with self._lock:
250
+ # 尝试使用当前索引
251
+ if self._current_index < len(available):
252
+ return available[self._current_index]
253
+ return available[0]
254
+
255
+ def switch_to_next(self) -> Optional[ProviderType]:
256
+ """
257
+ 切换到下一个提供者
258
+
259
+ Returns:
260
+ 下一个提供者类型,如果没有可用的返回 None
261
+ """
262
+ available = self.health_checker.get_available_providers(self.priority_order)
263
+ if not available:
264
+ return None
265
+
266
+ with self._lock:
267
+ self._current_index = (self._current_index + 1) % len(available)
268
+ next_provider = available[self._current_index]
269
+ logger.info(f"切换到提供者: {next_provider.value}")
270
+ return next_provider
271
+
272
+ def on_provider_success(self, provider_type: ProviderType):
273
+ """
274
+ 提供者成功时调用
275
+
276
+ Args:
277
+ provider_type: 提供者类型
278
+ """
279
+ self.health_checker.record_success(provider_type)
280
+
281
+ # 重置索引到成功的提供者
282
+ with self._lock:
283
+ available = self.health_checker.get_available_providers(self.priority_order)
284
+ if provider_type in available:
285
+ self._current_index = available.index(provider_type)
286
+
287
+ def on_provider_failure(self, provider_type: ProviderType, error: str):
288
+ """
289
+ 提供者失败时调用
290
+
291
+ Args:
292
+ provider_type: 提供者类型
293
+ error: 错误信息
294
+ """
295
+ self.health_checker.record_failure(provider_type, error)
296
+
297
+ def get_status(self) -> Dict[str, Any]:
298
+ """
299
+ 获取故障切换状态
300
+
301
+ Returns:
302
+ 状态字典
303
+ """
304
+ current = self.get_current_provider()
305
+ return {
306
+ "current_provider": current.value if current else None,
307
+ "priority_order": [p.value for p in self.priority_order],
308
+ "available_providers": [
309
+ p.value for p in self.health_checker.get_available_providers(self.priority_order)
310
+ ],
311
+ "health_status": self.health_checker.get_all_health_status(),
312
+ }
src/services/outlook/providers/__init__.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Outlook 提供者模块
3
+ """
4
+
5
+ from .base import OutlookProvider, ProviderConfig
6
+ from .imap_old import IMAPOldProvider
7
+ from .imap_new import IMAPNewProvider
8
+ from .graph_api import GraphAPIProvider
9
+
10
+ __all__ = [
11
+ 'OutlookProvider',
12
+ 'ProviderConfig',
13
+ 'IMAPOldProvider',
14
+ 'IMAPNewProvider',
15
+ 'GraphAPIProvider',
16
+ ]
17
+
18
+
19
+ # 提供者注册表
20
+ PROVIDER_REGISTRY = {
21
+ 'imap_old': IMAPOldProvider,
22
+ 'imap_new': IMAPNewProvider,
23
+ 'graph_api': GraphAPIProvider,
24
+ }
25
+
26
+
27
+ def get_provider_class(provider_type: str):
28
+ """获取提供者类"""
29
+ return PROVIDER_REGISTRY.get(provider_type)
src/services/outlook/providers/base.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Outlook 提供者抽象基类
3
+ """
4
+
5
+ import abc
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from typing import Dict, Any, List, Optional
9
+
10
+ from ..base import ProviderType, EmailMessage, ProviderHealth, ProviderStatus
11
+ from ..account import OutlookAccount
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class ProviderConfig:
19
+ """提供者配置"""
20
+ timeout: int = 30
21
+ max_retries: int = 3
22
+ proxy_url: Optional[str] = None
23
+
24
+ # 健康检查配置
25
+ health_failure_threshold: int = 3
26
+ health_disable_duration: int = 300 # 秒
27
+
28
+
29
+ class OutlookProvider(abc.ABC):
30
+ """
31
+ Outlook 提供者抽象基类
32
+ 定义所有提供者必须实现的接口
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ account: OutlookAccount,
38
+ config: Optional[ProviderConfig] = None,
39
+ ):
40
+ """
41
+ 初始化提供者
42
+
43
+ Args:
44
+ account: Outlook 账户
45
+ config: 提供者配置
46
+ """
47
+ self.account = account
48
+ self.config = config or ProviderConfig()
49
+
50
+ # 健康状态
51
+ self._health = ProviderHealth(provider_type=self.provider_type)
52
+
53
+ # 连接状态
54
+ self._connected = False
55
+ self._last_error: Optional[str] = None
56
+
57
+ @property
58
+ @abc.abstractmethod
59
+ def provider_type(self) -> ProviderType:
60
+ """获取提供者类型"""
61
+ pass
62
+
63
+ @property
64
+ def health(self) -> ProviderHealth:
65
+ """获取健康状态"""
66
+ return self._health
67
+
68
+ @property
69
+ def is_healthy(self) -> bool:
70
+ """检查是否健康"""
71
+ return (
72
+ self._health.status == ProviderStatus.HEALTHY
73
+ and not self._health.is_disabled()
74
+ )
75
+
76
+ @property
77
+ def is_connected(self) -> bool:
78
+ """检查是否已连接"""
79
+ return self._connected
80
+
81
+ @abc.abstractmethod
82
+ def connect(self) -> bool:
83
+ """
84
+ 连接到服务
85
+
86
+ Returns:
87
+ 是否连接成功
88
+ """
89
+ pass
90
+
91
+ @abc.abstractmethod
92
+ def disconnect(self):
93
+ """断开连接"""
94
+ pass
95
+
96
+ @abc.abstractmethod
97
+ def get_recent_emails(
98
+ self,
99
+ count: int = 20,
100
+ only_unseen: bool = True,
101
+ ) -> List[EmailMessage]:
102
+ """
103
+ 获取最近的邮件
104
+
105
+ Args:
106
+ count: 获取数量
107
+ only_unseen: 是否只获取未读
108
+
109
+ Returns:
110
+ 邮件列表
111
+ """
112
+ pass
113
+
114
+ @abc.abstractmethod
115
+ def test_connection(self) -> bool:
116
+ """
117
+ 测试连接是否正常
118
+
119
+ Returns:
120
+ 连接是否正常
121
+ """
122
+ pass
123
+
124
+ def record_success(self):
125
+ """记录成功操作"""
126
+ self._health.record_success()
127
+ self._last_error = None
128
+ logger.debug(f"[{self.account.email}] {self.provider_type.value} 操作成功")
129
+
130
+ def record_failure(self, error: str):
131
+ """记录失败操作"""
132
+ self._health.record_failure(error)
133
+ self._last_error = error
134
+
135
+ # 检查是否需要禁用
136
+ if self._health.should_disable(self.config.health_failure_threshold):
137
+ self._health.disable(self.config.health_disable_duration)
138
+ logger.warning(
139
+ f"[{self.account.email}] {self.provider_type.value} 已禁用 "
140
+ f"{self.config.health_disable_duration} 秒,原因: {error}"
141
+ )
142
+ else:
143
+ logger.warning(
144
+ f"[{self.account.email}] {self.provider_type.value} 操作失败 "
145
+ f"({self._health.failure_count}/{self.config.health_failure_threshold}): {error}"
146
+ )
147
+
148
+ def check_health(self) -> bool:
149
+ """
150
+ 检查健康状态
151
+
152
+ Returns:
153
+ 是否健康可用
154
+ """
155
+ # 检查是否被禁用
156
+ if self._health.is_disabled():
157
+ logger.debug(
158
+ f"[{self.account.email}] {self.provider_type.value} 已被禁用,"
159
+ f"将在 {self._health.disabled_until} 后恢复"
160
+ )
161
+ return False
162
+
163
+ return self._health.status in (ProviderStatus.HEALTHY, ProviderStatus.DEGRADED)
164
+
165
+ def __enter__(self):
166
+ """上下文管理器入口"""
167
+ self.connect()
168
+ return self
169
+
170
+ def __exit__(self, exc_type, exc_val, exc_tb):
171
+ """上下文管理器出口"""
172
+ self.disconnect()
173
+ return False
174
+
175
+ def __str__(self) -> str:
176
+ """字符串表示"""
177
+ return f"{self.__class__.__name__}({self.account.email})"
178
+
179
+ def __repr__(self) -> str:
180
+ return self.__str__()
src/services/outlook/providers/graph_api.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Graph API 提供者
3
+ 使用 Microsoft Graph REST API
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ from typing import List, Optional
9
+ from datetime import datetime
10
+
11
+ from curl_cffi import requests as _requests
12
+
13
+ from ..base import ProviderType, EmailMessage
14
+ from ..account import OutlookAccount
15
+ from ..token_manager import TokenManager
16
+ from .base import OutlookProvider, ProviderConfig
17
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class GraphAPIProvider(OutlookProvider):
23
+ """
24
+ Graph API 提供者
25
+ 使用 Microsoft Graph REST API 获取邮件
26
+ 需要 graph.microsoft.com/.default scope
27
+ """
28
+
29
+ # Graph API 端点
30
+ GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"
31
+ MESSAGES_ENDPOINT = "/me/mailFolders/inbox/messages"
32
+
33
+ @property
34
+ def provider_type(self) -> ProviderType:
35
+ return ProviderType.GRAPH_API
36
+
37
+ def __init__(
38
+ self,
39
+ account: OutlookAccount,
40
+ config: Optional[ProviderConfig] = None,
41
+ ):
42
+ super().__init__(account, config)
43
+
44
+ # Token 管理器
45
+ self._token_manager: Optional[TokenManager] = None
46
+
47
+ # 注意:Graph API 必须使用 OAuth2
48
+ if not account.has_oauth():
49
+ logger.warning(
50
+ f"[{self.account.email}] Graph API 提供者需要 OAuth2 配置 "
51
+ f"(client_id + refresh_token)"
52
+ )
53
+
54
+ def connect(self) -> bool:
55
+ """
56
+ 验证连接(获取 Token)
57
+
58
+ Returns:
59
+ 是否连接成功
60
+ """
61
+ if not self.account.has_oauth():
62
+ error = "Graph API 需要 OAuth2 配置"
63
+ self.record_failure(error)
64
+ logger.error(f"[{self.account.email}] {error}")
65
+ return False
66
+
67
+ if not self._token_manager:
68
+ self._token_manager = TokenManager(
69
+ self.account,
70
+ ProviderType.GRAPH_API,
71
+ self.config.proxy_url,
72
+ self.config.timeout,
73
+ )
74
+
75
+ # 尝试获取 Token
76
+ token = self._token_manager.get_access_token()
77
+ if token:
78
+ self._connected = True
79
+ self.record_success()
80
+ logger.info(f"[{self.account.email}] Graph API 连接成功")
81
+ return True
82
+
83
+ return False
84
+
85
+ def disconnect(self):
86
+ """断开连接(清除状态)"""
87
+ self._connected = False
88
+
89
+ def get_recent_emails(
90
+ self,
91
+ count: int = 20,
92
+ only_unseen: bool = True,
93
+ ) -> List[EmailMessage]:
94
+ """
95
+ 获取最近的邮件
96
+
97
+ Args:
98
+ count: 获取数量
99
+ only_unseen: 是否只获取未读
100
+
101
+ Returns:
102
+ 邮件列表
103
+ """
104
+ if not self._connected:
105
+ if not self.connect():
106
+ return []
107
+
108
+ try:
109
+ # 获取 Access Token
110
+ token = self._token_manager.get_access_token()
111
+ if not token:
112
+ self.record_failure("无法获取 Access Token")
113
+ return []
114
+
115
+ # 构建 API 请求
116
+ url = f"{self.GRAPH_API_BASE}{self.MESSAGES_ENDPOINT}"
117
+
118
+ params = {
119
+ "$top": count,
120
+ "$select": "id,subject,from,toRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,body",
121
+ "$orderby": "receivedDateTime desc",
122
+ }
123
+
124
+ # 只获取未读邮件
125
+ if only_unseen:
126
+ params["$filter"] = "isRead eq false"
127
+
128
+ # 构建代理配置
129
+ proxies = None
130
+ if self.config.proxy_url:
131
+ proxies = {"http": self.config.proxy_url, "https": self.config.proxy_url}
132
+
133
+ # 发送请求(curl_cffi 自动对 params 进行 URL 编码)
134
+ resp = _requests.get(
135
+ url,
136
+ params=params,
137
+ headers={
138
+ "Authorization": f"Bearer {token}",
139
+ "Accept": "application/json",
140
+ "Prefer": "outlook.body-content-type='text'",
141
+ },
142
+ proxies=proxies,
143
+ timeout=self.config.timeout,
144
+ impersonate="chrome110",
145
+ )
146
+
147
+ if resp.status_code == 401:
148
+ # Token 无 Graph 权限(client_id 未授权),清除缓存但不记录健康失败
149
+ # 避免因权限不足导致健康检查器禁用该提供者,影响其他账户
150
+ if self._token_manager:
151
+ self._token_manager.clear_cache()
152
+ self._connected = False
153
+ logger.warning(f"[{self.account.email}] Graph API 返回 401,client_id 可能无 Graph 权限,跳过")
154
+ return []
155
+
156
+ if resp.status_code != 200:
157
+ error_body = resp.text[:200]
158
+ self.record_failure(f"HTTP {resp.status_code}: {error_body}")
159
+ logger.error(f"[{self.account.email}] Graph API 请求失败: HTTP {resp.status_code}")
160
+ return []
161
+
162
+ data = resp.json()
163
+
164
+ # 解析邮件
165
+ messages = data.get("value", [])
166
+ emails = []
167
+
168
+ for msg in messages:
169
+ try:
170
+ email_msg = self._parse_graph_message(msg)
171
+ if email_msg:
172
+ emails.append(email_msg)
173
+ except Exception as e:
174
+ logger.warning(f"[{self.account.email}] 解析 Graph API 邮件失败: {e}")
175
+
176
+ self.record_success()
177
+ return emails
178
+
179
+ except Exception as e:
180
+ self.record_failure(str(e))
181
+ logger.error(f"[{self.account.email}] Graph API 获取邮件失败: {e}")
182
+ return []
183
+
184
+ def _parse_graph_message(self, msg: dict) -> Optional[EmailMessage]:
185
+ """
186
+ 解析 Graph API 消息
187
+
188
+ Args:
189
+ msg: Graph API 消息对象
190
+
191
+ Returns:
192
+ EmailMessage 对象
193
+ """
194
+ # 解析发件人
195
+ from_info = msg.get("from", {})
196
+ sender_info = from_info.get("emailAddress", {})
197
+ sender = sender_info.get("address", "")
198
+
199
+ # 解析收件人
200
+ recipients = []
201
+ for recipient in msg.get("toRecipients", []):
202
+ addr_info = recipient.get("emailAddress", {})
203
+ addr = addr_info.get("address", "")
204
+ if addr:
205
+ recipients.append(addr)
206
+
207
+ # 解析日期
208
+ received_at = None
209
+ received_timestamp = 0
210
+ try:
211
+ date_str = msg.get("receivedDateTime", "")
212
+ if date_str:
213
+ # ISO 8601 格式
214
+ received_at = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
215
+ received_timestamp = int(received_at.timestamp())
216
+ except Exception:
217
+ pass
218
+
219
+ # 获取正文
220
+ body_info = msg.get("body", {})
221
+ body = body_info.get("content", "")
222
+ body_preview = msg.get("bodyPreview", "")
223
+
224
+ return EmailMessage(
225
+ id=msg.get("id", ""),
226
+ subject=msg.get("subject", ""),
227
+ sender=sender,
228
+ recipients=recipients,
229
+ body=body,
230
+ body_preview=body_preview,
231
+ received_at=received_at,
232
+ received_timestamp=received_timestamp,
233
+ is_read=msg.get("isRead", False),
234
+ has_attachments=msg.get("hasAttachments", False),
235
+ )
236
+
237
+ def test_connection(self) -> bool:
238
+ """
239
+ 测试 Graph API 连接
240
+
241
+ Returns:
242
+ 连接是否正常
243
+ """
244
+ try:
245
+ # 尝试获取一封邮件来测试连接
246
+ emails = self.get_recent_emails(count=1, only_unseen=False)
247
+ return True
248
+ except Exception as e:
249
+ logger.warning(f"[{self.account.email}] Graph API 连接测试失败: {e}")
250
+ return False
src/services/outlook/providers/imap_new.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 新版 IMAP 提供者
3
+ 使用 outlook.live.com 服务器和 login.microsoftonline.com/consumers Token 端点
4
+ """
5
+
6
+ import email
7
+ import imaplib
8
+ import logging
9
+ from email.header import decode_header
10
+ from email.utils import parsedate_to_datetime
11
+ from typing import List, Optional
12
+
13
+ from ..base import ProviderType, EmailMessage
14
+ from ..account import OutlookAccount
15
+ from ..token_manager import TokenManager
16
+ from .base import OutlookProvider, ProviderConfig
17
+ from .imap_old import IMAPOldProvider
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class IMAPNewProvider(OutlookProvider):
24
+ """
25
+ 新版 IMAP 提供者
26
+ 使用 outlook.live.com:993 和 login.microsoftonline.com/consumers Token 端点
27
+ 需要 IMAP.AccessAsUser.All scope
28
+ """
29
+
30
+ # IMAP 服务器配置
31
+ IMAP_HOST = "outlook.live.com"
32
+ IMAP_PORT = 993
33
+
34
+ @property
35
+ def provider_type(self) -> ProviderType:
36
+ return ProviderType.IMAP_NEW
37
+
38
+ def __init__(
39
+ self,
40
+ account: OutlookAccount,
41
+ config: Optional[ProviderConfig] = None,
42
+ ):
43
+ super().__init__(account, config)
44
+
45
+ # IMAP 连接
46
+ self._conn: Optional[imaplib.IMAP4_SSL] = None
47
+
48
+ # Token 管理器
49
+ self._token_manager: Optional[TokenManager] = None
50
+
51
+ # 注意:新版 IMAP 必须使用 OAuth2
52
+ if not account.has_oauth():
53
+ logger.warning(
54
+ f"[{self.account.email}] 新版 IMAP 提供者需要 OAuth2 配置 "
55
+ f"(client_id + refresh_token)"
56
+ )
57
+
58
+ def connect(self) -> bool:
59
+ """
60
+ 连接到 IMAP 服务器
61
+
62
+ Returns:
63
+ 是否连接成功
64
+ """
65
+ if self._connected and self._conn:
66
+ try:
67
+ self._conn.noop()
68
+ return True
69
+ except Exception:
70
+ self.disconnect()
71
+
72
+ # 新版 IMAP 必须使用 OAuth2,无 OAuth 时静默跳过,不记录健康失败
73
+ if not self.account.has_oauth():
74
+ logger.debug(f"[{self.account.email}] 跳过 IMAP_NEW(无 OAuth)")
75
+ return False
76
+
77
+ try:
78
+ logger.debug(f"[{self.account.email}] 正在连接 IMAP ({self.IMAP_HOST})...")
79
+
80
+ # 创建连接
81
+ self._conn = imaplib.IMAP4_SSL(
82
+ self.IMAP_HOST,
83
+ self.IMAP_PORT,
84
+ timeout=self.config.timeout,
85
+ )
86
+
87
+ # XOAUTH2 认证
88
+ if self._authenticate_xoauth2():
89
+ self._connected = True
90
+ self.record_success()
91
+ logger.info(f"[{self.account.email}] 新版 IMAP 连接成功 (XOAUTH2)")
92
+ return True
93
+
94
+ return False
95
+
96
+ except Exception as e:
97
+ self.disconnect()
98
+ self.record_failure(str(e))
99
+ logger.error(f"[{self.account.email}] 新版 IMAP 连接失败: {e}")
100
+ return False
101
+
102
+ def _authenticate_xoauth2(self) -> bool:
103
+ """
104
+ 使用 XOAUTH2 认证
105
+
106
+ Returns:
107
+ 是否认证成功
108
+ """
109
+ if not self._token_manager:
110
+ self._token_manager = TokenManager(
111
+ self.account,
112
+ ProviderType.IMAP_NEW,
113
+ self.config.proxy_url,
114
+ self.config.timeout,
115
+ )
116
+
117
+ # 获取 Access Token
118
+ token = self._token_manager.get_access_token()
119
+ if not token:
120
+ logger.error(f"[{self.account.email}] 获取 IMAP Token 失败")
121
+ return False
122
+
123
+ try:
124
+ # 构建 XOAUTH2 认证字符串
125
+ auth_string = f"user={self.account.email}\x01auth=Bearer {token}\x01\x01"
126
+ self._conn.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
127
+ return True
128
+ except Exception as e:
129
+ logger.error(f"[{self.account.email}] XOAUTH2 认证异常: {e}")
130
+ # 清除缓存的 Token
131
+ self._token_manager.clear_cache()
132
+ return False
133
+
134
+ def disconnect(self):
135
+ """断开 IMAP 连接"""
136
+ if self._conn:
137
+ try:
138
+ self._conn.close()
139
+ except Exception:
140
+ pass
141
+ try:
142
+ self._conn.logout()
143
+ except Exception:
144
+ pass
145
+ self._conn = None
146
+
147
+ self._connected = False
148
+
149
+ def get_recent_emails(
150
+ self,
151
+ count: int = 20,
152
+ only_unseen: bool = True,
153
+ ) -> List[EmailMessage]:
154
+ """
155
+ 获取最近的邮件
156
+
157
+ Args:
158
+ count: 获取数量
159
+ only_unseen: 是否只获取未读
160
+
161
+ Returns:
162
+ 邮件列表
163
+ """
164
+ if not self._connected:
165
+ if not self.connect():
166
+ return []
167
+
168
+ try:
169
+ # 选择收件箱
170
+ self._conn.select("INBOX", readonly=True)
171
+
172
+ # 搜索邮件
173
+ flag = "UNSEEN" if only_unseen else "ALL"
174
+ status, data = self._conn.search(None, flag)
175
+
176
+ if status != "OK" or not data or not data[0]:
177
+ return []
178
+
179
+ # 获取最新的邮件 ID
180
+ ids = data[0].split()
181
+ recent_ids = ids[-count:][::-1]
182
+
183
+ emails = []
184
+ for msg_id in recent_ids:
185
+ try:
186
+ email_msg = self._fetch_email(msg_id)
187
+ if email_msg:
188
+ emails.append(email_msg)
189
+ except Exception as e:
190
+ logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}")
191
+
192
+ return emails
193
+
194
+ except Exception as e:
195
+ self.record_failure(str(e))
196
+ logger.error(f"[{self.account.email}] 获取邮件失败: {e}")
197
+ return []
198
+
199
+ def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]:
200
+ """获取并解析单封邮件"""
201
+ status, data = self._conn.fetch(msg_id, "(RFC822)")
202
+ if status != "OK" or not data or not data[0]:
203
+ return None
204
+
205
+ raw = b""
206
+ for part in data:
207
+ if isinstance(part, tuple) and len(part) > 1:
208
+ raw = part[1]
209
+ break
210
+
211
+ if not raw:
212
+ return None
213
+
214
+ return self._parse_email(raw)
215
+
216
+ @staticmethod
217
+ def _parse_email(raw: bytes) -> EmailMessage:
218
+ """解析原始邮件"""
219
+ # 使用旧版提供者的解析方法
220
+ return IMAPOldProvider._parse_email(raw)
221
+
222
+ def test_connection(self) -> bool:
223
+ """测试 IMAP 连接"""
224
+ try:
225
+ with self:
226
+ self._conn.select("INBOX", readonly=True)
227
+ self._conn.search(None, "ALL")
228
+ return True
229
+ except Exception as e:
230
+ logger.warning(f"[{self.account.email}] 新版 IMAP 连接测试失败: {e}")
231
+ return False
src/services/outlook/providers/imap_old.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 旧版 IMAP 提供者
3
+ 使用 outlook.office365.com 服务器和 login.live.com Token 端点
4
+ """
5
+
6
+ import email
7
+ import imaplib
8
+ import logging
9
+ from email.header import decode_header
10
+ from email.utils import parsedate_to_datetime
11
+ from typing import List, Optional
12
+
13
+ from ..base import ProviderType, EmailMessage
14
+ from ..account import OutlookAccount
15
+ from ..token_manager import TokenManager
16
+ from .base import OutlookProvider, ProviderConfig
17
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class IMAPOldProvider(OutlookProvider):
23
+ """
24
+ 旧版 IMAP 提供者
25
+ 使用 outlook.office365.com:993 和 login.live.com Token 端点
26
+ """
27
+
28
+ # IMAP 服务器配置
29
+ IMAP_HOST = "outlook.office365.com"
30
+ IMAP_PORT = 993
31
+
32
+ @property
33
+ def provider_type(self) -> ProviderType:
34
+ return ProviderType.IMAP_OLD
35
+
36
+ def __init__(
37
+ self,
38
+ account: OutlookAccount,
39
+ config: Optional[ProviderConfig] = None,
40
+ ):
41
+ super().__init__(account, config)
42
+
43
+ # IMAP 连接
44
+ self._conn: Optional[imaplib.IMAP4_SSL] = None
45
+
46
+ # Token 管理器
47
+ self._token_manager: Optional[TokenManager] = None
48
+
49
+ def connect(self) -> bool:
50
+ """
51
+ 连接到 IMAP 服务器
52
+
53
+ Returns:
54
+ 是否连接成功
55
+ """
56
+ if self._connected and self._conn:
57
+ # 检查现有连接
58
+ try:
59
+ self._conn.noop()
60
+ return True
61
+ except Exception:
62
+ self.disconnect()
63
+
64
+ try:
65
+ logger.debug(f"[{self.account.email}] 正在连接 IMAP ({self.IMAP_HOST})...")
66
+
67
+ # 创建连接
68
+ self._conn = imaplib.IMAP4_SSL(
69
+ self.IMAP_HOST,
70
+ self.IMAP_PORT,
71
+ timeout=self.config.timeout,
72
+ )
73
+
74
+ # 尝试 XOAUTH2 认证
75
+ if self.account.has_oauth():
76
+ if self._authenticate_xoauth2():
77
+ self._connected = True
78
+ self.record_success()
79
+ logger.info(f"[{self.account.email}] IMAP 连接成功 (XOAUTH2)")
80
+ return True
81
+ else:
82
+ logger.warning(f"[{self.account.email}] XOAUTH2 认证失败,尝试密码认证")
83
+
84
+ # 密码认证
85
+ if self.account.password:
86
+ self._conn.login(self.account.email, self.account.password)
87
+ self._connected = True
88
+ self.record_success()
89
+ logger.info(f"[{self.account.email}] IMAP 连接成功 (密码认证)")
90
+ return True
91
+
92
+ raise ValueError("没有可用的认证方式")
93
+
94
+ except Exception as e:
95
+ self.disconnect()
96
+ self.record_failure(str(e))
97
+ logger.error(f"[{self.account.email}] IMAP 连接失败: {e}")
98
+ return False
99
+
100
+ def _authenticate_xoauth2(self) -> bool:
101
+ """
102
+ 使用 XOAUTH2 认证
103
+
104
+ Returns:
105
+ 是否认证成功
106
+ """
107
+ if not self._token_manager:
108
+ self._token_manager = TokenManager(
109
+ self.account,
110
+ ProviderType.IMAP_OLD,
111
+ self.config.proxy_url,
112
+ self.config.timeout,
113
+ )
114
+
115
+ # 获取 Access Token
116
+ token = self._token_manager.get_access_token()
117
+ if not token:
118
+ return False
119
+
120
+ try:
121
+ # 构建 XOAUTH2 认证字符串
122
+ auth_string = f"user={self.account.email}\x01auth=Bearer {token}\x01\x01"
123
+ self._conn.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
124
+ return True
125
+ except Exception as e:
126
+ logger.debug(f"[{self.account.email}] XOAUTH2 认证异常: {e}")
127
+ # 清除缓存的 Token
128
+ self._token_manager.clear_cache()
129
+ return False
130
+
131
+ def disconnect(self):
132
+ """断开 IMAP 连接"""
133
+ if self._conn:
134
+ try:
135
+ self._conn.close()
136
+ except Exception:
137
+ pass
138
+ try:
139
+ self._conn.logout()
140
+ except Exception:
141
+ pass
142
+ self._conn = None
143
+
144
+ self._connected = False
145
+
146
+ def get_recent_emails(
147
+ self,
148
+ count: int = 20,
149
+ only_unseen: bool = True,
150
+ ) -> List[EmailMessage]:
151
+ """
152
+ 获取最近的邮件
153
+
154
+ Args:
155
+ count: 获取数量
156
+ only_unseen: 是否只获取未读
157
+
158
+ Returns:
159
+ 邮件列表
160
+ """
161
+ if not self._connected:
162
+ if not self.connect():
163
+ return []
164
+
165
+ try:
166
+ # 选择收件箱
167
+ self._conn.select("INBOX", readonly=True)
168
+
169
+ # 搜索邮件
170
+ flag = "UNSEEN" if only_unseen else "ALL"
171
+ status, data = self._conn.search(None, flag)
172
+
173
+ if status != "OK" or not data or not data[0]:
174
+ return []
175
+
176
+ # 获取最新的邮件 ID
177
+ ids = data[0].split()
178
+ recent_ids = ids[-count:][::-1] # 倒序,最新的在前
179
+
180
+ emails = []
181
+ for msg_id in recent_ids:
182
+ try:
183
+ email_msg = self._fetch_email(msg_id)
184
+ if email_msg:
185
+ emails.append(email_msg)
186
+ except Exception as e:
187
+ logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}")
188
+
189
+ return emails
190
+
191
+ except Exception as e:
192
+ self.record_failure(str(e))
193
+ logger.error(f"[{self.account.email}] 获取邮件失败: {e}")
194
+ return []
195
+
196
+ def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]:
197
+ """
198
+ 获取并解析单封邮件
199
+
200
+ Args:
201
+ msg_id: 邮件 ID
202
+
203
+ Returns:
204
+ EmailMessage 对象,失败返回 None
205
+ """
206
+ status, data = self._conn.fetch(msg_id, "(RFC822)")
207
+ if status != "OK" or not data or not data[0]:
208
+ return None
209
+
210
+ # 获取原始邮件内容
211
+ raw = b""
212
+ for part in data:
213
+ if isinstance(part, tuple) and len(part) > 1:
214
+ raw = part[1]
215
+ break
216
+
217
+ if not raw:
218
+ return None
219
+
220
+ return self._parse_email(raw)
221
+
222
+ @staticmethod
223
+ def _parse_email(raw: bytes) -> EmailMessage:
224
+ """
225
+ 解析原始邮件
226
+
227
+ Args:
228
+ raw: 原始邮件数据
229
+
230
+ Returns:
231
+ EmailMessage 对象
232
+ """
233
+ # 移除 BOM
234
+ if raw.startswith(b"\xef\xbb\xbf"):
235
+ raw = raw[3:]
236
+
237
+ msg = email.message_from_bytes(raw)
238
+
239
+ # 解析邮件头
240
+ subject = IMAPOldProvider._decode_header(msg.get("Subject", ""))
241
+ sender = IMAPOldProvider._decode_header(msg.get("From", ""))
242
+ to = IMAPOldProvider._decode_header(msg.get("To", ""))
243
+ delivered_to = IMAPOldProvider._decode_header(msg.get("Delivered-To", ""))
244
+ x_original_to = IMAPOldProvider._decode_header(msg.get("X-Original-To", ""))
245
+ date_str = IMAPOldProvider._decode_header(msg.get("Date", ""))
246
+
247
+ # 提取正文
248
+ body = IMAPOldProvider._extract_body(msg)
249
+
250
+ # 解析日期
251
+ received_timestamp = 0
252
+ received_at = None
253
+ try:
254
+ if date_str:
255
+ received_at = parsedate_to_datetime(date_str)
256
+ received_timestamp = int(received_at.timestamp())
257
+ except Exception:
258
+ pass
259
+
260
+ # 构建收件人列表
261
+ recipients = [r for r in [to, delivered_to, x_original_to] if r]
262
+
263
+ return EmailMessage(
264
+ id=msg.get("Message-ID", ""),
265
+ subject=subject,
266
+ sender=sender,
267
+ recipients=recipients,
268
+ body=body,
269
+ received_at=received_at,
270
+ received_timestamp=received_timestamp,
271
+ is_read=False, # 搜索的是未读邮件
272
+ raw_data=raw[:500] if len(raw) > 500 else raw,
273
+ )
274
+
275
+ @staticmethod
276
+ def _decode_header(header: str) -> str:
277
+ """解码邮件头"""
278
+ if not header:
279
+ return ""
280
+
281
+ parts = []
282
+ for chunk, encoding in decode_header(header):
283
+ if isinstance(chunk, bytes):
284
+ try:
285
+ decoded = chunk.decode(encoding or "utf-8", errors="replace")
286
+ parts.append(decoded)
287
+ except Exception:
288
+ parts.append(chunk.decode("utf-8", errors="replace"))
289
+ else:
290
+ parts.append(str(chunk))
291
+
292
+ return "".join(parts).strip()
293
+
294
+ @staticmethod
295
+ def _extract_body(msg) -> str:
296
+ """提取邮件正文"""
297
+ import html as html_module
298
+ import re
299
+
300
+ texts = []
301
+ parts = msg.walk() if msg.is_multipart() else [msg]
302
+
303
+ for part in parts:
304
+ content_type = part.get_content_type()
305
+ if content_type not in ("text/plain", "text/html"):
306
+ continue
307
+
308
+ payload = part.get_payload(decode=True)
309
+ if not payload:
310
+ continue
311
+
312
+ charset = part.get_content_charset() or "utf-8"
313
+ try:
314
+ text = payload.decode(charset, errors="replace")
315
+ except LookupError:
316
+ text = payload.decode("utf-8", errors="replace")
317
+
318
+ # 如果是 HTML,移除标签
319
+ if "<html" in text.lower():
320
+ text = re.sub(r"<[^>]+>", " ", text)
321
+
322
+ texts.append(text)
323
+
324
+ # 合并并清理文本
325
+ combined = " ".join(texts)
326
+ combined = html_module.unescape(combined)
327
+ combined = re.sub(r"\s+", " ", combined).strip()
328
+
329
+ return combined
330
+
331
+ def test_connection(self) -> bool:
332
+ """
333
+ 测试 IMAP 连接
334
+
335
+ Returns:
336
+ 连接是否正常
337
+ """
338
+ try:
339
+ with self:
340
+ self._conn.select("INBOX", readonly=True)
341
+ self._conn.search(None, "ALL")
342
+ return True
343
+ except Exception as e:
344
+ logger.warning(f"[{self.account.email}] IMAP 连接测试失败: {e}")
345
+ return False
src/services/outlook/service.py ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Outlook 邮箱服务主类
3
+ 支持多种 IMAP/API 连接方式,自动故障切换
4
+ """
5
+
6
+ import logging
7
+ import threading
8
+ import time
9
+ from typing import Optional, Dict, Any, List
10
+
11
+ from ..base import BaseEmailService, EmailServiceError, EmailServiceStatus, EmailServiceType
12
+ from ...config.constants import EmailServiceType as ServiceType
13
+ from ...config.settings import get_settings
14
+ from .account import OutlookAccount
15
+ from .base import ProviderType, EmailMessage
16
+ from .email_parser import EmailParser, get_email_parser
17
+ from .health_checker import HealthChecker, FailoverManager
18
+ from .providers.base import OutlookProvider, ProviderConfig
19
+ from .providers.imap_old import IMAPOldProvider
20
+ from .providers.imap_new import IMAPNewProvider
21
+ from .providers.graph_api import GraphAPIProvider
22
+
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # 默认提供者优先级
28
+ # IMAP_OLD 最兼容(只需 login.live.com token),IMAP_NEW 次之,Graph API 最后
29
+ # 原因:部分 client_id 没有 Graph API 权限,但有 IMAP 权限
30
+ DEFAULT_PROVIDER_PRIORITY = [
31
+ ProviderType.IMAP_OLD,
32
+ ProviderType.IMAP_NEW,
33
+ ProviderType.GRAPH_API,
34
+ ]
35
+
36
+
37
+ def get_email_code_settings() -> dict:
38
+ """获取验证码等待配置"""
39
+ settings = get_settings()
40
+ return {
41
+ "timeout": settings.email_code_timeout,
42
+ "poll_interval": settings.email_code_poll_interval,
43
+ }
44
+
45
+
46
+ class OutlookService(BaseEmailService):
47
+ """
48
+ Outlook 邮箱服务
49
+ 支持多种 IMAP/API 连接方式,自动故障切换
50
+ """
51
+
52
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
53
+ """
54
+ 初始化 Outlook 服务
55
+
56
+ Args:
57
+ config: 配置字典,支持以下键:
58
+ - accounts: Outlook 账户列表
59
+ - provider_priority: 提供者优先级列表
60
+ - health_failure_threshold: 连续失败次数阈值
61
+ - health_disable_duration: 禁用时长(秒)
62
+ - timeout: 请求超时时间
63
+ - proxy_url: 代理 URL
64
+ name: 服务名称
65
+ """
66
+ super().__init__(ServiceType.OUTLOOK, name)
67
+
68
+ # 默认配置
69
+ default_config = {
70
+ "accounts": [],
71
+ "provider_priority": [p.value for p in DEFAULT_PROVIDER_PRIORITY],
72
+ "health_failure_threshold": 5,
73
+ "health_disable_duration": 60,
74
+ "timeout": 30,
75
+ "proxy_url": None,
76
+ }
77
+
78
+ self.config = {**default_config, **(config or {})}
79
+
80
+ # 解析提供者优先级
81
+ self.provider_priority = [
82
+ ProviderType(p) for p in self.config.get("provider_priority", [])
83
+ ]
84
+ if not self.provider_priority:
85
+ self.provider_priority = DEFAULT_PROVIDER_PRIORITY
86
+
87
+ # 提供者配置
88
+ self.provider_config = ProviderConfig(
89
+ timeout=self.config.get("timeout", 30),
90
+ proxy_url=self.config.get("proxy_url"),
91
+ health_failure_threshold=self.config.get("health_failure_threshold", 3),
92
+ health_disable_duration=self.config.get("health_disable_duration", 300),
93
+ )
94
+
95
+ # 获取默认 client_id(供无 client_id 的账户使用)
96
+ try:
97
+ _default_client_id = get_settings().outlook_default_client_id
98
+ except Exception:
99
+ _default_client_id = "24d9a0ed-8787-4584-883c-2fd79308940a"
100
+
101
+ # 解析账户
102
+ self.accounts: List[OutlookAccount] = []
103
+ self._current_account_index = 0
104
+ self._account_lock = threading.Lock()
105
+
106
+ # 支持两种配置格式
107
+ if "email" in self.config and "password" in self.config:
108
+ account = OutlookAccount.from_config(self.config)
109
+ if not account.client_id and _default_client_id:
110
+ account.client_id = _default_client_id
111
+ if account.validate():
112
+ self.accounts.append(account)
113
+ else:
114
+ for account_config in self.config.get("accounts", []):
115
+ account = OutlookAccount.from_config(account_config)
116
+ if not account.client_id and _default_client_id:
117
+ account.client_id = _default_client_id
118
+ if account.validate():
119
+ self.accounts.append(account)
120
+
121
+ if not self.accounts:
122
+ logger.warning("未配置有效的 Outlook 账户")
123
+
124
+ # 健康检查器和故障切换管理器
125
+ self.health_checker = HealthChecker(
126
+ failure_threshold=self.provider_config.health_failure_threshold,
127
+ disable_duration=self.provider_config.health_disable_duration,
128
+ )
129
+ self.failover_manager = FailoverManager(
130
+ health_checker=self.health_checker,
131
+ priority_order=self.provider_priority,
132
+ )
133
+
134
+ # 邮件解析器
135
+ self.email_parser = get_email_parser()
136
+
137
+ # 提供者实例缓存: (email, provider_type) -> OutlookProvider
138
+ self._providers: Dict[tuple, OutlookProvider] = {}
139
+ self._provider_lock = threading.Lock()
140
+
141
+ # IMAP 连接限制(防止限流)
142
+ self._imap_semaphore = threading.Semaphore(5)
143
+
144
+ # 验证码去重机制
145
+ self._used_codes: Dict[str, set] = {}
146
+
147
+ def _get_provider(
148
+ self,
149
+ account: OutlookAccount,
150
+ provider_type: ProviderType,
151
+ ) -> OutlookProvider:
152
+ """
153
+ 获取或创建提供者实例
154
+
155
+ Args:
156
+ account: Outlook 账户
157
+ provider_type: 提供者类型
158
+
159
+ Returns:
160
+ 提供者实例
161
+ """
162
+ cache_key = (account.email.lower(), provider_type)
163
+
164
+ with self._provider_lock:
165
+ if cache_key not in self._providers:
166
+ provider = self._create_provider(account, provider_type)
167
+ self._providers[cache_key] = provider
168
+
169
+ return self._providers[cache_key]
170
+
171
+ def _create_provider(
172
+ self,
173
+ account: OutlookAccount,
174
+ provider_type: ProviderType,
175
+ ) -> OutlookProvider:
176
+ """
177
+ 创建提供者实例
178
+
179
+ Args:
180
+ account: Outlook 账户
181
+ provider_type: 提供者类型
182
+
183
+ Returns:
184
+ 提供者实例
185
+ """
186
+ if provider_type == ProviderType.IMAP_OLD:
187
+ return IMAPOldProvider(account, self.provider_config)
188
+ elif provider_type == ProviderType.IMAP_NEW:
189
+ return IMAPNewProvider(account, self.provider_config)
190
+ elif provider_type == ProviderType.GRAPH_API:
191
+ return GraphAPIProvider(account, self.provider_config)
192
+ else:
193
+ raise ValueError(f"未知的提供者类型: {provider_type}")
194
+
195
+ def _get_provider_priority_for_account(self, account: OutlookAccount) -> List[ProviderType]:
196
+ """根据账户是否有 OAuth,返回适合的提供者优先级列表"""
197
+ if account.has_oauth():
198
+ return self.provider_priority
199
+ else:
200
+ # 无 OAuth,直接走旧版 IMAP(密码认证),跳过需要 OAuth 的提供者
201
+ return [ProviderType.IMAP_OLD]
202
+
203
+ def _try_providers_for_emails(
204
+ self,
205
+ account: OutlookAccount,
206
+ count: int = 20,
207
+ only_unseen: bool = True,
208
+ ) -> List[EmailMessage]:
209
+ """
210
+ 尝试多个提供者获取邮件
211
+
212
+ Args:
213
+ account: Outlook 账户
214
+ count: 获取数量
215
+ only_unseen: 是否只获取未读
216
+
217
+ Returns:
218
+ 邮件列表
219
+ """
220
+ errors = []
221
+
222
+ # 根据账户类型选择合适的提供者优先级
223
+ priority = self._get_provider_priority_for_account(account)
224
+
225
+ # 按优先级尝试各提供者
226
+ for provider_type in priority:
227
+ # 检查提供者是否可用
228
+ if not self.health_checker.is_available(provider_type):
229
+ logger.debug(
230
+ f"[{account.email}] {provider_type.value} 不可用,跳过"
231
+ )
232
+ continue
233
+
234
+ try:
235
+ provider = self._get_provider(account, provider_type)
236
+
237
+ with self._imap_semaphore:
238
+ with provider:
239
+ emails = provider.get_recent_emails(count, only_unseen)
240
+
241
+ if emails:
242
+ # 成功获取邮件
243
+ self.health_checker.record_success(provider_type)
244
+ logger.debug(
245
+ f"[{account.email}] {provider_type.value} 获取到 {len(emails)} 封邮件"
246
+ )
247
+ return emails
248
+
249
+ except Exception as e:
250
+ error_msg = str(e)
251
+ errors.append(f"{provider_type.value}: {error_msg}")
252
+ self.health_checker.record_failure(provider_type, error_msg)
253
+ logger.warning(
254
+ f"[{account.email}] {provider_type.value} 获取邮件失败: {e}"
255
+ )
256
+
257
+ logger.error(
258
+ f"[{account.email}] 所有提供者都失败: {'; '.join(errors)}"
259
+ )
260
+ return []
261
+
262
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
263
+ """
264
+ 选择可用的 Outlook 账户
265
+
266
+ Args:
267
+ config: 配置参数(未使用)
268
+
269
+ Returns:
270
+ 包含邮箱信息的字典
271
+ """
272
+ if not self.accounts:
273
+ self.update_status(False, EmailServiceError("没有可用的 Outlook 账户"))
274
+ raise EmailServiceError("没有可用的 Outlook 账户")
275
+
276
+ # 轮询选择账户
277
+ with self._account_lock:
278
+ account = self.accounts[self._current_account_index]
279
+ self._current_account_index = (self._current_account_index + 1) % len(self.accounts)
280
+
281
+ email_info = {
282
+ "email": account.email,
283
+ "service_id": account.email,
284
+ "account": {
285
+ "email": account.email,
286
+ "has_oauth": account.has_oauth()
287
+ }
288
+ }
289
+
290
+ logger.info(f"选择 Outlook 账户: {account.email}")
291
+ self.update_status(True)
292
+ return email_info
293
+
294
+ def get_verification_code(
295
+ self,
296
+ email: str,
297
+ email_id: str = None,
298
+ timeout: int = None,
299
+ pattern: str = None,
300
+ otp_sent_at: Optional[float] = None,
301
+ ) -> Optional[str]:
302
+ """
303
+ 从 Outlook 邮箱获取验证码
304
+
305
+ Args:
306
+ email: 邮箱地址
307
+ email_id: 未使用
308
+ timeout: 超时时间(秒)
309
+ pattern: 验证码正则表达式(未使用)
310
+ otp_sent_at: OTP 发送时间戳
311
+
312
+ Returns:
313
+ 验证码字符串
314
+ """
315
+ # 查找对应的账户
316
+ account = None
317
+ for acc in self.accounts:
318
+ if acc.email.lower() == email.lower():
319
+ account = acc
320
+ break
321
+
322
+ if not account:
323
+ self.update_status(False, EmailServiceError(f"未找到邮箱对应的账户: {email}"))
324
+ return None
325
+
326
+ # 获取验证码等待配置
327
+ code_settings = get_email_code_settings()
328
+ actual_timeout = timeout or code_settings["timeout"]
329
+ poll_interval = code_settings["poll_interval"]
330
+
331
+ logger.info(
332
+ f"[{email}] 开始获取验证码,超时 {actual_timeout}s,"
333
+ f"提供者优先级: {[p.value for p in self.provider_priority]}"
334
+ )
335
+
336
+ # 初始化验证码去重集合
337
+ if email not in self._used_codes:
338
+ self._used_codes[email] = set()
339
+ used_codes = self._used_codes[email]
340
+
341
+ # 计算最小时间戳(留出 60 秒时钟偏差)
342
+ min_timestamp = (otp_sent_at - 60) if otp_sent_at else 0
343
+
344
+ start_time = time.time()
345
+ poll_count = 0
346
+
347
+ while time.time() - start_time < actual_timeout:
348
+ poll_count += 1
349
+
350
+ # 渐进式邮件检查:前 3 次只检查未读
351
+ only_unseen = poll_count <= 3
352
+
353
+ try:
354
+ # 尝试多个提供者获取邮件
355
+ emails = self._try_providers_for_emails(
356
+ account,
357
+ count=15,
358
+ only_unseen=only_unseen,
359
+ )
360
+
361
+ if emails:
362
+ logger.debug(
363
+ f"[{email}] 第 {poll_count} 次轮询获取到 {len(emails)} 封邮件"
364
+ )
365
+
366
+ # 从邮件中查找验证码
367
+ code = self.email_parser.find_verification_code_in_emails(
368
+ emails,
369
+ target_email=email,
370
+ min_timestamp=min_timestamp,
371
+ used_codes=used_codes,
372
+ )
373
+
374
+ if code:
375
+ used_codes.add(code)
376
+ elapsed = int(time.time() - start_time)
377
+ logger.info(
378
+ f"[{email}] 找到验证码: {code},"
379
+ f"总耗时 {elapsed}s,轮询 {poll_count} 次"
380
+ )
381
+ self.update_status(True)
382
+ return code
383
+
384
+ except Exception as e:
385
+ logger.warning(f"[{email}] 检查出错: {e}")
386
+
387
+ # 等待下次轮询
388
+ time.sleep(poll_interval)
389
+
390
+ elapsed = int(time.time() - start_time)
391
+ logger.warning(f"[{email}] 验证码超时 ({actual_timeout}s),共轮询 {poll_count} 次")
392
+ return None
393
+
394
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
395
+ """列出所有可用的 Outlook 账户"""
396
+ return [
397
+ {
398
+ "email": account.email,
399
+ "id": account.email,
400
+ "has_oauth": account.has_oauth(),
401
+ "type": "outlook"
402
+ }
403
+ for account in self.accounts
404
+ ]
405
+
406
+ def delete_email(self, email_id: str) -> bool:
407
+ """删除邮箱(Outlook 不支持删除账户)"""
408
+ logger.warning(f"Outlook 服务不支持删除账户: {email_id}")
409
+ return False
410
+
411
+ def check_health(self) -> bool:
412
+ """检查 Outlook 服务是否可用"""
413
+ if not self.accounts:
414
+ self.update_status(False, EmailServiceError("没有配置的账户"))
415
+ return False
416
+
417
+ # 测试第一个账户的连接
418
+ test_account = self.accounts[0]
419
+
420
+ # 尝试任一提供者连接
421
+ for provider_type in self.provider_priority:
422
+ try:
423
+ provider = self._get_provider(test_account, provider_type)
424
+ if provider.test_connection():
425
+ self.update_status(True)
426
+ return True
427
+ except Exception as e:
428
+ logger.warning(
429
+ f"Outlook 健康检查失败 ({test_account.email}, {provider_type.value}): {e}"
430
+ )
431
+
432
+ self.update_status(False, EmailServiceError("健康检查失败"))
433
+ return False
434
+
435
+ def get_provider_status(self) -> Dict[str, Any]:
436
+ """获取提供者状态"""
437
+ return self.failover_manager.get_status()
438
+
439
+ def get_account_stats(self) -> Dict[str, Any]:
440
+ """获取账户统计信息"""
441
+ total = len(self.accounts)
442
+ oauth_count = sum(1 for acc in self.accounts if acc.has_oauth())
443
+
444
+ return {
445
+ "total_accounts": total,
446
+ "oauth_accounts": oauth_count,
447
+ "password_accounts": total - oauth_count,
448
+ "accounts": [acc.to_dict() for acc in self.accounts],
449
+ "provider_status": self.get_provider_status(),
450
+ }
451
+
452
+ def add_account(self, account_config: Dict[str, Any]) -> bool:
453
+ """添加新的 Outlook 账户"""
454
+ try:
455
+ account = OutlookAccount.from_config(account_config)
456
+ if not account.validate():
457
+ return False
458
+
459
+ self.accounts.append(account)
460
+ logger.info(f"添加 Outlook 账户: {account.email}")
461
+ return True
462
+ except Exception as e:
463
+ logger.error(f"添加 Outlook 账户失败: {e}")
464
+ return False
465
+
466
+ def remove_account(self, email: str) -> bool:
467
+ """移除 Outlook 账户"""
468
+ for i, acc in enumerate(self.accounts):
469
+ if acc.email.lower() == email.lower():
470
+ self.accounts.pop(i)
471
+ logger.info(f"移除 Outlook 账户: {email}")
472
+ return True
473
+ return False
474
+
475
+ def reset_provider_health(self):
476
+ """重置所有提供者的健康状态"""
477
+ self.health_checker.reset_all()
478
+ logger.info("已重置所有提供者的健康状态")
479
+
480
+ def force_provider(self, provider_type: ProviderType):
481
+ """强制使用指定的提供者"""
482
+ self.health_checker.force_enable(provider_type)
483
+ # 禁用其他提供者
484
+ for pt in ProviderType:
485
+ if pt != provider_type:
486
+ self.health_checker.force_disable(pt, 60)
487
+ logger.info(f"已强制使用提供者: {provider_type.value}")