feat: finalize pool registration flow and docs
This commit is contained in:
1 parent
6d95c74272
commit
d50f01663c
27 files changed
+1268
-626
No files matched your search
@@ -1,25 +1,35 @@
|
||||
# Outlook Email Plus
|
||||
|
||||
个人自用的 Outlook 邮件管理与验证码提取工具,提供 Web 界面、多账号统一管理,并支持一组**受控的对外只读 API**(`/api/external/*`)。
|
||||
一个用于统一管理 Outlook / IMAP 邮箱账号、读取邮件、提取验证码,并支持邮箱池调度的 Web 项目(or 注册机)。
|
||||
|
||||
适用场景:自用/小团队受控环境,用于管理自己的邮箱、读取邮件、提取验证码/验证链接、对接内部自动化脚本。**不建议直接公网裸露**。
|
||||
|
||||
## 核心能力(现状)
|
||||
|
||||
- **多邮箱统一管理**:批量导入、分组、标签、搜索、批量操作
|
||||
- **多链路读信**:Microsoft Graph / IMAP(按账号类型与回退策略)
|
||||
- **验证码/链接提取**:支持按来源(subject/body/html)、长度范围、正则等策略增强提取稳定性
|
||||
- **定时任务**:Token 定时刷新、调度器心跳、Telegram 推送轮询、对外 API 异步探测轮询
|
||||
- **Telegram 实时推送(可选)**:后台轮询新邮件并推送到 Telegram,支持 Message-ID 去重与“首次启用不轰炸”策略
|
||||
- **临时邮箱(可选)**:对接 GPTMail 临时邮箱服务(生成/刷新/清理/拉取消息)
|
||||
- **对外只读 API(可选)**:API Key 鉴权 + 公网模式守卫(IP 白名单、限流、高风险端点可禁用)
|
||||
- **安全基线**:敏感数据加密存储、CSRF 防护、登录限速/锁定、审计日志、对外 API 访问审计
|
||||
## 项目优势
|
||||
|
||||
## 快速开始
|
||||
- 一套界面统一管理 Outlook OAuth 邮箱和 IMAP 邮箱(比如qq,gmail,163等等),减少多套工具切换成本
|
||||
- 不只是“收件箱查看器”,还覆盖验证码提取、链接提取、注册池流转这些高频自动化场景
|
||||
- 支持批量导入、分组、标签、搜索,适合多账号长期维护
|
||||
- 可本地快速启动,也可直接 Docker 部署,落地成本低
|
||||
- 提供受控对外接口和邮箱池能力,方便和注册、验证、自动化脚本衔接
|
||||
|
||||
### Docker(推荐)
|
||||
## 主要功能
|
||||
|
||||
GHCR:
|
||||
- 多邮箱账号管理:支持批量导入、分组、标签、搜索
|
||||
- Outlook OAuth 与 IMAP 接入:兼容多种邮箱提供商
|
||||
- 邮件读取与验证码提取:支持常见注册、登录、验证场景
|
||||
- 邮箱池管理:支持领取、释放、完成、状态流转
|
||||
- 可选扩展能力:Telegram 推送、临时邮箱、受控对外 API
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 统一维护多个 Outlook / IMAP 邮箱账号
|
||||
- 批量处理注册验证码、验证链接、通知邮件
|
||||
- 把邮箱账号接入邮箱池后,供外部任务领取和回收
|
||||
- 在受控环境中把邮件能力提供给内部脚本或自动化系统
|
||||
|
||||
## 部署方式
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/zeropointsix/outlook-email-plus:latest
|
||||
@@ -33,30 +43,12 @@ docker run -d \
|
||||
ghcr.io/zeropointsix/outlook-email-plus:latest
|
||||
```
|
||||
|
||||
Docker Hub(如已同步启用):
|
||||
|
||||
```bash
|
||||
docker pull guangshanshui/outlook-email-plus:latest
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
docker run -d `
|
||||
--name outlook-email-plus `
|
||||
-p 5000:5000 `
|
||||
-v ${PWD}/data:/app/data `
|
||||
-e SECRET_KEY=your-secret-key-here `
|
||||
-e LOGIN_PASSWORD=your-login-password `
|
||||
ghcr.io/zeropointsix/outlook-email-plus:latest
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 强烈建议将 `data/` 挂载为持久化目录(数据库与运行数据)
|
||||
- `SECRET_KEY` 用于会话与敏感字段加密。**丢失后无法解密旧数据**,请妥善保存并保持稳定
|
||||
- 建议把 `data/` 挂载出来做持久化
|
||||
- `SECRET_KEY` 请使用稳定且安全的值
|
||||
|
||||
### 本地运行
|
||||
### 本地部署
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
@@ -64,88 +56,13 @@ pip install -r requirements.txt
|
||||
python start.py
|
||||
```
|
||||
|
||||
`start.py` 会在缺少 `.env` 时自动从 `.env.example` 复制,并在 `SECRET_KEY` 为空/占位符时自动生成。
|
||||
常用环境变量:
|
||||
|
||||
## 配置说明
|
||||
|
||||
优先通过环境变量或 `.env` 配置(参考 `.env.example`)。常用配置如下:
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `SECRET_KEY` | 必填:会话/加密密钥(务必备份) | 无 |
|
||||
| `LOGIN_PASSWORD` | 登录密码(首次启动会哈希存储) | `admin123` |
|
||||
| `HOST` | 监听地址 | `0.0.0.0` |
|
||||
| `PORT` | 监听端口 | `5000` |
|
||||
| `DATABASE_PATH` | SQLite 数据库路径 | `data/outlook_accounts.db` |
|
||||
| `SCHEDULER_AUTOSTART` | 是否自动启动后台调度器 | `true` |
|
||||
| `PROXY_FIX_ENABLED` | 是否启用 ProxyFix(反代部署才需要) | `false` |
|
||||
| `TRUSTED_PROXIES` | 受信任代理列表(用于安全解析 `X-Forwarded-For`) | 空 |
|
||||
| `GPTMAIL_BASE_URL` | GPTMail 临时邮箱服务地址 | `https://mail.chatgpt.org.uk` |
|
||||
| `GPTMAIL_API_KEY` | GPTMail API Key | `gpt-test` |
|
||||
| `OAUTH_CLIENT_ID` | OAuth 助手默认 client_id(可选覆盖) | 内置默认值 |
|
||||
| `OAUTH_REDIRECT_URI` | OAuth 助手重定向地址(可选覆盖) | `http://localhost:8080` |
|
||||
|
||||
生成 `SECRET_KEY`:
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
## 账号接入与使用
|
||||
|
||||
1. 在 Web 页面中添加/导入账号(Outlook 或 IMAP)
|
||||
2. Outlook 账号需要 `client_id` 与 `refresh_token`(可在设置中使用 OAuth 助手换取 Refresh Token)
|
||||
3. 选择分组/账号后拉取邮件,或直接提取验证码/验证链接
|
||||
4. 如需后台能力(定时刷新/Telegram 推送/异步 probe),保持调度器开启(`SCHEDULER_AUTOSTART=true`)
|
||||
|
||||
常见导入格式:
|
||||
|
||||
```text
|
||||
user@outlook.com----password123----client_id----refresh_token
|
||||
```
|
||||
|
||||
## 对外只读 API(`/api/external/*`)
|
||||
|
||||
对外 API 仅用于**受控环境**调用,统一使用 `X-API-Key` 请求头鉴权(不依赖登录态)。API Key 可在「设置」中配置:
|
||||
|
||||
- 兼容旧模式:单个 legacy key(`external_api_key`)
|
||||
- 推荐模式:多 Key 管理(可启停/删除、可配置允许访问的邮箱白名单)
|
||||
|
||||
常用端点:
|
||||
|
||||
- `GET /api/external/health`:健康检查(含版本号)
|
||||
- `GET /api/external/capabilities`:能力与受限端点说明
|
||||
- `GET /api/external/account-status?email=...`:账号可读性/上游探测摘要
|
||||
- `GET /api/external/messages?email=...&folder=inbox&skip=0&top=20`
|
||||
- `GET /api/external/messages/latest?email=...`
|
||||
- `GET /api/external/messages/<message_id>` / `.../<message_id>/raw`
|
||||
- `GET /api/external/verification-code?email=...`
|
||||
- `GET /api/external/verification-link?email=...`
|
||||
- `GET /api/external/wait-message?email=...&timeout_seconds=30&poll_interval=5`
|
||||
- 同步模式:默认阻塞等待
|
||||
- 异步模式:`mode=async` 返回 `probe_id`(202),再轮询 `GET /api/external/probe/<probe_id>`
|
||||
|
||||
最小调用示例:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: <your-key>" http://localhost:5000/api/external/health
|
||||
```
|
||||
|
||||
### 公网模式守卫(强烈建议)
|
||||
|
||||
如果你确实要在更开放的网络环境使用对外 API,请至少:
|
||||
|
||||
1. 通过设置开启「公网模式」
|
||||
2. 配置 **IP/CIDR 白名单**(非白名单将返回 403)
|
||||
3. 配置 **按 IP 限流**(默认每分钟 60)
|
||||
4. 视需要禁用高风险端点:`raw_content`、`wait_message`
|
||||
5. 放在反向代理之后,并只在可信代理场景下启用 `PROXY_FIX_ENABLED=true`,同时设置 `TRUSTED_PROXIES`(否则不要信任 `X-Forwarded-For`)
|
||||
|
||||
## 开发与测试
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
- `SECRET_KEY`:会话与敏感字段加密密钥
|
||||
- `LOGIN_PASSWORD`:后台登录密码
|
||||
- `DATABASE_PATH`:SQLite 数据库路径
|
||||
- `OAUTH_CLIENT_ID`:Outlook OAuth 应用 ID
|
||||
- `OAUTH_REDIRECT_URI`:Outlook OAuth 回调地址
|
||||
|
||||
## 界面预览
|
||||
|
||||
@@ -154,14 +71,22 @@ python -m unittest discover -s tests -v
|
||||

|
||||

|
||||
|
||||
## 技术栈
|
||||
## 项目文档
|
||||
|
||||
- [注册与邮箱池接口文档](./注册与邮箱池接口文档.md)
|
||||
|
||||
如果你需要接入注册机之类的批量工作,请直接看上面的文档
|
||||
|
||||
## 感谢
|
||||
|
||||
本项目基于以下开源技术和服务能力构建:
|
||||
|
||||
- Flask
|
||||
- SQLite
|
||||
- Microsoft Graph API / IMAP
|
||||
- Microsoft Graph API
|
||||
- IMAP
|
||||
- APScheduler
|
||||
- 原生 JavaScript
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
Apache License 2.0
|
||||
@@ -1,5 +1,39 @@
|
||||
# DEVLOG
|
||||
|
||||
## v1.8.0 - 邮箱池与受控对外池 API 首次交付
|
||||
|
||||
发布日期:2026-03-17
|
||||
|
||||
### 新增功能
|
||||
|
||||
- 新增内部邮箱池接口:`/api/pool/claim-random`、`/api/pool/claim-release`、`/api/pool/claim-complete`、`/api/pool/stats`,支持随机领取、人工释放、结果回写与池统计。
|
||||
- 新增对外邮箱池接口:`/api/external/pool/*` 现已支持 API Key 鉴权访问,并接入既有公网模式守卫、访问审计与调用方日级使用统计。
|
||||
- 新增邮箱池状态机与持久化结构:账号新增 `pool_status`、`claimed_by`、`lease_expires_at`、`claim_token`、成功/失败计数等字段,同时引入 `account_claim_logs` 记录 claim/release/complete/expire 全链路动作。
|
||||
- 新增多 API Key 粒度权限:`external_api_keys` 现支持 `pool_access` 字段,可按调用方单独授予 external pool 访问能力。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修正对外邮箱池接口的返回格式,使 `claim-random`、`claim-release`、`claim-complete` 与 `stats` 全部对齐现有 external API contract,避免对接方处理分支不一致。
|
||||
- 修正设置接口对邮箱池总开关和公网模式细粒度禁用项的读写逻辑,确保 `pool_external_enabled` 与 `external_api_disable_pool_*` 系列配置可以稳定持久化并回显。
|
||||
- 修正租约超时回收行为,过期 claim 会自动写入 claim log、转入 `cooldown`,降低因调用方异常退出导致账号长期悬挂的风险。
|
||||
|
||||
### 重要变更
|
||||
|
||||
- 版本号从 `1.7.0` 提升到 `1.8.0`,应用 UI 侧边栏版本显示、系统/对外 API 返回的 `version` 字段继续由 `outlook_web.__version__` 统一驱动。
|
||||
- 数据库 schema 新增邮箱池相关字段、`account_claim_logs` 表,以及 `external_api_keys.pool_access` 权限列;现有库初始化/升级时会自动补齐。
|
||||
- 当前仓库不是 Tauri 工程,不包含 `Cargo.toml`、`package.json`、MSI 或 NSIS 构建链路;本次发布继续沿用仓库既有的 Docker 镜像 tar 与源码 zip 作为正式产物。
|
||||
|
||||
### 测试/验证
|
||||
|
||||
- 单元测试:`python -m unittest discover -s tests -v`
|
||||
- 结果:`Ran 440 tests in 42.599s`
|
||||
- 状态:全部通过
|
||||
- 构建验证:`docker build -t outlook-email-plus:v1.8.0 .`
|
||||
- 状态:失败
|
||||
- 原因:Docker daemon 未启动,`//./pipe/dockerDesktopLinuxEngine` 不存在,当前环境无法连接 Docker Desktop Linux Engine
|
||||
- 发布产物:
|
||||
- 未生成。由于镜像构建失败,本次未导出 Docker tar、源码 zip,也未同步到 GitHub Release 页面。
|
||||
|
||||
## v1.7.0 - 第二次发布:README 交付口径补全
|
||||
|
||||
发布日期:2026-03-15
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
__version__ = "1.7.0"
|
||||
__version__ = "1.8.0"
|
||||
|
||||
from outlook_web.app import create_app
|
||||
|
||||
|
||||
+4
-14
@@ -90,9 +90,7 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
|
||||
# ProxyFix 中间件(仅在配置启用时应用)
|
||||
# 注意:启用前必须配置 TRUSTED_PROXIES 环境变量
|
||||
if config.get_proxy_fix_enabled():
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
||||
)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
|
||||
# DB teardown(请求结束释放连接)
|
||||
register_db(app)
|
||||
@@ -102,11 +100,7 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
|
||||
import sys
|
||||
|
||||
_log_handler = logging.StreamHandler(sys.stderr)
|
||||
_log_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S"
|
||||
)
|
||||
)
|
||||
_log_handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S"))
|
||||
_ow_logger = logging.getLogger("outlook_web")
|
||||
if not _ow_logger.handlers:
|
||||
_ow_logger.addHandler(_log_handler)
|
||||
@@ -151,15 +145,11 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
|
||||
from outlook_web.services import scheduler as scheduler_service
|
||||
|
||||
if scheduler_service.should_autostart_scheduler():
|
||||
scheduler_service.init_scheduler(
|
||||
_APP_INSTANCE, graph_service.test_refresh_token_with_rotation
|
||||
)
|
||||
scheduler_service.init_scheduler(_APP_INSTANCE, graph_service.test_refresh_token_with_rotation)
|
||||
elif autostart_scheduler:
|
||||
from outlook_web.services import graph as graph_service
|
||||
from outlook_web.services import scheduler as scheduler_service
|
||||
|
||||
scheduler_service.init_scheduler(
|
||||
_APP_INSTANCE, graph_service.test_refresh_token_with_rotation
|
||||
)
|
||||
scheduler_service.init_scheduler(_APP_INSTANCE, graph_service.test_refresh_token_with_rotation)
|
||||
|
||||
return _APP_INSTANCE
|
||||
@@ -51,6 +51,17 @@ def sanitize_input(text: str, max_length: int = 500) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _parse_bool_flag(value: Any, default: bool = False) -> bool:
|
||||
"""解析请求中的布尔开关,兼容 bool / 数字 / 字符串。"""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# ==================== 账号基础 CRUD API ====================
|
||||
|
||||
|
||||
@@ -168,13 +179,14 @@ def api_add_account() -> Any:
|
||||
provider = (data.get("provider") or "outlook").strip().lower()
|
||||
custom_imap_host = (data.get("imap_host") or "").strip()
|
||||
custom_imap_port = data.get("imap_port")
|
||||
add_to_pool = _parse_bool_flag(data.get("add_to_pool"), default=False)
|
||||
|
||||
if not account_str:
|
||||
return jsonify({"success": False, "error": "请输入账号信息"})
|
||||
|
||||
# FD-00006: auto 模式允许 group_id=null(自动分组),需在分组校验前分流
|
||||
if provider == "auto":
|
||||
return _handle_auto_import(data)
|
||||
return _handle_auto_import(data, add_to_pool=add_to_pool)
|
||||
|
||||
# 校验分组
|
||||
target_group = groups_repo.get_group_by_id(group_id)
|
||||
@@ -352,6 +364,7 @@ def api_add_account() -> Any:
|
||||
imap_host=imap_host,
|
||||
imap_port=imap_port,
|
||||
imap_password=imap_pwd,
|
||||
add_to_pool=add_to_pool,
|
||||
db=db,
|
||||
commit=False,
|
||||
)
|
||||
@@ -455,6 +468,7 @@ def api_add_account() -> Any:
|
||||
client_id,
|
||||
refresh_token,
|
||||
group_id,
|
||||
add_to_pool=add_to_pool,
|
||||
db=db,
|
||||
commit=False,
|
||||
)
|
||||
@@ -743,7 +757,7 @@ def _handle_gptmail_import(
|
||||
return ok
|
||||
|
||||
|
||||
def _handle_auto_import(data: Dict[str, Any]) -> Any:
|
||||
def _handle_auto_import(data: Dict[str, Any], *, add_to_pool: bool = False) -> Any:
|
||||
"""处理 provider="auto" 的智能混合导入。"""
|
||||
account_str = data.get("account_string", "")
|
||||
duplicate_strategy = (data.get("duplicate_strategy") or "skip").strip().lower()
|
||||
@@ -883,6 +897,7 @@ def _handle_auto_import(data: Dict[str, Any]) -> Any:
|
||||
group_id=group_id,
|
||||
account_type="outlook",
|
||||
provider="outlook",
|
||||
add_to_pool=add_to_pool,
|
||||
)
|
||||
elif result["type"] == "imap":
|
||||
ok = accounts_repo.add_account(
|
||||
@@ -896,6 +911,7 @@ def _handle_auto_import(data: Dict[str, Any]) -> Any:
|
||||
imap_host=fields.get("imap_host", ""),
|
||||
imap_port=fields.get("imap_port", 993),
|
||||
imap_password=fields.get("imap_password", ""),
|
||||
add_to_pool=add_to_pool,
|
||||
)
|
||||
else:
|
||||
ok = False
|
||||
|
||||
@@ -5,8 +5,7 @@ from typing import Any
|
||||
from flask import jsonify, request
|
||||
|
||||
from outlook_web.repositories import settings as settings_repo
|
||||
from outlook_web.security.auth import api_key_required
|
||||
from outlook_web.security.auth import get_external_api_consumer
|
||||
from outlook_web.security.auth import api_key_required, get_external_api_consumer
|
||||
from outlook_web.security.external_api_guard import external_api_guards
|
||||
from outlook_web.services import external_api as external_api_service
|
||||
from outlook_web.services.pool import (
|
||||
@@ -18,9 +17,7 @@ from outlook_web.services.pool import (
|
||||
)
|
||||
|
||||
|
||||
def _audit(
|
||||
endpoint: str, status: str, *, details: dict[str, Any], email_addr: str = ""
|
||||
) -> None:
|
||||
def _audit(endpoint: str, status: str, *, details: dict[str, Any], email_addr: str = "") -> None:
|
||||
external_api_service.audit_external_api_access(
|
||||
action="external_api_access",
|
||||
email_addr=email_addr,
|
||||
@@ -148,16 +145,12 @@ def api_external_pool_claim_release():
|
||||
|
||||
if account_id is None:
|
||||
_audit(endpoint, "error", details={"code": "ACCOUNT_ID_MISSING"})
|
||||
return jsonify(
|
||||
external_api_service.fail("ACCOUNT_ID_MISSING", "account_id 不能为空")
|
||||
), 400
|
||||
return jsonify(external_api_service.fail("ACCOUNT_ID_MISSING", "account_id 不能为空")), 400
|
||||
try:
|
||||
account_id = int(account_id)
|
||||
except (TypeError, ValueError):
|
||||
_audit(endpoint, "error", details={"code": "ACCOUNT_ID_INVALID"})
|
||||
return jsonify(
|
||||
external_api_service.fail("ACCOUNT_ID_INVALID", "account_id 必须为整数")
|
||||
), 400
|
||||
return jsonify(external_api_service.fail("ACCOUNT_ID_INVALID", "account_id 必须为整数")), 400
|
||||
|
||||
try:
|
||||
release_claim(
|
||||
@@ -168,11 +161,7 @@ def api_external_pool_claim_release():
|
||||
reason=reason,
|
||||
)
|
||||
_audit(endpoint, "ok", details={"account_id": account_id})
|
||||
return jsonify(
|
||||
external_api_service.ok(
|
||||
{"account_id": account_id, "pool_status": "available"}
|
||||
)
|
||||
)
|
||||
return jsonify(external_api_service.ok({"account_id": account_id, "pool_status": "available"}))
|
||||
except PoolServiceError as exc:
|
||||
return _error_response(endpoint, exc)
|
||||
except Exception as exc:
|
||||
@@ -204,16 +193,12 @@ def api_external_pool_claim_complete():
|
||||
|
||||
if account_id is None:
|
||||
_audit(endpoint, "error", details={"code": "ACCOUNT_ID_MISSING"})
|
||||
return jsonify(
|
||||
external_api_service.fail("ACCOUNT_ID_MISSING", "account_id 不能为空")
|
||||
), 400
|
||||
return jsonify(external_api_service.fail("ACCOUNT_ID_MISSING", "account_id 不能为空")), 400
|
||||
try:
|
||||
account_id = int(account_id)
|
||||
except (TypeError, ValueError):
|
||||
_audit(endpoint, "error", details={"code": "ACCOUNT_ID_INVALID"})
|
||||
return jsonify(
|
||||
external_api_service.fail("ACCOUNT_ID_INVALID", "account_id 必须为整数")
|
||||
), 400
|
||||
return jsonify(external_api_service.fail("ACCOUNT_ID_INVALID", "account_id 必须为整数")), 400
|
||||
|
||||
try:
|
||||
new_status = complete_claim(
|
||||
@@ -233,11 +218,7 @@ def api_external_pool_claim_complete():
|
||||
"pool_status": new_status,
|
||||
},
|
||||
)
|
||||
return jsonify(
|
||||
external_api_service.ok(
|
||||
{"account_id": account_id, "pool_status": new_status}
|
||||
)
|
||||
)
|
||||
return jsonify(external_api_service.ok({"account_id": account_id, "pool_status": new_status}))
|
||||
except PoolServiceError as exc:
|
||||
return _error_response(endpoint, exc)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -38,9 +38,7 @@ def _success_response(data: dict, http_status: int = 200):
|
||||
|
||||
def _error_response(error_code: str, message: str, http_status: int = 400):
|
||||
"""统一失败响应:{"success": false, "error": "...", "message": "..."}"""
|
||||
return jsonify(
|
||||
{"success": False, "error": error_code, "message": message}
|
||||
), http_status
|
||||
return jsonify({"success": False, "error": error_code, "message": message}), http_status
|
||||
|
||||
|
||||
def api_claim_random():
|
||||
|
||||
@@ -27,9 +27,7 @@ def _mask_secret_value(value: str, head: int = 4, tail: int = 4) -> str:
|
||||
safe_value = str(value)
|
||||
if len(safe_value) <= head + tail:
|
||||
return "*" * len(safe_value)
|
||||
return (
|
||||
safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
|
||||
)
|
||||
return safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
|
||||
|
||||
|
||||
def _parse_allowed_emails_input(raw: Any) -> list[str]:
|
||||
@@ -45,10 +43,7 @@ def _parse_allowed_emails_input(raw: Any) -> list[str]:
|
||||
parsed = json.loads(text)
|
||||
values = parsed if isinstance(parsed, list) else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
values = [
|
||||
item.strip()
|
||||
for item in text.replace("\r", "\n").replace(",", "\n").split("\n")
|
||||
]
|
||||
values = [item.strip() for item in text.replace("\r", "\n").replace(",", "\n").split("\n")]
|
||||
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
@@ -87,12 +82,9 @@ def api_get_settings() -> Any:
|
||||
"refresh_delay_seconds": all_settings.get("refresh_delay_seconds", "5"),
|
||||
"refresh_cron": all_settings.get("refresh_cron", "0 2 * * *"),
|
||||
"use_cron_schedule": all_settings.get("use_cron_schedule", "false"),
|
||||
"enable_scheduled_refresh": all_settings.get(
|
||||
"enable_scheduled_refresh", "true"
|
||||
),
|
||||
"enable_scheduled_refresh": all_settings.get("enable_scheduled_refresh", "true"),
|
||||
# 轮询配置
|
||||
"enable_auto_polling": all_settings.get("enable_auto_polling", "false")
|
||||
== "true",
|
||||
"enable_auto_polling": all_settings.get("enable_auto_polling", "false") == "true",
|
||||
"polling_interval": int(all_settings.get("polling_interval", "10")),
|
||||
"polling_count": int(all_settings.get("polling_count", "5")),
|
||||
}
|
||||
@@ -101,9 +93,7 @@ def api_get_settings() -> Any:
|
||||
login_password_value = all_settings.get("login_password") or ""
|
||||
gptmail_api_key_value = all_settings.get("gptmail_api_key") or ""
|
||||
external_api_key_value = settings_repo.get_external_api_key()
|
||||
external_api_keys = external_api_keys_repo.list_external_api_keys(
|
||||
include_disabled=True
|
||||
)
|
||||
external_api_keys = external_api_keys_repo.list_external_api_keys(include_disabled=True)
|
||||
usage_summary = external_api_keys_repo.get_external_api_usage_summary(
|
||||
[item.get("consumer_key") or "" for item in external_api_keys]
|
||||
)
|
||||
@@ -121,45 +111,23 @@ def api_get_settings() -> Any:
|
||||
)
|
||||
safe_settings["login_password_set"] = bool(login_password_value)
|
||||
safe_settings["gptmail_api_key_set"] = bool(gptmail_api_key_value)
|
||||
safe_settings["gptmail_api_key_masked"] = (
|
||||
_mask_secret_value(gptmail_api_key_value) if gptmail_api_key_value else ""
|
||||
)
|
||||
safe_settings["gptmail_api_key_masked"] = _mask_secret_value(gptmail_api_key_value) if gptmail_api_key_value else ""
|
||||
safe_settings["external_api_key_set"] = bool(external_api_key_value)
|
||||
safe_settings["external_api_key_masked"] = (
|
||||
_mask_secret_value(external_api_key_value) if external_api_key_value else ""
|
||||
)
|
||||
safe_settings["external_api_key_masked"] = _mask_secret_value(external_api_key_value) if external_api_key_value else ""
|
||||
safe_settings["external_api_keys"] = external_api_keys
|
||||
safe_settings["external_api_keys_count"] = len(external_api_keys)
|
||||
safe_settings["external_api_multi_key_set"] = bool(external_api_keys)
|
||||
|
||||
# P1:公网模式安全配置
|
||||
safe_settings["external_api_public_mode"] = (
|
||||
settings_repo.get_external_api_public_mode()
|
||||
)
|
||||
safe_settings["external_api_ip_whitelist"] = (
|
||||
settings_repo.get_external_api_ip_whitelist()
|
||||
)
|
||||
safe_settings["external_api_rate_limit_per_minute"] = (
|
||||
settings_repo.get_external_api_rate_limit()
|
||||
)
|
||||
safe_settings["external_api_disable_raw_content"] = (
|
||||
settings_repo.get_external_api_disable_raw_content()
|
||||
)
|
||||
safe_settings["external_api_disable_wait_message"] = (
|
||||
settings_repo.get_external_api_disable_wait_message()
|
||||
)
|
||||
safe_settings["external_api_disable_pool_claim_random"] = (
|
||||
settings_repo.get_external_api_disable_pool_claim_random()
|
||||
)
|
||||
safe_settings["external_api_disable_pool_claim_release"] = (
|
||||
settings_repo.get_external_api_disable_pool_claim_release()
|
||||
)
|
||||
safe_settings["external_api_disable_pool_claim_complete"] = (
|
||||
settings_repo.get_external_api_disable_pool_claim_complete()
|
||||
)
|
||||
safe_settings["external_api_disable_pool_stats"] = (
|
||||
settings_repo.get_external_api_disable_pool_stats()
|
||||
)
|
||||
safe_settings["external_api_public_mode"] = settings_repo.get_external_api_public_mode()
|
||||
safe_settings["external_api_ip_whitelist"] = settings_repo.get_external_api_ip_whitelist()
|
||||
safe_settings["external_api_rate_limit_per_minute"] = settings_repo.get_external_api_rate_limit()
|
||||
safe_settings["external_api_disable_raw_content"] = settings_repo.get_external_api_disable_raw_content()
|
||||
safe_settings["external_api_disable_wait_message"] = settings_repo.get_external_api_disable_wait_message()
|
||||
safe_settings["external_api_disable_pool_claim_random"] = settings_repo.get_external_api_disable_pool_claim_random()
|
||||
safe_settings["external_api_disable_pool_claim_release"] = settings_repo.get_external_api_disable_pool_claim_release()
|
||||
safe_settings["external_api_disable_pool_claim_complete"] = settings_repo.get_external_api_disable_pool_claim_complete()
|
||||
safe_settings["external_api_disable_pool_stats"] = settings_repo.get_external_api_disable_pool_stats()
|
||||
safe_settings["pool_external_enabled"] = settings_repo.get_pool_external_enabled()
|
||||
|
||||
# Telegram 推送配置
|
||||
@@ -167,25 +135,19 @@ def api_get_settings() -> Any:
|
||||
if tg_bot_token_raw and is_encrypted(tg_bot_token_raw):
|
||||
try:
|
||||
plain_token = decrypt_data(tg_bot_token_raw)
|
||||
safe_settings["telegram_bot_token"] = (
|
||||
"****" + plain_token[-4:] if len(plain_token) > 4 else "****"
|
||||
)
|
||||
safe_settings["telegram_bot_token"] = "****" + plain_token[-4:] if len(plain_token) > 4 else "****"
|
||||
except Exception:
|
||||
safe_settings["telegram_bot_token"] = "****"
|
||||
else:
|
||||
safe_settings["telegram_bot_token"] = ""
|
||||
safe_settings["telegram_chat_id"] = all_settings.get("telegram_chat_id", "")
|
||||
safe_settings["telegram_poll_interval"] = int(
|
||||
all_settings.get("telegram_poll_interval", "600") or "600"
|
||||
)
|
||||
safe_settings["telegram_poll_interval"] = int(all_settings.get("telegram_poll_interval", "600") or "600")
|
||||
|
||||
response = {"success": True, "settings": safe_settings}
|
||||
# 同时在顶层暴露 telegram 字段(兼容前端直接访问)
|
||||
response["telegram_bot_token"] = safe_settings.get("telegram_bot_token", "")
|
||||
response["telegram_chat_id"] = safe_settings.get("telegram_chat_id", "")
|
||||
response["telegram_poll_interval"] = safe_settings.get(
|
||||
"telegram_poll_interval", 600
|
||||
)
|
||||
response["telegram_poll_interval"] = safe_settings.get("telegram_poll_interval", 600)
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
@@ -209,11 +171,7 @@ def api_update_settings() -> Any:
|
||||
pending_operations: list[Any] = []
|
||||
|
||||
def queue_setting_update(key: str, value: str) -> None:
|
||||
pending_operations.append(
|
||||
lambda key=key, value=value: settings_repo.set_setting(
|
||||
key, value, commit=False
|
||||
)
|
||||
)
|
||||
pending_operations.append(lambda key=key, value=value: settings_repo.set_setting(key, value, commit=False))
|
||||
|
||||
def queue_operation(op: Any) -> None:
|
||||
pending_operations.append(op)
|
||||
@@ -234,11 +192,7 @@ def api_update_settings() -> Any:
|
||||
if "gptmail_api_key" in data:
|
||||
new_api_key = str(data["gptmail_api_key"] or "").strip()
|
||||
existing_api_key = settings_repo.get_setting("gptmail_api_key", "") or ""
|
||||
if (
|
||||
new_api_key
|
||||
and existing_api_key
|
||||
and new_api_key == _mask_secret_value(existing_api_key)
|
||||
):
|
||||
if new_api_key and existing_api_key and new_api_key == _mask_secret_value(existing_api_key):
|
||||
updated.append("GPTMail API Key(未变更)")
|
||||
elif new_api_key:
|
||||
queue_setting_update("gptmail_api_key", new_api_key)
|
||||
@@ -273,10 +227,7 @@ def api_update_settings() -> Any:
|
||||
errors.append("external_api_keys 必须是数组")
|
||||
else:
|
||||
existing_keys = {
|
||||
int(item["id"]): item
|
||||
for item in external_api_keys_repo.list_external_api_keys(
|
||||
include_disabled=True
|
||||
)
|
||||
int(item["id"]): item for item in external_api_keys_repo.list_external_api_keys(include_disabled=True)
|
||||
}
|
||||
normalized_items: list[dict[str, Any]] = []
|
||||
seen_names: set[str] = set()
|
||||
@@ -320,13 +271,8 @@ def api_update_settings() -> Any:
|
||||
api_key_value = None
|
||||
|
||||
allowed_emails = _parse_allowed_emails_input(item.get("allowed_emails"))
|
||||
if (
|
||||
item.get("allowed_emails") not in (None, "", [])
|
||||
and not allowed_emails
|
||||
):
|
||||
errors.append(
|
||||
f"external_api_keys[{index}].allowed_emails 至少包含一个合法邮箱"
|
||||
)
|
||||
if item.get("allowed_emails") not in (None, "", []) and not allowed_emails:
|
||||
errors.append(f"external_api_keys[{index}].allowed_emails 至少包含一个合法邮箱")
|
||||
continue
|
||||
|
||||
normalized_items.append(
|
||||
@@ -335,9 +281,7 @@ def api_update_settings() -> Any:
|
||||
"name": name,
|
||||
"api_key": api_key_value,
|
||||
"allowed_emails": allowed_emails,
|
||||
"pool_access": _parse_bool_input(
|
||||
item.get("pool_access"), default=False
|
||||
),
|
||||
"pool_access": _parse_bool_input(item.get("pool_access"), default=False),
|
||||
"enabled": _parse_bool_input(item.get("enabled"), default=True),
|
||||
}
|
||||
)
|
||||
@@ -665,9 +609,7 @@ def api_validate_cron() -> Any:
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"success": False, "valid": False, "error": f"Cron 表达式无效: {str(e)}"}
|
||||
)
|
||||
return jsonify({"success": False, "valid": False, "error": f"Cron 表达式无效: {str(e)}"})
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -679,17 +621,11 @@ def api_test_telegram() -> Any:
|
||||
chat_id = settings_repo.get_setting("telegram_chat_id", "")
|
||||
|
||||
if not bot_token_raw or not chat_id:
|
||||
return jsonify(
|
||||
{"success": False, "error": "请先配置 Telegram Bot Token 和 Chat ID"}
|
||||
)
|
||||
return jsonify({"success": False, "error": "请先配置 Telegram Bot Token 和 Chat ID"})
|
||||
|
||||
bot_token = (
|
||||
decrypt_data(bot_token_raw) if is_encrypted(bot_token_raw) else bot_token_raw
|
||||
)
|
||||
bot_token = decrypt_data(bot_token_raw) if is_encrypted(bot_token_raw) else bot_token_raw
|
||||
|
||||
ok = _send_telegram_message(
|
||||
bot_token, chat_id, "✅ Outlook Email Plus 测试消息:配置正确!"
|
||||
)
|
||||
ok = _send_telegram_message(bot_token, chat_id, "✅ Outlook Email Plus 测试消息:配置正确!")
|
||||
if ok:
|
||||
log_audit("telegram_test", "settings", None, "测试消息发送成功")
|
||||
return jsonify({"success": True, "message": "测试消息已发送,请检查 Telegram"})
|
||||
|
||||
+13
-39
@@ -126,9 +126,7 @@ def init_db(database_path: Optional[str] = None):
|
||||
""")
|
||||
|
||||
# 在锁内读取当前 schema 版本(保证一致性)
|
||||
row = cursor.execute(
|
||||
"SELECT value FROM settings WHERE key = ?", (DB_SCHEMA_VERSION_KEY,)
|
||||
).fetchone()
|
||||
row = cursor.execute("SELECT value FROM settings WHERE key = ?", (DB_SCHEMA_VERSION_KEY,)).fetchone()
|
||||
current_version = int(row["value"]) if row and row["value"] is not None else 0
|
||||
|
||||
upgrading = current_version < DB_SCHEMA_VERSION
|
||||
@@ -137,9 +135,7 @@ def init_db(database_path: Optional[str] = None):
|
||||
if db_existed:
|
||||
try:
|
||||
print("=" * 60)
|
||||
print(
|
||||
f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}"
|
||||
)
|
||||
print(f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}")
|
||||
print(f"[升级提示] 强烈建议先备份数据库文件:{path}")
|
||||
print(f'[升级提示] 示例:cp "{path}" "{path}.backup"')
|
||||
print(f"[升级提示] trace_id={migration_trace_id}")
|
||||
@@ -353,39 +349,25 @@ def init_db(database_path: Optional[str] = None):
|
||||
if "remark" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN remark TEXT")
|
||||
if "status" not in columns:
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'"
|
||||
)
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'")
|
||||
if "updated_at" not in columns:
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
|
||||
)
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
|
||||
if "last_refresh_at" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN last_refresh_at TIMESTAMP")
|
||||
if "account_type" not in columns:
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'outlook'"
|
||||
)
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'outlook'")
|
||||
if "provider" not in columns:
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN provider TEXT DEFAULT 'outlook'"
|
||||
)
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN provider TEXT DEFAULT 'outlook'")
|
||||
if "imap_host" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_host TEXT")
|
||||
if "imap_port" not in columns:
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN imap_port INTEGER DEFAULT 993"
|
||||
)
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_port INTEGER DEFAULT 993")
|
||||
if "imap_password" not in columns:
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_password TEXT")
|
||||
if "telegram_push_enabled" not in columns:
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN telegram_push_enabled INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN telegram_push_enabled INTEGER NOT NULL DEFAULT 0")
|
||||
if "telegram_last_checked_at" not in columns:
|
||||
cursor.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN telegram_last_checked_at TEXT DEFAULT NULL"
|
||||
)
|
||||
cursor.execute("ALTER TABLE accounts ADD COLUMN telegram_last_checked_at TEXT DEFAULT NULL")
|
||||
|
||||
cursor.execute("PRAGMA table_info(groups)")
|
||||
group_columns = [col[1] for col in cursor.fetchall()]
|
||||
@@ -632,9 +614,7 @@ def init_db(database_path: Optional[str] = None):
|
||||
cursor.execute("PRAGMA table_info(external_api_keys)")
|
||||
external_api_keys_columns = [col[1] for col in cursor.fetchall()]
|
||||
if "pool_access" not in external_api_keys_columns:
|
||||
cursor.execute(
|
||||
"ALTER TABLE external_api_keys ADD COLUMN pool_access INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
cursor.execute("ALTER TABLE external_api_keys ADD COLUMN pool_access INTEGER NOT NULL DEFAULT 0")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_external_api_keys_enabled
|
||||
ON external_api_keys(enabled, updated_at)
|
||||
@@ -683,9 +663,7 @@ def init_db(database_path: Optional[str] = None):
|
||||
("fail_count", "INTEGER DEFAULT 0"),
|
||||
]:
|
||||
if col_def[0] not in accounts_columns_v11:
|
||||
cursor.execute(
|
||||
f"ALTER TABLE accounts ADD COLUMN {col_def[0]} {col_def[1]}"
|
||||
)
|
||||
cursor.execute(f"ALTER TABLE accounts ADD COLUMN {col_def[0]} {col_def[1]}")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS account_claim_logs (
|
||||
@@ -718,12 +696,8 @@ def init_db(database_path: Optional[str] = None):
|
||||
ON accounts(pool_status)
|
||||
""")
|
||||
|
||||
cursor.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('pool_cooldown_seconds', '86400')"
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('pool_default_lease_seconds', '600')"
|
||||
)
|
||||
cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('pool_cooldown_seconds', '86400')")
|
||||
cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('pool_default_lease_seconds', '600')")
|
||||
|
||||
# 迁移现有明文数据为加密数据
|
||||
migrate_sensitive_data(conn)
|
||||
|
||||
@@ -162,6 +162,7 @@ def add_account(
|
||||
imap_host: str = "",
|
||||
imap_port: int = 993,
|
||||
imap_password: str = "",
|
||||
add_to_pool: bool = False,
|
||||
db: Optional[sqlite3.Connection] = None,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
@@ -186,15 +187,16 @@ def add_account(
|
||||
encrypted_password = encrypt_data(password) if password else password
|
||||
encrypted_refresh_token = encrypt_data(refresh_token) if refresh_token else refresh_token
|
||||
encrypted_imap_password = encrypt_data(imap_password) if imap_password else imap_password
|
||||
initial_pool_status = "available" if add_to_pool else None
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO accounts (
|
||||
email, password, client_id, refresh_token,
|
||||
account_type, provider, imap_host, imap_port, imap_password,
|
||||
group_id, remark
|
||||
group_id, remark, pool_status
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
email_addr,
|
||||
@@ -208,6 +210,7 @@ def add_account(
|
||||
encrypted_imap_password,
|
||||
group_id,
|
||||
remark,
|
||||
initial_pool_status,
|
||||
),
|
||||
)
|
||||
if commit:
|
||||
|
||||
@@ -15,9 +15,7 @@ def _mask_secret_value(value: str, head: int = 4, tail: int = 4) -> str:
|
||||
safe_value = str(value)
|
||||
if len(safe_value) <= head + tail:
|
||||
return "*" * len(safe_value)
|
||||
return (
|
||||
safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
|
||||
)
|
||||
return safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
|
||||
|
||||
|
||||
def _parse_allowed_emails(raw: Any) -> list[str]:
|
||||
@@ -28,10 +26,7 @@ def _parse_allowed_emails(raw: Any) -> list[str]:
|
||||
try:
|
||||
values = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
values = [
|
||||
item.strip()
|
||||
for item in raw.replace("\r", "\n").replace(",", "\n").split("\n")
|
||||
]
|
||||
values = [item.strip() for item in raw.replace("\r", "\n").replace(",", "\n").split("\n")]
|
||||
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
@@ -48,9 +43,7 @@ def _parse_allowed_emails(raw: Any) -> list[str]:
|
||||
|
||||
|
||||
def _allowed_emails_json(allowed_emails: Iterable[str] | None) -> str:
|
||||
return json.dumps(
|
||||
_parse_allowed_emails(list(allowed_emails or [])), ensure_ascii=False
|
||||
)
|
||||
return json.dumps(_parse_allowed_emails(list(allowed_emails or [])), ensure_ascii=False)
|
||||
|
||||
|
||||
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
@@ -190,9 +183,7 @@ def update_external_api_key(
|
||||
(int(key_id),),
|
||||
).fetchone()["api_key_encrypted"]
|
||||
),
|
||||
_allowed_emails_json(
|
||||
existing["allowed_emails"] if allowed_emails is None else allowed_emails
|
||||
),
|
||||
_allowed_emails_json(existing["allowed_emails"] if allowed_emails is None else allowed_emails),
|
||||
int(
|
||||
_coerce_bool(
|
||||
existing["pool_access"] if pool_access is None else pool_access,
|
||||
@@ -221,9 +212,7 @@ def delete_external_api_key(key_id: int, *, commit: bool = True) -> bool:
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def replace_external_api_keys(
|
||||
items: list[dict[str, Any]], *, commit: bool = True
|
||||
) -> list[dict[str, Any]]:
|
||||
def replace_external_api_keys(items: list[dict[str, Any]], *, commit: bool = True) -> list[dict[str, Any]]:
|
||||
existing_rows = list_external_api_keys(include_disabled=True)
|
||||
existing_ids = {int(item["id"]): item for item in existing_rows}
|
||||
seen_ids: set[int] = set()
|
||||
@@ -257,12 +246,8 @@ def replace_external_api_keys(
|
||||
update_external_api_key(
|
||||
key_id,
|
||||
name=name,
|
||||
api_key=None
|
||||
if api_key in (None, "") and existing.get("api_key_masked")
|
||||
else api_key,
|
||||
allowed_emails=_parse_allowed_emails(allowed_emails)
|
||||
if allowed_emails is not None
|
||||
else existing["allowed_emails"],
|
||||
api_key=None if api_key in (None, "") and existing.get("api_key_masked") else api_key,
|
||||
allowed_emails=_parse_allowed_emails(allowed_emails) if allowed_emails is not None else existing["allowed_emails"],
|
||||
pool_access=pool_access,
|
||||
enabled=enabled,
|
||||
commit=False,
|
||||
@@ -310,12 +295,7 @@ def find_external_api_key_by_plaintext(provided_key: str) -> dict[str, Any] | No
|
||||
|
||||
def mark_external_api_key_used(key_id: int) -> None:
|
||||
db = get_db()
|
||||
used_at = (
|
||||
datetime.now(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
used_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE external_api_keys
|
||||
@@ -338,12 +318,7 @@ def record_external_api_consumer_usage(
|
||||
return
|
||||
db = get_db()
|
||||
usage_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
last_used_at = (
|
||||
datetime.now(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
last_used_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
success_inc = 1 if str(status or "").lower() == "ok" else 0
|
||||
error_inc = 0 if success_inc else 1
|
||||
db.execute(
|
||||
@@ -380,9 +355,7 @@ def record_external_api_consumer_usage(
|
||||
def get_external_api_usage_summary(
|
||||
consumer_keys: list[str],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
clean_keys = [
|
||||
str(item or "").strip() for item in consumer_keys if str(item or "").strip()
|
||||
]
|
||||
clean_keys = [str(item or "").strip() for item in consumer_keys if str(item or "").strip()]
|
||||
if not clean_keys:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
RESULT_TO_POOL_STATUS: Dict[str, str] = {
|
||||
"success": "used",
|
||||
"verification_timeout": "cooldown",
|
||||
@@ -57,9 +55,7 @@ def claim_atomic(
|
||||
params.append(tag_name)
|
||||
|
||||
if exclude_recent_minutes and exclude_recent_minutes > 0:
|
||||
cutoff = (
|
||||
_utcnow() - timedelta(minutes=exclude_recent_minutes)
|
||||
).isoformat() + "Z"
|
||||
cutoff = (_utcnow() - timedelta(minutes=exclude_recent_minutes)).isoformat() + "Z"
|
||||
sql += " AND (a.last_claimed_at IS NULL OR a.last_claimed_at < ?)"
|
||||
params.append(cutoff)
|
||||
|
||||
@@ -73,9 +69,7 @@ def claim_atomic(
|
||||
return None
|
||||
|
||||
now_str = _utcnow().isoformat() + "Z"
|
||||
lease_expires_at_str = (
|
||||
_utcnow() + timedelta(seconds=lease_seconds)
|
||||
).isoformat() + "Z"
|
||||
lease_expires_at_str = (_utcnow() + timedelta(seconds=lease_seconds)).isoformat() + "Z"
|
||||
token = "clm_" + secrets.token_urlsafe(9)
|
||||
|
||||
conn.execute(
|
||||
@@ -259,12 +253,10 @@ def recover_cooldown(conn: sqlite3.Connection, cooldown_seconds: int) -> int:
|
||||
|
||||
|
||||
def get_stats(conn: sqlite3.Connection) -> dict:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
rows = conn.execute("""
|
||||
SELECT pool_status, COUNT(*) as cnt FROM accounts
|
||||
GROUP BY pool_status
|
||||
"""
|
||||
).fetchall()
|
||||
""").fetchall()
|
||||
pool_counts: dict = {
|
||||
"available": 0,
|
||||
"claimed": 0,
|
||||
@@ -274,6 +266,7 @@ def get_stats(conn: sqlite3.Connection) -> dict:
|
||||
"retired": 0,
|
||||
}
|
||||
for row in rows:
|
||||
# external API 只暴露池内状态;NULL/池外账号不应出现在契约里。
|
||||
key = row["pool_status"]
|
||||
if key in pool_counts:
|
||||
pool_counts[key] = row["cnt"]
|
||||
|
||||
@@ -79,9 +79,7 @@ def get_external_api_key_masked(head: int = 4, tail: int = 4) -> str:
|
||||
safe_value = str(key)
|
||||
if len(safe_value) <= head + tail:
|
||||
return "*" * len(safe_value)
|
||||
return (
|
||||
safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
|
||||
)
|
||||
return safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
|
||||
|
||||
|
||||
# ── P1:公网模式安全配置 ──────────────────────────────
|
||||
@@ -128,23 +126,15 @@ def get_pool_external_enabled() -> bool:
|
||||
|
||||
|
||||
def get_external_api_disable_pool_claim_random() -> bool:
|
||||
return (
|
||||
get_setting("external_api_disable_pool_claim_random", "false").lower() == "true"
|
||||
)
|
||||
return get_setting("external_api_disable_pool_claim_random", "false").lower() == "true"
|
||||
|
||||
|
||||
def get_external_api_disable_pool_claim_release() -> bool:
|
||||
return (
|
||||
get_setting("external_api_disable_pool_claim_release", "false").lower()
|
||||
== "true"
|
||||
)
|
||||
return get_setting("external_api_disable_pool_claim_release", "false").lower() == "true"
|
||||
|
||||
|
||||
def get_external_api_disable_pool_claim_complete() -> bool:
|
||||
return (
|
||||
get_setting("external_api_disable_pool_claim_complete", "false").lower()
|
||||
== "true"
|
||||
)
|
||||
return get_setting("external_api_disable_pool_claim_complete", "false").lower() == "true"
|
||||
|
||||
|
||||
def get_external_api_disable_pool_stats() -> bool:
|
||||
|
||||
@@ -8,6 +8,11 @@ from outlook_web.controllers import external_pool as external_pool_controller
|
||||
|
||||
|
||||
def create_blueprint(csrf_exempt: Optional[Callable] = None) -> Blueprint:
|
||||
"""创建 external_pool Blueprint。
|
||||
|
||||
外部池接口面向 worker/合作方直接调用,POST 请求不应被浏览器态 CSRF 校验拦截。
|
||||
这里统一在 Blueprint 装配阶段透传 csrf_exempt,避免后续新增端点时遗漏。
|
||||
"""
|
||||
bp = Blueprint("external_pool", __name__)
|
||||
handlers = [
|
||||
(
|
||||
@@ -32,6 +37,7 @@ def create_blueprint(csrf_exempt: Optional[Callable] = None) -> Blueprint:
|
||||
),
|
||||
]
|
||||
|
||||
# 所有 external_pool 端点都走同一套鉴权链路,这里只负责统一挂载 CSRF 豁免。
|
||||
for path, handler, methods in handlers:
|
||||
view_func = csrf_exempt(handler) if csrf_exempt else handler
|
||||
bp.add_url_rule(path, view_func=view_func, methods=methods)
|
||||
|
||||
@@ -163,9 +163,7 @@ def login_required(f):
|
||||
trace_id=trace_id_value,
|
||||
)
|
||||
return (
|
||||
jsonify(
|
||||
{"success": False, "error": error_payload, "need_login": True}
|
||||
),
|
||||
jsonify({"success": False, "error": error_payload, "need_login": True}),
|
||||
401,
|
||||
)
|
||||
return redirect(url_for("pages.login"))
|
||||
@@ -205,20 +203,10 @@ def api_key_required(f):
|
||||
401,
|
||||
)
|
||||
|
||||
matched_consumer = external_api_keys_repo.find_external_api_key_by_plaintext(
|
||||
provided_key
|
||||
)
|
||||
matched_consumer = external_api_keys_repo.find_external_api_key_by_plaintext(provided_key)
|
||||
configured_key = settings_repo.get_external_api_key()
|
||||
any_enabled_multi_key_configured = (
|
||||
external_api_keys_repo.has_any_external_api_key_configured(
|
||||
enabled_only=True
|
||||
)
|
||||
)
|
||||
if (
|
||||
not matched_consumer
|
||||
and not configured_key
|
||||
and not any_enabled_multi_key_configured
|
||||
):
|
||||
any_enabled_multi_key_configured = external_api_keys_repo.has_any_external_api_key_configured(enabled_only=True)
|
||||
if not matched_consumer and not configured_key and not any_enabled_multi_key_configured:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
@@ -232,13 +220,10 @@ def api_key_required(f):
|
||||
)
|
||||
|
||||
if matched_consumer:
|
||||
external_api_keys_repo.mark_external_api_key_used(
|
||||
int(matched_consumer["id"])
|
||||
)
|
||||
external_api_keys_repo.mark_external_api_key_used(int(matched_consumer["id"]))
|
||||
g.external_api_consumer = {
|
||||
"id": matched_consumer["id"],
|
||||
"consumer_key": matched_consumer.get("consumer_key")
|
||||
or f"key:{matched_consumer['id']}",
|
||||
"consumer_key": matched_consumer.get("consumer_key") or f"key:{matched_consumer['id']}",
|
||||
"name": matched_consumer.get("name") or f"key-{matched_consumer['id']}",
|
||||
"source": "external_api_keys",
|
||||
"allowed_emails": matched_consumer.get("allowed_emails") or [],
|
||||
@@ -248,9 +233,7 @@ def api_key_required(f):
|
||||
}
|
||||
return f(*args, **kwargs)
|
||||
|
||||
if not configured_key or not secrets.compare_digest(
|
||||
str(provided_key), str(configured_key)
|
||||
):
|
||||
if not configured_key or not secrets.compare_digest(str(provided_key), str(configured_key)):
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
@@ -373,9 +356,7 @@ def issue_export_verify_token(client_ip: str, user_agent: str) -> str:
|
||||
return verify_token
|
||||
|
||||
|
||||
def consume_export_verify_token(
|
||||
verify_token: str, client_ip: str = "", user_agent: str = ""
|
||||
) -> tuple[bool, str]:
|
||||
def consume_export_verify_token(verify_token: str, client_ip: str = "", user_agent: str = "") -> tuple[bool, str]:
|
||||
"""
|
||||
校验并消费一次性导出验证 token(成功则删除)
|
||||
|
||||
@@ -406,9 +387,7 @@ def consume_export_verify_token(
|
||||
|
||||
expires_at = row["expires_at"] or 0
|
||||
if float(expires_at) < now_ts:
|
||||
db.execute(
|
||||
"DELETE FROM export_verify_tokens WHERE token = ?", (verify_token,)
|
||||
)
|
||||
db.execute("DELETE FROM export_verify_tokens WHERE token = ?", (verify_token,))
|
||||
db.commit()
|
||||
return False, "验证已过期,请重新验证"
|
||||
|
||||
|
||||
@@ -36,27 +36,21 @@ def _validate_caller_id(caller_id: str) -> None:
|
||||
if not caller_id or not caller_id.strip():
|
||||
raise PoolServiceError("caller_id 不能为空", "caller_id_empty")
|
||||
if len(caller_id) > CALLER_ID_MAX_LEN:
|
||||
raise PoolServiceError(
|
||||
f"caller_id 超过最大长度 {CALLER_ID_MAX_LEN}", "caller_id_too_long"
|
||||
)
|
||||
raise PoolServiceError(f"caller_id 超过最大长度 {CALLER_ID_MAX_LEN}", "caller_id_too_long")
|
||||
|
||||
|
||||
def _validate_task_id(task_id: str) -> None:
|
||||
if not task_id or not task_id.strip():
|
||||
raise PoolServiceError("task_id 不能为空", "task_id_empty")
|
||||
if len(task_id) > TASK_ID_MAX_LEN:
|
||||
raise PoolServiceError(
|
||||
f"task_id 超过最大长度 {TASK_ID_MAX_LEN}", "task_id_too_long"
|
||||
)
|
||||
raise PoolServiceError(f"task_id 超过最大长度 {TASK_ID_MAX_LEN}", "task_id_too_long")
|
||||
|
||||
|
||||
def _validate_lease_seconds(lease_seconds: int, max_lease: int = 3600) -> None:
|
||||
if lease_seconds <= 0:
|
||||
raise PoolServiceError("lease_seconds 必须大于 0", "lease_seconds_invalid")
|
||||
if lease_seconds > max_lease:
|
||||
raise PoolServiceError(
|
||||
f"lease_seconds 不能超过 {max_lease} 秒", "lease_seconds_too_large"
|
||||
)
|
||||
raise PoolServiceError(f"lease_seconds 不能超过 {max_lease} 秒", "lease_seconds_too_large")
|
||||
|
||||
|
||||
def _read_settings_via_conn(conn) -> dict:
|
||||
@@ -97,9 +91,7 @@ def claim_random(
|
||||
provider=provider,
|
||||
)
|
||||
if account is None:
|
||||
raise PoolServiceError(
|
||||
"池中没有符合条件的可用邮箱", "no_available_account", http_status=200
|
||||
)
|
||||
raise PoolServiceError("池中没有符合条件的可用邮箱", "no_available_account", http_status=200)
|
||||
return account
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -119,9 +111,7 @@ def release_claim(
|
||||
if not claim_token or not claim_token.strip():
|
||||
raise PoolServiceError("claim_token 不能为空", "claim_token_empty")
|
||||
if reason and len(reason) > REASON_MAX_LEN:
|
||||
raise PoolServiceError(
|
||||
f"reason 超过最大长度 {REASON_MAX_LEN}", "reason_too_long"
|
||||
)
|
||||
raise PoolServiceError(f"reason 超过最大长度 {REASON_MAX_LEN}", "reason_too_long")
|
||||
|
||||
conn = create_sqlite_connection()
|
||||
try:
|
||||
@@ -138,9 +128,7 @@ def release_claim(
|
||||
http_status=409,
|
||||
)
|
||||
if row["claim_token"] != claim_token:
|
||||
raise PoolServiceError(
|
||||
"claim_token 不匹配", "token_mismatch", http_status=403
|
||||
)
|
||||
raise PoolServiceError("claim_token 不匹配", "token_mismatch", http_status=403)
|
||||
expected_claimed_by = f"{caller_id}:{task_id}"
|
||||
if row["claimed_by"] != expected_claimed_by:
|
||||
raise PoolServiceError(
|
||||
@@ -178,9 +166,7 @@ def complete_claim(
|
||||
"invalid_result",
|
||||
)
|
||||
if detail and len(detail) > DETAIL_MAX_LEN:
|
||||
raise PoolServiceError(
|
||||
f"detail 超过最大长度 {DETAIL_MAX_LEN}", "detail_too_long"
|
||||
)
|
||||
raise PoolServiceError(f"detail 超过最大长度 {DETAIL_MAX_LEN}", "detail_too_long")
|
||||
|
||||
conn = create_sqlite_connection()
|
||||
try:
|
||||
@@ -197,9 +183,7 @@ def complete_claim(
|
||||
http_status=409,
|
||||
)
|
||||
if row["claim_token"] != claim_token:
|
||||
raise PoolServiceError(
|
||||
"claim_token 不匹配", "token_mismatch", http_status=403
|
||||
)
|
||||
raise PoolServiceError("claim_token 不匹配", "token_mismatch", http_status=403)
|
||||
expected_claimed_by = f"{caller_id}:{task_id}"
|
||||
if row["claimed_by"] != expected_claimed_by:
|
||||
raise PoolServiceError(
|
||||
@@ -208,9 +192,7 @@ def complete_claim(
|
||||
http_status=403,
|
||||
)
|
||||
|
||||
new_status = pool_repo.complete(
|
||||
conn, account_id, claim_token, caller_id, task_id, result, detail
|
||||
)
|
||||
new_status = pool_repo.complete(conn, account_id, claim_token, caller_id, task_id, result, detail)
|
||||
return new_status
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -160,9 +160,7 @@ def _configure_pool_maintenance_jobs(scheduler) -> None:
|
||||
|
||||
conn = create_sqlite_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key = 'pool_cooldown_seconds'"
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT value FROM settings WHERE key = 'pool_cooldown_seconds'").fetchone()
|
||||
cooldown_seconds = int(row["value"]) if row and row["value"] else 86400
|
||||
recovered = pool_repo.recover_cooldown(conn, cooldown_seconds)
|
||||
if recovered:
|
||||
@@ -204,16 +202,9 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
|
||||
return
|
||||
|
||||
with app.app_context():
|
||||
enable_scheduled = (
|
||||
settings_repo.get_setting("enable_scheduled_refresh", "true").lower()
|
||||
== "true"
|
||||
)
|
||||
use_cron = (
|
||||
settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
|
||||
)
|
||||
refresh_interval_days = int(
|
||||
settings_repo.get_setting("refresh_interval_days", "30")
|
||||
)
|
||||
enable_scheduled = settings_repo.get_setting("enable_scheduled_refresh", "true").lower() == "true"
|
||||
use_cron = settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
|
||||
refresh_interval_days = int(settings_repo.get_setting("refresh_interval_days", "30"))
|
||||
cron_expr = settings_repo.get_setting("refresh_cron", "0 2 * * *")
|
||||
|
||||
try:
|
||||
@@ -259,9 +250,7 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
|
||||
if len(parts) != 5:
|
||||
raise ValueError("Cron 表达式格式错误,应为 5 段")
|
||||
minute, hour, day, month, day_of_week = parts
|
||||
trigger = CronTrigger(
|
||||
minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week
|
||||
)
|
||||
trigger = CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week)
|
||||
scheduler.add_job(
|
||||
func=scheduled_task,
|
||||
trigger=trigger,
|
||||
@@ -287,9 +276,7 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
|
||||
coalesce=True,
|
||||
misfire_grace_time=600,
|
||||
)
|
||||
print(
|
||||
f"✓ 定时任务已配置:每天凌晨 2:00 检查刷新(周期:{refresh_interval_days} 天)"
|
||||
)
|
||||
print(f"✓ 定时任务已配置:每天凌晨 2:00 检查刷新(周期:{refresh_interval_days} 天)")
|
||||
|
||||
|
||||
def init_scheduler(app, test_refresh_token):
|
||||
@@ -332,17 +319,9 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
|
||||
try:
|
||||
with app.app_context():
|
||||
enable_scheduled = (
|
||||
settings_repo.get_setting("enable_scheduled_refresh", "true").lower()
|
||||
== "true"
|
||||
)
|
||||
use_cron = (
|
||||
settings_repo.get_setting("use_cron_schedule", "false").lower()
|
||||
== "true"
|
||||
)
|
||||
refresh_interval_days = int(
|
||||
settings_repo.get_setting("refresh_interval_days", "30")
|
||||
)
|
||||
enable_scheduled = settings_repo.get_setting("enable_scheduled_refresh", "true").lower() == "true"
|
||||
use_cron = settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
|
||||
refresh_interval_days = int(settings_repo.get_setting("refresh_interval_days", "30"))
|
||||
delay_seconds = int(settings_repo.get_setting("refresh_delay_seconds", "5"))
|
||||
|
||||
run_id = create_refresh_run(conn, "scheduled", trace_id, total=0)
|
||||
@@ -384,19 +363,13 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
return
|
||||
|
||||
# 获取刷新间隔配置
|
||||
delay_row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key = 'refresh_delay_seconds'"
|
||||
).fetchone()
|
||||
delay_row = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'").fetchone()
|
||||
delay_seconds = int(delay_row["value"]) if delay_row else 5
|
||||
|
||||
# 清理超过半年的刷新记录
|
||||
try:
|
||||
conn.execute(
|
||||
"DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')"
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),)
|
||||
)
|
||||
conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')")
|
||||
conn.execute("DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -419,13 +392,9 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
ttl_seconds = max(60 * 60 * 2, estimated)
|
||||
ttl_seconds = min(ttl_seconds, 60 * 60 * 24)
|
||||
|
||||
ok, lock_info = acquire_distributed_lock(
|
||||
conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds
|
||||
)
|
||||
ok, lock_info = acquire_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds)
|
||||
if not ok:
|
||||
finish_refresh_run(
|
||||
conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行"
|
||||
)
|
||||
finish_refresh_run(conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行")
|
||||
return
|
||||
lock_acquired = True
|
||||
|
||||
@@ -440,11 +409,7 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
|
||||
# 解密 refresh_token
|
||||
try:
|
||||
refresh_token = (
|
||||
decrypt_data(encrypted_refresh_token)
|
||||
if encrypted_refresh_token
|
||||
else encrypted_refresh_token
|
||||
)
|
||||
refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
error_msg = f"解密 token 失败: {str(e)}"
|
||||
@@ -473,17 +438,13 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
group_id = account["group_id"]
|
||||
if group_id:
|
||||
try:
|
||||
group_row = conn.execute(
|
||||
"SELECT proxy_url FROM groups WHERE id = ?", (group_id,)
|
||||
).fetchone()
|
||||
group_row = conn.execute("SELECT proxy_url FROM groups WHERE id = ?", (group_id,)).fetchone()
|
||||
if group_row:
|
||||
proxy_url = group_row["proxy_url"] or ""
|
||||
except Exception:
|
||||
proxy_url = ""
|
||||
|
||||
success, error_msg, new_refresh_token = test_refresh_token(
|
||||
client_id, refresh_token, proxy_url
|
||||
)
|
||||
success, error_msg, new_refresh_token = test_refresh_token(client_id, refresh_token, proxy_url)
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
@@ -503,11 +464,7 @@ def scheduled_refresh_task(app, test_refresh_token):
|
||||
|
||||
if success:
|
||||
# refresh token 可能滚动更新:保存新的 refresh_token(加密存储)
|
||||
if (
|
||||
isinstance(new_refresh_token, str)
|
||||
and new_refresh_token.strip()
|
||||
and new_refresh_token != refresh_token
|
||||
):
|
||||
if isinstance(new_refresh_token, str) and new_refresh_token.strip() and new_refresh_token != refresh_token:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE accounts
|
||||
@@ -570,9 +527,7 @@ def should_autostart_scheduler() -> bool:
|
||||
return False
|
||||
|
||||
# Flask CLI (flask run) + reloader:父进程不启动
|
||||
if config.env_true("FLASK_RUN_FROM_CLI", False) and not config.env_true(
|
||||
"WERKZEUG_RUN_MAIN", False
|
||||
):
|
||||
if config.env_true("FLASK_RUN_FROM_CLI", False) and not config.env_true("WERKZEUG_RUN_MAIN", False):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -162,6 +162,10 @@
|
||||
// 显示添加账号模态框
|
||||
function showAddAccountModal() {
|
||||
document.getElementById('accountInput').value = '';
|
||||
const addToPoolCheckbox = document.getElementById('addToPoolCheckbox');
|
||||
if (addToPoolCheckbox) {
|
||||
addToPoolCheckbox.checked = false;
|
||||
}
|
||||
// 设置默认分组为当前选中的分组
|
||||
if (currentGroupId) {
|
||||
document.getElementById('importGroupSelect').value = currentGroupId;
|
||||
@@ -203,6 +207,7 @@
|
||||
const groupId = parseInt(document.getElementById('importGroupSelect').value);
|
||||
const providerEl = document.getElementById('accountProvider');
|
||||
const provider = providerEl ? (providerEl.value || 'outlook') : 'outlook';
|
||||
const addToPool = Boolean(document.getElementById('addToPoolCheckbox')?.checked);
|
||||
|
||||
if (!input) {
|
||||
showToast('请输入账号信息', 'error');
|
||||
@@ -210,7 +215,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = { account_string: input, group_id: groupId };
|
||||
const payload = { account_string: input, group_id: groupId, add_to_pool: addToPool };
|
||||
|
||||
if (provider === 'auto') {
|
||||
payload.provider = 'auto';
|
||||
|
||||
@@ -115,6 +115,13 @@
|
||||
placeholder="邮箱----密码----client_id----refresh_token"></textarea>
|
||||
<div class="form-hint" id="accountFormatHint">格式:邮箱----密码----client_id----refresh_token,支持批量导入(每行一个)</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="addToPoolCheckbox">
|
||||
<span class="form-label" style="margin: 0;">导入后加入邮箱池</span>
|
||||
</label>
|
||||
<div class="form-hint">勾选后,新导入的 Outlook/IMAP 账号会以 `available` 状态进入邮箱池;不勾选则保持池外。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="hideAddAccountModal()">取消</button>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# 注册 Pool 人工测试清单
|
||||
|
||||
## 目标
|
||||
|
||||
验证“通过页面注册 Outlook 账号后,该账号能够进入邮箱池并可被 `/api/pool/*` 或 `/api/external/pool/*` 正常领取”的完整链路。
|
||||
|
||||
## 当前检查结论
|
||||
|
||||
当前代码已切换为“显式加入池”模式:
|
||||
|
||||
1. 前端存在“`🔑 注册`”按钮,可触发 OAuth 获取 Token。
|
||||
2. 导入弹窗新增“`导入后加入邮箱池`”复选框。
|
||||
3. 勾选时,新导入的 Outlook/IMAP 账号会写入 `pool_status='available'`。
|
||||
4. 不勾选时,账号保持池外,`pool_status` 为 `NULL`。
|
||||
|
||||
结论:当前版本不再采用“注册即自动入池”,而是由用户在导入时显式决定是否加入 Pool。
|
||||
|
||||
## 代码定位
|
||||
|
||||
- 前端注册入口:
|
||||
- `templates/index.html`
|
||||
- `static/js/features/groups.js`
|
||||
- `static/js/main.js`
|
||||
- 账号新增入库:
|
||||
- `outlook_web/repositories/accounts.py`
|
||||
- 池领取条件:
|
||||
- `outlook_web/repositories/pool.py`
|
||||
- 数据库字段默认值:
|
||||
- `outlook_web/db.py`
|
||||
|
||||
## 测试前置条件
|
||||
|
||||
1. 本地启动项目:
|
||||
|
||||
```bash
|
||||
python start.py
|
||||
```
|
||||
|
||||
2. 准备一个可用的 Outlook OAuth 授权流程。
|
||||
3. 准备至少一个普通分组,不使用“临时邮箱”系统分组。
|
||||
4. 登录系统后台。
|
||||
|
||||
## 人工测试用例
|
||||
|
||||
### RTP-001 注册按钮可见性
|
||||
|
||||
步骤:
|
||||
|
||||
1. 打开“账号管理”页面。
|
||||
2. 选择任意非“临时邮箱”的分组。
|
||||
3. 观察账号面板右上角是否显示“`🔑 注册`”按钮。
|
||||
|
||||
预期结果:
|
||||
|
||||
1. 普通分组显示“`🔑 注册`”按钮。
|
||||
2. 选择“临时邮箱”分组时该按钮隐藏。
|
||||
|
||||
### RTP-002 OAuth 获取 Token
|
||||
|
||||
步骤:
|
||||
|
||||
1. 点击“`🔑 注册`”按钮。
|
||||
2. 获取授权链接并完成微软授权。
|
||||
3. 将回调完整 URL 粘贴回弹窗。
|
||||
4. 点击“换取 Token”。
|
||||
|
||||
预期结果:
|
||||
|
||||
1. 能成功换取 `refresh_token`。
|
||||
2. 页面自动回到“导入账号”弹窗。
|
||||
3. 输入框被预填为 `邮箱----密码----client_id----refresh_token` 格式。
|
||||
|
||||
### RTP-003 注册 Outlook 账号
|
||||
|
||||
步骤:
|
||||
|
||||
1. 将预填内容中的占位邮箱、密码替换为真实值。
|
||||
2. 点击导入。
|
||||
3. 等待导入成功提示。
|
||||
4. 在当前分组列表中确认账号出现。
|
||||
|
||||
预期结果:
|
||||
|
||||
1. 页面提示导入成功。
|
||||
2. 分组账号列表可见该 Outlook 账号。
|
||||
|
||||
### RTP-004 注册后是否进入 Pool
|
||||
|
||||
步骤:
|
||||
|
||||
1. 完成 RTP-003。
|
||||
2. 打开数据库,执行以下 SQL:
|
||||
|
||||
```sql
|
||||
SELECT email, pool_status, provider, account_type
|
||||
FROM accounts
|
||||
WHERE email = '刚注册的邮箱';
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
1. 如果勾选“导入后加入邮箱池”,这里应返回 `pool_status = 'available'`。
|
||||
2. 如果未勾选,这里应返回 `pool_status = NULL`。
|
||||
|
||||
### RTP-005 内部池领取验证
|
||||
|
||||
步骤:
|
||||
|
||||
1. 在系统已登录状态下调用:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:5000/api/pool/claim-random ^
|
||||
-H "Content-Type: application/json" ^
|
||||
-d "{\"caller_id\":\"manual_test\",\"task_id\":\"rtp005\"}"
|
||||
```
|
||||
|
||||
2. 观察返回结果。
|
||||
|
||||
预期结果:
|
||||
|
||||
1. 如果注册账号已进池,应返回 `success=true`,并包含该账号的 `account_id`、`email`、`claim_token`。
|
||||
|
||||
当前验证重点:
|
||||
|
||||
1. 勾选加入池时,应能领取到刚注册的账号或至少使池内 `available` 数量增加。
|
||||
2. 不勾选时,即使注册成功,也不应被池领取到。
|
||||
|
||||
### RTP-006 对外池领取验证
|
||||
|
||||
步骤:
|
||||
|
||||
1. 在设置中开启 `pool_external_enabled`。
|
||||
2. 准备一个具备 `pool_access=true` 的 API Key。
|
||||
3. 调用:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:5000/api/external/pool/claim-random ^
|
||||
-H "Content-Type: application/json" ^
|
||||
-H "X-API-Key: <your-key>" ^
|
||||
-d "{\"caller_id\":\"manual_test\",\"task_id\":\"rtp006\"}"
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
1. 如果注册账号已进池,应能正常领取。
|
||||
2. 如果注册账号未进池,则依旧可能返回无可用账号。
|
||||
|
||||
### RTP-007 领取后状态流转
|
||||
|
||||
步骤:
|
||||
|
||||
1. 先确保池内存在一个 `available` 账号。
|
||||
2. 调用 `/api/pool/claim-random` 领取。
|
||||
3. 调用 `/api/pool/claim-complete`,分别传入:
|
||||
- `success`
|
||||
- `verification_timeout`
|
||||
- `provider_blocked`
|
||||
- `credential_invalid`
|
||||
- `network_error`
|
||||
|
||||
预期结果:
|
||||
|
||||
1. 状态分别流转到:
|
||||
- `used`
|
||||
- `cooldown`
|
||||
- `frozen`
|
||||
- `retired`
|
||||
- `available`
|
||||
|
||||
## 已确认的自动化基线
|
||||
|
||||
已执行:
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_pool -v
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
1. `39` 个池相关用例全部通过。
|
||||
2. 说明“池本身的 claim/release/complete/stats 逻辑”当前没有明显回归。
|
||||
3. 但这些测试主要覆盖“已有池内账号”的流转,不覆盖“注册后进入池”。
|
||||
|
||||
## 自动化补充
|
||||
|
||||
本轮已补充 API 级回归思路:
|
||||
|
||||
1. 默认导入不入池。
|
||||
2. 勾选 `add_to_pool=true` 时进入 `available`。
|
||||
3. `provider=auto` 的 Outlook/IMAP 导入同样支持该开关。
|
||||
+28
-21
@@ -30,12 +30,8 @@ class ExternalPoolApiTests(unittest.TestCase):
|
||||
settings_repo.set_setting("pool_external_enabled", "false")
|
||||
settings_repo.set_setting("external_api_ip_whitelist", "[]")
|
||||
settings_repo.set_setting("external_api_disable_pool_claim_random", "false")
|
||||
settings_repo.set_setting(
|
||||
"external_api_disable_pool_claim_release", "false"
|
||||
)
|
||||
settings_repo.set_setting(
|
||||
"external_api_disable_pool_claim_complete", "false"
|
||||
)
|
||||
settings_repo.set_setting("external_api_disable_pool_claim_release", "false")
|
||||
settings_repo.set_setting("external_api_disable_pool_claim_complete", "false")
|
||||
settings_repo.set_setting("external_api_disable_pool_stats", "false")
|
||||
|
||||
@staticmethod
|
||||
@@ -57,9 +53,7 @@ class ExternalPoolApiTests(unittest.TestCase):
|
||||
enabled: bool = True,
|
||||
):
|
||||
with self.app.app_context():
|
||||
from outlook_web.repositories import (
|
||||
external_api_keys as external_api_keys_repo,
|
||||
)
|
||||
from outlook_web.repositories import external_api_keys as external_api_keys_repo
|
||||
|
||||
return external_api_keys_repo.create_external_api_key(
|
||||
name=name,
|
||||
@@ -73,9 +67,7 @@ class ExternalPoolApiTests(unittest.TestCase):
|
||||
with self.app.app_context():
|
||||
from outlook_web.repositories import settings as settings_repo
|
||||
|
||||
settings_repo.set_setting(
|
||||
"external_api_public_mode", "true" if enabled else "false"
|
||||
)
|
||||
settings_repo.set_setting("external_api_public_mode", "true" if enabled else "false")
|
||||
|
||||
def _set_ip_whitelist(self, ips: list[str]):
|
||||
import json
|
||||
@@ -91,9 +83,7 @@ class ExternalPoolApiTests(unittest.TestCase):
|
||||
|
||||
settings_repo.set_setting(setting_key, "true" if enabled else "false")
|
||||
|
||||
def _insert_pool_account(
|
||||
self, *, provider: str = "outlook", pool_status: str = "available"
|
||||
) -> int:
|
||||
def _insert_pool_account(self, *, provider: str = "outlook", pool_status: str = "available") -> int:
|
||||
email_addr = f"{uuid.uuid4().hex}@extpool.test"
|
||||
with self.app.app_context():
|
||||
from outlook_web.db import get_db
|
||||
@@ -119,9 +109,7 @@ class ExternalPoolApiTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute(
|
||||
"SELECT id FROM accounts WHERE email = ?", (email_addr,)
|
||||
).fetchone()
|
||||
row = db.execute("SELECT id FROM accounts WHERE email = ?", (email_addr,)).fetchone()
|
||||
return int(row["id"])
|
||||
|
||||
def test_external_pool_stats_requires_api_key(self):
|
||||
@@ -185,6 +173,27 @@ class ExternalPoolApiTests(unittest.TestCase):
|
||||
self.assertTrue(data.get("success"))
|
||||
self.assertEqual(data.get("code"), "OK")
|
||||
|
||||
def test_external_pool_blueprint_applies_csrf_exempt_to_all_handlers(self):
|
||||
from outlook_web.routes import external_pool as external_pool_routes
|
||||
|
||||
wrapped_handlers = []
|
||||
|
||||
def fake_csrf_exempt(handler):
|
||||
wrapped_handlers.append(handler.__name__)
|
||||
return handler
|
||||
|
||||
external_pool_routes.create_blueprint(csrf_exempt=fake_csrf_exempt)
|
||||
|
||||
self.assertEqual(
|
||||
set(wrapped_handlers),
|
||||
{
|
||||
"api_external_pool_claim_random",
|
||||
"api_external_pool_claim_release",
|
||||
"api_external_pool_claim_complete",
|
||||
"api_external_pool_stats",
|
||||
},
|
||||
)
|
||||
|
||||
def test_external_pool_claim_release_caller_mismatch(self):
|
||||
client = self.app.test_client()
|
||||
self._set_external_api_key("abc123")
|
||||
@@ -436,9 +445,7 @@ class ExternalPoolApiTests(unittest.TestCase):
|
||||
|
||||
def test_external_pool_claim_random_requires_pool_access_for_multi_key(self):
|
||||
client = self.app.test_client()
|
||||
self._create_external_api_key(
|
||||
"partner-a", "multi-pool-random-deny", pool_access=False
|
||||
)
|
||||
self._create_external_api_key("partner-a", "multi-pool-random-deny", pool_access=False)
|
||||
with self.app.app_context():
|
||||
from outlook_web.repositories import settings as settings_repo
|
||||
|
||||
|
||||
@@ -32,12 +32,8 @@ class ExternalPoolE2ETests(unittest.TestCase):
|
||||
settings_repo.set_setting("external_api_ip_whitelist", "[]")
|
||||
settings_repo.set_setting("pool_external_enabled", "true")
|
||||
settings_repo.set_setting("external_api_disable_pool_claim_random", "false")
|
||||
settings_repo.set_setting(
|
||||
"external_api_disable_pool_claim_release", "false"
|
||||
)
|
||||
settings_repo.set_setting(
|
||||
"external_api_disable_pool_claim_complete", "false"
|
||||
)
|
||||
settings_repo.set_setting("external_api_disable_pool_claim_release", "false")
|
||||
settings_repo.set_setting("external_api_disable_pool_claim_complete", "false")
|
||||
settings_repo.set_setting("external_api_disable_pool_stats", "false")
|
||||
|
||||
@staticmethod
|
||||
@@ -46,9 +42,7 @@ class ExternalPoolE2ETests(unittest.TestCase):
|
||||
|
||||
def _create_external_api_key(self, name: str, api_key: str, *, pool_access: bool):
|
||||
with self.app.app_context():
|
||||
from outlook_web.repositories import (
|
||||
external_api_keys as external_api_keys_repo,
|
||||
)
|
||||
from outlook_web.repositories import external_api_keys as external_api_keys_repo
|
||||
|
||||
return external_api_keys_repo.create_external_api_key(
|
||||
name=name,
|
||||
@@ -84,9 +78,7 @@ class ExternalPoolE2ETests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute(
|
||||
"SELECT id FROM accounts WHERE email = ?", (email_addr,)
|
||||
).fetchone()
|
||||
row = db.execute("SELECT id FROM accounts WHERE email = ?", (email_addr,)).fetchone()
|
||||
return int(row["id"])
|
||||
|
||||
def _external_audit_logs(self):
|
||||
@@ -94,14 +86,12 @@ class ExternalPoolE2ETests(unittest.TestCase):
|
||||
from outlook_web.db import get_db
|
||||
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"""
|
||||
rows = db.execute("""
|
||||
SELECT action, resource_id, details
|
||||
FROM audit_logs
|
||||
WHERE resource_type = 'external_api'
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
""").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def _external_consumer_usage_rows(self):
|
||||
@@ -109,13 +99,11 @@ class ExternalPoolE2ETests(unittest.TestCase):
|
||||
from outlook_web.db import get_db
|
||||
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"""
|
||||
rows = db.execute("""
|
||||
SELECT consumer_key, consumer_name, endpoint, total_count, success_count, error_count
|
||||
FROM external_api_consumer_usage_daily
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
""").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def test_external_pool_e2e_claim_complete_records_audit_and_usage(self):
|
||||
@@ -165,26 +153,18 @@ class ExternalPoolE2ETests(unittest.TestCase):
|
||||
self.assertIn("/api/external/pool/claim-random", endpoints)
|
||||
self.assertIn("/api/external/pool/claim-complete", endpoints)
|
||||
self.assertIn("/api/external/pool/stats", endpoints)
|
||||
self.assertTrue(
|
||||
all(item.get("consumer_name") == "partner-e2e" for item in parsed_details)
|
||||
)
|
||||
self.assertTrue(all(item.get("consumer_name") == "partner-e2e" for item in parsed_details))
|
||||
|
||||
usage_rows = self._external_consumer_usage_rows()
|
||||
self.assertEqual(len(usage_rows), 3)
|
||||
by_endpoint = {row["endpoint"]: row for row in usage_rows}
|
||||
self.assertEqual(
|
||||
by_endpoint["/api/external/pool/claim-random"]["success_count"], 1
|
||||
)
|
||||
self.assertEqual(
|
||||
by_endpoint["/api/external/pool/claim-complete"]["success_count"], 1
|
||||
)
|
||||
self.assertEqual(by_endpoint["/api/external/pool/claim-random"]["success_count"], 1)
|
||||
self.assertEqual(by_endpoint["/api/external/pool/claim-complete"]["success_count"], 1)
|
||||
self.assertEqual(by_endpoint["/api/external/pool/stats"]["success_count"], 1)
|
||||
|
||||
def test_external_pool_e2e_public_mode_disable_and_pool_access_chain(self):
|
||||
client = self.app.test_client()
|
||||
self._create_external_api_key(
|
||||
"partner-deny", "pool-e2e-deny", pool_access=False
|
||||
)
|
||||
self._create_external_api_key("partner-deny", "pool-e2e-deny", pool_access=False)
|
||||
self._insert_pool_account(provider="outlook")
|
||||
|
||||
deny_resp = client.post(
|
||||
@@ -203,14 +183,10 @@ class ExternalPoolE2ETests(unittest.TestCase):
|
||||
from outlook_web.repositories import settings as settings_repo
|
||||
|
||||
settings_repo.set_setting("external_api_public_mode", "true")
|
||||
settings_repo.set_setting(
|
||||
"external_api_ip_whitelist", json.dumps(["127.0.0.1"])
|
||||
)
|
||||
settings_repo.set_setting("external_api_ip_whitelist", json.dumps(["127.0.0.1"]))
|
||||
settings_repo.set_setting("external_api_disable_pool_claim_random", "true")
|
||||
|
||||
allow_key = self._create_external_api_key(
|
||||
"partner-allow", "pool-e2e-allow", pool_access=True
|
||||
)
|
||||
allow_key = self._create_external_api_key("partner-allow", "pool-e2e-allow", pool_access=True)
|
||||
self.assertIsNotNone(allow_key)
|
||||
|
||||
feature_disabled_resp = client.post(
|
||||
|
||||
+50
-71
@@ -8,8 +8,8 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = import_web_app_module()
|
||||
from outlook_web.repositories import pool as pool_repo
|
||||
from outlook_web.db import create_sqlite_connection
|
||||
from outlook_web.repositories import pool as pool_repo
|
||||
|
||||
cls.pool_repo = pool_repo
|
||||
cls.create_conn = staticmethod(lambda: create_sqlite_connection())
|
||||
@@ -26,18 +26,14 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
(email, pool_status),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE email = ?", (email,)
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT id FROM accounts WHERE email = ?", (email,)).fetchone()
|
||||
return row["id"]
|
||||
|
||||
def test_claim_and_complete_success(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_001", lease_seconds=60
|
||||
)
|
||||
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_001", lease_seconds=60)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertTrue(result["claim_token"].startswith("clm_"))
|
||||
claimed_id = result["id"]
|
||||
@@ -72,9 +68,7 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_002", lease_seconds=60
|
||||
)
|
||||
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_002", lease_seconds=60)
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
self.pool_repo.release(
|
||||
@@ -99,15 +93,11 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_003", lease_seconds=60
|
||||
)
|
||||
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_003", lease_seconds=60)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
|
||||
before = conn.execute(
|
||||
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
|
||||
).fetchone()["fail_count"]
|
||||
before = conn.execute("SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)).fetchone()["fail_count"]
|
||||
|
||||
new_status = self.pool_repo.complete(
|
||||
conn,
|
||||
@@ -164,9 +154,7 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
expired_count = self.pool_repo.expire_stale_claims(conn)
|
||||
self.assertGreaterEqual(expired_count, 1)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status FROM accounts WHERE id = ?", (account_id,)
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT pool_status FROM accounts WHERE id = ?", (account_id,)).fetchone()
|
||||
self.assertEqual(row["pool_status"], "cooldown")
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -184,9 +172,7 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
recovered = self.pool_repo.recover_cooldown(conn, cooldown_seconds=3600)
|
||||
self.assertGreaterEqual(recovered, 1)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT pool_status FROM accounts WHERE id = ?", (account_id,)
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT pool_status FROM accounts WHERE id = ?", (account_id,)).fetchone()
|
||||
self.assertEqual(row["pool_status"], "available")
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -203,19 +189,40 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_get_stats_ignores_accounts_without_pool_status(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
import secrets
|
||||
|
||||
email = f"pool_null_status_{secrets.token_hex(4)}@example.com"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts (email, client_id, refresh_token, status, pool_status)
|
||||
VALUES (?, 'test_client', 'test_token', 'active', NULL)
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
stats = self.pool_repo.get_stats(conn)
|
||||
|
||||
self.assertEqual(
|
||||
set(stats["pool_counts"].keys()),
|
||||
{"available", "claimed", "used", "cooldown", "frozen", "retired"},
|
||||
)
|
||||
self.assertNotIn("not_in_pool", stats["pool_counts"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_complete_network_error_returns_available_and_increments_fail_count(self):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_ne", lease_seconds=60
|
||||
)
|
||||
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_ne", lease_seconds=60)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
|
||||
before = conn.execute(
|
||||
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
|
||||
).fetchone()["fail_count"]
|
||||
before = conn.execute("SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)).fetchone()["fail_count"]
|
||||
|
||||
new_status = self.pool_repo.complete(
|
||||
conn,
|
||||
@@ -241,15 +248,11 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_pb", lease_seconds=60
|
||||
)
|
||||
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_pb", lease_seconds=60)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
|
||||
before = conn.execute(
|
||||
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
|
||||
).fetchone()["fail_count"]
|
||||
before = conn.execute("SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)).fetchone()["fail_count"]
|
||||
|
||||
new_status = self.pool_repo.complete(
|
||||
conn,
|
||||
@@ -275,15 +278,11 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="reg_bot", task_id="task_ci", lease_seconds=60
|
||||
)
|
||||
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_ci", lease_seconds=60)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
|
||||
before = conn.execute(
|
||||
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
|
||||
).fetchone()["fail_count"]
|
||||
before = conn.execute("SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)).fetchone()["fail_count"]
|
||||
|
||||
new_status = self.pool_repo.complete(
|
||||
conn,
|
||||
@@ -309,9 +308,7 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="log_bot", task_id="log_task_001", lease_seconds=60
|
||||
)
|
||||
result = self.pool_repo.claim_atomic(conn, caller_id="log_bot", task_id="log_task_001", lease_seconds=60)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
log_row = conn.execute(
|
||||
@@ -334,9 +331,7 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="log_bot", task_id="log_task_002", lease_seconds=60
|
||||
)
|
||||
result = self.pool_repo.claim_atomic(conn, caller_id="log_bot", task_id="log_task_002", lease_seconds=60)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
self.pool_repo.complete(
|
||||
@@ -397,9 +392,7 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result1 = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="bot", task_id="excl_t1", lease_seconds=60
|
||||
)
|
||||
result1 = self.pool_repo.claim_atomic(conn, caller_id="bot", task_id="excl_t1", lease_seconds=60)
|
||||
self.assertIsNotNone(result1)
|
||||
claimed_id = result1["id"]
|
||||
self.pool_repo.release(
|
||||
@@ -434,9 +427,7 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
tokens = set()
|
||||
for i in range(5):
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="bot", task_id=f"uniq_t{i}", lease_seconds=60
|
||||
)
|
||||
result = self.pool_repo.claim_atomic(conn, caller_id="bot", task_id=f"uniq_t{i}", lease_seconds=60)
|
||||
self.assertIsNotNone(result)
|
||||
tokens.add(result["claim_token"])
|
||||
self.pool_repo.complete(
|
||||
@@ -494,9 +485,7 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
account_id = self._make_account(conn)
|
||||
result = self.pool_repo.claim_atomic(
|
||||
conn, caller_id="log_bot", task_id="log_rel_001", lease_seconds=60
|
||||
)
|
||||
result = self.pool_repo.claim_atomic(conn, caller_id="log_bot", task_id="log_rel_001", lease_seconds=60)
|
||||
self.assertIsNotNone(result)
|
||||
claimed_id = result["id"]
|
||||
|
||||
@@ -593,12 +582,8 @@ class PoolRepositoryTests(unittest.TestCase):
|
||||
claimed_after = stats_after["pool_counts"]["claimed"]
|
||||
cooldown_after = stats_after["pool_counts"]["cooldown"]
|
||||
|
||||
self.assertLess(
|
||||
claimed_after, claimed_before, "expire 后 claimed 数量应减少"
|
||||
)
|
||||
self.assertGreater(
|
||||
cooldown_after, cooldown_before, "expire 后 cooldown 数量应增加"
|
||||
)
|
||||
self.assertLess(claimed_after, claimed_before, "expire 后 claimed 数量应减少")
|
||||
self.assertGreater(cooldown_after, cooldown_before, "expire 后 cooldown 数量应增加")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -607,8 +592,8 @@ class PoolServiceTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = import_web_app_module()
|
||||
from outlook_web.services import pool as pool_service
|
||||
from outlook_web.db import create_sqlite_connection
|
||||
from outlook_web.services import pool as pool_service
|
||||
|
||||
cls.pool_service = pool_service
|
||||
cls.create_conn = staticmethod(lambda: create_sqlite_connection())
|
||||
@@ -627,9 +612,7 @@ class PoolServiceTests(unittest.TestCase):
|
||||
(email, pool_status),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE email = ?", (email,)
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT id FROM accounts WHERE email = ?", (email,)).fetchone()
|
||||
return row["id"]
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -830,9 +813,7 @@ class PoolApiTests(unittest.TestCase):
|
||||
(email,),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE email = ?", (email,)
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT id FROM accounts WHERE email = ?", (email,)).fetchone()
|
||||
return row["id"]
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -897,9 +878,7 @@ class PoolApiTests(unittest.TestCase):
|
||||
self.assertEqual(complete_resp.status_code, 200)
|
||||
complete_data = json.loads(complete_resp.data)
|
||||
self.assertTrue(complete_data["success"])
|
||||
self.assertEqual(
|
||||
complete_data["data"]["account_id"], account_data["account_id"]
|
||||
)
|
||||
self.assertEqual(complete_data["data"]["account_id"], account_data["account_id"])
|
||||
self.assertEqual(complete_data["data"]["pool_status"], "used")
|
||||
|
||||
def test_claim_release_flow(self):
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from tests._import_app import import_web_app_module
|
||||
|
||||
|
||||
class PoolFlowSuiteTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = import_web_app_module()
|
||||
cls.app = cls.module.app
|
||||
cls.client = cls.app.test_client()
|
||||
from outlook_web.db import create_sqlite_connection
|
||||
|
||||
cls.create_conn = staticmethod(lambda: create_sqlite_connection())
|
||||
|
||||
def _make_pool_account(self, *, provider: str = "outlook", pool_status: str = "available") -> dict:
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
email_addr = f"flow_{uuid.uuid4().hex}@poolflow.test"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts (
|
||||
email, client_id, refresh_token, status,
|
||||
account_type, provider, group_id, pool_status
|
||||
)
|
||||
VALUES (?, 'test_client', 'test_token', 'active', 'outlook', ?, 1, ?)
|
||||
""",
|
||||
(email_addr, provider, pool_status),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT id, email, pool_status, provider FROM accounts WHERE email = ?",
|
||||
(email_addr,),
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_claim_complete_success_changes_status_to_used(self):
|
||||
self._make_pool_account()
|
||||
|
||||
claim_resp = self.client.post(
|
||||
"/api/pool/claim-random",
|
||||
json={"caller_id": "suite_bot", "task_id": "success_flow"},
|
||||
)
|
||||
self.assertEqual(claim_resp.status_code, 200)
|
||||
claim_data = json.loads(claim_resp.data)
|
||||
self.assertTrue(claim_data["success"])
|
||||
|
||||
complete_resp = self.client.post(
|
||||
"/api/pool/claim-complete",
|
||||
json={
|
||||
"account_id": claim_data["data"]["account_id"],
|
||||
"claim_token": claim_data["data"]["claim_token"],
|
||||
"caller_id": "suite_bot",
|
||||
"task_id": "success_flow",
|
||||
"result": "success",
|
||||
"detail": "manual suite success",
|
||||
},
|
||||
)
|
||||
self.assertEqual(complete_resp.status_code, 200)
|
||||
complete_data = json.loads(complete_resp.data)
|
||||
self.assertTrue(complete_data["success"])
|
||||
self.assertEqual(complete_data["data"]["pool_status"], "used")
|
||||
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT pool_status, success_count, fail_count FROM accounts WHERE id = ?",
|
||||
(claim_data["data"]["account_id"],),
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "used")
|
||||
self.assertEqual(row["success_count"], 1)
|
||||
self.assertEqual(row["fail_count"], 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_claim_complete_failure_changes_status_to_cooldown(self):
|
||||
self._make_pool_account()
|
||||
|
||||
claim_resp = self.client.post(
|
||||
"/api/pool/claim-random",
|
||||
json={"caller_id": "suite_bot", "task_id": "cooldown_flow"},
|
||||
)
|
||||
self.assertEqual(claim_resp.status_code, 200)
|
||||
claim_data = json.loads(claim_resp.data)
|
||||
self.assertTrue(claim_data["success"])
|
||||
|
||||
complete_resp = self.client.post(
|
||||
"/api/pool/claim-complete",
|
||||
json={
|
||||
"account_id": claim_data["data"]["account_id"],
|
||||
"claim_token": claim_data["data"]["claim_token"],
|
||||
"caller_id": "suite_bot",
|
||||
"task_id": "cooldown_flow",
|
||||
"result": "verification_timeout",
|
||||
"detail": "manual suite timeout",
|
||||
},
|
||||
)
|
||||
self.assertEqual(complete_resp.status_code, 200)
|
||||
complete_data = json.loads(complete_resp.data)
|
||||
self.assertTrue(complete_data["success"])
|
||||
self.assertEqual(complete_data["data"]["pool_status"], "cooldown")
|
||||
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT pool_status, success_count, fail_count, last_result FROM accounts WHERE id = ?",
|
||||
(claim_data["data"]["account_id"],),
|
||||
).fetchone()
|
||||
self.assertEqual(row["pool_status"], "cooldown")
|
||||
self.assertEqual(row["success_count"], 0)
|
||||
self.assertEqual(row["fail_count"], 1)
|
||||
self.assertEqual(row["last_result"], "verification_timeout")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_multiple_consecutive_claims_do_not_repeat_accounts(self):
|
||||
provider = f"suiteprov_{uuid.uuid4().hex}"
|
||||
created_ids = []
|
||||
for _ in range(3):
|
||||
created = self._make_pool_account(provider=provider)
|
||||
created_ids.append(created["id"])
|
||||
|
||||
claimed_ids = []
|
||||
claimed_tokens = []
|
||||
for idx in range(3):
|
||||
resp = self.client.post(
|
||||
"/api/pool/claim-random",
|
||||
json={
|
||||
"caller_id": "suite_bot",
|
||||
"task_id": f"batch_claim_{idx}",
|
||||
"provider": provider,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = json.loads(resp.data)
|
||||
self.assertTrue(data["success"])
|
||||
claimed_ids.append(data["data"]["account_id"])
|
||||
claimed_tokens.append(
|
||||
(
|
||||
data["data"]["account_id"],
|
||||
data["data"]["claim_token"],
|
||||
f"batch_claim_{idx}",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(len(claimed_ids), len(set(claimed_ids)))
|
||||
self.assertTrue(set(claimed_ids).issubset(set(created_ids)))
|
||||
|
||||
for account_id, claim_token, task_id in claimed_tokens:
|
||||
release_resp = self.client.post(
|
||||
"/api/pool/claim-release",
|
||||
json={
|
||||
"account_id": account_id,
|
||||
"claim_token": claim_token,
|
||||
"caller_id": "suite_bot",
|
||||
"task_id": task_id,
|
||||
"reason": "suite cleanup",
|
||||
},
|
||||
)
|
||||
self.assertEqual(release_resp.status_code, 200)
|
||||
release_data = json.loads(release_resp.data)
|
||||
self.assertTrue(release_data["success"])
|
||||
self.assertEqual(release_data["data"]["pool_status"], "available")
|
||||
|
||||
conn = self.create_conn()
|
||||
try:
|
||||
placeholders = ",".join(["?"] * len(created_ids))
|
||||
rows = conn.execute(
|
||||
f"SELECT id, pool_status, claim_token FROM accounts WHERE id IN ({placeholders})",
|
||||
created_ids,
|
||||
).fetchall()
|
||||
self.assertEqual(len(rows), 3)
|
||||
for row in rows:
|
||||
self.assertEqual(row["pool_status"], "available")
|
||||
self.assertIsNone(row["claim_token"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from tests._import_app import clear_login_attempts, import_web_app_module
|
||||
|
||||
|
||||
class PoolRegistrationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = import_web_app_module()
|
||||
cls.app = cls.module.app
|
||||
|
||||
def setUp(self):
|
||||
with self.app.app_context():
|
||||
clear_login_attempts()
|
||||
|
||||
def _login(self, client):
|
||||
resp = client.post("/login", json={"password": "testpass123"})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.get_json().get("success"))
|
||||
|
||||
def _account_row(self, email_addr: str):
|
||||
conn = self.module.create_sqlite_connection()
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT email, pool_status, provider, account_type FROM accounts WHERE email = ? LIMIT 1",
|
||||
(email_addr,),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_standard_import_keeps_account_outside_pool_by_default(self):
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
email_addr = f"default_pool_{uuid.uuid4().hex}@poolreg.test"
|
||||
resp = client.post(
|
||||
"/api/accounts",
|
||||
json={
|
||||
"account_string": f"{email_addr}----pwd----cid----rtoken",
|
||||
"group_id": 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.get_json()
|
||||
self.assertTrue(data.get("success"))
|
||||
|
||||
row = self._account_row(email_addr)
|
||||
self.assertIsNotNone(row)
|
||||
self.assertIsNone(row["pool_status"])
|
||||
|
||||
def test_standard_import_can_explicitly_join_pool(self):
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
email_addr = f"join_pool_{uuid.uuid4().hex}@poolreg.test"
|
||||
resp = client.post(
|
||||
"/api/accounts",
|
||||
json={
|
||||
"account_string": f"{email_addr}----pwd----cid----rtoken",
|
||||
"group_id": 1,
|
||||
"add_to_pool": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.get_json()
|
||||
self.assertTrue(data.get("success"))
|
||||
|
||||
row = self._account_row(email_addr)
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["pool_status"], "available")
|
||||
self.assertEqual(row["provider"], "outlook")
|
||||
self.assertEqual(row["account_type"], "outlook")
|
||||
|
||||
def test_auto_import_can_explicitly_join_pool(self):
|
||||
client = self.app.test_client()
|
||||
self._login(client)
|
||||
|
||||
email_addr = f"auto_join_{uuid.uuid4().hex}@gmail.com"
|
||||
resp = client.post(
|
||||
"/api/accounts",
|
||||
json={
|
||||
"provider": "auto",
|
||||
"group_id": None,
|
||||
"duplicate_strategy": "skip",
|
||||
"add_to_pool": True,
|
||||
"account_string": f"{email_addr}----imap-pass",
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.get_json()
|
||||
self.assertTrue(data.get("success"))
|
||||
|
||||
row = self._account_row(email_addr)
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["pool_status"], "available")
|
||||
self.assertEqual(row["provider"], "gmail")
|
||||
self.assertEqual(row["account_type"], "imap")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,12 +22,8 @@ class ExternalApiKeySettingsTests(unittest.TestCase):
|
||||
settings_repo.set_setting("external_api_key", "")
|
||||
settings_repo.set_setting("pool_external_enabled", "false")
|
||||
settings_repo.set_setting("external_api_disable_pool_claim_random", "false")
|
||||
settings_repo.set_setting(
|
||||
"external_api_disable_pool_claim_release", "false"
|
||||
)
|
||||
settings_repo.set_setting(
|
||||
"external_api_disable_pool_claim_complete", "false"
|
||||
)
|
||||
settings_repo.set_setting("external_api_disable_pool_claim_release", "false")
|
||||
settings_repo.set_setting("external_api_disable_pool_claim_complete", "false")
|
||||
settings_repo.set_setting("external_api_disable_pool_stats", "false")
|
||||
|
||||
def _login(self, client, password: str = "testpass123"):
|
||||
@@ -107,9 +103,7 @@ class ExternalApiKeySettingsTests(unittest.TestCase):
|
||||
|
||||
def test_get_settings_exposes_external_api_keys_list(self):
|
||||
with self.app.app_context():
|
||||
from outlook_web.repositories import (
|
||||
external_api_keys as external_api_keys_repo,
|
||||
)
|
||||
from outlook_web.repositories import external_api_keys as external_api_keys_repo
|
||||
|
||||
external_api_keys_repo.create_external_api_key(
|
||||
name="partner-a",
|
||||
|
||||
+462
@@ -0,0 +1,462 @@
|
||||
# 注册机接入与邮箱池使用说明
|
||||
|
||||
这份文档不是给开发框架人员看的,而是给注册机业务方、脚本接入方、流程运营方看的。
|
||||
|
||||
你们真正需要关心的只有 4 件事:
|
||||
|
||||
1. 账号怎么进入池子
|
||||
2. 注册机怎么拿号
|
||||
3. 注册完成后怎么回传结果
|
||||
4. 不同结果会把账号变成什么状态
|
||||
|
||||
如果你只想先跑通,直接看「一页版接入流程」。
|
||||
|
||||
## 一页版接入流程
|
||||
|
||||
### 业务流程
|
||||
|
||||
1. 运营或后台人员先把邮箱账号导入系统
|
||||
2. 导入时显式勾选或传入 `add_to_pool=true`
|
||||
3. 注册机调用领取接口拿一个邮箱
|
||||
4. 注册机拿到邮箱后去执行注册任务
|
||||
5. 任务结束后必须回传结果
|
||||
6. 系统按结果把邮箱改成 `used` / `cooldown` / `available` 等状态
|
||||
|
||||
### 你们只需要调用这 3 个接口
|
||||
|
||||
- `POST /api/pool/claim-random`
|
||||
- `POST /api/pool/claim-complete`
|
||||
- `POST /api/pool/claim-release`
|
||||
|
||||
`GET /api/pool/stats` 只是查池子情况,可选。
|
||||
|
||||
## 先说结论
|
||||
|
||||
### 什么样的账号能被注册机拿到
|
||||
|
||||
只有同时满足下面条件的账号才会被分配出来:
|
||||
|
||||
- 已经导入系统
|
||||
- 导入时加入了邮箱池
|
||||
- 当前池状态是 `available`
|
||||
- 当前账号状态是 `active`
|
||||
|
||||
### 注册机拿到邮箱后必须做什么
|
||||
|
||||
拿到邮箱后,任务结束时必须二选一:
|
||||
|
||||
- 成功或失败后,调用 `claim-complete`
|
||||
- 中途放弃不用了,调用 `claim-release`
|
||||
|
||||
不能拿完不回传。
|
||||
|
||||
### 最重要的状态流转
|
||||
|
||||
| 任务结果 | 你们回传的 `result` | 邮箱最终状态 |
|
||||
|---|---|---|
|
||||
| 注册成功,账号已消耗 | `success` | `used` |
|
||||
| 长时间没等到验证码,建议冷却后再试 | `verification_timeout` | `cooldown` |
|
||||
| 供应商风控、被封、被限制 | `provider_blocked` | `frozen` |
|
||||
| 邮箱密码或凭据已失效 | `credential_invalid` | `retired` |
|
||||
| 网络问题、临时故障,可以马上重试 | `network_error` | `available` |
|
||||
|
||||
这张表就是注册机接入时最关键的业务规则。
|
||||
|
||||
## 角色分工
|
||||
|
||||
### 后台人员负责
|
||||
|
||||
- 导入邮箱账号
|
||||
- 决定是否把账号加入池子
|
||||
- 查看池子数量和状态
|
||||
|
||||
### 注册机负责
|
||||
|
||||
- 领取邮箱
|
||||
- 用领取到的邮箱执行注册
|
||||
- 把最终结果准确回传
|
||||
|
||||
### 你们不需要关心的内容
|
||||
|
||||
- Outlook OAuth 内部换 token 细节
|
||||
- 后台导入时的内部校验逻辑
|
||||
- 数据库存储方式
|
||||
- 内部控制器和仓库层实现
|
||||
|
||||
## 账号如何进入邮箱池
|
||||
|
||||
账号不是导入后自动进池。
|
||||
|
||||
必须在导入账号时明确指定 `add_to_pool=true`,这个账号才会进入池子,初始状态才会是 `available`。
|
||||
|
||||
如果没有加池:
|
||||
|
||||
- 账号只是存在系统里
|
||||
- 注册机永远领不到它
|
||||
|
||||
这点是整个流程里最容易出错的地方。
|
||||
|
||||
## 注册机标准接入流程
|
||||
|
||||
## 第一步:领取一个邮箱
|
||||
|
||||
接口:
|
||||
|
||||
```text
|
||||
POST /api/pool/claim-random
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"caller_id": "register-worker-1",
|
||||
"task_id": "job-20260317-0001",
|
||||
"provider": "outlook"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `caller_id`:谁在调用,建议写固定的业务方标识或机器标识
|
||||
- `task_id`:当前这次注册任务的唯一 ID
|
||||
- `provider`:可选。如果你们只想领 Outlook,就传 `outlook`
|
||||
|
||||
成功返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"account_id": 12,
|
||||
"email": "demo@outlook.com",
|
||||
"claim_token": "clm_xxxxx",
|
||||
"lease_expires_at": "2026-03-17T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
你们拿到后要保存这 4 个值:
|
||||
|
||||
- `account_id`
|
||||
- `email`
|
||||
- `claim_token`
|
||||
- `lease_expires_at`
|
||||
|
||||
其中真正必须原样带回来的,是:
|
||||
|
||||
- `account_id`
|
||||
- `claim_token`
|
||||
- `caller_id`
|
||||
- `task_id`
|
||||
|
||||
### 领取失败是什么样
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "no_available_account",
|
||||
"message": "池中没有符合条件的可用邮箱"
|
||||
}
|
||||
```
|
||||
|
||||
这表示当前没有可分配邮箱,不是系统崩了。
|
||||
|
||||
注册机这时应该:
|
||||
|
||||
- 等待
|
||||
- 稍后重试
|
||||
- 或切换 provider
|
||||
|
||||
## 第二步:执行注册任务
|
||||
|
||||
这一步是你们自己的业务逻辑,系统不关心你们怎么注册,只关心最终结果。
|
||||
|
||||
常见动作包括:
|
||||
|
||||
- 打开目标网站
|
||||
- 输入邮箱
|
||||
- 收验证码
|
||||
- 验证邮箱
|
||||
- 完成注册
|
||||
|
||||
## 第三步:回传结果
|
||||
|
||||
任务做完以后,必须回传。
|
||||
|
||||
接口:
|
||||
|
||||
```text
|
||||
POST /api/pool/claim-complete
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"account_id": 12,
|
||||
"claim_token": "clm_xxxxx",
|
||||
"caller_id": "register-worker-1",
|
||||
"task_id": "job-20260317-0001",
|
||||
"result": "success",
|
||||
"detail": "注册成功"
|
||||
}
|
||||
```
|
||||
|
||||
成功返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"account_id": 12,
|
||||
"pool_status": "used"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里返回的 `pool_status` 就是这个邮箱的最终状态。
|
||||
|
||||
## 中途放弃时怎么处理
|
||||
|
||||
如果你们已经领到了号,但这次任务不做了,不想把它记成成功或失败,就不要调 `claim-complete`,而是调释放接口。
|
||||
|
||||
接口:
|
||||
|
||||
```text
|
||||
POST /api/pool/claim-release
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"account_id": 12,
|
||||
"claim_token": "clm_xxxxx",
|
||||
"caller_id": "register-worker-1",
|
||||
"task_id": "job-20260317-0001",
|
||||
"reason": "任务取消"
|
||||
}
|
||||
```
|
||||
|
||||
成功返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"account_id": 12,
|
||||
"pool_status": "available"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这表示该邮箱被放回池子,可以再次分配。
|
||||
|
||||
## 最推荐的业务回传规则
|
||||
|
||||
为了避免业务方乱传结果,建议你们内部直接按下面这套规则接:
|
||||
|
||||
### 场景 1:注册成功
|
||||
|
||||
回传:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "success"
|
||||
}
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- 邮箱状态变 `used`
|
||||
- 默认理解为这个号已经消耗掉
|
||||
|
||||
### 场景 2:一直收不到验证码
|
||||
|
||||
回传:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "verification_timeout"
|
||||
}
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- 邮箱状态变 `cooldown`
|
||||
- 表示不要立刻继续发给下一个任务
|
||||
|
||||
### 场景 3:网站风控、邮箱域名被卡、供应商限制
|
||||
|
||||
回传:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "provider_blocked"
|
||||
}
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- 邮箱状态变 `frozen`
|
||||
|
||||
### 场景 4:密码失效、refresh token 失效、邮箱已经不可登录
|
||||
|
||||
回传:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "credential_invalid"
|
||||
}
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- 邮箱状态变 `retired`
|
||||
|
||||
### 场景 5:代理挂了、网络抖动、请求失败,但邮箱本身没问题
|
||||
|
||||
回传:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "network_error"
|
||||
}
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- 邮箱状态回到 `available`
|
||||
- 后续可以再次分配
|
||||
|
||||
## 业务方最容易踩的坑
|
||||
|
||||
### 坑 1:账号导入了,但注册机领不到
|
||||
|
||||
一般原因只有两个:
|
||||
|
||||
- 导入时没有 `add_to_pool=true`
|
||||
- 账号不是 `active` 或不在 `available`
|
||||
|
||||
### 坑 2:重复回传或回传错任务
|
||||
|
||||
`claim-complete` 和 `claim-release` 都要求下面 4 个字段对应同一笔领取记录:
|
||||
|
||||
- `account_id`
|
||||
- `claim_token`
|
||||
- `caller_id`
|
||||
- `task_id`
|
||||
|
||||
任何一个不一致,系统都会拒绝。
|
||||
|
||||
所以注册机必须把领取时的原始参数保存好,不能自己拼。
|
||||
|
||||
### 坑 3:拿了号但不回传
|
||||
|
||||
这会导致邮箱停留在 `claimed`,影响后续分配。
|
||||
|
||||
你们内部必须保证:
|
||||
|
||||
- 成功就 `claim-complete`
|
||||
- 失败也 `claim-complete`
|
||||
- 放弃就 `claim-release`
|
||||
|
||||
### 坑 4:把所有失败都回成 `network_error`
|
||||
|
||||
这样会导致本来已经废掉的号继续回池,被重复分配。
|
||||
|
||||
特别是下面两种情况不能回 `network_error`:
|
||||
|
||||
- 邮箱凭据失效
|
||||
- 明确被目标站或供应商风控
|
||||
|
||||
## 推荐接入规范
|
||||
|
||||
### `caller_id` 怎么写
|
||||
|
||||
建议固定,不要一会一个名字。
|
||||
|
||||
例如:
|
||||
|
||||
- `register-worker-1`
|
||||
- `register-cluster-a`
|
||||
- `tg-signup-service`
|
||||
|
||||
### `task_id` 怎么写
|
||||
|
||||
建议每次任务唯一。
|
||||
|
||||
例如:
|
||||
|
||||
- `job-20260317-0001`
|
||||
- `order-928371`
|
||||
- `signup-batch-7-item-23`
|
||||
|
||||
### `detail` 怎么写
|
||||
|
||||
不是必填,但建议写。
|
||||
|
||||
可以写:
|
||||
|
||||
- 失败原因
|
||||
- 目标站名称
|
||||
- 风控现象
|
||||
- 错误摘要
|
||||
|
||||
这样后面排查方便。
|
||||
|
||||
## 你们真正要记住的 5 行规则
|
||||
|
||||
1. 只有 `add_to_pool=true` 的邮箱才会被注册机拿到
|
||||
2. 注册机用 `claim-random` 拿号
|
||||
3. 任务结束必须回传
|
||||
4. 成功或失败用 `claim-complete`
|
||||
5. 中途放弃用 `claim-release`
|
||||
|
||||
## 常用接口清单
|
||||
|
||||
### 1. 领取邮箱
|
||||
|
||||
```text
|
||||
POST /api/pool/claim-random
|
||||
```
|
||||
|
||||
### 2. 完成任务并回传结果
|
||||
|
||||
```text
|
||||
POST /api/pool/claim-complete
|
||||
```
|
||||
|
||||
### 3. 放弃任务并释放邮箱
|
||||
|
||||
```text
|
||||
POST /api/pool/claim-release
|
||||
```
|
||||
|
||||
### 4. 查看池子数量
|
||||
|
||||
```text
|
||||
GET /api/pool/stats
|
||||
```
|
||||
|
||||
## 补充说明
|
||||
|
||||
如果你们不是直接调用内网接口,而是走外部受控接口,那么路径会对应变成:
|
||||
|
||||
- `/api/external/pool/claim-random`
|
||||
- `/api/external/pool/claim-complete`
|
||||
- `/api/external/pool/claim-release`
|
||||
- `/api/external/pool/stats`
|
||||
|
||||
但业务含义和回传规则是一致的。
|
||||
|
||||
## 最后一句
|
||||
|
||||
注册机业务方不需要理解系统内部实现,你们只要按这套规则接:
|
||||
|
||||
- 领号
|
||||
- 干活
|
||||
- 准确回传结果
|
||||
|
||||
这套流程就能跑通。
|
||||
Reference in new issue
Block a user