From 7f8e85b0aad6c2bc84843c459c56a8fe30acad74 Mon Sep 17 00:00:00 2001 From: cnlimiter Date: Thu, 19 Mar 2026 19:56:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(tm):=20=E5=8E=BB=E9=99=A4=E5=86=97=E4=BD=99?= =?UTF-8?q?=E7=9A=84=E4=B8=8A=E4=BC=A0=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-03-19-duckmail-design.md | 77 --------- .../2026-03-19-duckmail-implementation.md | 151 ------------------ src/core/upload/team_manager_upload.py | 3 +- src/web/routes/accounts.py | 32 ++-- src/web/routes/registration.py | 4 +- 5 files changed, 15 insertions(+), 252 deletions(-) delete mode 100644 docs/plans/2026-03-19-duckmail-design.md delete mode 100644 docs/plans/2026-03-19-duckmail-implementation.md diff --git a/docs/plans/2026-03-19-duckmail-design.md b/docs/plans/2026-03-19-duckmail-design.md deleted file mode 100644 index 355ff13..0000000 --- a/docs/plans/2026-03-19-duckmail-design.md +++ /dev/null @@ -1,77 +0,0 @@ -# DuckMail 邮箱服务设计说明 - -**目标:** 为系统新增独立的 `duck_mail` 邮箱服务类型,支持在邮箱服务管理页配置 DuckMail,并在注册页选择该服务进行注册。 - -## 背景 - -当前项目已有三类邮箱服务: - -- `tempmail`:公共临时邮箱 -- `custom_domain`:MoeMail 风格 REST API -- `temp_mail`:自部署 Cloudflare Worker 临时邮箱 - -DuckMail 的接口模型与 `custom_domain` 不兼容。它采用 `/accounts`、`/token`、`/messages` 资源模型,并通过 Bearer Token 或 API Key 访问。因此需要新增独立服务类型,而不是复用现有 `custom_domain` 实现。 - -## 设计决策 - -### 1. 独立服务类型 - -新增 `duck_mail` 枚举值、服务类、前后端配置项与注册页可见性逻辑,避免与现有 MoeMail 接口混淆。 - -### 2. 配置模型 - -DuckMail 仅支持手动填写默认域名,不预拉取 `/domains`,配置项如下: - -- `base_url`:DuckMail API 地址 -- `default_domain`:默认域名,创建邮箱时直接拼接 -- `password_length`:自动创建 DuckMail 账户时使用的随机密码长度 -- `api_key`:可选,私有域名时通过 `Authorization: Bearer dk_xxx` 调用 -- `timeout` -- `max_retries` -- `proxy_url` - -### 3. 创建邮箱流程 - -DuckMail 服务在 `create_email()` 中: - -1. 生成合法用户名(至少 3 个字符) -2. 用 `default_domain` 拼接邮箱地址 -3. 生成随机密码 -4. 调用 `POST /accounts` -5. 调用 `POST /token` 获取 Bearer Token -6. 返回 `email`、`service_id`、`account_id`、`token`、内部随机密码等信息 - -### 4. 取验证码流程 - -DuckMail 的列表接口不返回正文,需: - -1. 轮询 `GET /messages` -2. 筛选 OpenAI 邮件 -3. 对新消息调用 `GET /messages/{id}` -4. 从 `text/html` 中提取验证码 - -### 5. 删除与健康检查 - -- `delete_email()`:使用账户 Bearer Token 调 `DELETE /accounts/{id}` -- `check_health()`:优先调 `GET /domains`;如果配置了私有域名 API Key,也沿用相同入口 - -### 6. 前端接入 - -邮箱服务管理页新增 DuckMail 子类型: - -- 列表中单独展示为 DuckMail -- 新建/编辑表单展示 DuckMail 专属字段 - -注册页: - -- 从 `/registration/available-services` 中显示 DuckMail 服务 -- 选择后与其他数据库邮箱服务一样传递 `email_service_type=duck_mail` 与 `email_service_id` - -## 测试范围 - -至少覆盖: - -- DuckMail `create_email()` 会调用账户创建与 token 获取 -- DuckMail `get_verification_code()` 会按“列表 -> 详情”提取验证码 -- 注册接口的可用服务列表包含 `duck_mail` -- 邮箱服务类型接口与敏感字段过滤支持 `duck_mail` diff --git a/docs/plans/2026-03-19-duckmail-implementation.md b/docs/plans/2026-03-19-duckmail-implementation.md deleted file mode 100644 index 53bd144..0000000 --- a/docs/plans/2026-03-19-duckmail-implementation.md +++ /dev/null @@ -1,151 +0,0 @@ -# DuckMail 邮箱服务实现计划 - -> **给 Codex:** 必须按 TDD 执行本计划,先写失败测试,再写最小实现。 - -**目标:** 新增独立 `duck_mail` 邮箱服务类型,支持 DuckMail 配置、测试、注册可见性与实际验证码拉取。 - -**架构:** 在现有 `BaseEmailService` 体系中新增 `DuckMailService`,并把它接入服务工厂、邮箱服务管理 API、注册可用服务 API 与前端页面。DuckMail 不复用 `custom_domain` 逻辑,单独维护请求头、创建邮箱、登录取 token、拉取消息与删除账户逻辑。 - -**技术栈:** Python、FastAPI、SQLAlchemy、原生 JavaScript、pytest - ---- - -### 任务 1:补 DuckMail 服务层测试 - -**文件:** -- 新建:`tests/test_duck_mail_service.py` -- 参考:`src/services/temp_mail.py` -- 参考:`src/services/moe_mail.py` - -**步骤 1:写失败测试** - -- 测试 `create_email()`: - - 会先调用 `POST /accounts` - - 再调用 `POST /token` - - 返回值包含 `email`、`service_id`、`account_id`、`token` -- 测试 `get_verification_code()`: - - 先调 `GET /messages` - - 再调 `GET /messages/{id}` - - 能从正文提取 6 位验证码 - -**步骤 2:运行失败测试** - -运行:`pytest tests/test_duck_mail_service.py -q` - -预期:因为 `DuckMailService` 尚不存在而失败。 - -**步骤 3:写最小实现** - -- 新增 `src/services/duck_mail.py` -- 只实现本轮测试所需的最小方法: - - 初始化配置 - - 请求封装 - - 创建邮箱 - - 获取 token - - 拉取消息与验证码 - - 删除账户 - - 健康检查 - -**步骤 4:运行测试确认通过** - -运行:`pytest tests/test_duck_mail_service.py -q` - -### 任务 2:补服务枚举与工厂接入测试 - -**文件:** -- 新建:`tests/test_email_service_duckmail_routes.py` -- 修改:`src/config/constants.py` -- 修改:`src/services/__init__.py` - -**步骤 1:写失败测试** - -- 断言 `EmailServiceType("duck_mail")` 可用 -- 断言 `EmailServiceFactory.get_service_class(EmailServiceType.DUCK_MAIL)` 已注册 - -**步骤 2:运行失败测试** - -运行:`pytest tests/test_email_service_duckmail_routes.py::test_duck_mail_service_registered -q` - -**步骤 3:写最小实现** - -- 在枚举中加入 `DUCK_MAIL` -- 在服务工厂注册 `DuckMailService` - -**步骤 4:运行测试确认通过** - -运行:`pytest tests/test_email_service_duckmail_routes.py::test_duck_mail_service_registered -q` - -### 任务 3:补邮箱服务 API 与注册可见性测试 - -**文件:** -- 修改:`src/web/routes/email.py` -- 修改:`src/web/routes/registration.py` -- 参考:`src/database/models.py` - -**步骤 1:写失败测试** - -- 测试邮箱服务类型接口包含 `duck_mail` -- 测试敏感配置过滤支持 `api_key` -- 测试 `/registration/available-services` 会返回 `duck_mail` - -**步骤 2:运行失败测试** - -运行:`pytest tests/test_email_service_duckmail_routes.py -q` - -**步骤 3:写最小实现** - -- `email.py` - - 统计增加 `duck_mail_count` - - `get_service_types()` 增加 DuckMail 配置项 -- `registration.py` - - `available-services` 增加 `duck_mail` - - `_normalize_email_service_config()` 支持 DuckMail 字段 - - `_run_sync_registration_task()` 支持 DuckMail 默认选择逻辑 - -**步骤 4:运行测试确认通过** - -运行:`pytest tests/test_email_service_duckmail_routes.py -q` - -### 任务 4:补前端 DuckMail 配置与注册入口 - -**文件:** -- 修改:`templates/email_services.html` -- 修改:`static/js/email_services.js` -- 修改:`static/js/app.js` -- 可选修改:`templates/index.html` - -**步骤 1:先补最小前端逻辑** - -- 邮箱服务页新增 DuckMail 类型展示与新建/编辑字段 -- 注册页邮箱服务下拉新增 DuckMail 分组 -- 选择 DuckMail 时传 `duck_mail:` - -**步骤 2:人工自检** - -- 检查新增字段是否与后端字段名一致: - - `base_url` - - `api_key` - - `default_domain` - - `password_length` - -### 任务 5:完整验证 - -**文件:** -- 修改:`README.md` - -**步骤 1:补文档** - -- 在功能列表与邮箱服务说明中加入 DuckMail - -**步骤 2:运行完整验证** - -运行:`pytest tests/test_duck_mail_service.py tests/test_email_service_duckmail_routes.py -q` - -如环境允许,再运行: - -`python -m compileall src` - -**步骤 3:检查结果** - -- 确认 pytest 退出码为 0 -- 确认编译检查无语法错误 diff --git a/src/core/upload/team_manager_upload.py b/src/core/upload/team_manager_upload.py index d42d587..1197348 100644 --- a/src/core/upload/team_manager_upload.py +++ b/src/core/upload/team_manager_upload.py @@ -53,8 +53,7 @@ def upload_to_team_manager( headers=headers, json=payload, proxies=None, - timeout=30, - impersonate="chrome110", + timeout=30 ) if resp.status_code in (200, 201): return True, "上传成功" diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 2a7e697..45ac73e 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -1,21 +1,28 @@ """ 账号管理 API 路由 """ - +import io import json import logging -from typing import List, Optional +import zipfile from datetime import datetime +from typing import List, Optional from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body from fastapi.responses import StreamingResponse from pydantic import BaseModel -from ...database import crud -from ...database.session import get_db -from ...database.models import Account from ...config.constants import AccountStatus from ...config.settings import get_settings +from ...core.openai.token_refresh import refresh_account_token as do_refresh +from ...core.openai.token_refresh import validate_account_token as do_validate +from ...core.upload.cpa_upload import generate_token_json, batch_upload_to_cpa, upload_to_cpa +from ...core.upload.team_manager_upload import upload_to_team_manager, batch_upload_to_team_manager +from ...core.upload.sub2api_upload import batch_upload_to_sub2api, upload_to_sub2api + +from ...database import crud +from ...database.models import Account +from ...database.session import get_db logger = logging.getLogger(__name__) router = APIRouter() @@ -477,10 +484,6 @@ async def export_accounts_sub2api(request: BatchExportRequest): @router.post("/export/cpa") async def export_accounts_cpa(request: BatchExportRequest): """导出账号为 CPA Token JSON 格式(每个账号单独一个 JSON 文件,打包为 ZIP)""" - import io - import zipfile - from ...core.upload.cpa_upload import generate_token_json - with get_db() as db: ids = resolve_account_ids( db, request.ids, request.select_all, @@ -582,8 +585,6 @@ class BatchValidateRequest(BaseModel): @router.post("/batch-refresh") async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: BackgroundTasks): """批量刷新账号 Token""" - from ...core.openai.token_refresh import refresh_account_token as do_refresh - # 使用传入的代理或全局代理配置 proxy = request.proxy if request.proxy else get_settings().proxy_url @@ -617,7 +618,6 @@ async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: B @router.post("/{account_id}/refresh") async def refresh_account_token(account_id: int, request: Optional[TokenRefreshRequest] = Body(default=None)): """刷新单个账号的 Token""" - from ...core.openai.token_refresh import refresh_account_token as do_refresh # 使用传入的代理或全局代理配置 proxy = request.proxy if request and request.proxy else get_settings().proxy_url @@ -639,7 +639,6 @@ async def refresh_account_token(account_id: int, request: Optional[TokenRefreshR @router.post("/batch-validate") async def batch_validate_tokens(request: BatchValidateRequest): """批量验证账号 Token 有效性""" - from ...core.openai.token_refresh import validate_account_token as do_validate # 使用传入的代理或全局代理配置 proxy = request.proxy if request.proxy else get_settings().proxy_url @@ -682,7 +681,6 @@ async def batch_validate_tokens(request: BatchValidateRequest): @router.post("/{account_id}/validate") async def validate_account_token(account_id: int, request: Optional[TokenValidateRequest] = Body(default=None)): """验证单个账号的 Token 有效性""" - from ...core.openai.token_refresh import validate_account_token as do_validate # 使用传入的代理或全局代理配置 proxy = request.proxy if request and request.proxy else get_settings().proxy_url @@ -717,7 +715,6 @@ class BatchCPAUploadRequest(BaseModel): @router.post("/batch-upload-cpa") async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): """批量上传账号到 CPA""" - from ...core.upload.cpa_upload import batch_upload_to_cpa proxy = request.proxy if request.proxy else get_settings().proxy_url @@ -745,7 +742,6 @@ async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): @router.post("/{account_id}/upload-cpa") async def upload_account_to_cpa(account_id: int, request: Optional[CPAUploadRequest] = Body(default=None)): """上传单个账号到 CPA""" - from ...core.upload.cpa_upload import upload_to_cpa, generate_token_json proxy = request.proxy if request and request.proxy else get_settings().proxy_url cpa_service_id = request.cpa_service_id if request else None @@ -809,7 +805,6 @@ class BatchSub2ApiUploadRequest(BaseModel): @router.post("/batch-upload-sub2api") async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): """批量上传账号到 Sub2API""" - from ...core.upload.sub2api_upload import batch_upload_to_sub2api # 解析指定的 Sub2API 服务 api_url = None @@ -848,7 +843,6 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): @router.post("/{account_id}/upload-sub2api") async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUploadRequest] = Body(default=None)): """上传单个账号到 Sub2API""" - from ...core.upload.sub2api_upload import upload_to_sub2api service_id = request.service_id if request else None concurrency = request.concurrency if request else 3 @@ -908,7 +902,6 @@ class BatchUploadTMRequest(BaseModel): @router.post("/batch-upload-tm") async def batch_upload_accounts_to_tm(request: BatchUploadTMRequest): """批量上传账号到 Team Manager""" - from ...core.upload.team_manager_upload import batch_upload_to_team_manager with get_db() as db: if request.service_id: @@ -935,7 +928,6 @@ async def batch_upload_accounts_to_tm(request: BatchUploadTMRequest): @router.post("/{account_id}/upload-tm") async def upload_account_to_tm(account_id: int, request: Optional[UploadTMRequest] = Body(default=None)): """上传单账号到 Team Manager""" - from ...core.upload.team_manager_upload import upload_to_team_manager service_id = request.service_id if request else None diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index 8e878a6..d6ff3c8 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -444,7 +444,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: # 自动上传到 Team Manager(可多服务) if auto_upload_tm: try: - from ...core.upload.team_manager_upload import upload_account_to_tm + from ...core.upload.team_manager_upload import upload_to_team_manager from ...database.models import Account as AccountModel saved_account = db.query(AccountModel).filter_by(email=result.email).first() if saved_account and saved_account.access_token: @@ -459,7 +459,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: if not _svc: continue log_callback(f"[TM] 上传到服务: {_svc.name}") - _ok, _msg = upload_account_to_tm(saved_account, _svc.api_url, _svc.api_key) + _ok, _msg = upload_to_team_manager(saved_account, _svc.api_url, _svc.api_key) log_callback(f"[TM] {'成功' if _ok else '失败'}({_svc.name}): {_msg}") except Exception as _e: log_callback(f"[TM] 异常({_sid}): {_e}")