Spaces:
Paused
Paused
Deploy codex-console to HF Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +18 -0
- .gitattributes +2 -35
- .gitignore +58 -0
- Dockerfile +27 -0
- LICENSE +21 -0
- README.md +212 -11
- deploy/huggingface/start.sh +48 -0
- pyproject.toml +43 -0
- requirements.txt +16 -0
- src/__init__.py +24 -0
- src/config/__init__.py +53 -0
- src/config/constants.py +408 -0
- src/config/settings.py +767 -0
- src/core/__init__.py +32 -0
- src/core/dynamic_proxy.py +118 -0
- src/core/http_client.py +429 -0
- src/core/openai/__init__.py +3 -0
- src/core/openai/oauth.py +370 -0
- src/core/openai/payment.py +261 -0
- src/core/openai/sentinel.py +98 -0
- src/core/openai/token_refresh.py +332 -0
- src/core/register.py +1009 -0
- src/core/upload/__init__.py +3 -0
- src/core/upload/cpa_upload.py +312 -0
- src/core/upload/sub2api_upload.py +224 -0
- src/core/upload/team_manager_upload.py +204 -0
- src/core/utils.py +570 -0
- src/database/__init__.py +20 -0
- src/database/crud.py +714 -0
- src/database/init_db.py +86 -0
- src/database/models.py +229 -0
- src/database/session.py +182 -0
- src/services/__init__.py +76 -0
- src/services/base.py +386 -0
- src/services/cloud_mail.py +529 -0
- src/services/duck_mail.py +366 -0
- src/services/freemail.py +324 -0
- src/services/imap_mail.py +217 -0
- src/services/moe_mail.py +556 -0
- src/services/outlook/__init__.py +8 -0
- src/services/outlook/account.py +51 -0
- src/services/outlook/base.py +153 -0
- src/services/outlook/email_parser.py +228 -0
- src/services/outlook/health_checker.py +312 -0
- src/services/outlook/providers/__init__.py +29 -0
- src/services/outlook/providers/base.py +180 -0
- src/services/outlook/providers/graph_api.py +250 -0
- src/services/outlook/providers/imap_new.py +231 -0
- src/services/outlook/providers/imap_old.py +345 -0
- 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 |
-
*
|
| 2 |
-
*.
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# codex-console
|
| 2 |
+
|
| 3 |
+
基于 [cnlimiter/codex-manager](https://github.com/cnlimiter/codex-manager) 持续修复和维护的增强版本。
|
| 4 |
+
|
| 5 |
+
这个版本的目标很直接: 把近期 OpenAI 注册链路里那些“昨天还能跑,今天突然翻车”的坑补上,让注册、登录、拿 token、打包运行都更稳一点。
|
| 6 |
+
|
| 7 |
+
[](LICENSE)
|
| 8 |
+
[](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}")
|