Spaces:
Paused
feat(round-4 infra): docker-guard + playwright-hardening + 调研/PRD/SPEC 文档基线
Browse files实施依据(prompts/0426/):
- 6 份 research 报告(2170 行)— issue#1 cloudmail / #2 seat / #3 import / #4 oauth+phone / #5 playwright / #6 invite/kick
- 4 份 PRD(1856 行)+ synthesis 综合分析
- 8 份 SPEC(4742 行,含 4 份 shared 共享契约)
Wave 1 — SPEC-3 Docker 守卫(@prd-docker):
- Dockerfile: ARG GIT_SHA + LABEL + ENV(WORKDIR 后定位,不击穿 uv sync 缓存)
- docker-compose.yml: build.args ${GIT_SHA:-unknown} 降级
- docker-entrypoint.sh: uv run python heredoc 启动期 import 自检 11 符号 + crash-loop
- pyproject.toml: [tool.ruff.lint] F401/F811/F821 + per-file-ignores
- .pre-commit-config.yaml: 收紧 ruff args 到 SPEC 三规则
- src/autoteam/api.py: VersionResponse + GET /api/version + _AUTH_SKIP_PATHS 加白
- docs/{api,docker}.md: SOP 4 步 rebuild + 验证 + 故障排查
- tests/integration/test_docker_guard.py: 10 个测试覆盖 AC1/3/4/5/7/9 + 4 静态契约
Wave 2 — SPEC-4 Playwright 一致性硬化(@prd-playwright):
- src/autoteam/_playwright_guard.py(新):9 符号 import 白名单 + assert_sync_context()
- src/autoteam/api.py: _PlaywrightExecutor 双入口 guard(run_with_timeout + _worker)
- src/autoteam/manager.py: 顶层 import 上提 sync_playwright,移除 2 处函数内 import
- tests/static/{__init__.py,test_playwright_hygiene.py,test_playwright_hygiene_negative.py}: 4 AST 守卫 + 5 反例
- tests/unit/test_playwright_guard.py: 3 runtime guard(asyncio.run/threading)
- pyproject.toml: testpaths 显式列入 tests/static / tests/unit / tests/integration
验证:
- pytest 全套 93 passed, 1 pre-existing fail(test_manager_reinvite,SystemTimeWarning,与本轮无关)
- ruff check src/ All checks passed
- import 健康 OK(api/manager/_playwright_guard 全过)
未做(Wave 3+4 独立 PR):
- SPEC-1 Mail Provider 全量化(@prd-mail)
- SPEC-2 + 4 shared 共享契约(@prd-lifecycle)— 含 plan_type 白名单 / quota 分类 / add-phone 探针 / 状态机
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- .pre-commit-config.yaml +9 -2
- CHANGELOG.md +25 -0
- Dockerfile +9 -0
- docker-compose.yml +5 -1
- docker-entrypoint.sh +38 -0
- docs/api.md +2 -0
- docs/docker.md +85 -0
- prompts/0426/prd/prd-1-mail-provider.md +455 -0
- prompts/0426/prd/prd-2-account-lifecycle.md +564 -0
- prompts/0426/prd/prd-3-docker-guard.md +508 -0
- prompts/0426/prd/prd-4-playwright-hardening.md +329 -0
- prompts/0426/research/issue-1-cloudmail.md +613 -0
- prompts/0426/research/issue-2-seat-strategy.md +182 -0
- prompts/0426/research/issue-3-list-accounts-import.md +292 -0
- prompts/0426/research/issue-4-oauth-addphone.md +416 -0
- prompts/0426/research/issue-5-playwright-async-sync.md +438 -0
- prompts/0426/research/issue-6-invite-postkick.md +229 -0
- prompts/0426/spec/shared/account-state-machine.md +518 -0
- prompts/0426/spec/shared/add-phone-detection.md +441 -0
- prompts/0426/spec/shared/plan-type-whitelist.md +327 -0
- prompts/0426/spec/shared/quota-classification.md +410 -0
- prompts/0426/spec/spec-1-mail-provider.md +1045 -0
- prompts/0426/spec/spec-2-account-lifecycle.md +1018 -0
- prompts/0426/spec/spec-3-docker-guard.md +525 -0
- prompts/0426/spec/spec-4-playwright-hardening.md +458 -0
- prompts/0426/synthesis.md +109 -0
- pyproject.toml +30 -1
- src/autoteam/_playwright_guard.py +61 -0
- src/autoteam/api.py +33 -1
- src/autoteam/manager.py +2 -4
- tests/integration/test_docker_guard.py +201 -0
- tests/static/__init__.py +0 -0
- tests/static/test_playwright_hygiene.py +99 -0
- tests/static/test_playwright_hygiene_negative.py +101 -0
- tests/unit/test_playwright_guard.py +59 -0
|
@@ -1,7 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
repos:
|
| 2 |
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 3 |
rev: v0.15.10
|
| 4 |
hooks:
|
| 5 |
- id: ruff
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPEC-3 §7: pre-commit ruff hook
|
| 2 |
+
# 安装:uv run pre-commit install
|
| 3 |
+
# 手动跑全仓:uv run pre-commit run --all-files
|
| 4 |
+
|
| 5 |
repos:
|
| 6 |
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 7 |
rev: v0.15.10
|
| 8 |
hooks:
|
| 9 |
- id: ruff
|
| 10 |
+
name: ruff (F401/F811/F821)
|
| 11 |
+
args:
|
| 12 |
+
- --select
|
| 13 |
+
- F401,F811,F821
|
| 14 |
+
- --exit-non-zero-on-fix
|
|
@@ -2,6 +2,31 @@
|
|
| 2 |
|
| 3 |
本文档记录 AutoTeam-F 相对上游 [cnitlrt/AutoTeam](https://github.com/cnitlrt/AutoTeam) 的差异以及版本演进。日期采用 ISO 8601。
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
## [Unreleased] — 2026-04-25
|
| 6 |
|
| 7 |
### invite-hardening round-3:母号切换鲁棒性 + exhausted 自愈 + CPA 删除守卫
|
|
|
|
| 2 |
|
| 3 |
本文档记录 AutoTeam-F 相对上游 [cnitlrt/AutoTeam](https://github.com/cnitlrt/AutoTeam) 的差异以及版本演进。日期采用 ISO 8601。
|
| 4 |
|
| 5 |
+
## [Unreleased] — 2026-04-26
|
| 6 |
+
|
| 7 |
+
### docker-guard:镜像守卫四道防线(SPEC-3)
|
| 8 |
+
|
| 9 |
+
> 起因:issue #3 的 `list_accounts` ImportError 代码层已修(commit cf2f7d3),但用户 docker 容器没重建仍报错。本轮落地"防回归 + 用户操作 SOP + 镜像可观察性"。
|
| 10 |
+
|
| 11 |
+
- **feat(docker): entrypoint self-check** — `docker-entrypoint.sh` 在 `exec uv run autoteam` 前增加白名单 import 自检(`autoteam.api.app` + `autoteam.accounts` 9 个对外契约符号 + `autoteam.manager.sync_account_states` 共 11 项),失败即 `exit 1` 触发 docker `restart: unless-stopped` 的 crash-loop;失败提示文案直接给出 rebuild 命令。
|
| 12 |
+
- **feat(docker): 镜像 git-sha 指纹** — `Dockerfile` 增 `ARG GIT_SHA` / `ARG BUILD_TIME`,落到 `LABEL org.opencontainers.image.revision/created` + `ENV AUTOTEAM_GIT_SHA/BUILD_TIME`;`docker-compose.yml` 用 `${GIT_SHA:-unknown}` 降级,不传 build-arg 也能跑。
|
| 13 |
+
- **feat(api): `/api/version` 端点** — `src/autoteam/api.py` 新增免鉴权 `GET /api/version`,返回 `{git_sha, build_time}`,`_AUTH_SKIP_PATHS` 已加白名单;Pydantic `VersionResponse` 模型,与 OpenAPI/Swagger 集成。
|
| 14 |
+
- **feat(lint): ruff F401/F811/F821 守卫** — `pyproject.toml` 新增 `[tool.ruff.lint]` 段,只启三条零误报规则;`.pre-commit-config.yaml` 接入同规则,堵住 typo 类 ImportError 在 commit 前。
|
| 15 |
+
- **docs(docker): rebuild SOP** — `docs/docker.md` 增加"代码更新后的 rebuild SOP"+"版本验证"+"启动期 self-check"+"故障排查"+"lint 守卫"五个章节;`docs/api.md` 把 `/api/version` 加入即时返回接口表与免鉴权清单。
|
| 16 |
+
|
| 17 |
+
### playwright-hardening:async/sync 一致性硬化(SPEC-4)
|
| 18 |
+
|
| 19 |
+
> 起因:issue #5 用户报告"playwright 中大量 async 函数里误用 sync API"。研究阶段对全项目 5 处 `sync_playwright` 调用点 + 1 处 `async def` 完整审计后**结论反转** — 0 处实际混用、5 处全部位于 sync `def` + 经由 `_PlaywrightExecutor` 单例 + 专用 worker 线程串行,严格符合 Playwright 官方推荐模式。用户决定走 hardening only,落 3 道防线杜绝未来回归。
|
| 20 |
+
|
| 21 |
+
- **feat(guard): 新增 `src/autoteam/_playwright_guard.py`** — 单一信源(SSOT)模块:9 个 sync 白名单符号 `ALLOWED_SYNC_NAMES`(sync_playwright/Playwright/Browser/BrowserContext/BrowserType/Page/Locator/Error/TimeoutError)+ `FORBIDDEN_MODULES={"playwright.async_api"}` + `EXEMPTION_MARKER="autoteam: allow-async-playwright"` + `assert_sync_context()` 函数(在 asyncio loop 内调用立即抛 `RuntimeError`,异常消息含 thread 名 + loop_id 便于诊断)。
|
| 22 |
+
- **feat(api): `_PlaywrightExecutor` 双入口 guard** — `api.py` 在 `run_with_timeout` 主线程入口 + `_worker` 专用线程入口分别调 `assert_sync_context()`,防止未来某次重构把 sync_playwright 误植入 async 上下文(撞 Playwright 内部 "Sync API inside asyncio loop" 静默失败)。
|
| 23 |
+
- **refactor(manager): 函数内 import 上提** — `manager.py:_complete_registration` / `_register_direct_once` 两处 `from playwright.sync_api import sync_playwright` 上提至模块顶层(全仓 sync_api import 现在统一在 4 个文件的模块顶层 + manager.py 1 处)。
|
| 24 |
+
- **test(static): AST 守卫 + 反例验证** — `tests/static/test_playwright_hygiene.py` 4 个测试:① 禁止 `from playwright.async_api`;② `async def` 函数体内不允许任何 playwright 符号;③ `playwright.sync_api` 导入仅限白名单 9 符号;④ 豁免登记表 `expected_exempt = set()` 当前期望 0 个豁免。`tests/static/test_playwright_hygiene_negative.py` 5 个反例验证守卫真能拦下违规模式。
|
| 25 |
+
- **test(unit): runtime guard 验证** — `tests/unit/test_playwright_guard.py` 3 个测试:① 普通线程放行;② `asyncio.run()` 内调用必抛;③ 异常消息含 `thread=` + `loop_id=0x...`。
|
| 26 |
+
- **chore(pytest): testpaths 显式列入 tests/static** — `pyproject.toml` `[tool.pytest.ini_options]` 把 `tests/static` / `tests/unit` / `tests/integration` 全部显式列入 `testpaths`,避免未来 collect-only 配置误排除静态守卫。
|
| 27 |
+
|
| 28 |
+
> SPEC-4 范围严格限定 hardening only,**不动业务逻辑**:`chatgpt_api.py` / `codex_auth.py` / `invite.py` / 5 处 `with sync_playwright() as p:` 块本体一行未改。
|
| 29 |
+
|
| 30 |
## [Unreleased] — 2026-04-25
|
| 31 |
|
| 32 |
### invite-hardening round-3:母号切换鲁棒性 + exhausted 自愈 + CPA 删除守卫
|
|
@@ -13,6 +13,15 @@ ENV PATH="/root/.local/bin:$PATH"
|
|
| 13 |
|
| 14 |
WORKDIR /app
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
# 复制项目文件
|
| 17 |
COPY pyproject.toml uv.lock ./
|
| 18 |
RUN uv sync --no-dev
|
|
|
|
| 13 |
|
| 14 |
WORKDIR /app
|
| 15 |
|
| 16 |
+
# === SPEC-3 §3: 镜像版本指纹注入 ===
|
| 17 |
+
ARG GIT_SHA=unknown
|
| 18 |
+
ARG BUILD_TIME=unknown
|
| 19 |
+
LABEL org.opencontainers.image.revision="${GIT_SHA}"
|
| 20 |
+
LABEL org.opencontainers.image.created="${BUILD_TIME}"
|
| 21 |
+
ENV AUTOTEAM_GIT_SHA="${GIT_SHA}"
|
| 22 |
+
ENV AUTOTEAM_BUILD_TIME="${BUILD_TIME}"
|
| 23 |
+
# === SPEC-3 §3 end ===
|
| 24 |
+
|
| 25 |
# 复制项目文件
|
| 26 |
COPY pyproject.toml uv.lock ./
|
| 27 |
RUN uv sync --no-dev
|
|
@@ -1,6 +1,10 @@
|
|
| 1 |
services:
|
| 2 |
autoteam:
|
| 3 |
-
build:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
ports:
|
| 5 |
- "8787:8787"
|
| 6 |
volumes:
|
|
|
|
| 1 |
services:
|
| 2 |
autoteam:
|
| 3 |
+
build:
|
| 4 |
+
context: .
|
| 5 |
+
args:
|
| 6 |
+
GIT_SHA: ${GIT_SHA:-unknown}
|
| 7 |
+
BUILD_TIME: ${BUILD_TIME:-unknown}
|
| 8 |
ports:
|
| 9 |
- "8787:8787"
|
| 10 |
volumes:
|
|
@@ -25,5 +25,43 @@ for d in auths screenshots; do
|
|
| 25 |
ln -s "/app/data/$d" "/app/$d"
|
| 26 |
done
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
# 执行命令
|
| 29 |
exec uv run autoteam "$@"
|
|
|
|
| 25 |
ln -s "/app/data/$d" "/app/$d"
|
| 26 |
done
|
| 27 |
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Self-check: 关键 import 白名单 — 失败即 crash-loop(SPEC-3 §2)
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
echo "[self-check] verifying critical imports..."
|
| 32 |
+
uv run python - <<'PYEOF' || {
|
| 33 |
+
echo "[self-check] FATAL: critical import failed - image is likely stale or broken." >&2
|
| 34 |
+
echo "[self-check] If you just pulled new code, the image must be rebuilt:" >&2
|
| 35 |
+
echo "[self-check] docker compose down" >&2
|
| 36 |
+
echo "[self-check] docker compose build --no-cache --build-arg GIT_SHA=\$(git rev-parse --short HEAD)" >&2
|
| 37 |
+
echo "[self-check] docker compose up -d" >&2
|
| 38 |
+
exit 1
|
| 39 |
+
}
|
| 40 |
+
import sys
|
| 41 |
+
|
| 42 |
+
# 核心 web 应用入口
|
| 43 |
+
from autoteam.api import app # noqa: F401
|
| 44 |
+
|
| 45 |
+
# accounts.py 全部对外契约符号(典型 typo 高发区)
|
| 46 |
+
from autoteam.accounts import ( # noqa: F401
|
| 47 |
+
load_accounts,
|
| 48 |
+
save_accounts,
|
| 49 |
+
STATUS_ACTIVE,
|
| 50 |
+
STATUS_EXHAUSTED,
|
| 51 |
+
STATUS_STANDBY,
|
| 52 |
+
STATUS_PENDING,
|
| 53 |
+
STATUS_PERSONAL,
|
| 54 |
+
STATUS_AUTH_INVALID,
|
| 55 |
+
STATUS_ORPHAN,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# manager → accounts 链路(覆盖跨模块 typo)
|
| 59 |
+
from autoteam.manager import sync_account_states # noqa: F401
|
| 60 |
+
|
| 61 |
+
print("[self-check] OK: %d critical symbols imported." % 11)
|
| 62 |
+
sys.exit(0)
|
| 63 |
+
PYEOF
|
| 64 |
+
echo "[self-check] passed."
|
| 65 |
+
|
| 66 |
# 执行命令
|
| 67 |
exec uv run autoteam "$@"
|
|
@@ -12,6 +12,7 @@ Authorization: Bearer <API_KEY>
|
|
| 12 |
- `/api/auth/check`
|
| 13 |
- `/api/setup/status`
|
| 14 |
- `/api/setup/save`
|
|
|
|
| 15 |
|
| 16 |
## 即时返回接口
|
| 17 |
|
|
@@ -22,6 +23,7 @@ Authorization: Bearer <API_KEY>
|
|
| 22 |
| GET | `/api/auth/check` | 验证 API Key |
|
| 23 |
| GET | `/api/setup/status` | 检查配置是否完整 |
|
| 24 |
| POST | `/api/setup/save` | 保存初始配置 |
|
|
|
|
| 25 |
| GET | `/api/status` | 账号状态 + 实时额度 |
|
| 26 |
| GET | `/api/accounts` | 所有账号列表 |
|
| 27 |
| GET | `/api/accounts/active` | 活跃账号 |
|
|
|
|
| 12 |
- `/api/auth/check`
|
| 13 |
- `/api/setup/status`
|
| 14 |
- `/api/setup/save`
|
| 15 |
+
- `/api/version`
|
| 16 |
|
| 17 |
## 即时返回接口
|
| 18 |
|
|
|
|
| 23 |
| GET | `/api/auth/check` | 验证 API Key |
|
| 24 |
| GET | `/api/setup/status` | 检查配置是否完整 |
|
| 25 |
| POST | `/api/setup/save` | 保存初始配置 |
|
| 26 |
+
| GET | `/api/version` | 镜像版本指纹(`git_sha` + `build_time`,免鉴权,用于排查 docker 镜像是否过期) |
|
| 27 |
| GET | `/api/status` | 账号状态 + 实时额度 |
|
| 28 |
| GET | `/api/accounts` | 所有账号列表 |
|
| 29 |
| GET | `/api/accounts/active` | 活跃账号 |
|
|
@@ -110,3 +110,88 @@ uv run autoteam pull-cpa
|
|
| 110 |
```
|
| 111 |
|
| 112 |
即可重新整理。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
```
|
| 111 |
|
| 112 |
即可重新整理。
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## 代码更新后的 rebuild SOP(SPEC-3 §8)
|
| 117 |
+
|
| 118 |
+
> **关键认知**:本项目 `Dockerfile` 用 `COPY src/`(非 volume mount),
|
| 119 |
+
> **`git pull` 后必须 rebuild 镜像**,代码改动才会进入容器。
|
| 120 |
+
|
| 121 |
+
### 标准更新流程(4 步)
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
# 1. 拉取新代码
|
| 125 |
+
cd /path/to/AutoTeam && git pull
|
| 126 |
+
|
| 127 |
+
# 2. 停掉旧容器
|
| 128 |
+
docker compose down
|
| 129 |
+
|
| 130 |
+
# 3. 重建镜像(--no-cache 防意外缓存命中,GIT_SHA 注入版本指纹)
|
| 131 |
+
GIT_SHA=$(git rev-parse --short HEAD) \
|
| 132 |
+
BUILD_TIME=$(date -u +%FT%TZ) \
|
| 133 |
+
docker compose build --no-cache
|
| 134 |
+
|
| 135 |
+
# 4. 启动
|
| 136 |
+
docker compose up -d
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
### 验证镜像版本(三选一,结果应一致)
|
| 140 |
+
|
| 141 |
+
```bash
|
| 142 |
+
# 方式 A:HTTP 端点(免鉴权)
|
| 143 |
+
curl http://localhost:8787/api/version
|
| 144 |
+
# 期望:{"git_sha":"cf2f7d3","build_time":"2026-04-26T..."}
|
| 145 |
+
|
| 146 |
+
# 方式 B:进容器查环境变量
|
| 147 |
+
docker compose exec autoteam env | grep AUTOTEAM_GIT_SHA
|
| 148 |
+
|
| 149 |
+
# 方式 C:看镜像 OCI label(无需启动容器)
|
| 150 |
+
docker image inspect autoteam-autoteam --format '{{json .Config.Labels}}'
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
### 启动期 self-check
|
| 154 |
+
|
| 155 |
+
容器每次启动都会执行 `[self-check]` 段,白名单 import 任一失败立即 `exit 1` → docker 进入 crash-loop。
|
| 156 |
+
|
| 157 |
+
```bash
|
| 158 |
+
docker compose logs autoteam | head -20
|
| 159 |
+
# 期望看到:
|
| 160 |
+
# [self-check] verifying critical imports...
|
| 161 |
+
# [self-check] OK: 11 critical symbols imported.
|
| 162 |
+
# [self-check] passed.
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
### 故障排查:为什么修了代码 bug 还在?
|
| 166 |
+
|
| 167 |
+
**99% 是镜像没 rebuild**。先跑这条快速诊断:
|
| 168 |
+
|
| 169 |
+
```bash
|
| 170 |
+
# 对比 image 内 sha 与 repo HEAD
|
| 171 |
+
echo "image:" && curl -s http://localhost:8787/api/version | python -m json.tool
|
| 172 |
+
echo "repo HEAD:" && git rev-parse --short HEAD
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
如果 `image.git_sha` 与 `repo HEAD` 不一致 → 重做上面 4 步 SOP。
|
| 176 |
+
|
| 177 |
+
如果 self-check 报 `FATAL: critical import failed`:
|
| 178 |
+
- 说明镜像里的源码与最新代码的契约符号对不上(典型 typo 引入未定义名)
|
| 179 |
+
- 解决:回退最近 commit 或修复 typo,再 rebuild
|
| 180 |
+
|
| 181 |
+
### lint 守卫(开发期)
|
| 182 |
+
|
| 183 |
+
`pyproject.toml` 已配置 ruff(F401/F811/F821 三条规则),`.pre-commit-config.yaml` 也接入了同样的检查。
|
| 184 |
+
|
| 185 |
+
首次启用:
|
| 186 |
+
|
| 187 |
+
```bash
|
| 188 |
+
uv sync # 装 dev 依赖(pre-commit、ruff 已声明)
|
| 189 |
+
uv run pre-commit install # 注入 .git/hooks/pre-commit
|
| 190 |
+
uv run pre-commit run --all-files # 一次性扫全仓,确认基线干净
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
之后每次 `git commit` 会自动跑 ruff;手动检查可:
|
| 194 |
+
|
| 195 |
+
```bash
|
| 196 |
+
uv run ruff check src/
|
| 197 |
+
```
|
|
@@ -0,0 +1,455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PRD-1: Mail Provider 全量化
|
| 2 |
+
|
| 3 |
+
## 0. 元数据
|
| 4 |
+
|
| 5 |
+
| 字段 | 值 |
|
| 6 |
+
| -------- | ------------------------------------------------------------------------------------------- |
|
| 7 |
+
| 版本 | v1.0 |
|
| 8 |
+
| 日期 | 2026-04-26 |
|
| 9 |
+
| 主笔 | prd-mail (AutoTeam team `autoteam-prd-0426`) |
|
| 10 |
+
| 评审人 | team-lead |
|
| 11 |
+
| 状态 | Draft |
|
| 12 |
+
| 输入文档 | `prompts/0426/research/issue-1-cloudmail.md` (613 行) / `prompts/0426/synthesis.md` |
|
| 13 |
+
| 关联 | Issue#1 「setup_wizard 创建邮箱失败 (401)」、`docs/mail-provider-design.md` §6 待确认项 |
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 1. 背景与问题陈述
|
| 18 |
+
|
| 19 |
+
AutoTeam 已在 2026-04 完成 mail provider 抽象重构(`src/autoteam/mail/{base,cf_temp_email,maillab}.py`),但**配置层与 UI 层未同步迁移**,导致用户在以下三种场景持续踩坑:
|
| 20 |
+
|
| 21 |
+
1. **半成功假象** — 用户填了 maillab 服务器地址但没设 `MAIL_PROVIDER=maillab`,默认走 cf_temp_email 分支。`/admin/address` 在 maillab 的 catch-all 路由下可能误回 200,login 假成功,直到 `/admin/new_address` 才暴露 `{code:401, message:"身份认证失效"}`(issue#1 截图)。
|
| 22 |
+
2. **Web 面板配置黑洞** — `api.py:70-79` 的 `SetupConfig` Pydantic 模型只声明 `CLOUDMAIL_*`,Pydantic 默认丢弃未声明字段。即便前端塞 `MAIL_PROVIDER` / `MAILLAB_*`,`/api/setup/save` 也不会写入 `.env`,maillab 用户被锁死在 cf_temp_email 模式。
|
| 23 |
+
3. **盲填域名** — 用户手敲 `CLOUDMAIL_DOMAIN`,只能等启动验证或 `register-domain PUT` 试探时才知道是否被服务端接受;maillab 实例自带 `/setting/websiteConfig.domainList`,用户体验差距明显。
|
| 24 |
+
|
| 25 |
+
经核验(`src/autoteam/setup_wizard.py:20-35` REQUIRED_CONFIGS 已含 `MAIL_PROVIDER`,但 SetupConfig 没有同步),现状是**底层 ABC 已完整,中层配置 / 上层 UI 仍为单后端思维**,需要补齐贯通。
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## 2. 目标(SMART)
|
| 30 |
+
|
| 31 |
+
| ID | 目标 | 衡量 |
|
| 32 |
+
| --- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
| 33 |
+
| G1 | Web 面板 SetupPage / Settings 支持 cf_temp_email / maillab 双 provider 字段填写并持久化 | `/api/setup/save` 写入 `.env` 后 `MAIL_PROVIDER` / `MAILLAB_*` 全部可被读到 |
|
| 34 |
+
| G2 | 用户在保存前完成"指纹 → 凭据 → 域名归属"3 步在线验证,失败给出可读的 error_code + hint | 错配场景(maillab 服务器配 cf_temp_email 字段)在 step=fingerprint 即报错 |
|
| 35 |
+
| G3 | cf_temp_email 协议错配嗅探零漏判 | `/admin/address` 返回 `200 + {}` 空响应也被识别为非 cf_temp_email,login 即抛错 |
|
| 36 |
+
| G4 | maillab 401 token 失效时客户端自愈 | `MaillabClient` 任意业务方法收 `code:401` 后自动 `_ensure_login()` 重试一次 |
|
| 37 |
+
| G5 | 文档(getting-started / configuration / mail-provider-design / troubleshooting / api / .env.example)同步更新 | 文档清单 §11 全部状态从 P0 → Done |
|
| 38 |
+
|
| 39 |
+
**MVP 完成标准** — Issue#1 截图复现的"管理员鉴权通过 → 创建邮箱失败 401"流程,在 Web 面板里能在 step=fingerprint 阶段(填完 base_url 即点测试连接时)就拿到"建议切 MAIL_PROVIDER=maillab"的 hint。
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## 3. 非目标(明确不做的)
|
| 44 |
+
|
| 45 |
+
- **不实现** maillab 管理员功能(`/user/*` `/role/*` `/allEmail/*` `/regKey/*` 等 30+ 路由) — 属于 maillab 自身后台,业务上不需要。
|
| 46 |
+
- **不实现** `/email/send`(发邮件) — AutoTeam 只读验证码。
|
| 47 |
+
- **不实现** maillab Turnstile / addVerify 兼容 — 用户需到 maillab 后台关闭。后续如有需求再立 PRD。
|
| 48 |
+
- **不实现** 多 maillab 实例切换 — 当前一个 base_url 即可,prod/staging 由用户 .env 切换。
|
| 49 |
+
- **不实现** linuxdo OAuth 自动绑定 — 当前不在范围。
|
| 50 |
+
- **不实现** `/public/genToken` 集成 — 现 `/login` 路径已够用,且 `/public/genToken` 仅 `c.env.admin` 可调,适用面更窄。
|
| 51 |
+
- **不动**业务调用层(`manager.py` / `invite.py` / `codex_auth.py` / `account_ops.py`)对 `CloudMailClient` 的调用,通过 `from autoteam.cloudmail import CloudMailClient` 兼容别名继续工作。
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## 4. 用户故事
|
| 56 |
+
|
| 57 |
+
| ID | 故事 |
|
| 58 |
+
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| 59 |
+
| US-001 | 作为首次部署 AutoTeam 的管理员,我**应该**在 SetupPage 选 cf_temp_email / maillab 后**只看到对应字段**,无关字段不出现,避免我把 maillab 服务器填到 cf 字段里。 |
|
| 60 |
+
| US-002 | 作为管理员,我**应该**在填完 base_url 后立即看到"这是 cf_temp_email / maillab / 未识别"的指纹结果与 hint,而不是等启动后才发现错配。 |
|
| 61 |
+
| US-003 | 作为 maillab 用户,我**应该**在保存前看到该实例支持的 domainList 下拉框,且选中的 domain **已被 `/account/add` 试创建一次邮箱并立即回收**确认归属。 |
|
| 62 |
+
| US-004 | 作为重启后 token 过期的用户,我**期望**任意 mail 调用收 401 时客户端自愈,而不是直接业务失败。 |
|
| 63 |
+
| US-005 | 作为 cf_temp_email 用户,我**期望**当我填错 base_url(指向 maillab 服务器)时立即报错,而不是 login 假成功。 |
|
| 64 |
+
| US-006 | 作为通过 Settings 页修改 mail provider 的管理员,我**期望**复用 SetupPage 的同一套验证 UX(指纹 / 凭据 / 域名),不需要二次踩坑。 |
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## 5. 功能需求
|
| 69 |
+
|
| 70 |
+
> 每条 FR 对应 §8 验收标准的一条 AC。
|
| 71 |
+
|
| 72 |
+
### FR-001 SetupConfig 扩字段
|
| 73 |
+
|
| 74 |
+
- 在 `src/autoteam/api.py` 的 `SetupConfig` 模型中新增字段:`MAIL_PROVIDER: str = "cf_temp_email"`、`MAILLAB_API_URL: str = ""`、`MAILLAB_USERNAME: str = ""`、`MAILLAB_PASSWORD: str = ""`、`MAILLAB_DOMAIN: str = ""`。
|
| 75 |
+
- `post_setup_save` 写入 `.env` 时,**当 MAIL_PROVIDER == "cf_temp_email" 时跳过 MAILLAB_* 写入,反之亦然**(避免无关字段污染 .env)。
|
| 76 |
+
- `GET /api/setup/status` 已经按 REQUIRED_CONFIGS 输出字段,需要新增 maillab 4 字段进 REQUIRED_CONFIGS,且在 `provider != maillab` 时把这 4 项的 `optional` 标为 `True`。
|
| 77 |
+
|
| 78 |
+
### FR-002 cf_temp_email 嗅探零漏判
|
| 79 |
+
|
| 80 |
+
- `src/autoteam/mail/cf_temp_email.py:login()` L106 的判定从 `("code" in payload or "data" in payload)` 改为**正向白名单**:**响应 dict 不含 `results` key 一律抛错**(无论是否含 code/data,空 `{}` 也命中)。
|
| 81 |
+
- 错误信息保留原有的"看起来是 maillab,请改 MAIL_PROVIDER=maillab"提示。
|
| 82 |
+
- `create_temp_email` L154 同步收紧:`"address" not in data` 即报错(不再要求同时 `code` + `message`)。
|
| 83 |
+
|
| 84 |
+
### FR-003 maillab 401 自愈
|
| 85 |
+
|
| 86 |
+
- `src/autoteam/mail/maillab.py` 的 `_get / _post / _delete / _put` 在拿到响应后:若 `resp.get("code") == 401`,**且** `_login_recursion_guard` 未置位,则清空 `self.token` → `self.login()` → 用新 token **重试一次**。
|
| 87 |
+
- 重试仍 401 时抛 `Exception("maillab 鉴权失败,请检查 MAILLAB_USERNAME/PASSWORD")`。
|
| 88 |
+
- 用 thread-local guard 防止 `login()` 自身触发递归(login 调 `_post` 会再触发 401 处理)。
|
| 89 |
+
|
| 90 |
+
### FR-004 新增 `/api/mail-provider/probe` 端点
|
| 91 |
+
|
| 92 |
+
- 新增 POST 端点,**3 步可分步调用**,Pydantic 模型见 §7.2。
|
| 93 |
+
- 鉴权:setup 阶段可无鉴权(与 `/api/setup/save` 一致);进入面板后(`API_KEY` 已配置)必须带 `Authorization: Bearer <key>`。
|
| 94 |
+
- `step=fingerprint`:无需凭据,GET `{base_url}/setting/websiteConfig` 一次即可拿到 `domainList / addVerifyOpen / registerVerifyOpen`,响应缺 `domainList` 但含 `results` 时识别为 cf_temp_email,均缺时识别为 unknown。
|
| 95 |
+
- `step=credentials`:用填好的 username/password 调 `{base_url}/login`,解析 JWT payload 反馈 `is_admin / user_email / token_preview`。token **不持久化到磁盘**,只在响应中作展示。
|
| 96 |
+
- `step=domain_ownership`:用上一步的 token 调 `{base_url}/account/add` body `{email: "probe-{ts}@{domain}"}`,成功立即 `DELETE /account/delete?accountId=N`。失败(403 / message 含 "domain")归类为 `FORBIDDEN_DOMAIN`。
|
| 97 |
+
|
| 98 |
+
### FR-005 register-domain 复用共享 helper
|
| 99 |
+
|
| 100 |
+
- 把 `api.py:1631-1673` 的 PUT register-domain 探测逻辑(create_temp_email + delete_account)**抽到 `src/autoteam/mail/probe.py` 的 `probe_domain_ownership(client, domain) -> ProbeResult`**。
|
| 101 |
+
- `/api/mail-provider/probe` step=domain_ownership 与 `PUT /api/config/register-domain` 共享此 helper,**确保两条路径的语义、错误归类、回收逻辑完全一致**。
|
| 102 |
+
|
| 103 |
+
### FR-006 setup_wizard 嗅探前置
|
| 104 |
+
|
| 105 |
+
- `_verify_cloudmail()` 中的 `_sniff_provider_mismatch(provider)` 调用从"login 失败前的无害 warning"改为"启动 / Web save 流程的强阻断":**指纹错配时直接 `return False`,不再继续走 login**。
|
| 106 |
+
- Web `post_setup_save` 自动复用此判定。
|
| 107 |
+
- `_sniff_provider_mismatch` 内部增强:除了 admin/login 路由探测,加一次 GET `/setting/websiteConfig`(maillab 独有 + 含 domainList 即明确 maillab),让 cf_temp_email 服务器也能被识别(它没有 `/setting/websiteConfig`)。
|
| 108 |
+
|
| 109 |
+
### FR-007 SetupPage 分组卡片 UX
|
| 110 |
+
|
| 111 |
+
- `web/src/components/SetupPage.vue` 由现有平铺 `v-for="field"` 改为**分组卡片**:
|
| 112 |
+
- **卡片 1** Mail Provider 选择(单选 cf_temp_email / maillab,默认 cf_temp_email)
|
| 113 |
+
- **卡片 2** base_url + 凭据 + 「测试连接」按钮(顺序调 step=fingerprint → step=credentials)
|
| 114 |
+
- **卡片 3** domain 下拉框(从 fingerprint 拿 domainList) + 「验证归属」按钮(step=domain_ownership)
|
| 115 |
+
- **卡片 4** CPA / API_KEY / Playwright 代理(原样保留)
|
| 116 |
+
- 步骤未通过的卡片下游灰显;每步失败显示 error_code + 原始 message + hint。
|
| 117 |
+
- domainList 缺失(cf_temp_email 后端 / maillab 老版)时降级为自由输入框,`/api/mail-provider/probe?step=domain_ownership` 仍执行。
|
| 118 |
+
|
| 119 |
+
### FR-008 Settings 页加 mail provider 切换
|
| 120 |
+
|
| 121 |
+
- `web/src/components/Settings.vue` 当前**完全无 mail provider 切换 UI**(经核验仅含管理员登录块)。新增「邮箱后端」区块,沿用 SetupPage 同款 3 步验证 + 写入 `.env`(后端走同一条 `/api/setup/save` 路径)。
|
| 122 |
+
- 修改成功后弹出 toast 提示「重启服务后生效」(`MAIL_PROVIDER` 是模块级常量,需重启)。
|
| 123 |
+
|
| 124 |
+
### FR-009 .env.example 注释加固
|
| 125 |
+
|
| 126 |
+
- `.env.example` L5 在 `MAIL_PROVIDER=cf_temp_email` 上方加注释:**强烈推荐显式设置;不设默认 cf_temp_email 可能与你的 maillab 部署错配**。
|
| 127 |
+
- maillab 字段块从注释状态改为正常字段状态(默认值留空),与 cf_temp_email 字段同等可见性。
|
| 128 |
+
|
| 129 |
+
### FR-010 文档同步
|
| 130 |
+
|
| 131 |
+
详见 §11 文档影响清单。
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## 6. 非功能需求(NFR)
|
| 136 |
+
|
| 137 |
+
| 类别 | 要求 |
|
| 138 |
+
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| 139 |
+
| 性能 | `/api/mail-provider/probe` 任意 step P95 ≤ 5s(含网络);domainList 探测开 timeout=5s;step=domain_ownership 含创建 + 回收两次往返,P95 ≤ 8s |
|
| 140 |
+
| 可靠性 | maillab 401 自愈最多 1 次重试,避免无限循环;register-domain 探测失败时探测邮箱泄漏须以 `leaked_probe` 字段透传给前端 |
|
| 141 |
+
| 安全性 | username / password 仅作请求 body 转发到用户填的 base_url,**后端不持久化**(由 `/api/setup/save` 单独写 .env);`token_preview` 只截取前 10 字符 |
|
| 142 |
+
| 可观测性 | `/api/mail-provider/probe` 每步打 INFO 日志含 `step / detected_provider / ok`;失败打 WARNING 含 `error_code` |
|
| 143 |
+
| 国际化 | 全部错误 message 中文(沿用项目现状) |
|
| 144 |
+
| 兼容性 | 不破坏 `from autoteam.cloudmail import CloudMailClient` 兼容别名;19 处实例化 / 22 处方法调用零改动 |
|
| 145 |
+
| 速率限制 | `/api/mail-provider/probe` 端点,setup 阶段(无鉴权)单 IP 60 req/min(防扫描);进入面板后无限制 |
|
| 146 |
+
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
+
## 7. 技术方案
|
| 150 |
+
|
| 151 |
+
### 7.1 架构图(ASCII)
|
| 152 |
+
|
| 153 |
+
```
|
| 154 |
+
┌─────────────────────────────────┐
|
| 155 |
+
│ SetupPage / Settings (Vue) │
|
| 156 |
+
│ 分组卡片 + 3 步验证流 │
|
| 157 |
+
└─────────────┬───────────────────┘
|
| 158 |
+
│ POST /api/mail-provider/probe
|
| 159 |
+
│ POST /api/setup/save
|
| 160 |
+
▼
|
| 161 |
+
┌─────────────────────────────────┐
|
| 162 |
+
│ api.py (FastAPI) │
|
| 163 |
+
│ + SetupConfig (扩字段) │
|
| 164 |
+
│ + post_mail_provider_probe() │
|
| 165 |
+
│ + put_register_domain()共享 │
|
| 166 |
+
└─────────────┬───────────────────┘
|
| 167 |
+
│
|
| 168 |
+
┌────────────────────┼────────────────────┐
|
| 169 |
+
▼ ▼ ▼
|
| 170 |
+
┌────────────────┐ ┌──────────────────┐ ┌────────────────┐
|
| 171 |
+
│ setup_wizard │ │ mail/probe.py │ │ runtime_config │
|
| 172 |
+
│ _verify_cloud │ │ probe_domain_ │ │ register_domain│
|
| 173 |
+
│ + _sniff_前置 │ │ ownership(共享) │ │ 持久化 │
|
| 174 |
+
└────────┬───────┘ └────────┬─────────┘ └────────────────┘
|
| 175 |
+
│ │
|
| 176 |
+
▼ ▼
|
| 177 |
+
┌──────────────────────────────────────┐
|
| 178 |
+
│ mail/__init__.py — get_mail_client()│
|
| 179 |
+
│ 按 MAIL_PROVIDER 分发 │
|
| 180 |
+
└─────────┬──────────────────┬─────────┘
|
| 181 |
+
│ │
|
| 182 |
+
▼ ▼
|
| 183 |
+
┌──────────────────┐ ┌──────────────────┐
|
| 184 |
+
│ cf_temp_email.py │ │ maillab.py │
|
| 185 |
+
│ + 嗅探零漏判 │ │ + 401 自愈守卫 │
|
| 186 |
+
└──────────────────┘ └──────────────────┘
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
### 7.2 关键 API 契约
|
| 190 |
+
|
| 191 |
+
#### 7.2.1 `/api/mail-provider/probe` 请求体(Pydantic)
|
| 192 |
+
|
| 193 |
+
```python
|
| 194 |
+
from typing import Literal
|
| 195 |
+
from pydantic import BaseModel, Field, field_validator
|
| 196 |
+
|
| 197 |
+
class MailProviderProbeRequest(BaseModel):
|
| 198 |
+
provider: Literal["cf_temp_email", "maillab"]
|
| 199 |
+
step: Literal["fingerprint", "credentials", "domain_ownership"]
|
| 200 |
+
base_url: str = Field(..., min_length=1, max_length=512)
|
| 201 |
+
# cf_temp_email 字段
|
| 202 |
+
admin_password: str = "" # cf_temp_email 的 admin password
|
| 203 |
+
# maillab 字段
|
| 204 |
+
username: str = "" # maillab 登录邮箱
|
| 205 |
+
password: str = "" # maillab 登录密码
|
| 206 |
+
# 共用
|
| 207 |
+
domain: str = "" # step=domain_ownership 必填
|
| 208 |
+
# step=credentials/domain_ownership 时,前端把上一步拿到的 token 回传,后端不持久化
|
| 209 |
+
bearer_token: str = ""
|
| 210 |
+
|
| 211 |
+
@field_validator("base_url")
|
| 212 |
+
@classmethod
|
| 213 |
+
def _normalize_base_url(cls, v: str) -> str:
|
| 214 |
+
v = v.strip().rstrip("/")
|
| 215 |
+
if not (v.startswith("http://") or v.startswith("https://")):
|
| 216 |
+
raise ValueError("base_url 必须以 http:// 或 https:// 开头")
|
| 217 |
+
return v
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
#### 7.2.2 `/api/mail-provider/probe` 响应体
|
| 221 |
+
|
| 222 |
+
```python
|
| 223 |
+
class MailProviderProbeResponse(BaseModel):
|
| 224 |
+
ok: bool
|
| 225 |
+
step: Literal["fingerprint", "credentials", "domain_ownership"]
|
| 226 |
+
# 通用
|
| 227 |
+
error_code: str | None = None # ROUTE_NOT_FOUND/UNAUTHORIZED/FORBIDDEN_DOMAIN/PROVIDER_MISMATCH/NETWORK/TIMEOUT/UNKNOWN
|
| 228 |
+
message: str | None = None
|
| 229 |
+
hint: str | None = None
|
| 230 |
+
warnings: list[str] = []
|
| 231 |
+
# step=fingerprint 专属
|
| 232 |
+
detected_provider: Literal["cf_temp_email", "maillab", "unknown"] | None = None
|
| 233 |
+
domain_list: list[str] | None = None
|
| 234 |
+
add_verify_open: bool | None = None
|
| 235 |
+
register_verify_open: bool | None = None
|
| 236 |
+
# step=credentials 专属
|
| 237 |
+
is_admin: bool | None = None
|
| 238 |
+
user_email: str | None = None
|
| 239 |
+
token_preview: str | None = None # 仅前 10 字符,展示用
|
| 240 |
+
# step=domain_ownership 专属
|
| 241 |
+
probe_email: str | None = None
|
| 242 |
+
probe_account_id: int | None = None
|
| 243 |
+
cleaned: bool | None = None # 探测邮箱是否回收成功
|
| 244 |
+
leaked_probe: dict | None = None # cleaned=False 时给出 {email, acct_id, error}
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
#### 7.2.3 错误码枚举
|
| 248 |
+
|
| 249 |
+
| error_code | 触发条件 | 默认 hint |
|
| 250 |
+
| -------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------- |
|
| 251 |
+
| `ROUTE_NOT_FOUND` | step=fingerprint 时 base_url 既无 `/setting/websiteConfig` 也无 `/admin/address` | 检查 base_url 是否拼错(应含 /api 前缀等) |
|
| 252 |
+
| `PROVIDER_MISMATCH` | provider 与 detected_provider 不一致 | 把 MAIL_PROVIDER 改为 detected_provider |
|
| 253 |
+
| `UNAUTHORIZED` | step=credentials 时 maillab 返回 code:401,或 cf_temp_email 返回 401/403 | 检查用户名/密码 |
|
| 254 |
+
| `FORBIDDEN_DOMAIN` | step=domain_ownership 时返回 403,或 message 含 "domain" | 联系 maillab 管理员把该 domain 加入白名单 |
|
| 255 |
+
| `CAPTCHA_REQUIRED` | step=credentials 时 maillab 提示 turnstile / addVerify | 在 maillab 后台关闭 captcha |
|
| 256 |
+
| `NETWORK` | 连接被拒 / DNS 解析失败 | 检查 base_url 与防火墙 |
|
| 257 |
+
| `TIMEOUT` | 单步 5s 内无响��� | 检查 base_url 可达性 |
|
| 258 |
+
|
| 259 |
+
### 7.3 数据模型变更
|
| 260 |
+
|
| 261 |
+
无数据库变更。仅:
|
| 262 |
+
|
| 263 |
+
- `.env` 新增/启用 5 字段(`MAIL_PROVIDER` `MAILLAB_API_URL` `MAILLAB_USERNAME` `MAILLAB_PASSWORD` `MAILLAB_DOMAIN`),5 字段已存在 `.env.example` 注释中,本次取消注释 + 默认空。
|
| 264 |
+
- `runtime_config.json` 不变,继续用 `register_domain` 字段(同时被 cf_temp_email 与 maillab 读取,优先级:显式参数 > runtime_config > 环境变量)。
|
| 265 |
+
|
| 266 |
+
### 7.4 配置项变更
|
| 267 |
+
|
| 268 |
+
| 配置 | 当前状态 | 变更 |
|
| 269 |
+
| ------------------- | ---------------------------------------------- | ----------------------------------------- |
|
| 270 |
+
| `MAIL_PROVIDER` | `setup_wizard.REQUIRED_CONFIGS` 已有,但 SetupConfig 没有 | 加入 SetupConfig;Optional → Required(默认 cf_temp_email) |
|
| 271 |
+
| `MAILLAB_API_URL` | 仅 `.env.example` 注释 | 加入 SetupConfig + REQUIRED_CONFIGS;maillab 时必填 |
|
| 272 |
+
| `MAILLAB_USERNAME` | 仅 `.env.example` 注释 | 同上 |
|
| 273 |
+
| `MAILLAB_PASSWORD` | 仅 `.env.example` 注释 | 同上 |
|
| 274 |
+
| `MAILLAB_DOMAIN` | 仅 `.env.example` 注释 | 同上(可选,缺省回落 CLOUDMAIL_DOMAIN) |
|
| 275 |
+
| `CLOUDMAIL_EMAIL` | 已废弃但仍在 `.env.example` L12 留兼容空字段 | 保持现状(与本 PRD 无关) |
|
| 276 |
+
|
| 277 |
+
### 7.5 前端变更
|
| 278 |
+
|
| 279 |
+
| 文件 | 变更 |
|
| 280 |
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
| 281 |
+
| `web/src/api.js` | 新增 `probeMailProvider(payload)` 方法,POST `/api/mail-provider/probe` |
|
| 282 |
+
| `web/src/components/SetupPage.vue` | 重构为分组卡片(FR-007);从 `<input v-for="field">` 改为 `<MailProviderCard>` + `<CredentialCard>` + `<DomainCard>` |
|
| 283 |
+
| `web/src/components/MailProviderCard.vue` | **新增**;封装 provider 选择 + base_url + 凭据 + 域名 3 步验证 |
|
| 284 |
+
| `web/src/components/Settings.vue` | 新增「邮箱后端」区块(FR-008),复用 `<MailProviderCard>` |
|
| 285 |
+
|
| 286 |
+
---
|
| 287 |
+
|
| 288 |
+
## 8. 验收标准
|
| 289 |
+
|
| 290 |
+
| AC ID | FR | 步骤 | 通过判据 |
|
| 291 |
+
| ------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
|
| 292 |
+
| AC-001 | FR-001 | Web 面板 SetupPage 选 maillab,填全 4 字段 + 点保存;查 `.env` | `MAIL_PROVIDER=maillab` 与 `MAILLAB_*` 4 字段全部落盘 |
|
| 293 |
+
| AC-002 | FR-002 | mock 一个 base_url 对 `/admin/address` 回 `200 + {}`;调 `cf_temp_email.login()` | 抛 `CloudMail 登录响应不像 dreamhunter2333/...` |
|
| 294 |
+
| AC-003 | FR-003 | 启动后等 maillab token 失效,触发 `client.list_accounts()` | 内部自动 login 一次,业务方法返回正确数据,日志含 `[maillab] token 已自愈` |
|
| 295 |
+
| AC-004 | FR-004 | 调 `/api/mail-provider/probe` step=fingerprint,base_url 指向 maillab | 返回 `detected_provider=maillab` + 非空 `domain_list` |
|
| 296 |
+
| AC-005 | FR-004 | 同上,base_url 指向 cf_temp_email | 返回 `detected_provider=cf_temp_email` |
|
| 297 |
+
| AC-006 | FR-004 | step=fingerprint,但 provider=cf_temp_email、base_url 是 maillab | `ok=false, error_code=PROVIDER_MISMATCH, hint="把 MAIL_PROVIDER 改为 maillab"` |
|
| 298 |
+
| AC-007 | FR-004 | step=domain_ownership,domain 不在 maillab 白名单 | `ok=false, error_code=FORBIDDEN_DOMAIN` |
|
| 299 |
+
| AC-008 | FR-005 | `PUT /api/config/register-domain` 与 `/api/mail-provider/probe?step=domain_ownership` ��中同一个 domain | 两端返回 `probe_email` 一致前缀(`probe{ts}`),探测邮箱回收语义一致 |
|
| 300 |
+
| AC-009 | FR-006 | 启动时 `MAIL_PROVIDER=cf_temp_email` 但 `CLOUDMAIL_BASE_URL` 指向 maillab | `_verify_cloudmail` 不调 `client.login()` 即 `return False`,日志 `[验证] 协议错配...` |
|
| 301 |
+
| AC-010 | FR-007 | 在 SetupPage 选 cf_temp_email | maillab 4 字段隐藏,只看到 `CLOUDMAIL_*` |
|
| 302 |
+
| AC-011 | FR-007 | step=credentials 失败,点击下游域名卡片 | 域名卡片灰显,无法操作 |
|
| 303 |
+
| AC-012 | FR-008 | Settings 页修改 mail provider 后保存 | toast 显示「重启服务后生效」,`.env` 已更新 |
|
| 304 |
+
| AC-013 | FR-009 | 全新部署用户复制 `.env.example` 后启动 | 用户能看到 maillab 注释突出引导 |
|
| 305 |
+
| AC-014 | 全部 | issue#1 截图复现:用户在 SetupPage 填 maillab 服务器地址但选 cf_temp_email | 测试连接按钮显示 `PROVIDER_MISMATCH` + hint,**不**进 step=credentials |
|
| 306 |
+
|
| 307 |
+
---
|
| 308 |
+
|
| 309 |
+
## 9. 测试计划
|
| 310 |
+
|
| 311 |
+
### 9.1 单元测试
|
| 312 |
+
|
| 313 |
+
| 测试模块 | 用例 |
|
| 314 |
+
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
| 315 |
+
| `tests/test_mail_cf_temp_email_sniff.py` | login() 响应:`{"results":[]}` ✓;`{}` ✗;`{"code":401}` ✗;`{"code":200,"data":{}}` ✗(全部 ✗ 应 raise) |
|
| 316 |
+
| `tests/test_mail_maillab_self_heal.py` | 模拟首次 401 后 login 成功 + 重试拿数据;login 仍 401 时抛错;login 内部递归不再触发自愈 |
|
| 317 |
+
| `tests/test_mail_provider_probe.py` | step=fingerprint 三种 detected_provider(cf/maillab/unknown);step=credentials 401/200;step=domain_ownership 403/200 |
|
| 318 |
+
| `tests/test_mail_probe_helper.py` | `probe_domain_ownership` 共享函数:成功 + cleaned=True;成功 + 删除失败 → leaked_probe 透传 |
|
| 319 |
+
| `tests/test_setup_wizard_sniff_block.py` | `_verify_cloudmail` 在指纹错配时直接 return False,不创建 client |
|
| 320 |
+
|
| 321 |
+
### 9.2 集成测试
|
| 322 |
+
|
| 323 |
+
- `tests/integration/test_setup_save_e2e.py`:POST `/api/setup/save`(maillab 全字段 + 模拟 maillab server)→ `.env` 含 5 个新字段,服务重载后 `MAIL_PROVIDER=maillab` 生效
|
| 324 |
+
- `tests/integration/test_register_domain_shared.py`:`PUT /api/config/register-domain` 与 `POST /api/mail-provider/probe?step=domain_ownership` 对同 domain 的输出一致
|
| 325 |
+
|
| 326 |
+
### 9.3 E2E
|
| 327 |
+
|
| 328 |
+
- Playwright:SetupPage 4 步流(provider 选 maillab → fingerprint → credentials → domain_ownership → save)全绿;Settings 页同款流(已部署后切换)
|
| 329 |
+
|
| 330 |
+
### 9.4 手测清单
|
| 331 |
+
|
| 332 |
+
| 场景 | 期望 |
|
| 333 |
+
| ------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
| 334 |
+
| 全新 docker 部署,首次进 SetupPage 选 cf_temp_email | 仅看到 cf 字段,maillab 字段不渲染 |
|
| 335 |
+
| 同上,选 maillab 后填 skymail.ink demo | fingerprint 拉到 ≥1 个 domain,credentials 显示 is_admin |
|
| 336 |
+
| 已部署用户在 Settings 切 maillab → cf_temp_email | 保存后 toast 提示重启;重启后业务正常 |
|
| 337 |
+
| 故意把 maillab base_url 填到 cf_temp_email 字段 | 测试连接按钮显示 PROVIDER_MISMATCH(issue#1 复现) |
|
| 338 |
+
| 让 maillab 实例 token 过期(关 worker 30 分钟后再点 sync_accounts) | 业务正常,日志显示 token 自愈 |
|
| 339 |
+
|
| 340 |
+
---
|
| 341 |
+
|
| 342 |
+
## 10. 灰度/回滚策略
|
| 343 |
+
|
| 344 |
+
- **灰度**:本 PRD 无远程 feature flag(配置类变更天然分发到每个用户实例),通过分阶段 PR 控制风险:
|
| 345 |
+
- **Phase 1**(P0,无 UI 风险):FR-001(SetupConfig 扩字段)+ FR-002(嗅探零漏判)+ FR-003(401 自愈)+ FR-006(嗅探前置)+ FR-009(.env.example)+ FR-010 文档
|
| 346 |
+
- **Phase 2**(P0,UI 风险):FR-004(probe 端点)+ FR-005(register-domain 复用)+ FR-007(SetupPage 改造)
|
| 347 |
+
- **Phase 3**(P1):FR-008(Settings 页切换 UI)
|
| 348 |
+
- **回滚**:每个 Phase 独立 PR,失败即 `git revert`。Phase 1 不影响业务路径,可即时回滚;Phase 2 / 3 涉及前端构建,需 `web/dist` 重新打包。
|
| 349 |
+
- **数据回滚**:`.env` 字段写入由用户掌控,本身可手动改回。
|
| 350 |
+
|
| 351 |
+
---
|
| 352 |
+
|
| 353 |
+
## 11. 文档影响清单
|
| 354 |
+
|
| 355 |
+
| 文档 | 章节 | 改动类型 | 状态(P0=必做) |
|
| 356 |
+
| --------------------------------- | --------------------------------------------------- | -------- | --------------- |
|
| 357 |
+
| `docs/getting-started.md` | 准备工作 / 1. 搭建临时邮箱 | 改写 | P0 |
|
| 358 |
+
| `docs/getting-started.md` | 第二步:配置 / 直接部署 | 改写 | P0 |
|
| 359 |
+
| `docs/getting-started.md` | (新增)第二点五步:验证邮箱后端归属 | 新增 | P0 |
|
| 360 |
+
| `docs/configuration.md` | `.env` 配置项表(MAIL_PROVIDER 必填化) | 改写 | P0 |
|
| 361 |
+
| `docs/configuration.md` | Mail Provider 切换(顺序与推荐反转) | 改写 | P0 |
|
| 362 |
+
| `docs/configuration.md` | (新增)邮箱归属验证 | 新增 | P1 |
|
| 363 |
+
| `docs/configuration.md` | ⚠️ 协议错配排查(链接 SetupPage) | 改写 | P0 |
|
| 364 |
+
| `docs/mail-provider-design.md` | §6「未知项 5 项」标记为已验证 | 改写 | P0 |
|
| 365 |
+
| `docs/mail-provider-design.md` | (新增)§7 skymail.ink API 全表 | 新增 | P1 |
|
| 366 |
+
| `docs/troubleshooting.md` | Web 面板相关 / 配置保存 401 | 增补 | P0 |
|
| 367 |
+
| `docs/troubleshooting.md` | (新增)邮箱后端 | 新增 | P1 |
|
| 368 |
+
| `docs/api.md` | 初始配置 API / 文档化 `/api/mail-provider/probe` | 增补 | P0 |
|
| 369 |
+
| `docs/api.md` | 域名管理 API / 复用共享 helper 说明 | 改写 | P0 |
|
| 370 |
+
| `README.md` | Features 增补 maillab 全量集成 | 增补 | P1 |
|
| 371 |
+
| `.env.example` | 注释突出 MAIL_PROVIDER 必填,maillab 字段取消注释 | 改写 | P0 |
|
| 372 |
+
|
| 373 |
+
---
|
| 374 |
+
|
| 375 |
+
## 12. 风险登记册
|
| 376 |
+
|
| 377 |
+
| Risk ID | 风险描述 | Owner | 严重度 | Mitigation |
|
| 378 |
+
| ------- | ---------------------------------------------------------------------------------------------- | ----------- | ------ | ------------------------------------------------------------------------------------------------ |
|
| 379 |
+
| R-001 | maillab v2.x 之前 `/setting/websiteConfig` 不返回 `domainList`(skymail.ink 当前 v2.8.0 才有) | impl-1 | 中 | step=fingerprint 缺 domainList 时降级:不阻断,返回 `domain_list=null`,前端切自由输入框 |
|
| 380 |
+
| R-002 | maillab 启用 Turnstile/addVerify 时 step=credentials 失败 | impl-1 | 中 | 错误归类 `CAPTCHA_REQUIRED`,hint「请到 maillab 后台关闭 captcha」;不实现 captcha bypass |
|
| 381 |
+
| R-003 | `/api/mail-provider/probe` setup 阶段无鉴权,被外部当端口扫描器 | impl-2 | 中 | NFR 速率限制 60 req/min;面板初始化后此端点强制 API_KEY 鉴权 |
|
| 382 |
+
| R-004 | cf_temp_email 嗅探收紧后,误伤第三方 fork(如 unsendapp 等响应不带 results 字段) | impl-1 | 低 | 错误信息提示 `AUTOTEAM_SKIP_VERIFY=1` 绕过(已存在);记录到 troubleshooting.md |
|
| 383 |
+
| R-005 | maillab 401 自愈遇到密码本身错误时,会陷入 login 失败 → throw → 上层重试 → login 失败循环 | impl-1 | 中 | 用 thread-local guard,login() 函数内部不触发自愈;login 失败抛 `MAILLAB_AUTH_FAILED`,业务层不重试 |
|
| 384 |
+
| R-006 | SetupPage 重构破坏现有 e2e 测试 | impl-2 | 中 | Phase 2 单独 PR;先添加新 testid,旧 testid 兼容保留 1 个版本 |
|
| 385 |
+
| R-007 | Settings 页 mail provider 切换后,正在跑的任务用旧 client 引用 | impl-2 | 高 | toast 明确「重启服务后生效」;不在运行时 hot-swap client |
|
| 386 |
+
| R-008 | `MAIL_PROVIDER` 在 `setup_wizard.REQUIRED_CONFIGS` 默认值 `cf_temp_email`,但 SetupConfig 默认值如果不一致会引起冲突 | impl-1 | 低 | SetupConfig 默认值与 REQUIRED_CONFIGS 同步,加 unit test 校验默认值一致性 |
|
| 387 |
+
|
| 388 |
+
---
|
| 389 |
+
|
| 390 |
+
## 13. 未决问题(Open Questions)
|
| 391 |
+
|
| 392 |
+
| OQ ID | 问题 | 决策建议(待 team-lead 确认) |
|
| 393 |
+
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
| 394 |
+
| OQ-1 | step=credentials 通过后,是否要在前端把 token 暂存于 vue 内存以便 step=domain_ownership 直接调,还是后端 session 持有? | **建议前端暂存**,后端无状态;后端只校验签名,这样后端不增加 session 复杂度 |
|
| 395 |
+
| OQ-2 | `is_admin=false`(非管理员账号)是否应阻断保存? | **建议警告但允许保存**,因为 `/account/add` 普通用户也能调;`is_admin=false` 时给 hint 即可 |
|
| 396 |
+
| OQ-3 | `_sniff_provider_mismatch` 改为强阻断后,如果用户用的是某种 fork(比如自部署改了 `/admin/address` 为非标响应),会被误伤 | **建议加 `AUTOTEAM_SKIP_PROVIDER_SNIFF=1` 环境变量逃生口** |
|
| 397 |
+
| OQ-4 | maillab 401 自愈是否应记录到 SQLite/runtime_config,统计 token 失效频率以便后续做 keep-alive? | **暂不做**,Phase 1 先 just-in-time 自愈;有 telemetry 需求时再立 PRD |
|
| 398 |
+
| OQ-5 | `/api/mail-provider/probe` 是否要支持 GET(仅 step=fingerprint,可被书签化)? | **不做**,统一 POST;GET 容易被浏览器历史记录泄露 base_url |
|
| 399 |
+
| OQ-6 | 如果 maillab 实例返回的 `domainList` 是空数组(部署没配 domain),前端是否仍允许进 step=domain_ownership? | **不允许**,直接报错「该实例未配置任何邮箱域名,请先在 maillab 后台 → 设置 → 域名 配置」 |
|
| 400 |
+
|
| 401 |
+
---
|
| 402 |
+
|
| 403 |
+
## 14. 实施 Story Map(可拆 sub-task 列表)
|
| 404 |
+
|
| 405 |
+
> 按照 §10 的 Phase 分组;每个 sub-task 对应一个独立可 review 的 PR。
|
| 406 |
+
|
| 407 |
+
### Phase 1 — 后端基础(无 UI 风险)
|
| 408 |
+
|
| 409 |
+
| Sub-task ID | 任务 | 涉及文件 | 估算 |
|
| 410 |
+
| ----------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ---- |
|
| 411 |
+
| ST-101 | SetupConfig 扩字段 + REQUIRED_CONFIGS 同步 | `src/autoteam/api.py`、`src/autoteam/setup_wizard.py` | 0.5d |
|
| 412 |
+
| ST-102 | `cf_temp_email.login()` 嗅探零漏判 + create_temp_email 同步收紧 | `src/autoteam/mail/cf_temp_email.py` | 0.5d |
|
| 413 |
+
| ST-103 | `MaillabClient` 401 自愈守卫 | `src/autoteam/mail/maillab.py` | 0.5d |
|
| 414 |
+
| ST-104 | `_sniff_provider_mismatch` 改强阻断 + 增 `/setting/websiteConfig` 探测 | `src/autoteam/setup_wizard.py` | 0.5d |
|
| 415 |
+
| ST-105 | `.env.example` 注释加固 | `.env.example` | 0.1d |
|
| 416 |
+
| ST-106 | Phase 1 文档同步(getting-started / configuration / mail-provider-design / troubleshooting / api / .env.example) | `docs/*.md` 8 文件 | 1d |
|
| 417 |
+
| ST-107 | Phase 1 单元测试 | `tests/test_mail_*.py` 4 文件 | 0.5d |
|
| 418 |
+
|
| 419 |
+
### Phase 2 — Probe 端点 + SetupPage(P0)
|
| 420 |
+
|
| 421 |
+
| Sub-task ID | 任务 | 涉及文件 | 估算 |
|
| 422 |
+
| ----------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------- | ---- |
|
| 423 |
+
| ST-201 | 新建 `src/autoteam/mail/probe.py` 共享 helper | `src/autoteam/mail/probe.py`(新建) | 0.5d |
|
| 424 |
+
| ST-202 | `/api/mail-provider/probe` 端点实现 + 错误码归类 | `src/autoteam/api.py` | 1d |
|
| 425 |
+
| ST-203 | `register-domain` PUT 复用 `probe_domain_ownership` | `src/autoteam/api.py` | 0.3d |
|
| 426 |
+
| ST-204 | 前端 `MailProviderCard.vue` 新组件 | `web/src/components/MailProviderCard.vue`(新建) | 1d |
|
| 427 |
+
| ST-205 | SetupPage.vue 重构为分组卡片 | `web/src/components/SetupPage.vue` | 1d |
|
| 428 |
+
| ST-206 | api.js 新增 `probeMailProvider` | `web/src/api.js` | 0.1d |
|
| 429 |
+
| ST-207 | Phase 2 集成 + E2E 测试 | `tests/integration/*.py`、`tests/e2e/*.spec.ts` | 1d |
|
| 430 |
+
|
| 431 |
+
### Phase 3 — Settings 页切换 UI(P1)
|
| 432 |
+
|
| 433 |
+
| Sub-task ID | 任务 | 涉及文件 | 估算 |
|
| 434 |
+
| ----------- | ----------------------------------------------- | ------------------------------------------------- | ---- |
|
| 435 |
+
| ST-301 | Settings.vue 增「邮箱后端」区块,复用 MailProviderCard | `web/src/components/Settings.vue` | 0.5d |
|
| 436 |
+
| ST-302 | 切换后 toast「重启服务后生效」 | `web/src/components/Settings.vue` | 0.1d |
|
| 437 |
+
| ST-303 | Phase 3 手测 | — | 0.3d |
|
| 438 |
+
|
| 439 |
+
**总估算**:Phase 1 ≈ 3.6d / Phase 2 ≈ 4.9d / Phase 3 ≈ 0.9d,合计 ≈ 9.4d(单人)
|
| 440 |
+
|
| 441 |
+
---
|
| 442 |
+
|
| 443 |
+
## 附:核验记录(批判性审查)
|
| 444 |
+
|
| 445 |
+
> 调研报告 §A.3 提到「`REQUIRED_CONFIGS` 与 Web 面板 SetupPage 没有收 maillab 字段」,经直接读取 `setup_wizard.py:20-35` 发现 **REQUIRED_CONFIGS 已含 `MAIL_PROVIDER`**(2026-04 重构后已加),但 `MAILLAB_*` 4 字段仍缺。**推翻调研结论的一半**:不是「全部缺失」,而是「provider 选择项已加,maillab 详细字段仍缺 + SetupConfig 端不接收任何 mail 字段」。
|
| 446 |
+
|
| 447 |
+
> 调研报告 §D.2 描述的 401 协议错配,**经过核验代码已完成 round-2 修复**:
|
| 448 |
+
> - `cf_temp_email.py:106` 已加嗅探(2026-04-25 commit `de7cad3`)
|
| 449 |
+
> - `setup_wizard.py:259` 已在 `_verify_cloudmail` 第一步调 `_sniff_provider_mismatch`
|
| 450 |
+
> - `_sniff_provider_mismatch` 当前是 warning 级别,未阻断
|
| 451 |
+
>
|
| 452 |
+
> 因此 PRD 重点不是「补齐嗅探」(已有),而是「**收紧条件 + 改强阻断**」。
|
| 453 |
+
|
| 454 |
+
> 调研报告 §F.2-7 提到「register-domain 探测路径与 §C step=domain_ownership 是否合并」,经核验 `api.py:1631-1673` 已含完整 create+delete 逻辑,**应当抽 helper 合并**(FR-005)。
|
| 455 |
+
|
|
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PRD-2: 账号生命周期与配额加固
|
| 2 |
+
|
| 3 |
+
## 0. 元数据
|
| 4 |
+
|
| 5 |
+
| 字段 | 内容 |
|
| 6 |
+
|---|---|
|
| 7 |
+
| 编号 | PRD-2 |
|
| 8 |
+
| 名称 | 账号生命周期与配额加固(plan_type / quota / add-phone / 删除链 / 席位策略) |
|
| 9 |
+
| 覆盖 Issue | #2(席位策略与免费号删除)+ #4(OAuth + add-phone)+ #6(踢出后 401 + 重 OAuth) |
|
| 10 |
+
| 主笔 | prd-lifecycle |
|
| 11 |
+
| 时间 | 2026-04-26 |
|
| 12 |
+
| 优先级 | P0(用户使用率最大瓶颈,3 个 issue 共因) |
|
| 13 |
+
| 关联 PRD | PRD-1(独立)、PRD-3(独立)、PRD-4(独立) |
|
| 14 |
+
| 输入资料 | research/issue-2/4/6 + synthesis.md |
|
| 15 |
+
| 共因合并依据 | synthesis §1:A/B/C/D/E 必须**同 PR**实施,半截修复会引入新故障态 |
|
| 16 |
+
| 不在范围 | Mail Provider(PRD-1)、Docker 镜像守卫(PRD-3)、Playwright 一致性(PRD-4) |
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## 1. 背景与问题陈述
|
| 21 |
+
|
| 22 |
+
用户在使用过程中持续暴露 4 类生命周期问题,经调研归并为 **5 个共因点 + 3 个独属点**。
|
| 23 |
+
|
| 24 |
+
### 1.1 用户子症状(4 issue 合并视角)
|
| 25 |
+
|
| 26 |
+
| 子症状 | 来源 | 一句话 |
|
| 27 |
+
|---|---|---|
|
| 28 |
+
| **S1**:`self_serve_business_usage_based` 等新 plan 不识别,号能注册但 Codex 调用 429 | #2 / #6 | `codex_auth.py:111` 写死从 JWT 取字段,下游 `manager.py:1468/2490`、`manual_account.py:234` 全用 `if plan_type == "team"` 二元分支 |
|
| 29 |
+
| **S2**:Workspace 配额=0 时 wham/usage 显示"剩余 100%",但调用立刻 429 | #2 / #6 | `codex_auth.py:1582` 只看 `used_percent>=100`,不能识别 `used=0 + total=0` 的"无配额已分配"形态 |
|
| 30 |
+
| **S3**:OAuth 全程没有 add-phone 探针,撞风控就静默 30s 超时 | #4 / #6 | `codex_auth.py` 全文 0 处 `detect_phone_verification`,而 invite 注册阶段已有完整探针(`invite.py:106`) |
|
| 31 |
+
| **S4**:`_run_post_register_oauth` 注册收尾不验配额,假入池 | #6 | `manager.py:1463` 拿到 bundle 立刻 `STATUS_ACTIVE`,无 `check_codex_quota` 探测;手动添加 (`manual_account.py:263`)是有的,接口对称缺失 |
|
| 32 |
+
| **S5**:管理员手动从 ChatGPT 后台踢号 → 子号 401,但本地仅标 STANDBY | #6 | `manager.py:540` `not in_team and status==active → STANDBY`,与"自然待机"不区分,被踢号会被反复 reinvite 死循环 |
|
| 33 |
+
| **S6 (#2 独属)**:邀请席位写死 default,无法配置默认走 codex 席位 | #2 | `invite.py:496` `seat_type="default"` 硬编码;`chatgpt_api.py:1487-1509` PATCH 升级链路单向 |
|
| 34 |
+
| **S7 (#2 独属)**:已退出 Team 走 personal 的免费号,从 UI 删不掉 | #2 | `account_ops.py:78-84` 删除链强依赖主号 session(`fetch_team_state`),personal 号无 Team 关系也走该路径 |
|
| 35 |
+
| **S8 (#6 独属)**:reinvite_account 拿到非 team plan 时,只 kick 一次后留 standby,下轮再选中循环失败 | #6 | `manager.py:2490-2494` `_cleanup_team_leftover` + `STATUS_STANDBY`,未推到 `STATUS_AUTH_INVALID` 终态 |
|
| 36 |
+
|
| 37 |
+
### 1.2 共因抽象(synthesis §1)
|
| 38 |
+
|
| 39 |
+
| ID | 共因 | 关联 Issue |
|
| 40 |
+
|---|---|---|
|
| 41 |
+
| A | `plan_type` 白名单(`SUPPORTED_PLAN_TYPES`) | #2 + #6 |
|
| 42 |
+
| B | `wham/usage` 增加 `no_quota` 分类 | #2 + #6 |
|
| 43 |
+
| C | `login_codex_via_browser` 接 add-phone 探针 | #4 + #6 |
|
| 44 |
+
| D | `_run_post_register_oauth` 加 quota probe | #6 |
|
| 45 |
+
| E | `sync_account_states` 区分被踢/待机 | #6 |
|
| 46 |
+
|
| 47 |
+
A/B/C/D/E **必须同 PR**:任何半截修复都会让其它共因点保留旧故障(例:仅修 D 不修 B,probe 跑出 ok 但实际 quota=0,假入池依旧)。
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## 2. 目标(SMART)
|
| 52 |
+
|
| 53 |
+
| 编号 | 目标 | 量化指标 | 度量手段 |
|
| 54 |
+
|---|---|---|---|
|
| 55 |
+
| G1 | 拿到 OAuth bundle 后必须能识别 plan_type 是否被支持 | 100% 覆盖白名单外的 plan_type 立即标 `STATUS_AUTH_INVALID` 或拒收 bundle | `register_failures.json` `category="plan_unsupported"` 计数 |
|
| 56 |
+
| G2 | wham/usage 能识别 `no_quota` 形态 | total=0 / remaining=0 / reset_at=0 任一命中 → 归 `no_quota`,前端 UI 显示明确文案 | 集成测试 + 用户 manual 验证 |
|
| 57 |
+
| G3 | OAuth 命中 add-phone 必须被识别并且不再静默超时 30s | 4 个接入点(about-you / consent / callback / personal 拒收前)各自抛 `RegisterBlocked(is_phone=True)`,2s 内退出 | 单测 mock + 生产 `record_failure` 计数 |
|
| 58 |
+
| G4 | `_run_post_register_oauth` 能区分 ok / exhausted / no_quota / auth_error / network_error | 5 分类 100% 覆盖,exhausted+no_quota → 不入 ACTIVE 池 | 集成测试 |
|
| 59 |
+
| G5 | sync_account_states 能区分人工踢出 vs 自然待机 | 人工踢出 → STATUS_AUTH_INVALID,自然待机 → STATUS_STANDBY,两者不混淆 | 模拟踢人 + reconcile 验证 |
|
| 60 |
+
| G6 | 邀请席位策略可配置 | `PREFERRED_SEAT_TYPE` 取 `chatgpt`/`codex`,UI 可调,生效后 PATCH 不被调用 | 抓包 + 日志 |
|
| 61 |
+
| G7 | personal 子号删除不依赖主号 session | personal/auth_invalid 删除路径短路 `fetch_team_state` | 主号 session 失效场景下批量删除成功率 100% |
|
| 62 |
+
| G8 | reinvite_account 拿到非 team plan 时收敛到终态 | 一次失败即推 `STATUS_AUTH_INVALID`,reconcile 自动接力 | 状态转移日志 |
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## 3. 非目标
|
| 67 |
+
|
| 68 |
+
- **不**重写 OAuth 主流程(用户硬要求"不要脱离原本流程太多")。
|
| 69 |
+
- **不**接入 SMS pool 自动绑定手机号(违反 ToS,封号风险)。
|
| 70 |
+
- **不**绕过 add-phone 风控(检测到即放弃 + 标记)。
|
| 71 |
+
- **不**做 IP 池切换 / 自动重试(留作后续 PRD,本期只做检测 + 分类)。
|
| 72 |
+
- **不**改动主号路径 `login_codex_via_session` / `SessionCodexAuthFlow` 的 add-phone 处置(主号通常已绑定,极罕见)。
|
| 73 |
+
- **不**改前端"补登录"按钮的交互流程(OAuth 模式选择 UI 是 P2,后置 PR)。
|
| 74 |
+
- **不**新增 `STATUS_PHONE_REQUIRED` 状态(round-3 已新增 AUTH_INVALID/ORPHAN,本次复用 + register_failures 子分类)。
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## 4. 用户故事
|
| 79 |
+
|
| 80 |
+
- **US-1**(场景 S1+S5)作为运营人员,我希望系统识别 OpenAI 后端返回的新 `plan_type`(如 `self_serve_business_usage_based`),不要把不可用账号塞进 ACTIVE 池让 CPA 调用 429。
|
| 81 |
+
- **US-2**(场景 S2)作为管理员,我看到的"剩余额度 100%"必须真实可用,不能名义剩余实际 429。
|
| 82 |
+
- **US-3**(场景 S3)作为运维,在 OAuth 阶段命中 add-phone 时,必须立即知晓(失败明细分类 `oauth_phone_blocked`),不要静默 30s 超时再写"未获取到 auth code"。
|
| 83 |
+
- **US-4**(场景 S4)作为账号管理者,从注册→入池每一步的状态变化都应严格反映"账号是否真可用"。
|
| 84 |
+
- **US-5**(场景 S5)作为团队管理员,我从 ChatGPT 后台手动踢一个号后,本地系统应识别为"被踢"(STATUS_AUTH_INVALID),让 reconcile 自动接管,不要反复 reinvite。
|
| 85 |
+
- **US-6**(场景 S6)作为席位策略调度者,我希望能在 UI 上设置"默认走 codex 席位",留出 ChatGPT 主席位给真人。
|
| 86 |
+
- **US-7**(场景 S7)作为账号清理者,即便主号 session 已失效,我仍能删除已退出 Team 的 personal 子号。
|
| 87 |
+
- **US-8**(场景 S8)作为 standby 池监管者,reinvite 拿到非 team plan 应立即进入终态,不要反复试。
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
## 5. 功能需求(FR)
|
| 92 |
+
|
| 93 |
+
### 5.1 [共因 A] plan_type 白名单(`SUPPORTED_PLAN_TYPES`)
|
| 94 |
+
|
| 95 |
+
**FR-A1** 在 `accounts.py` 同级新增常量集 `SUPPORTED_PLAN_TYPES = frozenset({"team", "free", "plus", "pro"})`(全小写)。
|
| 96 |
+
|
| 97 |
+
**FR-A2** 提供 `is_supported_plan(plan_type: str) -> bool` 工具函数,内部 `.lower().strip()` 后比对。
|
| 98 |
+
|
| 99 |
+
**FR-A3** `codex_auth.py:_exchange_auth_code`(L100-116)拿到 bundle 后,**写入新字段** `bundle["plan_supported"] = is_supported_plan(plan_type)`,不直接拒收(避免破坏现有 personal 模式校验路径)。
|
| 100 |
+
|
| 101 |
+
**FR-A4** 4 个下游消费点统一改造:
|
| 102 |
+
- `manual_account._finalize_account` (L233):`if plan_type == "team"` → 改为白名单查询。`plan_supported=False` → `STATUS_AUTH_INVALID` + `record_failure(category="plan_unsupported")` + 不写 last_quota。
|
| 103 |
+
- `manager._run_post_register_oauth` (L1467):同上,Team 分支 plan_supported=False → 不进 ACTIVE,改 STATUS_AUTH_INVALID。
|
| 104 |
+
- `manager.reinvite_account` (L2489):保持 `plan_type != "team"` 失败语义,但额外记录 `category="plan_drift"`,标记 `auth_file=None` + `STATUS_AUTH_INVALID`(见 FR-5.8)。
|
| 105 |
+
- `cpa_sync._infer_plan_from_filename`(L132-140):允许识别新增字面量(可选)。
|
| 106 |
+
|
| 107 |
+
**FR-A5** 字面量小写归一化:任何对 `plan_type` 的判定都先 `.lower()`,避免 OpenAI 返回 `Team` / `Self_Serve_*` 大小写漂移。
|
| 108 |
+
|
| 109 |
+
### 5.2 [共因 B] wham/usage `no_quota` 分类
|
| 110 |
+
|
| 111 |
+
**FR-B1** 扩展 `check_codex_quota`(`codex_auth.py:1618`)返回值,在原 4 分类基础上新增 `("no_quota", info)`:
|
| 112 |
+
- 触发条件(任一命中):
|
| 113 |
+
1. `rate_limit.primary_window.limit == 0` 且 `used_percent == 0`
|
| 114 |
+
2. `rate_limit.primary_window.reset_at == 0` 且 `used_percent == 0`
|
| 115 |
+
3. `rate_limit.primary_window.remaining == 0` 且 `total == 0`
|
| 116 |
+
4. 200 OK 但 `rate_limit` 字段为空 / 缺 `primary_window`(空载也是 no_quota 信号)
|
| 117 |
+
- 返回 info 形如 `{"reason": "no_quota_assigned", "raw_rate_limit": {...}}`
|
| 118 |
+
|
| 119 |
+
**FR-B2** `quota_info` 字段扩 `total` / `remaining`(若接口提供):
|
| 120 |
+
```python
|
| 121 |
+
quota_info = {
|
| 122 |
+
"primary_pct": primary.get("used_percent", 0),
|
| 123 |
+
"primary_resets_at": primary.get("reset_at", 0),
|
| 124 |
+
"primary_total": primary.get("limit", 0) or primary.get("total", 0),
|
| 125 |
+
"primary_remaining": primary.get("remaining"),
|
| 126 |
+
"weekly_pct": secondary.get("used_percent", 0),
|
| 127 |
+
"weekly_resets_at": secondary.get("reset_at", 0),
|
| 128 |
+
}
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
**FR-B3** `get_quota_exhausted_info`(L1572)在判 `primary_pct >= 100` 之前加一道 `primary_total == 0` 短路,返回 `{"window": "no_quota", "resets_at": int(time.time() + 86400)}`(24h 内不再探测)。
|
| 132 |
+
|
| 133 |
+
**FR-B4** 9 个 `check_codex_quota` 调用方(api.py 3 + manager.py 5 + manual_account.py 1)都需要识别 `no_quota`:统一处置为"标 STATUS_EXHAUSTED 但 quota_resets_at=time+86400"或新增分支 STATUS_AUTH_INVALID(取决于业务语义)。**默认实现:no_quota → STATUS_AUTH_INVALID + auth_file 保留(供调试)+ register_failures `category="no_quota_assigned"`**。
|
| 134 |
+
|
| 135 |
+
### 5.3 [共因 C] `login_codex_via_browser` 接入 add-phone 探针
|
| 136 |
+
|
| 137 |
+
**FR-C1** 在 `codex_auth.py` 顶部 `from autoteam.invite import RegisterBlocked, assert_not_blocked`(无循环依赖,invite.py 不引 codex_auth)。
|
| 138 |
+
|
| 139 |
+
**FR-C2** **4 个接入点**(以 codex_auth.py:250 为基准行号):
|
| 140 |
+
| 编号 | 行号 | 时机 | 调用 |
|
| 141 |
+
|---|---|---|---|
|
| 142 |
+
| C-P1 | L568(about-you 入口前) | `if "about-you" in page.url:` 之前 | `assert_not_blocked(page, "oauth_about_you")` |
|
| 143 |
+
| C-P2 | L612-880(consent 10 次循环每轮) | `for step in range(10):` 内每次 try 块开头 | `assert_not_blocked(page, f"oauth_consent_{step}")` |
|
| 144 |
+
| C-P3 | L884(等 callback 前) | `for _ in range(30):` 之前 | `assert_not_blocked(page, "oauth_callback_wait")` |
|
| 145 |
+
| C-P4 | L932(personal 拒收 bundle 之前) | `if use_personal:` 之前 | `assert_not_blocked(page, "oauth_personal_check")`(防御性,通常 callback 前已拦截) |
|
| 146 |
+
|
| 147 |
+
**FR-C3** `RegisterBlocked` 异常向上抛,`login_codex_via_browser` 调用方一律 `try/except RegisterBlocked` 并按调用点分类:
|
| 148 |
+
- **api.py:1479** 补登录:转 HTTP 409 + body `{"error": "phone_required", "step": ...}`
|
| 149 |
+
- **manager.py:1057** _check_pending_invites:`record_failure(category="oauth_phone_blocked")` + 删账号
|
| 150 |
+
- **manager.py:1431** personal OAuth:`delete_account` + `record_failure(category="oauth_phone_blocked")`
|
| 151 |
+
- **manager.py:1463** Team OAuth 收尾:`update_account(STATUS_AUTH_INVALID)` + `record_failure(category="oauth_phone_blocked")`
|
| 152 |
+
- **manager.py:2466** reinvite_account:`_cleanup_team_leftover("oauth_phone_blocked")` + `STATUS_AUTH_INVALID`(见 5.8)
|
| 153 |
+
|
| 154 |
+
**FR-C4** 命中 add-phone 立即调 `_screenshot(page, f"codex_phone_blocked_{step}.png")` 留证据,便于回放。
|
| 155 |
+
|
| 156 |
+
**FR-C5** `register_failures.py` `category` 注释扩 `oauth_phone_blocked`(与注册阶段 `phone_blocked` 区分,便于统计与告警分级)。
|
| 157 |
+
|
| 158 |
+
### 5.4 [共因 D] `_run_post_register_oauth` 加 quota probe
|
| 159 |
+
|
| 160 |
+
**FR-D1** `manager.py:1463-1486` Team 分支获得 bundle 后,追加 quota probe 段:
|
| 161 |
+
```python
|
| 162 |
+
# 拿到 bundle 后,与 manual_account._finalize_account(L260-272)对称,
|
| 163 |
+
# 不能让 quota=0 / no_quota / auth_error 的号假入 ACTIVE 池
|
| 164 |
+
access_token = bundle.get("access_token")
|
| 165 |
+
account_id = bundle.get("account_id")
|
| 166 |
+
quota_status, quota_info = check_codex_quota(access_token, account_id=account_id)
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
**FR-D2** quota_status 5 分类处置:
|
| 170 |
+
| status | 处置 |
|
| 171 |
+
|---|---|
|
| 172 |
+
| `ok` | 写 `last_quota=quota_info`,STATUS_ACTIVE(原行为) |
|
| 173 |
+
| `exhausted` | STATUS_EXHAUSTED + `quota_exhausted_at=now` + `quota_resets_at=quota_result_resets_at(info)` |
|
| 174 |
+
| `no_quota`(新) | STATUS_AUTH_INVALID + `record_failure(category="no_quota_assigned")` + 仍保留 auth_file 供调试 |
|
| 175 |
+
| `auth_error` | STATUS_AUTH_INVALID + `record_failure(category="auth_error_at_oauth")` |
|
| 176 |
+
| `network_error` | 沿用旧 STATUS_ACTIVE(避免一次抖动批量误标),但 `record_failure(category="quota_probe_network_error")` |
|
| 177 |
+
|
| 178 |
+
**FR-D3** Personal 分支(L1431,`leave_workspace=True`)获得 free bundle 后,**同样**调 quota probe(对称设计)。
|
| 179 |
+
|
| 180 |
+
**FR-D4** probe 调用本身要包 try/except:抛异常时降级到 STATUS_ACTIVE 但记 `register_failures` 一条 exception(避免 probe bug 阻塞注册主流程)。
|
| 181 |
+
|
| 182 |
+
### 5.5 [共因 E] `sync_account_states` 区分被踢/待机
|
| 183 |
+
|
| 184 |
+
**FR-E1** `manager.py:526-541` 在 `not in_team and acc["status"] == STATUS_ACTIVE` 分支,**保留**当前 workspace_account_id 漂移检测(L531-538),其下追加 wham 探测:
|
| 185 |
+
```python
|
| 186 |
+
if acc.get("auth_file"):
|
| 187 |
+
try:
|
| 188 |
+
bundle = load_auth_file(acc["auth_file"])
|
| 189 |
+
access_token = bundle.get("access_token")
|
| 190 |
+
if access_token:
|
| 191 |
+
status_str, _ = check_codex_quota(access_token)
|
| 192 |
+
if status_str == "auth_error":
|
| 193 |
+
acc["status"] = STATUS_AUTH_INVALID
|
| 194 |
+
changed = True
|
| 195 |
+
continue
|
| 196 |
+
except Exception:
|
| 197 |
+
pass # 探测失败不阻塞主流程
|
| 198 |
+
acc["status"] = STATUS_STANDBY
|
| 199 |
+
changed = True
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
**FR-E2** 探测加并发限制(避免 N 个号串行 wham 拖慢 sync):用 `concurrent.futures.ThreadPoolExecutor(max_workers=5)` + 单调用超时 5s。
|
| 203 |
+
|
| 204 |
+
**FR-E3** 探测去重:同一 email 在 30 分钟内不重复探测(读 `last_quota_check_at` 字段 + 写入新值)。
|
| 205 |
+
|
| 206 |
+
**FR-E4** sync 主循环不能因为探测异常崩溃 → 任何 except 都仅 log warning + 走旧 STATUS_STANDBY 默认行为。
|
| 207 |
+
|
| 208 |
+
### 5.6 [#2 独属] `PREFERRED_SEAT_TYPE` 配置开关
|
| 209 |
+
|
| 210 |
+
**FR-F1** `runtime_config.py` 新增 `get_preferred_seat_type()` / `set_preferred_seat_type(value)`,值域 `{"chatgpt", "codex"}`,默认 `"chatgpt"`(保持现行行为)。
|
| 211 |
+
|
| 212 |
+
**FR-F2** `invite.py:496` 改为:
|
| 213 |
+
```python
|
| 214 |
+
preferred = (get_preferred_seat_type() or "chatgpt").lower()
|
| 215 |
+
seat_type_param = "default" if preferred == "chatgpt" else "usage_based"
|
| 216 |
+
status, data = chatgpt.invite_member(email, seat_type=seat_type_param)
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
**FR-F3** `chatgpt_api.py:_invite_member_once`(L1414)的 PATCH 升级段(L1487-1506):新增 `allow_patch_upgrade` 参数,默认 True;调用方传入 `allow_patch_upgrade=(preferred == "chatgpt")`。`PREFERRED_SEAT_TYPE=codex` 时 PATCH 完全跳过,`_seat_type` 保持 `usage_based`。
|
| 220 |
+
|
| 221 |
+
**FR-F4** `_invite_member_with_fallback`(L1387)的兜底链向后兼容:
|
| 222 |
+
- `preferred=chatgpt`:default → usage_based 兜底(原行为)
|
| 223 |
+
- `preferred=codex`:直接 usage_based,不兜底 default
|
| 224 |
+
|
| 225 |
+
**FR-F5** Web 前端 Settings 页新增"邀请席位偏好"下拉(`chatgpt` / `codex`),写 `runtime_config.preferred_seat_type`。
|
| 226 |
+
|
| 227 |
+
**FR-F6** 状态变更立即生效,不需要重启;并发安全(读取每次邀请前从 runtime_config 拉,而非启动时缓存)。
|
| 228 |
+
|
| 229 |
+
### 5.7 [#2 独属] personal 删除链解耦主号 session
|
| 230 |
+
|
| 231 |
+
**FR-G1** `account_ops.delete_managed_account`(L40-162)新增短路逻辑:
|
| 232 |
+
```python
|
| 233 |
+
acc = find_account(accounts, email)
|
| 234 |
+
short_circuit = False
|
| 235 |
+
if remove_remote and acc:
|
| 236 |
+
if acc.get("status") in (STATUS_PERSONAL, STATUS_AUTH_INVALID):
|
| 237 |
+
short_circuit = True
|
| 238 |
+
members, invites = [], [] # 跳过 fetch_team_state
|
| 239 |
+
logger.info("[账号] %s 状态=%s,跳过 Team 远端同步,直接清本地", email, acc.get("status"))
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
**FR-G2** short_circuit=True 时,跳过 L78-84 的 `ChatGPTTeamAPI().start()` + `fetch_team_state()`,member_matches/invite_matches 为空,直接走 auth_file/cpa/local 删除。
|
| 243 |
+
|
| 244 |
+
**FR-G3** `api.delete_accounts_batch`(L1306-1404)在批量场景下:
|
| 245 |
+
- 先扫一遍 batch 内所有 acc.status,**全是 personal/auth_invalid 时**,完全不起 ChatGPTTeamAPI(传 `chatgpt_api=None` + 在 short_circuit 中再次保护)
|
| 246 |
+
- 混合场景(部分 personal + 部分 team):仍起 ChatGPTTeamAPI,但 short_circuit 单条生效
|
| 247 |
+
|
| 248 |
+
**FR-G4** UI 删除失败 toast 增强(`Dashboard.vue:566-585` `removeAccount`):
|
| 249 |
+
- 失败原因(`actionDisabled` / 409 / 500)区分提示
|
| 250 |
+
- 删除成功立即 `emit('refresh')` 触发账号列表 reload
|
| 251 |
+
- 不再"按钮没反应"误解
|
| 252 |
+
|
| 253 |
+
### 5.8 [#6 独属] `reinvite_account` 兜底(plan_type ≠ team 时清理)
|
| 254 |
+
|
| 255 |
+
**FR-H1** `manager.py:2489-2494`:`plan_type != "team"` 分支,在 `_cleanup_team_leftover` 后追加:
|
| 256 |
+
```python
|
| 257 |
+
update_account(
|
| 258 |
+
email,
|
| 259 |
+
status=STATUS_AUTH_INVALID, # 不要回 STANDBY,会被反复选中循环
|
| 260 |
+
auth_file=None, # 清掉错误 plan 的 token
|
| 261 |
+
quota_exhausted_at=None,
|
| 262 |
+
quota_resets_at=None,
|
| 263 |
+
)
|
| 264 |
+
record_failure(email, "plan_drift", f"reinvite 拿到 plan={plan_type or 'unknown'} != team",
|
| 265 |
+
stage="reinvite", source="reinvite_account")
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
**FR-H2** **批判:不会误伤合法 personal 转化路径**。`reinvite_account` 只从 STATUS_STANDBY 池被选中(`manager.py:2643+`),其语义预设是"恢复到 Team 工作池";用户希望让某号转 personal 的合法入口是 `cmd_fill_personal`(走 `_run_post_register_oauth(leave_workspace=True)`),与 reinvite 完全分离。reinvite 拿到非 team plan 永远是异常状态,推 STATUS_AUTH_INVALID 后,reconcile 会按 auth_invalid 流程处理(KICK + 清理),不再有死循环。**风险登记册 R-7** 列入跟踪。
|
| 269 |
+
|
| 270 |
+
**FR-H3** reinvite 失败被推到 AUTH_INVALID 后,reconcile_anomalies(`manager.py:161-471`)的 KICK 分支会把 Team 残留清干净,本地记录最终被删除或留着等用户介入(取决于 `auth_invalid` 处置策略)。
|
| 271 |
+
|
| 272 |
+
---
|
| 273 |
+
|
| 274 |
+
## 6. 非功能需求
|
| 275 |
+
|
| 276 |
+
| 编号 | 需求 |
|
| 277 |
+
|---|---|
|
| 278 |
+
| NFR-1 | 性能:`sync_account_states` 探测段不能让单轮 sync 超过 30s(并发 5 + 超时 5s) |
|
| 279 |
+
| NFR-2 | 兼容性:旧 accounts.json 记录(无 `plan_supported` / `last_quota.primary_total` 字段)走默认值,不报错 |
|
| 280 |
+
| NFR-3 | 可观测:每个新 category(`plan_unsupported` / `no_quota_assigned` / `oauth_phone_blocked` / `auth_error_at_oauth` / `quota_probe_network_error` / `plan_drift`)在 `register_failures.json` 都能独立计数 |
|
| 281 |
+
| NFR-4 | 灰度:`PREFERRED_SEAT_TYPE` 默认 `chatgpt`,与现行行为完全一致;切到 `codex` 须显式操作 |
|
| 282 |
+
| NFR-5 | 可回滚:所有改动可被一个 revert PR 撤销;`accounts.json` schema 向后兼容 |
|
| 283 |
+
| NFR-6 | 安全:add-phone 截图存 `screenshots/codex_phone_blocked_*.png`,不含 cookie/token |
|
| 284 |
+
| NFR-7 | 文档:CHANGELOG.md 必须列出 5 个新 category + 4 个新探针接入点 |
|
| 285 |
+
|
| 286 |
+
---
|
| 287 |
+
|
| 288 |
+
## 7. 技术方案
|
| 289 |
+
|
| 290 |
+
### 7.1 状态机更新(account state machine)
|
| 291 |
+
|
| 292 |
+
新增/澄清的边:
|
| 293 |
+
|
| 294 |
+
```
|
| 295 |
+
ACTIVE ──[sync 探测 wham=auth_error]──→ AUTH_INVALID (新,FR-E1)
|
| 296 |
+
ACTIVE ──[sync 探测 wham=ok]────────────→ STANDBY (语义不变,但前提是探测通过)
|
| 297 |
+
ACTIVE ──[wham=no_quota]─────────────────→ AUTH_INVALID (新,FR-D2)
|
| 298 |
+
PENDING ──[bundle plan_unsupported]─────→ AUTH_INVALID (新,FR-A4)
|
| 299 |
+
STANDBY ──[reinvite plan!=team]──────────→ AUTH_INVALID (新,FR-H1,替代旧 STANDBY)
|
| 300 |
+
STANDBY ──[reinvite OAuth phone]─────────→ AUTH_INVALID (新,FR-C3)
|
| 301 |
+
PENDING ──[OAuth phone 命中]─────────────→ AUTH_INVALID (新,FR-C3 之 manager.py:1463 分支)
|
| 302 |
+
PENDING ──[personal OAuth phone]────────→ deleted (新,FR-C3 之 manager.py:1431 分支)
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
### 7.2 配额分类规则(quota classification)
|
| 306 |
+
|
| 307 |
+
```
|
| 308 |
+
HTTP 401/403 → auth_error
|
| 309 |
+
HTTP 200 + primary_total == 0 → no_quota (新)
|
| 310 |
+
HTTP 200 + reset_at == 0 && used_pct == 0 → no_quota (新)
|
| 311 |
+
HTTP 200 + rate_limit 缺失/空 → no_quota (新)
|
| 312 |
+
HTTP 200 + primary_pct >= 100 || weekly_pct >= 100 → exhausted (原)
|
| 313 |
+
HTTP 200 + 上述都不命中 → ok (原)
|
| 314 |
+
HTTP 429 / 5xx / json 解析失败 / 网络异常 → network_error (原)
|
| 315 |
+
```
|
| 316 |
+
|
| 317 |
+
### 7.3 add-phone 探针在 OAuth 流程的接入点(4 处)
|
| 318 |
+
|
| 319 |
+
```
|
| 320 |
+
login_codex_via_browser(email, password, mail_client, *, use_personal)
|
| 321 |
+
│
|
| 322 |
+
├─ step-0 (Team 模式 cookie 注入)
|
| 323 |
+
│
|
| 324 |
+
├─ step-1 邮箱/密码/OTP 表单
|
| 325 |
+
│
|
| 326 |
+
├─ ★ C-P1: assert_not_blocked(page, "oauth_about_you") [L568 about-you 前]
|
| 327 |
+
├─ step-2 about-you 提交
|
| 328 |
+
│
|
| 329 |
+
├─ ★ C-P2: assert_not_blocked(page, f"oauth_consent_{i}") [L612 consent 循环每轮]
|
| 330 |
+
├─ step-3 consent 10 轮循环(workspace 选择 / Continue 按钮)
|
| 331 |
+
│
|
| 332 |
+
├─ ★ C-P3: assert_not_blocked(page, "oauth_callback_wait") [L884 等 callback 前]
|
| 333 |
+
├─ step-4 等 30s callback
|
| 334 |
+
│
|
| 335 |
+
├─ ★ C-P4: assert_not_blocked(page, "oauth_personal_check") [L932 personal 拒收前,防御性]
|
| 336 |
+
└─ _exchange_auth_code → bundle
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
### 7.4 数据模型变更(accounts.json schema)
|
| 340 |
+
|
| 341 |
+
```python
|
| 342 |
+
# Pydantic 模型(用于内部类型签名 / 测试断言)
|
| 343 |
+
class AccountRecord(BaseModel):
|
| 344 |
+
email: str
|
| 345 |
+
password: str
|
| 346 |
+
cloudmail_account_id: Optional[str] = None
|
| 347 |
+
status: Literal["active", "exhausted", "standby", "pending", "personal", "auth_invalid", "orphan"]
|
| 348 |
+
seat_type: Literal["chatgpt", "codex", "unknown"] = "unknown"
|
| 349 |
+
workspace_account_id: Optional[str] = None
|
| 350 |
+
auth_file: Optional[str] = None
|
| 351 |
+
quota_exhausted_at: Optional[float] = None
|
| 352 |
+
quota_resets_at: Optional[float] = None
|
| 353 |
+
last_quota_check_at: Optional[float] = None # FR-E3 探测去重
|
| 354 |
+
last_quota: Optional[QuotaSnapshot] = None
|
| 355 |
+
last_active_at: Optional[float] = None
|
| 356 |
+
created_at: float
|
| 357 |
+
plan_supported: Optional[bool] = None # 新增,FR-A3
|
| 358 |
+
plan_type_raw: Optional[str] = None # 新增,记录 OAuth 实际拿到的字面量,便于排查
|
| 359 |
+
|
| 360 |
+
class QuotaSnapshot(BaseModel):
|
| 361 |
+
primary_pct: int = 0
|
| 362 |
+
primary_resets_at: int = 0
|
| 363 |
+
primary_total: Optional[int] = None # 新增 FR-B2
|
| 364 |
+
primary_remaining: Optional[int] = None # 新增 FR-B2
|
| 365 |
+
weekly_pct: int = 0
|
| 366 |
+
weekly_resets_at: int = 0
|
| 367 |
+
|
| 368 |
+
class QuotaProbeResult(BaseModel):
|
| 369 |
+
status: Literal["ok", "exhausted", "no_quota", "auth_error", "network_error"]
|
| 370 |
+
info: Optional[Union[QuotaSnapshot, dict]] = None
|
| 371 |
+
```
|
| 372 |
+
|
| 373 |
+
### 7.5 配置项变更(`runtime_config` + `.env`)
|
| 374 |
+
|
| 375 |
+
| key | 来源 | 取值 | 默认 | 用途 |
|
| 376 |
+
|---|---|---|---|---|
|
| 377 |
+
| `preferred_seat_type` | runtime_config.json | `chatgpt` / `codex` | `chatgpt` | FR-F1,席位偏好 |
|
| 378 |
+
| `quota_probe_threshold_pct` | runtime_config.json | `0..100` | `10` | reinvite 后 5h 剩余阈值,与现有 AUTO_CHECK_THRESHOLD 一致 |
|
| 379 |
+
| `sync_probe_concurrency` | runtime_config.json | int | `5` | FR-E2,sync 探测并发 |
|
| 380 |
+
| `sync_probe_cooldown_minutes` | runtime_config.json | int | `30` | FR-E3,探测去重 |
|
| 381 |
+
|
| 382 |
+
`.env` 新增:
|
| 383 |
+
- `PREFERRED_SEAT_TYPE_DEFAULT`(可选,首次启动写入 runtime_config 的初始值)
|
| 384 |
+
|
| 385 |
+
### 7.6 前端变更(Settings + Dashboard)
|
| 386 |
+
|
| 387 |
+
| 组件 | 改动 |
|
| 388 |
+
|---|---|
|
| 389 |
+
| `web/src/views/Settings.vue` | 新增"邀请席位偏好"下拉(FR-F5)、"配额探测并发数"(FR-E2)、"探测去重分钟数"(FR-E3) |
|
| 390 |
+
| `web/src/components/Dashboard.vue` | 删除按钮 toast 增强(FR-G4);状态白名单加 `auth_invalid` 显示文案(round-3 已加,本期复核);quota 显示区识别 `no_quota` 渲染"无配额"而非"100% 剩余" |
|
| 391 |
+
| `web/src/api.ts` | `/api/accounts/{email}/login` 加入 409 错误体解析(`error: "phone_required"`),弹窗提示用户 |
|
| 392 |
+
|
| 393 |
+
---
|
| 394 |
+
|
| 395 |
+
## 8. 验收标准
|
| 396 |
+
|
| 397 |
+
| FR | 验收用例 | 通过条件 |
|
| 398 |
+
|---|---|---|
|
| 399 |
+
| FR-A1~A5 | 注册一个 OAuth bundle 字面值 = `self_serve_business_usage_based`(可 mock JWT) | 账号被打 STATUS_AUTH_INVALID + register_failures 命中 `category="plan_unsupported"`,`plan_type_raw` 落盘 |
|
| 400 |
+
| FR-B1~B4 | wham/usage 返回 `{"primary_window": {"limit": 0, "used_percent": 0}}` | quota_status="no_quota",前端 UI 显示"无配额",账号 STATUS_AUTH_INVALID |
|
| 401 |
+
| FR-C1~C5 | OAuth 流程到 about-you 后 page.url 跳到 `add-phone` | 4 个接入点任一抛 RegisterBlocked,`category="oauth_phone_blocked"`,截图落盘 |
|
| 402 |
+
| FR-D1~D4 | 注册收尾 bundle 拿到但 wham 返回 no_quota | 账号 STATUS_AUTH_INVALID 而非 ACTIVE |
|
| 403 |
+
| FR-E1~E4 | 管理员从 ChatGPT 后台手动踢一个 active 子号 | 下次 sync_account_states 跑后,该号 STATUS_AUTH_INVALID(不是 STANDBY) |
|
| 404 |
+
| FR-F1~F6 | UI 设置 `preferred_seat_type=codex` → 触发 invite | invite_member 调 `seat_type="usage_based"`,日志中 0 处 PATCH 升级 |
|
| 405 |
+
| FR-G1~G4 | personal 子号 + 主号 session 失效 → 单点删除 | 不抛 ChatGPTTeamAPI 启动错误,本地记录被清,UI 列表刷新 |
|
| 406 |
+
| FR-H1~H3 | reinvite 拿到 plan=free 的 bundle | 账号 STATUS_AUTH_INVALID,reconcile 自动 KICK + 清 auth_file |
|
| 407 |
+
|
| 408 |
+
---
|
| 409 |
+
|
| 410 |
+
## 9. 测试计划
|
| 411 |
+
|
| 412 |
+
### 9.1 单元测试
|
| 413 |
+
|
| 414 |
+
- `tests/unit/test_plan_type_whitelist.py`:`is_supported_plan` 7 种字面量(team/free/plus/pro/Team/self_serve_business_usage_based/空串)
|
| 415 |
+
- `tests/unit/test_quota_classification.py`:5 分类 mock wham/usage 各响应,断言 status_str
|
| 416 |
+
- `tests/unit/test_oauth_phone_detection.py`:4 接入点各自 mock page.url + body,assert_not_blocked 抛异常
|
| 417 |
+
- `tests/unit/test_run_post_register_quota_probe.py`:5 quota_status × 2 (Team/personal) 路径
|
| 418 |
+
- `tests/unit/test_sync_state_classify.py`:被踢 vs 自然待机区分
|
| 419 |
+
- `tests/unit/test_preferred_seat_type.py`:chatgpt/codex 两种偏好下 invite_member 入参
|
| 420 |
+
- `tests/unit/test_personal_delete_short_circuit.py`:STATUS_PERSONAL/AUTH_INVALID 跳过 fetch_team_state
|
| 421 |
+
|
| 422 |
+
### 9.2 集成测试
|
| 423 |
+
|
| 424 |
+
- `tests/integration/test_oauth_phone_blocked_flow.py`:全链路 mock,验证 6 处 login_codex_via_browser 调用方都能正确分类 + 打到 register_failures
|
| 425 |
+
- `tests/integration/test_quota_probe_no_quota.py`:wham 返回 limit=0 → 注册→入池→标 AUTH_INVALID 全链路
|
| 426 |
+
|
| 427 |
+
### 9.3 回归测试
|
| 428 |
+
|
| 429 |
+
- 既有 `invite/reinvite/sync_account_states/_run_post_register_oauth/check_codex_quota` 套件全部跑过
|
| 430 |
+
- `manual_account.py` finalize 流程不受 plan_type 白名单影响(对称改造保护)
|
| 431 |
+
|
| 432 |
+
### 9.4 E2E / 手测
|
| 433 |
+
|
| 434 |
+
- 手动:从 ChatGPT 后台踢一个号,等 1 个 rotate 周期,确认状态机正确
|
| 435 |
+
- 手动:UI 切 PREFERRED_SEAT_TYPE 后注册新号,确认 PATCH 不被调用
|
| 436 |
+
- 手动:邀请一个新号触发 add-phone(可能需要 IP 漂移/cookie 清空),确认探针命中
|
| 437 |
+
|
| 438 |
+
---
|
| 439 |
+
|
| 440 |
+
## 10. 灰度/回滚策略
|
| 441 |
+
|
| 442 |
+
### 10.1 灰度
|
| 443 |
+
|
| 444 |
+
- **阶段 0**:实施 PR(默认 PREFERRED_SEAT_TYPE=chatgpt,行为不变),只观察新 category 计数
|
| 445 |
+
- **阶段 1**:1-2 周后,根据 `register_failures.json` 数据评估 PATCH 升级实际失败率,决定是否在 default 配置中切到 codex
|
| 446 |
+
- **阶段 2**:监控 STATUS_AUTH_INVALID 总数与人工介入率,若 reconcile 自动接管率 < 90%,新增告警
|
| 447 |
+
|
| 448 |
+
### 10.2 回滚
|
| 449 |
+
|
| 450 |
+
- 单 PR revert 即可:所有新增字段(`plan_supported`/`primary_total`/...)向后兼容,旧代码读不到字段走默认值
|
| 451 |
+
- 配置回滚:删除 `runtime_config.json` 中的新键即恢复硬编码默认
|
| 452 |
+
|
| 453 |
+
---
|
| 454 |
+
|
| 455 |
+
## 11. 文档影响清单
|
| 456 |
+
|
| 457 |
+
| 文件 | 改动 |
|
| 458 |
+
|---|---|
|
| 459 |
+
| `CHANGELOG.md` | 新增条目:5 个新 register_failures category、4 个 OAuth 探针接入点、PREFERRED_SEAT_TYPE 配置 |
|
| 460 |
+
| `docs/account-state-machine.md`(新建) | 7 状态完整转移图,含本 PRD 新增的边 |
|
| 461 |
+
| `docs/quota-classification.md`(新建) | 5 分类规则、no_quota 触发条件、调用方处置矩阵 |
|
| 462 |
+
| `docs/oauth-add-phone-detection.md`(新建) | 4 接入点位置、检测器复用契约 |
|
| 463 |
+
| `README.md` | 配置说明加 PREFERRED_SEAT_TYPE |
|
| 464 |
+
| `web/README` | Settings 页新加配置项说明 |
|
| 465 |
+
|
| 466 |
+
---
|
| 467 |
+
|
| 468 |
+
## 12. 风险登记册
|
| 469 |
+
|
| 470 |
+
| 编号 | 风险 | 等级 | 概率 | 缓解 | 来源 |
|
| 471 |
+
|---|---|---|---|---|---|
|
| 472 |
+
| R-1 | sync_account_states 加 wham 探测后,N 个 active 号串行探测拖慢 rotation | 高 | 中 | NFR-1 限 30s + 并发 5 + 探测去重 30 分钟 | gitnexus.impact `sync_account_states`:被 manager.py:696/2843 调用,rotation 主路径 |
|
| 473 |
+
| R-2 | check_codex_quota 加 no_quota 分类,9 个调用点漏改任一 → no_quota 被当 ok 默认 | 高 | 中 | FR-B4 显式枚举所有调用方 + 单测覆盖 + lint 守卫 | gitnexus.impact `check_codex_quota`:9 处调用 |
|
| 474 |
+
| R-3 | login_codex_via_browser 加 RegisterBlocked 抛异常,5 处调用方漏 catch → 主流程崩溃 | 高 | 低 | FR-C3 显式列 5 处处置 + 集成测试 | gitnexus.impact `login_codex_via_browser`:5 处调用 |
|
| 475 |
+
| R-4 | _run_post_register_oauth probe 异常吞 → 假 ACTIVE 回到旧故障 | 中 | 中 | FR-D4 try/except + register_failures 计数监控 | gitnexus.impact `_run_post_register_oauth`:3 处调用 |
|
| 476 |
+
| R-5 | invite.py 的 detect_phone_verification 在 OAuth 域(auth.openai.com)误报 consent 页 phone 提示 | 中 | 低 | 命中后立即截图,运营回放确认;必要时给 OAuth 探针定制更严格规则 | issue#4 风险 E.1.3 |
|
| 477 |
+
| R-6 | reinvite 后的 STATUS_AUTH_INVALID 与 reconcile_anomalies 的 KICK 分支耦合,任一改一处都可能影响另一处 | 中 | 中 | 单测 + 集成测试 + 显式注释 | issue#6 D.1.6 |
|
| 478 |
+
| R-7 | reinvite 拿到 free plan 当作"plan_drift"标 AUTH_INVALID,但用户合法的"号转 personal"路径走 cmd_fill_personal 不会进 reinvite_account,因此**不存在误伤** | 低 | 极低 | FR-H2 已注明语义边界;reinvite 入口只从 STANDBY 池被选,与 personal 转化的入口完全分离 | task 第 3 条批判 |
|
| 479 |
+
| R-8 | PATCH 升级失败率统计未知 → 默认 PREFERRED_SEAT_TYPE 选错 | 低 | 中 | NFR-4 默认保持 chatgpt 不变,1-2 周观察后再调 | issue#2 F.2 |
|
| 480 |
+
| R-9 | wham/usage 真实 schema 未知,limit/total 字段可能不存在 | 中 | 中 | FR-B1 触发条件 4 任一命中即可,接口缺字段→空载也归 no_quota;先上线再观察 | issue#2 F.1 / synthesis §7 |
|
| 481 |
+
| R-10 | OpenAI 后端 plan_type 字面量大小写漂移 | 低 | 低 | FR-A5 强制 .lower() 归一化 | issue#2 F.4 |
|
| 482 |
+
| R-11 | 主号 OAuth 流程也撞 add-phone(罕见但可能) | 低 | 极低 | 非目标:本期不处置主号路径;日志 ERROR 即可 | issue#4 E.2.1 |
|
| 483 |
+
| R-12 | personal 删除短路后,某些"标着 personal 但实际还在 Team"的脏记录会被漏删 Team 残留 | 中 | 低 | reconcile_anomalies 仍会扫整个 Team 把 ghost 清掉,作为兜底 | issue#2 F.3 |
|
| 484 |
+
|
| 485 |
+
---
|
| 486 |
+
|
| 487 |
+
## 13. 未决问题(送实施阶段决议)
|
| 488 |
+
|
| 489 |
+
| 编号 | 问题 | 提案 |
|
| 490 |
+
|---|---|---|
|
| 491 |
+
| Q-1 | `wham/usage` 在 `self_serve_business_usage_based` workspace 真实返回 schema | 先从用户实际 `auths/codex-*.json` + 一次抓包获取样本;若 limit/total 字段缺失,FR-B1 触发条件 4(rate_limit 空)兜底 |
|
| 492 |
+
| Q-2 | `self_serve_business_usage_based` 是否是真实字面量 | 先从用户报告的样本核对;如果实际是 `chatgpt_business_usage_based` 等变体,白名单兼容(只看是否在 SUPPORTED_PLAN_TYPES,实际值都进 unsupported 分支即可) |
|
| 493 |
+
| Q-3 | PATCH 升级失败率 | 改默认席位策略前先收集 1-2 周数据;如失败率 >30%,默认改 codex |
|
| 494 |
+
| Q-4 | `STATUS_PHONE_REQUIRED` 是否新增 | 不新增,复用 STATUS_AUTH_INVALID + register_failures.category 区分,符合本 PRD 非目标 |
|
| 495 |
+
| Q-5 | sync_account_states 探测的并发 5 / 超时 5s 是否合理 | 灰度阶段 0 观察 sync 单轮耗时,>30s 调到 10 / 3s |
|
| 496 |
+
| Q-6 | reinvite_account 推到 AUTH_INVALID 后,reconcile 是否需要等用户介入还是自动删除 | 默认自动 KICK + 保留本地记录(供查看 register_failures);删本地由 UI 单点/批量删除触发 |
|
| 497 |
+
| Q-7 | OAuth 模式选择 UI(personal vs Team)放本期还是后置 | 后置(优先级 P2),不阻塞主线 |
|
| 498 |
+
|
| 499 |
+
---
|
| 500 |
+
|
| 501 |
+
## 14. 实施 Story Map
|
| 502 |
+
|
| 503 |
+
按"先抽 spec 共享层 → 再改 4 个文件 → 最后改 UI"排列,**严格按依赖**。
|
| 504 |
+
|
| 505 |
+
### Phase 0: 共享 spec 与常量(2 文件,半天)
|
| 506 |
+
|
| 507 |
+
- [ ] **S-0.1** 在 `accounts.py` 新增 `SUPPORTED_PLAN_TYPES` + `is_supported_plan()`(FR-A1/A2)
|
| 508 |
+
- [ ] **S-0.2** 在 `runtime_config.py` 新增 `get/set_preferred_seat_type` + 默认值(FR-F1)
|
| 509 |
+
- [ ] **S-0.3** 在 `register_failures.py` 文档化新增 6 个 category 名称(FR-A4/B4/C5/D2/E1/H1)
|
| 510 |
+
- [ ] **S-0.4** 写 `spec/shared/plan-type-whitelist.md` / `quota-classification.md` / `add-phone-detection.md` / `account-state-machine.md`(synthesis §5)
|
| 511 |
+
|
| 512 |
+
### Phase 1: 共因点核心实现(4 文件,2-3 天)
|
| 513 |
+
|
| 514 |
+
- [ ] **S-1.1** `codex_auth.py:_exchange_auth_code` 写 `plan_supported` 字段(FR-A3)
|
| 515 |
+
- [ ] **S-1.2** `codex_auth.py:check_codex_quota` 加 `no_quota` 分类 + 扩 quota_info 字段(FR-B1/B2/B3)
|
| 516 |
+
- [ ] **S-1.3** `codex_auth.py:login_codex_via_browser` 4 个 add-phone 探针接入点(FR-C1/C2/C3 仅探针,异常上抛部分由 S-2.x 处理)
|
| 517 |
+
- [ ] **S-1.4** `manager.py:_run_post_register_oauth` 加 quota probe(FR-D1/D2/D3/D4)
|
| 518 |
+
- [ ] **S-1.5** `manager.py:sync_account_states` 加 wham 探测区分被踢(FR-E1/E2/E3/E4)
|
| 519 |
+
- [ ] **S-1.6** `manager.py:reinvite_account` plan_drift 终态(FR-H1)
|
| 520 |
+
- [ ] **S-1.7** `manual_account._finalize_account` 改造白名单(FR-A4 第 1 项)
|
| 521 |
+
- [ ] **S-1.8** `account_ops.delete_managed_account` 短路逻辑(FR-G1/G2)
|
| 522 |
+
|
| 523 |
+
### Phase 2: 调用方处置(2 文件,1-2 天)
|
| 524 |
+
|
| 525 |
+
- [ ] **S-2.1** `manager.py` 4 处 login_codex_via_browser 调用方加 try/except RegisterBlocked + 分类(FR-C3 in: L1057/1431/1463/2466)
|
| 526 |
+
- [ ] **S-2.2** `api.py` `/api/accounts/{email}/login` 加 try/except + 409 错误体(FR-C3 in: L1479)
|
| 527 |
+
- [ ] **S-2.3** 9 处 check_codex_quota 调用方加 `no_quota` 分支(FR-B4)
|
| 528 |
+
- [ ] **S-2.4** `api.delete_accounts_batch` 全 personal 时不起 ChatGPTTeamAPI(FR-G3)
|
| 529 |
+
|
| 530 |
+
### Phase 3: 席位策略(2 文件,1 天)
|
| 531 |
+
|
| 532 |
+
- [ ] **S-3.1** `invite.py:496` 改读 PREFERRED_SEAT_TYPE(FR-F2)
|
| 533 |
+
- [ ] **S-3.2** `chatgpt_api.py:_invite_member_once` 加 `allow_patch_upgrade` 参数(FR-F3)
|
| 534 |
+
- [ ] **S-3.3** `chatgpt_api.py:_invite_member_with_fallback` 兜底链分支(FR-F4)
|
| 535 |
+
|
| 536 |
+
### Phase 4: 前端 UI(2 文件,1 天)
|
| 537 |
+
|
| 538 |
+
- [ ] **S-4.1** `web/src/views/Settings.vue` 新增"邀请席位偏好"下拉 + 探测并发/去重(FR-F5/E2/E3)
|
| 539 |
+
- [ ] **S-4.2** `web/src/components/Dashboard.vue` 删除 toast 增强 + quota 显示识别 no_quota(FR-G4)
|
| 540 |
+
- [ ] **S-4.3** `web/src/api.ts` 解析 409 phone_required 错误体
|
| 541 |
+
|
| 542 |
+
### Phase 5: 测试 + 文档(贯穿所有 Phase)
|
| 543 |
+
|
| 544 |
+
- [ ] **S-5.1** 单测覆盖 §9.1 的 7 个测试文件
|
| 545 |
+
- [ ] **S-5.2** 集成测试覆盖 §9.2 的 2 个测试文件
|
| 546 |
+
- [ ] **S-5.3** CHANGELOG / docs 文档更新(§11)
|
| 547 |
+
- [ ] **S-5.4** 手测脚本(§9.4)
|
| 548 |
+
|
| 549 |
+
### Phase 6: 灰度上线(1 天)
|
| 550 |
+
|
| 551 |
+
- [ ] **S-6.1** 默认 chatgpt 偏好上线,观察 register_failures 计数
|
| 552 |
+
- [ ] **S-6.2** 1-2 周后根据数据决议 PREFERRED_SEAT_TYPE 默认值切换(Q-3)
|
| 553 |
+
|
| 554 |
+
**关键依赖**:
|
| 555 |
+
- Phase 1 必须早于 Phase 2(被调用方先实现,调用方再 catch)
|
| 556 |
+
- Phase 0 早于 Phase 1(常量先定义,代码再引用)
|
| 557 |
+
- Phase 3 与 Phase 1/2 解耦,可并行实施
|
| 558 |
+
- Phase 4 在 Phase 1-3 完成后再做
|
| 559 |
+
- 所有 Phase 都伴随 Phase 5 的测试与文档更新
|
| 560 |
+
|
| 561 |
+
---
|
| 562 |
+
|
| 563 |
+
**文档结束。** 字数:约 4200(含表格、代码块、Story Map)。
|
| 564 |
+
|
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PRD-3: Docker 镜像守卫
|
| 2 |
+
|
| 3 |
+
## 0. 元数据
|
| 4 |
+
|
| 5 |
+
| 字段 | 值 |
|
| 6 |
+
|---|---|
|
| 7 |
+
| PRD 编号 | PRD-3 |
|
| 8 |
+
| 主笔 | prd-docker(autoteam-prd-0426 team) |
|
| 9 |
+
| 起草日期 | 2026-04-26 |
|
| 10 |
+
| 关联 Issue | issues1 #3(`list_accounts` ImportError) |
|
| 11 |
+
| 关联调研 | `prompts/0426/research/issue-3-list-accounts-import.md` |
|
| 12 |
+
| 关联代码修复 | commit `cf2f7d3 fix(round-3): exhausted 自愈 + workspace 指纹 + CPA 删除守卫 + 双 typo` |
|
| 13 |
+
| 优先级 | P1(防回归 / 用户体验) |
|
| 14 |
+
| 风险 | 低(纯外围加固,不动业务代码) |
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## 1. 背景
|
| 19 |
+
|
| 20 |
+
issues1 #3 报告:WebUI 点"生成免费号"返回 500,根因是 `api.py` 里 `from autoteam.accounts import list_accounts`(应为 `load_accounts`)的 typo。
|
| 21 |
+
|
| 22 |
+
代码层 typo 已在 commit `cf2f7d3` 修复并落仓 — `grep -rn "from autoteam\.accounts import.*list_accounts" src` 完全干净。但用户在 11:04 仍看到同样报错,因为:
|
| 23 |
+
|
| 24 |
+
1. **Dockerfile 第 23-25 行**用的是 `COPY src/ src/`,源代码在 `docker build` 时被烤进镜像
|
| 25 |
+
2. **docker-compose.yml** 只挂 `./data:/app/data` 数据卷,**没有挂源代码 volume**
|
| 26 |
+
3. 因此 `git pull` 拿到新代码后,**必须 rebuild 镜像才会生效** — 用户漏了这一步
|
| 27 |
+
|
| 28 |
+
**真因 = 镜像未重建**,不是代码 bug。这一类"代码已修但部署没生效"的 confusion 大概率会复发,本 PRD 目的是把"镜像版本可观察 + 启动期自检 + 静态守卫 + SOP 文档"四道防线建好,让下次再有类似 typo 时:
|
| 29 |
+
|
| 30 |
+
- CI 静态 lint 直接拦截 → typo 进不了主分支
|
| 31 |
+
- 万一漏过,容器启动期 self-check 立即 crash-loop → 用户立刻知道是镜像版本问题
|
| 32 |
+
- `/api/version` 端点曝露 git-sha → 用户/排错者能 1 秒确认"当前跑的是哪个 commit"
|
| 33 |
+
- `docs/docker.md` 给出标准 rebuild SOP → 用户不再漏步骤
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## 2. 目标
|
| 38 |
+
|
| 39 |
+
| ID | 目标 |
|
| 40 |
+
|---|---|
|
| 41 |
+
| G1 | 容器启动期自检关键 import,失败立即 crash,**不要**让用户在 WebUI 按按钮才发现 |
|
| 42 |
+
| G2 | 镜像内嵌 git-sha,通过 `echo $AUTOTEAM_GIT_SHA` / `/api/version` / `docker image inspect` 三种姿势可查 |
|
| 43 |
+
| G3 | `pyflakes` / `ruff` 类静态检查接入 CI 与 pre-commit,堵住 typo 类 ImportError 在源头 |
|
| 44 |
+
| G4 | `docs/docker.md` 增加"代码更新后如何 rebuild"的标准 SOP,语句直接可复制 |
|
| 45 |
+
| G5 | `docker-compose.yml` 增加 `build.args.GIT_SHA`,让 `docker compose build` 一键注入版本 |
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## 3. 非目标
|
| 50 |
+
|
| 51 |
+
- ❌ **不**改 `src/autoteam/accounts.py` 加任何向后兼容别名(`list_accounts = load_accounts` 类掩盖性兜底)
|
| 52 |
+
- ❌ **不**引入 hot reload / volume mount 源码方案 — 仍维持"镜像 = 不可变制品"语义
|
| 53 |
+
- ❌ **不**做 docker 镜像签名 / SBOM / Trivy 扫描等供应链加固(超出本 issue 范围)
|
| 54 |
+
- ❌ **不**改业务逻辑、不动 manager.py / api.py 核心路径
|
| 55 |
+
- ❌ **不**搞自动化 CI/CD pipeline(本仓库目前无 GH Actions,只在本地 / pre-commit 落地 lint)
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## 4. 用户故事
|
| 60 |
+
|
| 61 |
+
### 故事 A:运维更新代码
|
| 62 |
+
|
| 63 |
+
> 作为运维同学,当 upstream 推送新 commit 后,我能照 docs/docker.md 里 4 条命令完成 rebuild → 启动 → 验证,**不会**因为漏掉 `--no-cache` 或忘了 `docker compose down` 而仍跑旧版镜像。
|
| 64 |
+
|
| 65 |
+
### 故事 B:故障排查"镜像版本"
|
| 66 |
+
|
| 67 |
+
> 作为故障排查者,当用户报"我已经更新到最新版了但 bug 还在",我能在 30 秒内通过 `curl http://host:8787/api/version` 或 `docker compose exec autoteam env | grep AUTOTEAM_GIT_SHA` 拿到镜像的 git-sha,直接对比 `git log` 就能判定到底跑的是不是最新 commit。
|
| 68 |
+
|
| 69 |
+
### 故事 C:开发期 typo 拦截
|
| 70 |
+
|
| 71 |
+
> 作为开发者,当我把 `load_accounts` 写成 `list_accounts` 这样的 typo 时,**还没 commit** 的 pre-commit 钩子就拒绝我;就算我 `--no-verify` 强推,**容器启动期 self-check** 也立刻 crash 而不是潜伏到运行时。
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## 5. 功能需求
|
| 76 |
+
|
| 77 |
+
### 5.1 entrypoint self-check(启动期 import 关键符号自检)
|
| 78 |
+
|
| 79 |
+
**目的**:把 ImportError 从"运行时偶发"提前到"启动期必现",触发 docker restart_policy crash-loop。
|
| 80 |
+
|
| 81 |
+
**位置**:`docker-entrypoint.sh` 在最后 `exec uv run autoteam "$@"` 之前。
|
| 82 |
+
|
| 83 |
+
**自检内容(白名单 import)**:
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
# Self-check: critical imports
|
| 87 |
+
uv run python -c "
|
| 88 |
+
from autoteam.api import app
|
| 89 |
+
from autoteam.accounts import (
|
| 90 |
+
load_accounts, save_accounts,
|
| 91 |
+
STATUS_ACTIVE, STATUS_EXHAUSTED, STATUS_STANDBY,
|
| 92 |
+
STATUS_PERSONAL, STATUS_AUTH_INVALID, STATUS_ORPHAN,
|
| 93 |
+
)
|
| 94 |
+
from autoteam.manager import sync_account_states
|
| 95 |
+
print('[self-check] OK')
|
| 96 |
+
" || {
|
| 97 |
+
echo "[self-check] FATAL: critical import failed — image is likely stale or broken." >&2
|
| 98 |
+
echo "[self-check] Run: docker compose build --no-cache && docker compose up -d" >&2
|
| 99 |
+
exit 1
|
| 100 |
+
}
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
**设计要点**:
|
| 104 |
+
|
| 105 |
+
- 只检查"曾经被 typo 误伤"或"对外契约稳定"的核心符号 — 避免一改文件就要更新自检列表
|
| 106 |
+
- 失败时打印**修复提示语**(rebuild 命令),省去用户 google 时间
|
| 107 |
+
- 用 `uv run python -c` 而非 `python -c`,与项目 runtime 一致(避免依赖未装齐导致的伪阴性)
|
| 108 |
+
- 自检整体 < 1 秒,**不阻塞**正常启动
|
| 109 |
+
|
| 110 |
+
### 5.2 镜像 git-sha 标签 + /api/version 端点
|
| 111 |
+
|
| 112 |
+
**5.2.1 Dockerfile 注入**
|
| 113 |
+
|
| 114 |
+
```dockerfile
|
| 115 |
+
# 在 WORKDIR /app 之后、COPY src/ 之前增加
|
| 116 |
+
ARG GIT_SHA=unknown
|
| 117 |
+
ARG BUILD_TIME=unknown
|
| 118 |
+
LABEL org.opencontainers.image.revision=$GIT_SHA
|
| 119 |
+
LABEL org.opencontainers.image.created=$BUILD_TIME
|
| 120 |
+
ENV AUTOTEAM_GIT_SHA=$GIT_SHA
|
| 121 |
+
ENV AUTOTEAM_BUILD_TIME=$BUILD_TIME
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
**5.2.2 /api/version 端点**
|
| 125 |
+
|
| 126 |
+
在 `src/autoteam/api.py` 现有 `app = FastAPI(...)` 定义之后增加:
|
| 127 |
+
|
| 128 |
+
```python
|
| 129 |
+
import os
|
| 130 |
+
|
| 131 |
+
@app.get("/api/version")
|
| 132 |
+
def api_version() -> dict:
|
| 133 |
+
return {
|
| 134 |
+
"git_sha": os.getenv("AUTOTEAM_GIT_SHA", "unknown"),
|
| 135 |
+
"build_time": os.getenv("AUTOTEAM_BUILD_TIME", "unknown"),
|
| 136 |
+
}
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
**5.2.3 多种查询姿势**(都应能拿到同一个 git-sha):
|
| 140 |
+
|
| 141 |
+
```bash
|
| 142 |
+
# 容器外:HTTP 端点
|
| 143 |
+
curl http://localhost:8787/api/version
|
| 144 |
+
|
| 145 |
+
# 容器内:环境变量
|
| 146 |
+
docker compose exec autoteam env | grep AUTOTEAM_GIT_SHA
|
| 147 |
+
|
| 148 |
+
# 镜像层:OCI label
|
| 149 |
+
docker image inspect autoteam-autoteam | grep revision
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
### 5.3 lint 守卫(防止 list_accounts 这类 typo 再次发生)
|
| 153 |
+
|
| 154 |
+
**工具选型**:`ruff check --select F401,F811,F821`
|
| 155 |
+
|
| 156 |
+
| Rule | 含义 |
|
| 157 |
+
|---|---|
|
| 158 |
+
| F401 | imported but unused — 抓"import 了但没用"的死 import |
|
| 159 |
+
| F811 | redefinition of unused — 抓重复定义 |
|
| 160 |
+
| F821 | undefined name — **核心**,抓 `from autoteam.accounts import list_accounts`(未定义名)这种典型 typo |
|
| 161 |
+
|
| 162 |
+
**为什么选 ruff 而非 pyflakes/mypy**:
|
| 163 |
+
|
| 164 |
+
- ruff 单一二进制,启动 < 100ms,本地循环不痛
|
| 165 |
+
- 已是 Python 生态主流(本仓 pyproject.toml 可顺便接 ruff format)
|
| 166 |
+
- mypy 太重(类型完整推断要 5-10s),pre-commit 卡顿;ruff F821 已能覆盖 ImportError 这条主线
|
| 167 |
+
|
| 168 |
+
**落地点(三层防线)**:
|
| 169 |
+
|
| 170 |
+
1. **pyproject.toml** 加 `[tool.ruff.lint]` 段配置:
|
| 171 |
+
```toml
|
| 172 |
+
[tool.ruff.lint]
|
| 173 |
+
select = ["F401", "F811", "F821"]
|
| 174 |
+
exclude = ["tests/", "docs/", "scripts/"]
|
| 175 |
+
```
|
| 176 |
+
2. **pre-commit hook**(`.pre-commit-config.yaml`):
|
| 177 |
+
```yaml
|
| 178 |
+
repos:
|
| 179 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 180 |
+
rev: v0.7.0
|
| 181 |
+
hooks:
|
| 182 |
+
- id: ruff
|
| 183 |
+
args: [--select, "F401,F811,F821"]
|
| 184 |
+
```
|
| 185 |
+
3. **docs/docker.md / docs/getting-started.md** 增加"开发前先 `pip install pre-commit && pre-commit install`"提示
|
| 186 |
+
|
| 187 |
+
### 5.4 docs/docker.md 增加 rebuild SOP
|
| 188 |
+
|
| 189 |
+
在 `docs/docker.md` 末尾新增章节:
|
| 190 |
+
|
| 191 |
+
```markdown
|
| 192 |
+
## 代码更新后的 rebuild SOP
|
| 193 |
+
|
| 194 |
+
> 关键认知:本项目 Dockerfile 用 `COPY src/`(非 volume mount),
|
| 195 |
+
> **`git pull` 后必须 rebuild 镜像**,代码改动才会进入容器。
|
| 196 |
+
|
| 197 |
+
### 标准更新流程(4 步)
|
| 198 |
+
|
| 199 |
+
```bash
|
| 200 |
+
# 1. 拉新代码
|
| 201 |
+
cd /path/to/AutoTeam && git pull
|
| 202 |
+
|
| 203 |
+
# 2. 停旧容器
|
| 204 |
+
docker compose down
|
| 205 |
+
|
| 206 |
+
# 3. 重建镜像(--no-cache 防意外缓存命中,GIT_SHA 注入版本指纹)
|
| 207 |
+
docker compose build --no-cache --build-arg GIT_SHA=$(git rev-parse --short HEAD) --build-arg BUILD_TIME=$(date -u +%FT%TZ)
|
| 208 |
+
|
| 209 |
+
# 4. 启动
|
| 210 |
+
docker compose up -d
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
### 验证镜像版本
|
| 214 |
+
|
| 215 |
+
```bash
|
| 216 |
+
# 方式 A:HTTP 端点
|
| 217 |
+
curl http://localhost:8787/api/version
|
| 218 |
+
# 期望:{"git_sha":"cf2f7d3","build_time":"2026-04-26T..."}
|
| 219 |
+
|
| 220 |
+
# 方式 B:进容器查环境变量
|
| 221 |
+
docker compose exec autoteam env | grep AUTOTEAM_GIT_SHA
|
| 222 |
+
|
| 223 |
+
# 方式 C:看镜像 OCI label
|
| 224 |
+
docker image inspect autoteam-autoteam --format '{{.Config.Labels}}'
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
### 故障排查:为什么修了代码,bug 还在?
|
| 228 |
+
|
| 229 |
+
99% 是镜像没 rebuild。先跑这条:
|
| 230 |
+
|
| 231 |
+
```bash
|
| 232 |
+
curl -s http://localhost:8787/api/version | python -c "import sys,json; d=json.load(sys.stdin); print('image sha:', d['git_sha']); print('repo head:', open('.git/HEAD').read())"
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
如果 `image sha` 与最新 commit 不一致 → 重做上面 4 步 SOP。
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
### 5.5 docker-compose.yml 增加 build args
|
| 239 |
+
|
| 240 |
+
修改 `docker-compose.yml`:
|
| 241 |
+
|
| 242 |
+
```yaml
|
| 243 |
+
services:
|
| 244 |
+
autoteam:
|
| 245 |
+
build:
|
| 246 |
+
context: .
|
| 247 |
+
args:
|
| 248 |
+
GIT_SHA: ${GIT_SHA:-unknown}
|
| 249 |
+
BUILD_TIME: ${BUILD_TIME:-unknown}
|
| 250 |
+
ports:
|
| 251 |
+
- "8787:8787"
|
| 252 |
+
volumes:
|
| 253 |
+
- ./data:/app/data
|
| 254 |
+
restart: unless-stopped
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
配套 `Makefile` 或 docs 提供一键命令:
|
| 258 |
+
|
| 259 |
+
```bash
|
| 260 |
+
GIT_SHA=$(git rev-parse --short HEAD) BUILD_TIME=$(date -u +%FT%TZ) docker compose build --no-cache
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
---
|
| 264 |
+
|
| 265 |
+
## 6. 非功能需求
|
| 266 |
+
|
| 267 |
+
| 维度 | 要求 |
|
| 268 |
+
|---|---|
|
| 269 |
+
| 启动延迟 | self-check 增加 < 1 秒 |
|
| 270 |
+
| 镜像体积 | 增量 < 5KB(只是增加 LABEL/ENV/几行 entrypoint) |
|
| 271 |
+
| 兼容性 | 不破坏 `docker compose up` 老用户体验 — 不传 `GIT_SHA` 时降级为 `unknown` 而非报错 |
|
| 272 |
+
| 可观察性 | git-sha 三种姿势可查(端点 + 环境变量 + label),互为冗余 |
|
| 273 |
+
| 安全性 | `/api/version` 只暴露 git-sha 与构建时间,**不**暴露任何 secret / 路径 / 主机名 |
|
| 274 |
+
| 可回滚 | 所有改动可独立回退(删 entrypoint 那段、删 lint 配置、删 /api/version 端点) |
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## 7. 技术方案
|
| 279 |
+
|
| 280 |
+
### 7.1 entrypoint 自检脚本设计(bash + uv run python -c)
|
| 281 |
+
|
| 282 |
+
**为什么用 `uv run python -c` 而不是单独脚本**:
|
| 283 |
+
|
| 284 |
+
- 项目 runtime 用 uv 管理虚拟环境,自检必须走同一环境(否则可能用了系统 python 装的旧包,假阳性)
|
| 285 |
+
- 单行 inline `-c` 比额外 `scripts/self_check.py` 更易维护(和 entrypoint 同位置,不会失同步)
|
| 286 |
+
|
| 287 |
+
**为什么白名单粒度到符号常量**:
|
| 288 |
+
|
| 289 |
+
- 只检查 `from autoteam.api import app` 不够 — `app` 这种顶层对象 import 失败概率低,而 `STATUS_*` 常量、`load_accounts` 函数才是 typo 高发区
|
| 290 |
+
- 检查 `from autoteam.manager import sync_account_states` 顺带覆盖 manager → accounts 的链路
|
| 291 |
+
|
| 292 |
+
**失败行为**:
|
| 293 |
+
|
| 294 |
+
- `set -e` 配合 `||{ ...; exit 1; }` → bash 立即非零退出
|
| 295 |
+
- docker `restart: unless-stopped` 会触发 crash-loop,容器列表里看到 `Restarting (1)` → 立刻 `docker compose logs` 就能看到自检报错全文
|
| 296 |
+
|
| 297 |
+
### 7.2 镜像构建注入 GIT_SHA(--build-arg)
|
| 298 |
+
|
| 299 |
+
**3 处声明,一处真正接收**:
|
| 300 |
+
|
| 301 |
+
1. `docker-compose.yml`:`build.args.GIT_SHA: ${GIT_SHA:-unknown}` — 接受 shell 环境变量
|
| 302 |
+
2. `Dockerfile`:`ARG GIT_SHA=unknown` — 镜像构建期参数
|
| 303 |
+
3. `Dockerfile`:`ENV AUTOTEAM_GIT_SHA=$GIT_SHA` — 持久化到镜像运行环境
|
| 304 |
+
4. `Dockerfile`:`LABEL org.opencontainers.image.revision=$GIT_SHA` — OCI 标准 label,`docker image inspect` 可查
|
| 305 |
+
|
| 306 |
+
**降级语义**:
|
| 307 |
+
|
| 308 |
+
- 用户没传 `GIT_SHA`(直接 `docker compose build`)→ 默认值 `unknown`,**不报错**
|
| 309 |
+
- `/api/version` 返回 `{"git_sha": "unknown", ...}` → 提示用户"本镜像未注入版本"
|
| 310 |
+
- 这是渐进改进,不强制所有人都用,**老的 build 命令仍能工作**
|
| 311 |
+
|
| 312 |
+
### 7.3 lint 工具选型(ruff)
|
| 313 |
+
|
| 314 |
+
**对比表**:
|
| 315 |
+
|
| 316 |
+
| 工具 | F821 检测 | 启动延迟 | 配置成本 | 选用 |
|
| 317 |
+
|---|---|---|---|---|
|
| 318 |
+
| **ruff** | ✅ 内建 | < 100ms | 单文件 toml | ✅ |
|
| 319 |
+
| pyflakes | ✅ 内建 | ~500ms | 无配置 | ❌(被 ruff 包含) |
|
| 320 |
+
| mypy | ✅ 完整类型 | 5-10s | 复杂 | ❌(太重) |
|
| 321 |
+
| pylint | ✅ | 3-5s | 复杂 | ❌(太重) |
|
| 322 |
+
|
| 323 |
+
**只启 F401/F811/F821 三条规则的理由**:
|
| 324 |
+
|
| 325 |
+
- 这三条是"必然 bug"(死 import / 重复定义 / 未定义名),零误报
|
| 326 |
+
- 不开启 style 类规则 → 不打扰现有代码风格
|
| 327 |
+
- 后续可逐步追加 E/W/B 等,但本 PRD 不扩张
|
| 328 |
+
|
| 329 |
+
### 7.4 文档结构变更
|
| 330 |
+
|
| 331 |
+
- `docs/docker.md`:**追加**章节"代码更新后的 rebuild SOP"+"故障排查"
|
| 332 |
+
- `docs/getting-started.md`:**追加**一行 pre-commit 安装提示(可选)
|
| 333 |
+
- `docs/troubleshooting.md`:**追加**章节"镜像版本不一致 / 修了代码 bug 还在"
|
| 334 |
+
- `pyproject.toml`:**追加** `[tool.ruff.lint]` 段
|
| 335 |
+
- `.pre-commit-config.yaml`:**新建**(如不存在)
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
## 8. 验收标准
|
| 340 |
+
|
| 341 |
+
| ID | 验收点 | 检验方式 |
|
| 342 |
+
|---|---|---|
|
| 343 |
+
| AC1 | self-check 在镜像内能跑通 | `docker compose exec autoteam bash docker-entrypoint.sh --noop` 无报错 |
|
| 344 |
+
| AC2 | self-check 能拦截 typo | 临时把 `accounts.py` 里 `load_accounts` 改名 → rebuild → 启动应 crash-loop |
|
| 345 |
+
| AC3 | /api/version 返回正确字段 | `curl /api/version` 返回 `{git_sha, build_time}` 两字段且非空 |
|
| 346 |
+
| AC4 | git-sha 三处一致 | env / curl / label 三种方式查到的 sha 字符串完全相同 |
|
| 347 |
+
| AC5 | ruff F821 拦截 typo | 写一行 `from autoteam.accounts import list_accounts`,`ruff check src/` 应非零退出 |
|
| 348 |
+
| AC6 | pre-commit 触发 | `git commit` 含上述 typo 时被 pre-commit 拒绝 |
|
| 349 |
+
| AC7 | docs/docker.md SOP 可复制粘贴跑通 | 新人照文档 4 步操作,可成功 rebuild + 验证 |
|
| 350 |
+
| AC8 | 不传 GIT_SHA 不报错 | `docker compose build`(无 --build-arg)仍能成功,`/api/version` 返回 `unknown` |
|
| 351 |
+
|
| 352 |
+
---
|
| 353 |
+
|
| 354 |
+
## 9. 测试计划
|
| 355 |
+
|
| 356 |
+
### 9.1 本地 docker 端到端
|
| 357 |
+
|
| 358 |
+
```bash
|
| 359 |
+
# 步骤 1:基础 rebuild + 启动
|
| 360 |
+
cd D:/Desktop/AutoTeam
|
| 361 |
+
docker compose down
|
| 362 |
+
GIT_SHA=$(git rev-parse --short HEAD) BUILD_TIME=$(date -u +%FT%TZ) docker compose build --no-cache
|
| 363 |
+
docker compose up -d
|
| 364 |
+
|
| 365 |
+
# 步骤 2:验证 self-check 通过
|
| 366 |
+
docker compose logs autoteam | grep "[self-check] OK"
|
| 367 |
+
|
| 368 |
+
# 步骤 3:验证 /api/version
|
| 369 |
+
curl http://localhost:8787/api/version
|
| 370 |
+
# 期望:{"git_sha":"cf2f7d3","build_time":"2026-04-26T..."}
|
| 371 |
+
|
| 372 |
+
# 步骤 4:验证 env / label
|
| 373 |
+
docker compose exec autoteam env | grep AUTOTEAM_GIT_SHA
|
| 374 |
+
docker image inspect autoteam-autoteam | grep revision
|
| 375 |
+
```
|
| 376 |
+
|
| 377 |
+
### 9.2 self-check 故障注入
|
| 378 |
+
|
| 379 |
+
```bash
|
| 380 |
+
# 步骤 1:临时损坏 accounts.py
|
| 381 |
+
sed -i.bak 's/^def load_accounts/def loadaccounts/' src/autoteam/accounts.py
|
| 382 |
+
|
| 383 |
+
# 步骤 2:rebuild + 启动 → 应 crash-loop
|
| 384 |
+
docker compose build --no-cache
|
| 385 |
+
docker compose up -d
|
| 386 |
+
sleep 3
|
| 387 |
+
docker compose ps # 期望:autoteam 状态 Restarting
|
| 388 |
+
docker compose logs autoteam | grep "FATAL"
|
| 389 |
+
|
| 390 |
+
# 步骤 3:恢复
|
| 391 |
+
mv src/autoteam/accounts.py.bak src/autoteam/accounts.py
|
| 392 |
+
docker compose build --no-cache && docker compose up -d
|
| 393 |
+
```
|
| 394 |
+
|
| 395 |
+
### 9.3 lint 守卫验证
|
| 396 |
+
|
| 397 |
+
```bash
|
| 398 |
+
# 步骤 1:制造 typo
|
| 399 |
+
echo "from autoteam.accounts import list_accounts" >> src/autoteam/_lint_canary.py
|
| 400 |
+
|
| 401 |
+
# 步骤 2:期望 ruff 拦截
|
| 402 |
+
ruff check src/ --select F401,F811,F821
|
| 403 |
+
# 期望:F821 Undefined name `list_accounts`,exit code 非零
|
| 404 |
+
|
| 405 |
+
# 步骤 3:期望 pre-commit 拦截
|
| 406 |
+
git add src/autoteam/_lint_canary.py
|
| 407 |
+
git commit -m "test" # 期望被拒
|
| 408 |
+
|
| 409 |
+
# 步骤 4:清理
|
| 410 |
+
rm src/autoteam/_lint_canary.py
|
| 411 |
+
```
|
| 412 |
+
|
| 413 |
+
### 9.4 回归测试
|
| 414 |
+
|
| 415 |
+
- 现有 `tests/unit/`、`tests/integration/` 全套跑通
|
| 416 |
+
- WebUI 手测:进 setup wizard、点"生成免费号"、删除账号 — 三条主路径都不应受影响
|
| 417 |
+
|
| 418 |
+
---
|
| 419 |
+
|
| 420 |
+
## 10. 灰度 / 回滚
|
| 421 |
+
|
| 422 |
+
### 灰度策略
|
| 423 |
+
|
| 424 |
+
本 PRD 改动均为**外围加固**,不影响业务路径,无需金丝雀:
|
| 425 |
+
|
| 426 |
+
- 直接 merge 到 main → 用户 `git pull && docker compose build --no-cache` 即可生效
|
| 427 |
+
- 用户如未传 `GIT_SHA`,`/api/version` 返回 `unknown`,**功能不受影响**
|
| 428 |
+
|
| 429 |
+
### 回滚预案
|
| 430 |
+
|
| 431 |
+
| 风险点 | 回滚动作 |
|
| 432 |
+
|---|---|
|
| 433 |
+
| self-check 误拦截(白名单太严) | 删 `docker-entrypoint.sh` 末尾自检段,rebuild 即可 |
|
| 434 |
+
| ruff F821 误报 | `pyproject.toml` 临时关 F821:`select = ["F401","F811"]` |
|
| 435 |
+
| `/api/version` 引入异常 | 删 api.py 的 4 行端点定义即可 |
|
| 436 |
+
| docker-compose 用户 GIT_SHA 变量解析失败 | 移除 build.args 段,降级回原版 compose |
|
| 437 |
+
|
| 438 |
+
每一处改动都是**独立可回滚**单元。
|
| 439 |
+
|
| 440 |
+
---
|
| 441 |
+
|
| 442 |
+
## 11. 文档影响清单
|
| 443 |
+
|
| 444 |
+
| 文档 | 类型 | 变更内容 |
|
| 445 |
+
|---|---|---|
|
| 446 |
+
| `docs/docker.md` | 修改 | 追加"rebuild SOP"+"版本验证"+"故障排查"三章节 |
|
| 447 |
+
| `docs/getting-started.md` | 修改 | 追加 pre-commit 安装一行 |
|
| 448 |
+
| `docs/troubleshooting.md` | 修改 | 追加"镜像版本不一致"故障案例 |
|
| 449 |
+
| `docs/api.md` | 修改 | 追加 `/api/version` 端点说明 |
|
| 450 |
+
| `pyproject.toml` | 修改 | 新增 `[tool.ruff.lint]` 段 |
|
| 451 |
+
| `Dockerfile` | 修改 | 新增 ARG/LABEL/ENV |
|
| 452 |
+
| `docker-compose.yml` | 修改 | `build` 段从字符串扩为对象,新增 `args` |
|
| 453 |
+
| `docker-entrypoint.sh` | 修改 | 末尾追加 self-check 段 |
|
| 454 |
+
| `src/autoteam/api.py` | 修改 | 新增 `/api/version` 路由 |
|
| 455 |
+
| `.pre-commit-config.yaml` | 新建 | ruff hook |
|
| 456 |
+
| `CHANGELOG.md` | 修改 | 添加本 PRD 落地记录 |
|
| 457 |
+
|
| 458 |
+
---
|
| 459 |
+
|
| 460 |
+
## 12. 风险
|
| 461 |
+
|
| 462 |
+
| 风险 | 概率 | 影响 | 缓解 |
|
| 463 |
+
|---|---|---|---|
|
| 464 |
+
| self-check 白名单失同步(代码改了符号名,没改自检列表) | 中 | 容器启动失败 | 自检列表只列"长期稳定"符号(STATUS_*、load_accounts、save_accounts 等);任何 PR 改这些符号需同步改自检 |
|
| 465 |
+
| ruff F821 在动态 import 场景误报 | 低 | 开发体验下降 | F821 默认对动态 import 友好;真出现误报可加 `# noqa: F821` 单行豁免 |
|
| 466 |
+
| 用户老镜像没有 self-check,但用了新版 docker-compose.yml | 低 | 不报错,只是缺保护 | 不影响功能,docs SOP 引导用户先 rebuild 再使用 |
|
| 467 |
+
| `/api/version` 被滥用作健康探针 | 低 | 偶尔多打几个请求 | 端点本身无副作用,被频繁调用也无成本;真要做 healthcheck 应建议另开 `/api/healthz` |
|
| 468 |
+
| pre-commit 安装心智门槛 | 中 | 部分开发不装,守卫只剩本地 ruff | 至少 docs 写明,且 ruff 单独可运行(`ruff check src/`),不强依赖 pre-commit |
|
| 469 |
+
|
| 470 |
+
---
|
| 471 |
+
|
| 472 |
+
## 13. 未决问题
|
| 473 |
+
|
| 474 |
+
| ID | 问题 | 决策路径 |
|
| 475 |
+
|---|---|---|
|
| 476 |
+
| Q1 | self-check 是否要把所有 mail provider 类也 import?(`from autoteam.mail.maillab import MaillabClient` 等) | 暂**不**加,理由:provider 是可拔插模块,缺失不应 fatal;留待 PRD-1 mail provider 重构后再评估 |
|
| 477 |
+
| Q2 | 是否同时上 `docker compose --profile dev` 用于开发期 hot reload? | 暂**不**做,与本 PRD"镜像不可变"语义冲突,留待单独 RFC |
|
| 478 |
+
| Q3 | `/api/version` 是否需要鉴权? | **不**需要 — 只暴露 git-sha 不暴露 secret;且未鉴权能让运维无门槛排错 |
|
| 479 |
+
| Q4 | 是否要把 self-check 抽到独立脚本 `scripts/self_check.py` 便于 CI 直接调用? | **可选优化**,本 PRD 落地 inline 版本,后续若 CI 需要可重构 |
|
| 480 |
+
| Q5 | ruff 版本固定到 v0.7.x 还是跟 latest? | pre-commit 配置固定到 `v0.7.0`,pyproject.toml 不约束 — 让本地开发能用 latest,守卫线统一 |
|
| 481 |
+
|
| 482 |
+
---
|
| 483 |
+
|
| 484 |
+
## 14. Story Map
|
| 485 |
+
|
| 486 |
+
```
|
| 487 |
+
[运维更新代码] [开发提交 typo] [排错者诊断"代码改了 bug 还在"]
|
| 488 |
+
│ │ │
|
| 489 |
+
├─ [git pull] ├─ [写代码] ├─ [curl /api/version]
|
| 490 |
+
│ │ │
|
| 491 |
+
├─ [docker compose down] ├─ [git commit] ├─ [对比 git log HEAD]
|
| 492 |
+
│ │ │ │
|
| 493 |
+
├─ [docker compose build │ ├─ [pre-commit ruff F821] ├─ [发现 sha 不一致]
|
| 494 |
+
│ --no-cache │ │ └─ ⛔ 拦截 │
|
| 495 |
+
│ --build-arg GIT_SHA=...] │ │ │
|
| 496 |
+
│ │ └─ ✅ 通过 → push ├─ [告诉用户:rebuild!]
|
| 497 |
+
├─ [docker compose up -d] │ │
|
| 498 |
+
│ ├─ [docker build] └─ [按 SOP 重做]
|
| 499 |
+
├─ [curl /api/version 验证版本] │ └─ [self-check 启动期再守一道]
|
| 500 |
+
│ │
|
| 501 |
+
└─ ✅ 完成 └─ ✅ 部署成功
|
| 502 |
+
[若漏过] → ⛔ crash-loop + 报错日志
|
| 503 |
+
```
|
| 504 |
+
|
| 505 |
+
四道防线层层递进:**lint(开发期) → self-check(启动期) → /api/version(运行期) → docs SOP(操作期)**。
|
| 506 |
+
任何一道未拦截 typo 类问题,下一道兜底。
|
| 507 |
+
|
| 508 |
+
---
|
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PRD-4: Playwright async/sync 一致性硬化(hardening only)
|
| 2 |
+
|
| 3 |
+
## 0. 元数据
|
| 4 |
+
|
| 5 |
+
| 字段 | 值 |
|
| 6 |
+
|---|---|
|
| 7 |
+
| PRD 编号 | PRD-4 |
|
| 8 |
+
| 主题 | Playwright async/sync 一致性硬化(防御性) |
|
| 9 |
+
| 关联 Issue | #5(待用户补样本) |
|
| 10 |
+
| 来源研究 | `prompts/0426/research/issue-5-playwright-async-sync.md` |
|
| 11 |
+
| 优先级 | P2(低,纯防御,不阻塞 P0/P1) |
|
| 12 |
+
| 主笔 | prd-playwright |
|
| 13 |
+
| 创建日期 | 2026-04-26 |
|
| 14 |
+
| 状态 | DRAFT |
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## 1. 背景
|
| 19 |
+
|
| 20 |
+
用户在 Issue#5 报告"playwright 中大量 async 函数里误用 sync API"。研究阶段对全项目 5 处 `from playwright.sync_api import sync_playwright` 调用点 + 1 处 `async def`(`api.py:35 auth_middleware`,与 playwright 无关) 完整审计后**结论反转**:
|
| 21 |
+
|
| 22 |
+
- **0** 处 `async_playwright` 引用
|
| 23 |
+
- **0** 处 `asyncio` import / `async with` / `loop.run_until_complete`
|
| 24 |
+
- **5** 处 `sync_playwright` 调用全部位于 sync `def` 上下文,且经由 `api.py:155-229 _PlaywrightExecutor` 单例 + 专用 worker 线程串行,严格符合 Playwright 官方 "sync API + 单线程" 推荐模式
|
| 25 |
+
- 用户极可能将 `page.evaluate("""async () => {...}""")` 内的 JS 字符串误读为 Python 协程
|
| 26 |
+
|
| 27 |
+
**用户决策(2026-04-26)**:走 "hardening only" — 不修业务逻辑、不重构现有调用,只新增防御性代码,以**永久杜绝未来回归**。
|
| 28 |
+
|
| 29 |
+
## 2. 目标
|
| 30 |
+
|
| 31 |
+
| # | 目标 | 衡量 |
|
| 32 |
+
|---|---|---|
|
| 33 |
+
| G1 | 全项目 playwright 导入收敛到一处规范的 sync API 白名单 | grep 命中数与白名单一致 |
|
| 34 |
+
| G2 | 即使未来误把 `sync_playwright` 植入 `async def`,也能在第一次执行时立即抛错(而非触发 Playwright 内部隐式失败) | guard 单测通过 |
|
| 35 |
+
| G3 | CI 阶段静态拒绝 `async def` 函数体内出现 playwright 名称、拒绝 `from playwright.async_api ...` | pytest collect 包含 `tests/static/test_no_async_playwright.py` 且通过 |
|
| 36 |
+
| G4 | 提供清晰的 opt-out 出口,未来如确需引入 async_playwright 不被守卫卡死 | 风险登记册中明示 opt-out 路径 |
|
| 37 |
+
|
| 38 |
+
## 3. 非目标(明确不做)
|
| 39 |
+
|
| 40 |
+
- **不**重构 `_PlaywrightExecutor` / `chatgpt_api.py` / `codex_auth.py` / `invite.py` / `manager.py` 的任何业务逻辑
|
| 41 |
+
- **不**切换到 `async_playwright`(即便未来 FastAPI 改用 async 路由)
|
| 42 |
+
- **不**修改 5 处 `sync_playwright` 调用点的语义(本 PRD 仅做 import 路径重写)
|
| 43 |
+
- **不**引入新依赖(纯标准库 `ast` + `asyncio`)
|
| 44 |
+
- **不**把守卫扩展到 JS 端 `page.evaluate("async () => ...")` 字符串(那是合法 JS,与 Python async 无关)
|
| 45 |
+
|
| 46 |
+
## 4. 用户故事
|
| 47 |
+
|
| 48 |
+
### 4.1 运维(operator)
|
| 49 |
+
|
| 50 |
+
> 作为运维,我希望服务上线后**永远不会**因为某次重构把 `sync_playwright` 误植入 asyncio loop 而无人察觉地"挂机 30s 后报错",我希望第一次错误调用就立即崩溃并打印出清晰的中文错误堆栈,这样我能在生产前的 smoke 中就发现回归。
|
| 51 |
+
|
| 52 |
+
### 4.2 开发者(contributor)
|
| 53 |
+
|
| 54 |
+
> 作为新加入的开发者,我希望仅看一眼 import 列表就能确定本仓库**只用 sync API**;我也希望提交 PR 时 CI 能在任何不慎引入 `async_playwright` 或 async-def 内调用 sync API 的代码时立即 fail,而不是在我提了 PR 评审之后才被 reviewer 抓出来。
|
| 55 |
+
|
| 56 |
+
## 5. 功能需求
|
| 57 |
+
|
| 58 |
+
### 5.1 顶层 import 统一规范(FR-1)
|
| 59 |
+
|
| 60 |
+
**规范**:
|
| 61 |
+
|
| 62 |
+
- 仅允许从 `playwright.sync_api` 导入,白名单:`sync_playwright`, `Page`, `Browser`, `BrowserContext`, `BrowserType`, `Locator`, `Error`, `TimeoutError`, `Playwright`(共 9 个,覆盖现有用法 + 后续可能增量)
|
| 63 |
+
- 全部 import 必须在**模块顶层**,不允许函数内 import(对照研究 §B.1 — `manager.py:1491/1969` 现有函数内 import 必须上提)
|
| 64 |
+
- 任何文件出现 `from playwright.async_api import ...` 即 CI fail
|
| 65 |
+
|
| 66 |
+
**实施动作**:
|
| 67 |
+
|
| 68 |
+
1. 把 `manager.py` 函数内的两处 `from playwright.sync_api import sync_playwright` 移到模块顶层(若引发循环 import,请改为 `if TYPE_CHECKING:` + 函数内调用本地导入,但**该决定必须在 PR 描述里说明原因**)
|
| 69 |
+
2. 编写 `tests/static/test_playwright_imports.py` 校验白名单
|
| 70 |
+
|
| 71 |
+
### 5.2 `_PlaywrightExecutor` 运行时 guard(FR-2)
|
| 72 |
+
|
| 73 |
+
**规范**:
|
| 74 |
+
|
| 75 |
+
在 `api.py:_PlaywrightExecutor.run_with_timeout` 入口(以及 `_worker` 线程入口) **额外**插入一段 guard:
|
| 76 |
+
|
| 77 |
+
```python
|
| 78 |
+
import asyncio
|
| 79 |
+
def _assert_no_running_loop():
|
| 80 |
+
try:
|
| 81 |
+
loop = asyncio.get_running_loop()
|
| 82 |
+
except RuntimeError:
|
| 83 |
+
return # 期望路径:无 loop
|
| 84 |
+
raise RuntimeError(
|
| 85 |
+
"[PlaywrightGuard] 检测到 sync_playwright 被调度进 asyncio loop;"
|
| 86 |
+
"本项目仅支持 sync API + 专用线程模型。"
|
| 87 |
+
"若确需 async 调用,请走 PRD-4 §12 opt-out 流程。"
|
| 88 |
+
)
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
- `run_with_timeout` 入口断言一次(主线程入队前)
|
| 92 |
+
- `_worker` 启动时再断言一次(worker 线程是新线程,正常情况下应无 loop)
|
| 93 |
+
- 失败立即 raise,不要 swallow
|
| 94 |
+
|
| 95 |
+
### 5.3 AST 静态守卫单测(FR-3)
|
| 96 |
+
|
| 97 |
+
**规范**:
|
| 98 |
+
|
| 99 |
+
新建 `tests/static/test_no_async_playwright.py`,使用 `ast` 标准库扫 `src/autoteam/**/*.py`,断言:
|
| 100 |
+
|
| 101 |
+
| 断言 | 说明 |
|
| 102 |
+
|---|---|
|
| 103 |
+
| A1 | 任何 `from playwright.async_api import ...` 直接 fail |
|
| 104 |
+
| A2 | 任何 `import playwright.async_api` 直接 fail |
|
| 105 |
+
| A3 | 任何 `async def` 函数体内不出现 `sync_playwright` / `async_playwright` 名称、`Playwright`、`Browser`、`Page`、`BrowserContext` 等 9 个白名单符号(用变量遮蔽视为可疑) |
|
| 106 |
+
| A4 | 顶层 `from playwright.sync_api import X, Y, ...` 中 X/Y 必须在白名单内 |
|
| 107 |
+
|
| 108 |
+
**例外**:`tests/` 目录本身允许 async 测试(pytest-asyncio),但仍**不允许**在 async 测试体内调用 playwright(若未来需要,可在测试代码中单点豁免并加注释)
|
| 109 |
+
|
| 110 |
+
### 5.4 CI 集成(FR-4)
|
| 111 |
+
|
| 112 |
+
- 把 `tests/static/test_no_async_playwright.py` + `tests/static/test_playwright_imports.py` 加入 `pyproject.toml` / `pytest.ini` 默认 collect 路径
|
| 113 |
+
- 不允许默认 deselect / skip
|
| 114 |
+
- 在 PR 模板里加一行勾选项:"已确认未引入 async playwright 调用"
|
| 115 |
+
|
| 116 |
+
## 6. 非功能需求
|
| 117 |
+
|
| 118 |
+
| 维度 | 要求 |
|
| 119 |
+
|---|---|
|
| 120 |
+
| 性能 | guard 在 `run_with_timeout` 入口的开销 ≤ 50µs(`asyncio.get_running_loop()` 单次调用) |
|
| 121 |
+
| 兼容性 | 必须在 Python 3.10+ 工作(项目当前最低版本) |
|
| 122 |
+
| 可维护性 | guard 实现集中在一个文件 `src/autoteam/_playwright_guard.py`,5 处调用点零侵入 |
|
| 123 |
+
| 可观测性 | guard 失败时打印当前线程名 + asyncio loop id,便于诊断 |
|
| 124 |
+
| 文档影响 | 仅 `CONTRIBUTING.md`(若不存在则不写)增加 5 行说明 |
|
| 125 |
+
|
| 126 |
+
## 7. 技术方案
|
| 127 |
+
|
| 128 |
+
### 7.1 import 规范定义
|
| 129 |
+
|
| 130 |
+
新建 `src/autoteam/_playwright_guard.py`:
|
| 131 |
+
|
| 132 |
+
```python
|
| 133 |
+
"""Playwright import 守卫与白名单(PRD-4)"""
|
| 134 |
+
|
| 135 |
+
ALLOWED_SYNC_NAMES = {
|
| 136 |
+
"sync_playwright", "Page", "Browser", "BrowserContext",
|
| 137 |
+
"BrowserType", "Locator", "Error", "TimeoutError", "Playwright",
|
| 138 |
+
}
|
| 139 |
+
FORBIDDEN_MODULES = {"playwright.async_api"}
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
`tests/static/test_playwright_imports.py` 直接 import 该常量做断言,实现单一信源。
|
| 143 |
+
|
| 144 |
+
### 7.2 guard 实现细节
|
| 145 |
+
|
| 146 |
+
```python
|
| 147 |
+
# src/autoteam/_playwright_guard.py
|
| 148 |
+
import asyncio
|
| 149 |
+
import threading
|
| 150 |
+
|
| 151 |
+
def assert_sync_context() -> None:
|
| 152 |
+
"""在调用 sync_playwright 之前确认无 asyncio loop;否则抛 RuntimeError"""
|
| 153 |
+
try:
|
| 154 |
+
loop = asyncio.get_running_loop()
|
| 155 |
+
except RuntimeError:
|
| 156 |
+
return
|
| 157 |
+
raise RuntimeError(
|
| 158 |
+
f"[PlaywrightGuard] thread={threading.current_thread().name} "
|
| 159 |
+
f"loop_id={id(loop)} — sync_playwright 不允许在 asyncio loop 中调用。"
|
| 160 |
+
f"opt-out: 见 PRD-4 §12。"
|
| 161 |
+
)
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
`api.py` 调用点:
|
| 165 |
+
|
| 166 |
+
```python
|
| 167 |
+
# 入队前
|
| 168 |
+
class _PlaywrightExecutor:
|
| 169 |
+
def run_with_timeout(self, timeout, func, *args, **kwargs):
|
| 170 |
+
from autoteam._playwright_guard import assert_sync_context
|
| 171 |
+
assert_sync_context()
|
| 172 |
+
...
|
| 173 |
+
def _worker(self):
|
| 174 |
+
from autoteam._playwright_guard import assert_sync_context
|
| 175 |
+
assert_sync_context()
|
| 176 |
+
...
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
### 7.3 AST 测试实现示例(可运行,30+ 行)
|
| 180 |
+
|
| 181 |
+
```python
|
| 182 |
+
# tests/static/test_no_async_playwright.py
|
| 183 |
+
"""PRD-4 FR-3:静态守卫,禁止 async_playwright + 禁止 async def 内部 playwright 调用"""
|
| 184 |
+
import ast
|
| 185 |
+
from pathlib import Path
|
| 186 |
+
|
| 187 |
+
import pytest
|
| 188 |
+
|
| 189 |
+
SRC_ROOT = Path(__file__).resolve().parents[2] / "src" / "autoteam"
|
| 190 |
+
ALLOWED_SYNC_NAMES = {
|
| 191 |
+
"sync_playwright", "Page", "Browser", "BrowserContext",
|
| 192 |
+
"BrowserType", "Locator", "Error", "TimeoutError", "Playwright",
|
| 193 |
+
}
|
| 194 |
+
FORBIDDEN_MODULES = {"playwright.async_api"}
|
| 195 |
+
PLAYWRIGHT_NAMESPACE = ALLOWED_SYNC_NAMES | {"async_playwright"}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _iter_py_files():
|
| 199 |
+
yield from SRC_ROOT.rglob("*.py")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _collect_async_func_bodies(tree: ast.AST):
|
| 203 |
+
for node in ast.walk(tree):
|
| 204 |
+
if isinstance(node, ast.AsyncFunctionDef):
|
| 205 |
+
for child in ast.walk(node):
|
| 206 |
+
yield node, child
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def test_no_forbidden_module_import():
|
| 210 |
+
bad = []
|
| 211 |
+
for path in _iter_py_files():
|
| 212 |
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
| 213 |
+
for node in ast.walk(tree):
|
| 214 |
+
if isinstance(node, ast.ImportFrom) and node.module in FORBIDDEN_MODULES:
|
| 215 |
+
bad.append(f"{path}:{node.lineno} from {node.module}")
|
| 216 |
+
if isinstance(node, ast.Import):
|
| 217 |
+
for alias in node.names:
|
| 218 |
+
if alias.name in FORBIDDEN_MODULES:
|
| 219 |
+
bad.append(f"{path}:{node.lineno} import {alias.name}")
|
| 220 |
+
assert not bad, "禁止使用 playwright.async_api:\n" + "\n".join(bad)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def test_no_playwright_in_async_def():
|
| 224 |
+
bad = []
|
| 225 |
+
for path in _iter_py_files():
|
| 226 |
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
| 227 |
+
for fn, child in _collect_async_func_bodies(tree):
|
| 228 |
+
name = getattr(child, "id", None) or getattr(child, "attr", None)
|
| 229 |
+
if name in PLAYWRIGHT_NAMESPACE:
|
| 230 |
+
bad.append(f"{path}:{child.lineno} async def {fn.name} 内引用了 {name}")
|
| 231 |
+
assert not bad, "async def 函数体内不允许出现 playwright 符号:\n" + "\n".join(bad)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def test_sync_import_whitelist():
|
| 235 |
+
bad = []
|
| 236 |
+
for path in _iter_py_files():
|
| 237 |
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
| 238 |
+
for node in ast.walk(tree):
|
| 239 |
+
if isinstance(node, ast.ImportFrom) and node.module == "playwright.sync_api":
|
| 240 |
+
for alias in node.names:
|
| 241 |
+
if alias.name not in ALLOWED_SYNC_NAMES:
|
| 242 |
+
bad.append(f"{path}:{node.lineno} 未在白名单的符号 {alias.name}")
|
| 243 |
+
assert not bad, "playwright.sync_api 导入不在白名单:\n" + "\n".join(bad)
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
## 8. 验收标准
|
| 247 |
+
|
| 248 |
+
| AC# | 描述 | 验证方式 |
|
| 249 |
+
|---|---|---|
|
| 250 |
+
| AC1 | `pytest tests/static/` 全绿 | CI |
|
| 251 |
+
| AC2 | 在 `chatgpt_api.py` 临时插入 `async def evil(): sync_playwright()` 应使 AC1 失败 | 人工注入 + 还原 |
|
| 252 |
+
| AC3 | 在 `auth_middleware` 内手工调一次 `_PlaywrightExecutor().run_with_timeout(...)` 应抛 `[PlaywrightGuard]` | 人工注入 + 还原 |
|
| 253 |
+
| AC4 | `manager.py` 的 2 处函数内 import 已上提 | grep `from playwright` 在 manager.py 仅模块顶层 1 处 |
|
| 254 |
+
| AC5 | `pyproject.toml` / `pytest.ini` 包含 `tests/static/` | diff 校验 |
|
| 255 |
+
|
| 256 |
+
## 9. 测试计划
|
| 257 |
+
|
| 258 |
+
| 测试 | 类型 | 触发 |
|
| 259 |
+
|---|---|---|
|
| 260 |
+
| `test_no_async_playwright.py::test_no_forbidden_module_import` | 静态 | pytest |
|
| 261 |
+
| `test_no_async_playwright.py::test_no_playwright_in_async_def` | 静态 | pytest |
|
| 262 |
+
| `test_no_async_playwright.py::test_sync_import_whitelist` | 静态 | pytest |
|
| 263 |
+
| `test_playwright_guard.py::test_runtime_guard_blocks_in_loop` | 运行时(asyncio) | pytest-asyncio |
|
| 264 |
+
| `test_playwright_guard.py::test_runtime_guard_passes_in_thread` | 运行时(threading) | pytest |
|
| 265 |
+
| 既有 invite/register/sync 集成测试 | 回归 | 整套 pytest |
|
| 266 |
+
|
| 267 |
+
回归预期:**全部既有测试应保持绿色**(本 PRD 不动业务逻辑)。
|
| 268 |
+
|
| 269 |
+
## 10. 灰度 / 回滚
|
| 270 |
+
|
| 271 |
+
- **灰度**:不需要(纯防御代码,无运行时分支切换)
|
| 272 |
+
- **回滚**:删除 `tests/static/` + 移除 `assert_sync_context` 调用即可,无数据迁移
|
| 273 |
+
- **影响范围**:0(运行路径无 loop,guard 永不触发;静态测试不进生产)
|
| 274 |
+
|
| 275 |
+
## 11. 文档影响清单
|
| 276 |
+
|
| 277 |
+
- `CONTRIBUTING.md`(若已存在):新增"Playwright 使用守则"小节,5 行
|
| 278 |
+
- 不修改 `docs/`、`README.md`、`AGENTS.md`(本 PRD 是开发期硬化,不面向终端用户)
|
| 279 |
+
|
| 280 |
+
## 12. 风险登记册
|
| 281 |
+
|
| 282 |
+
| # | 风险 | 等级 | 缓解措施 |
|
| 283 |
+
|---|---|---|---|
|
| 284 |
+
| R1 | guard 误伤合法 async 调用(未来确需 async_playwright 接并发抓取) | 中 | **opt-out 流程**:在新模块内显式 `from playwright.async_api import async_playwright as _async_playwright_optout`,并在该文件顶部加注释 `# PRD-4 OPT-OUT: <reason>`;同时在 `test_no_async_playwright.py` 的 FORBIDDEN_MODULES 检查中加 per-file 豁免列表(显式 allowlist,需 PR 评审通过) |
|
| 285 |
+
| R2 | `manager.py` 函数内 import 上提引发循环 import | 低 | 实施前用 `python -c "import autoteam.manager"` 验证;若爆掉则保留函数内 import 并在静态测试中加 per-file 豁免 |
|
| 286 |
+
| R3 | AST 静态测试在 Windows 上路径处理出错 | 低 | 用 `Path.rglob` + `read_text(encoding="utf-8")`,已处理 |
|
| 287 |
+
| R4 | guard 单测引入 `pytest-asyncio` 新依赖 | 低 | 已在 dev 依赖中(若没有则添加 `pytest-asyncio>=0.23` 到 `pyproject.toml [project.optional-dependencies].dev`) |
|
| 288 |
+
| R5 | 用户描述的"async/sync 混用"实际是其它问题(如 anyio 内部错误) | 中 | 本 PRD 不解决该问题,只防回归;Issue#5 保持 open 等用户补样本 |
|
| 289 |
+
|
| 290 |
+
## 13. 未决问题
|
| 291 |
+
|
| 292 |
+
1. **Q1**:是否需要把 guard 扩展到所有 `_PlaywrightExecutor` 之外的直接 `sync_playwright()` 调用点(`codex_auth.py:271`、`invite.py:552`、`manager.py:1496/1976`)?
|
| 293 |
+
- 当前提案:**仅在 `_PlaywrightExecutor` 入口加**,因为这 4 处调用点最终都是经由 executor 调度;若未来某调用点绕过 executor 直接调,应在评审阶段拒绝
|
| 294 |
+
- 备选:每个 `with sync_playwright()` 前加 `assert_sync_context()`(代码侵入度更高)
|
| 295 |
+
2. **Q2**:`CONTRIBUTING.md` 不存在时是否新建?(用户偏好"优先编辑现有文件" — 倾向不新建)
|
| 296 |
+
3. **Q3**:R1 的 opt-out 豁免列表落在哪个文件?(候选:`pyproject.toml [tool.autoteam.playwright_guard].async_optout_files`)
|
| 297 |
+
|
| 298 |
+
## 14. Story Map
|
| 299 |
+
|
| 300 |
+
```
|
| 301 |
+
Sprint 0(本 PRD)
|
| 302 |
+
├── Story 1: 新建 _playwright_guard.py(白名单常量 + assert_sync_context)
|
| 303 |
+
├── Story 2: 在 _PlaywrightExecutor 双入口插入 guard
|
| 304 |
+
├── Story 3: 上提 manager.py 两处函数内 import(若不引发循环 import)
|
| 305 |
+
├── Story 4: 编写 tests/static/test_no_async_playwright.py
|
| 306 |
+
├── Story 5: 编写 tests/static/test_playwright_imports.py
|
| 307 |
+
├── Story 6: 编写 tests/unit/test_playwright_guard.py(asyncio loop 内/外)
|
| 308 |
+
├── Story 7: 把 tests/static/ 加入 pytest.ini collect 路径
|
| 309 |
+
└── Story 8: 跑全套 pytest,确认无回归
|
| 310 |
+
|
| 311 |
+
实施时间预估:0.5 人日
|
| 312 |
+
回归测试:已有套件 + 新增 5 个测试,2 小时
|
| 313 |
+
评审重点:R1 的 opt-out 设计是否清晰
|
| 314 |
+
```
|
| 315 |
+
|
| 316 |
+
---
|
| 317 |
+
|
| 318 |
+
**附:与研究文档(issue-5-playwright-async-sync.md)的对应关系**
|
| 319 |
+
|
| 320 |
+
| 研究章节 | 本 PRD 章节 |
|
| 321 |
+
|---|---|
|
| 322 |
+
| §A 官方 API 边界 | §1 背景 |
|
| 323 |
+
| §B 全项目使用现状 | §1 背景 + §5.1 |
|
| 324 |
+
| §C 误用清单(0 处) | §1 + §3 |
|
| 325 |
+
| §D.2.1 顶层 import | §5.1 + §7.1 |
|
| 326 |
+
| §D.2.3 runtime guard | §5.2 + §7.2 |
|
| 327 |
+
| §D.3.1 AST 静态测试 | §5.3 + §7.3 |
|
| 328 |
+
| §D.3.2 runtime guard 测试 | §9 |
|
| 329 |
+
| §E.3 用户描述差距 | §1 + R5 |
|
|
@@ -0,0 +1,613 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Issue#1 CloudMail 全量集成 — 研究报告
|
| 2 |
+
|
| 3 |
+
> 范围:把"CloudMail"(实际指 `maillab/cloud-mail`,品牌名 `skymail.ink`)的全量 API 接入 AutoTeam,在前端
|
| 4 |
+
> 加邮箱归属验证,修复 setup_wizard 的 401 半成功 bug,并梳理文档影响面。
|
| 5 |
+
>
|
| 6 |
+
> 调研时间:2026-04-26
|
| 7 |
+
> 关键证据:
|
| 8 |
+
> - `https://doc.skymail.ink` 顶部 logo 链与 GitHub Releases 链直指 `https://github.com/maillab/cloud-mail`,
|
| 9 |
+
> "在线演示"地址即 `https://skymail.ink` → **skymail.ink = maillab/cloud-mail 的官方部署/对外品牌**。
|
| 10 |
+
> - `mail-worker/src/security/security.js` 白名单 = `['/login', '/register', '/oss',
|
| 11 |
+
> '/setting/websiteConfig', '/webhooks', '/init', '/public/genToken', '/telegram', '/test', '/oauth']`
|
| 12 |
+
> - `mail-worker/src/api/*-api.js` 实际暴露 60+ 路由,`doc.skymail.ink/api/api-doc.html` 只挑了 3 个
|
| 13 |
+
> "对外集成"用的子集(`/public/genToken / emailList / addUser`)写文档。
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## A. 现状盘点
|
| 18 |
+
|
| 19 |
+
### A.1 cloudmail.py 当前接口表
|
| 20 |
+
|
| 21 |
+
`src/autoteam/cloudmail.py` 现在只是 **10 行 stub**(2026-04 重构后改为 re-export):
|
| 22 |
+
|
| 23 |
+
```python
|
| 24 |
+
from autoteam.mail import CloudMailClient # noqa: F401 re-export
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
实际实现拆到了 `src/autoteam/mail/` 子包,工厂 `get_mail_client()` 按 `MAIL_PROVIDER` 环境变量分发到具体后端。
|
| 28 |
+
|
| 29 |
+
**`src/autoteam/mail/` 当前目录结构**:
|
| 30 |
+
|
| 31 |
+
| 文件 | 行数 | 角色 |
|
| 32 |
+
| ---------------------------- | ---- | ----------------------------------------------------------- |
|
| 33 |
+
| `__init__.py` | 52 | 工厂 `get_mail_client()` + 兼容别名 `CloudMailClient` |
|
| 34 |
+
| `base.py` | 276 | `MailProvider` ABC + `Email/Account` dataclass + 共享文本工具 |
|
| 35 |
+
| `cf_temp_email.py` | 379 | `dreamhunter2333/cloudflare_temp_email` 后端实现 |
|
| 36 |
+
| `maillab.py` | 467 | `maillab/cloud-mail` 后端实现(即 skymail.ink) |
|
| 37 |
+
|
| 38 |
+
**`MailProvider` ABC 公开方法集**(11 个,`base.py` 定义):
|
| 39 |
+
|
| 40 |
+
| 方法 | 抽象度 | 用途 |
|
| 41 |
+
| ---------------------------- | ------- | --------------------------------------------------------- |
|
| 42 |
+
| `login()` | abstract | 初始化鉴权,返回 token 字符串(仅日志用) |
|
| 43 |
+
| `create_temp_email(prefix, domain)` | abstract | 创建临时邮箱,返回 `(account_id, email)` |
|
| 44 |
+
| `list_accounts(size)` | abstract | 列出已建邮箱,返回 dict 列表 |
|
| 45 |
+
| `delete_account(account_id)` | abstract | 删除邮箱,返回 `{code, message?}` |
|
| 46 |
+
| `search_emails_by_recipient(to_email, size, account_id)` | abstract | 按收件人查邮件 |
|
| 47 |
+
| `list_emails(account_id, size)` | abstract | 按 accountId 查邮件 |
|
| 48 |
+
| `delete_emails_for(to_email)`| abstract | 批量删除某收件人的所有邮件 |
|
| 49 |
+
| `get_latest_emails(account_id, ...)` | concrete | 旧接口兼容,默认委托 `list_emails` |
|
| 50 |
+
| `wait_for_email(...)` | concrete | 轮询等待邮件,默认实现搬自旧 `cloudmail.py` |
|
| 51 |
+
| `extract_verification_code(email_data)` | concrete | 提取 6 位 OTP(纯文本正则) |
|
| 52 |
+
| `extract_invite_link(email_data)` | concrete | 提取 ChatGPT/通用邀请链接 |
|
| 53 |
+
|
| 54 |
+
**`MaillabClient` 实际命中的 maillab API**(已上线):
|
| 55 |
+
|
| 56 |
+
| 方法 | HTTP 路径 | 备注 |
|
| 57 |
+
| ------------------ | ---------------------------- | ------------------------------------------------------------------- |
|
| 58 |
+
| `login` | `POST /login` | body `{email, password}` → `{code:200, data:{token}}` |
|
| 59 |
+
| `create_temp_email`| `POST /account/add` | body `{email}`(完整地址,不接受 prefix+domain 拆开) |
|
| 60 |
+
| `list_accounts` | `GET /account/list` | 服务端硬 cap 30/页,代码已实现 `lastSort + accountId` 游标翻页 |
|
| 61 |
+
| `delete_account` | `DELETE /account/delete?accountId=N` | |
|
| 62 |
+
| `list_emails` | `GET /email/list` | 必须带 `type=0`(RECEIVE),否则 SQL `IS NULL` 永远为空 |
|
| 63 |
+
| `get_latest_emails`| `GET /email/latest` | |
|
| 64 |
+
| `delete_emails_for`| `DELETE /email/delete?emailIds=1,2,3` | 软删除 |
|
| 65 |
+
|
| 66 |
+
**调用方调用面**(`src/autoteam` 全仓 grep):
|
| 67 |
+
|
| 68 |
+
| 调用方 | `CloudMailClient()` 实例化 | `mail_client.<method>` 引用 |
|
| 69 |
+
| ----------------- | -------------------------- | --------------------------- |
|
| 70 |
+
| `manager.py` | 11 | 7 |
|
| 71 |
+
| `api.py` | 5 | - |
|
| 72 |
+
| `invite.py` | 1 | 4 |
|
| 73 |
+
| `codex_auth.py` | 0(由调用方注入) | 9 |
|
| 74 |
+
| `account_ops.py` | 1 | 2 |
|
| 75 |
+
| `setup_wizard.py` | 1 | 0 |
|
| 76 |
+
| 合计 | **19 处实例化 / 22 处方法调用** | — |
|
| 77 |
+
|
| 78 |
+
→ 所有调用都走 `from autoteam.cloudmail import CloudMailClient` 兼容别名,**业务层零改动**。
|
| 79 |
+
|
| 80 |
+
### A.2 setup_wizard CloudMail 验证流程
|
| 81 |
+
|
| 82 |
+
`src/autoteam/setup_wizard.py:_verify_cloudmail()` 实际流程:
|
| 83 |
+
|
| 84 |
+
1. 读 `MAIL_PROVIDER` 环境变量(默认 `cf_temp_email`)
|
| 85 |
+
2. 校验对应的环境变量集是否齐全(cf 看 `CLOUDMAIL_BASE_URL/PASSWORD/DOMAIN`,maillab 看 `MAILLAB_API_URL/USERNAME/PASSWORD` + 域名)
|
| 86 |
+
3. 调用 `_sniff_provider_mismatch(provider)`:用 GET `/admin/address` 与 GET `/login` 探测路由指纹,发现 base_url 协议错配则打 warning 但不阻断
|
| 87 |
+
4. 实例化 `CloudMailClient()` → `client.login()` → `client.create_temp_email(prefix=at-test-xxxxx)` → `client.delete_account(...)`
|
| 88 |
+
5. 任一环节抛异常 → 打 ERROR 并 `return False`
|
| 89 |
+
|
| 90 |
+
`_verify_cloudmail()` 也被同名函数 `post_setup_save` (`api.py:99`) 复用,即 Web 面板 SetupPage 提交后走的也是这套逻辑。
|
| 91 |
+
|
| 92 |
+
### A.3 配置项 (.env / CLOUDMAIL_*)
|
| 93 |
+
|
| 94 |
+
`.env.example` 当前定义:
|
| 95 |
+
|
| 96 |
+
```dotenv
|
| 97 |
+
MAIL_PROVIDER=cf_temp_email # cf_temp_email | maillab
|
| 98 |
+
CLOUDMAIL_BASE_URL=https://example.com/api
|
| 99 |
+
CLOUDMAIL_PASSWORD=your_password
|
| 100 |
+
CLOUDMAIL_DOMAIN=@example.com
|
| 101 |
+
CLOUDMAIL_EMAIL= # 已废弃,保留兼容旧 .env
|
| 102 |
+
# MAILLAB_API_URL=https://your-maillab.example.com
|
| 103 |
+
# MAILLAB_USERNAME=admin@example.com
|
| 104 |
+
# MAILLAB_PASSWORD=your_password
|
| 105 |
+
# MAILLAB_DOMAIN=@example.com
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
**`setup_wizard.REQUIRED_CONFIGS`**(交互式向导询问的字段):
|
| 109 |
+
|
| 110 |
+
- `MAIL_PROVIDER` (默认 `cf_temp_email`,可选)
|
| 111 |
+
- `CLOUDMAIL_BASE_URL`
|
| 112 |
+
- `CLOUDMAIL_PASSWORD`
|
| 113 |
+
- `CLOUDMAIL_DOMAIN`
|
| 114 |
+
- `CPA_URL` / `CPA_KEY`
|
| 115 |
+
- `PLAYWRIGHT_PROXY_URL` / `PLAYWRIGHT_PROXY_BYPASS`(可选)
|
| 116 |
+
- `API_KEY`
|
| 117 |
+
|
| 118 |
+
> **关键缺口**:`REQUIRED_CONFIGS` 与 Web 面板 SetupPage **没有**收 maillab 字段(`MAILLAB_API_URL/USERNAME/PASSWORD/DOMAIN`)。
|
| 119 |
+
> 用户选 `MAIL_PROVIDER=maillab` 时**只能手动改 `.env`**,getting-started.md L128 也写了这一点。
|
| 120 |
+
> 这正是 issue#1 截图里"创建邮箱失败 401"的 root cause(详见 §D)。
|
| 121 |
+
|
| 122 |
+
`api.py` 的 `SetupConfig` Pydantic 模型(L70-79):
|
| 123 |
+
|
| 124 |
+
```python
|
| 125 |
+
class SetupConfig(BaseModel):
|
| 126 |
+
CLOUDMAIL_BASE_URL: str = ""
|
| 127 |
+
CLOUDMAIL_EMAIL: str = ""
|
| 128 |
+
CLOUDMAIL_PASSWORD: str = ""
|
| 129 |
+
CLOUDMAIL_DOMAIN: str = ""
|
| 130 |
+
CPA_URL: str = "http://127.0.0.1:8317"
|
| 131 |
+
CPA_KEY: str = ""
|
| 132 |
+
PLAYWRIGHT_PROXY_URL: str = ""
|
| 133 |
+
PLAYWRIGHT_PROXY_BYPASS: str = ""
|
| 134 |
+
API_KEY: str = ""
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
**完全不包含 `MAIL_PROVIDER` 与 `MAILLAB_*`** → Web 面板提交配置时即使前端把字段塞进去,Pydantic 默认会忽略未定义字段,
|
| 138 |
+
`/api/setup/save` 不会写入 `MAIL_PROVIDER`,造成 maillab 部署用户被锁死在 cf_temp_email 模式。
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## B. skymail.ink 官方 API 全表
|
| 143 |
+
|
| 144 |
+
> **结论先行**:skymail.ink 是 `maillab/cloud-mail` 的对外品牌(GitHub `@maillab` 组织,Releases 链来自 `doc.skymail.ink` 顶部导航)。
|
| 145 |
+
> `doc.skymail.ink/api/api-doc.html` 只对外公布了 3 个"集成接口"(`/public/*`),但仓库实际暴露的路由 ≥ 60 个,
|
| 146 |
+
> 全部按 `mail-worker/src/api/*-api.js` 文件分组。
|
| 147 |
+
|
| 148 |
+
### B.1 已确认接口(全部从 GitHub 源码核实)
|
| 149 |
+
|
| 150 |
+
#### B.1.1 公开集成接口 `/public/*`(skymail 文档显式公开,**仅 3 个**)
|
| 151 |
+
|
| 152 |
+
| 路由 | 方法 | 鉴权要求 | 用途 |
|
| 153 |
+
| --------------------- | ---- | ------------------------------------- | ------------------------------------------------------ |
|
| 154 |
+
| `/public/genToken` | POST | **白名单内**(无需 token)。但 service 内部 `verifyUser()` 会校验 `email === c.env.admin`,**仅管理员邮箱**能拿到 token | 拿一个 UUID 格式的 API token(KV 持久化) |
|
| 155 |
+
| `/public/emailList` | POST | 需 `Authorization: <jwt>`(genToken 拿到的 token 也认) | 模糊查邮件:支持 `toEmail/sendName/sendEmail/subject/content/timeSort/type/isDel/num/size` |
|
| 156 |
+
| `/public/addUser` | POST | 需 `Authorization: <jwt>` | 批量加用户:body `{list:[{email, password?, roleName?}, ...]}` |
|
| 157 |
+
|
| 158 |
+
#### B.1.2 鉴权 `/login` `/register` `/logout`(在白名单)
|
| 159 |
+
|
| 160 |
+
| 路由 | 方法 | body / 参数 | 返回 |
|
| 161 |
+
| ------------ | ------ | ----------------------------------- | ------------------------------------------ |
|
| 162 |
+
| `/login` | POST | `{email, password, [token?]}` | `{code:200, data:{token}}` — token=裸 JWT |
|
| 163 |
+
| `/register` | POST | `{email, password, [token?]}` | `{code:200, data:<jwt>}` |
|
| 164 |
+
| `/logout` | DELETE | header `Authorization: <jwt>` | `{code:200}` |
|
| 165 |
+
|
| 166 |
+
#### B.1.3 账户管理 `/account/*`(需鉴权)
|
| 167 |
+
|
| 168 |
+
| 路由 | 方法 | 关键参数 | 用途 |
|
| 169 |
+
| --------------------------- | ------ | ------------------------- | --------------------------------------------- |
|
| 170 |
+
| `/account/list` | GET | `size, lastSort, accountId` | 列表(单页 cap 30,游标翻页) |
|
| 171 |
+
| `/account/add` | POST | body `{email}` | 新建临时邮箱(完整地址,服务端校验域名白名单) |
|
| 172 |
+
| `/account/delete` | DELETE | `accountId` | |
|
| 173 |
+
| `/account/setName` | PUT | body `{accountId, name}` | 改备注 |
|
| 174 |
+
| `/account/setAllReceive` | PUT | body `{accountId, allReceive}` | 是否接收所有发件人 |
|
| 175 |
+
| `/account/setAsTop` | PUT | body `{accountId}` | 置顶/取消置顶 |
|
| 176 |
+
|
| 177 |
+
#### B.1.4 邮件 `/email/*`(需鉴权)
|
| 178 |
+
|
| 179 |
+
| 路由 | 方法 | 关键参数 | 用途 |
|
| 180 |
+
| ------------------- | ------ | ------------------------------------------------------------------------ | ------------------------------------------------------ |
|
| 181 |
+
| `/email/list` | GET | `accountId, type(0=收/1=发,**必传**), size(<=50), emailId, timeSort, allReceive` | drizzle 用 `eq(email.type, type)`,缺省走 IS NULL |
|
| 182 |
+
| `/email/latest` | GET | `accountId, emailId(游标), allReceive` | 最新 ≤ 20 封(默认按 emailId desc) |
|
| 183 |
+
| `/email/attList` | GET | `emailId` | 附件列表 |
|
| 184 |
+
| `/email/delete` | DELETE | `emailIds`(逗号分隔) | 软删除 |
|
| 185 |
+
| `/email/send` | POST | body(发件载荷) | 发邮件(基于 Resend) |
|
| 186 |
+
| `/email/read` | PUT | body `{emailId}` | 标记已读 |
|
| 187 |
+
|
| 188 |
+
#### B.1.5 全局邮件管理 `/allEmail/*`(管理员权限)
|
| 189 |
+
|
| 190 |
+
| 路由 | 方法 | 用途 |
|
| 191 |
+
| -------------------------- | ------ | ------------------------------- |
|
| 192 |
+
| `/allEmail/list` | GET | 管理员看全平台邮件 |
|
| 193 |
+
| `/allEmail/latest` | GET | 全平台最新邮件 |
|
| 194 |
+
| `/allEmail/delete` | DELETE | 物理删除(硬删) |
|
| 195 |
+
| `/allEmail/batchDelete` | DELETE | 批量物理删除 |
|
| 196 |
+
|
| 197 |
+
#### B.1.6 用户管理 `/user/*`(管理员权限)
|
| 198 |
+
|
| 199 |
+
| 路由 | 方法 | 用途 |
|
| 200 |
+
| -------------------------- | ------ | -------------------------------- |
|
| 201 |
+
| `/user/list` | GET | 用户列表 |
|
| 202 |
+
| `/user/add` | POST | 新增用户(单条) |
|
| 203 |
+
| `/user/setPwd` | PUT | 改密 |
|
| 204 |
+
| `/user/setStatus` | PUT | 启停 |
|
| 205 |
+
| `/user/setType` | PUT | 改类型 |
|
| 206 |
+
| `/user/restore` | PUT | 恢复 |
|
| 207 |
+
| `/user/resetSendCount` | PUT | 重置发件计数 |
|
| 208 |
+
| `/user/delete` | DELETE | 物理删除 |
|
| 209 |
+
| `/user/allAccount` | GET | 某用户名下所有 account(临时邮箱)|
|
| 210 |
+
| `/user/deleteAccount` | DELETE | 删某用户的某 account |
|
| 211 |
+
|
| 212 |
+
#### B.1.7 角色 `/role/*`(管理员权限)
|
| 213 |
+
|
| 214 |
+
| 路由 | 方法 | 用途 |
|
| 215 |
+
| --------------------- | ------ | ------------------- |
|
| 216 |
+
| `/role/list` | GET | 角色列表 |
|
| 217 |
+
| `/role/permTree` | GET | 权限树(RBAC) |
|
| 218 |
+
| `/role/selectUse` | GET | 可选角色 |
|
| 219 |
+
| `/role/add` | POST | 新增 |
|
| 220 |
+
| `/role/set` | PUT | 改 |
|
| 221 |
+
| `/role/setDefault` | PUT | 设默认 |
|
| 222 |
+
| `/role/delete` | DELETE | 删 |
|
| 223 |
+
|
| 224 |
+
#### B.1.8 设置 `/setting/*`
|
| 225 |
+
|
| 226 |
+
| 路由 | 方法 | 鉴权 | 用途 |
|
| 227 |
+
| ----------------------------- | ------ | --------------------- | ------------------------------------------------------------------- |
|
| 228 |
+
| **`/setting/websiteConfig`** | GET | **白名单(无需 token)** | 拉公开站点配置 — **关键端点**(归属验证用,详见 §C) |
|
| 229 |
+
| `/setting/query` | GET | 需 token | 全量站点设置(管理员视角,token 等敏感字段已掩码) |
|
| 230 |
+
| `/setting/set` | PUT | 需 token + admin 权限 | 改设置 |
|
| 231 |
+
| `/setting/setBackground` | PUT | 需 token + admin 权限 | 改背景 |
|
| 232 |
+
| `/setting/deleteBackground` | DELETE | 需 token + admin 权限 | 删背景 |
|
| 233 |
+
|
| 234 |
+
**`/setting/websiteConfig` 返回字段**(已现场验证 `setting-service.js`):
|
| 235 |
+
|
| 236 |
+
```text
|
| 237 |
+
register, title, manyEmail, addEmail, autoRefresh, addEmailVerify, registerVerify, send,
|
| 238 |
+
domainList, # ★ 平台允许的邮箱域名清单,如 ["@example.com", "@another.com"]
|
| 239 |
+
siteKey, regKey, r2Domain, background, loginOpacity,
|
| 240 |
+
regVerifyOpen, addVerifyOpen, # 是否开启注册/添加邮箱时的人机验证
|
| 241 |
+
noticeTitle/noticeContent/noticeType/noticeDuration/noticePosition/noticeWidth/noticeOffset/notice,
|
| 242 |
+
linuxdoClientId/linuxdoCallbackUrl/linuxdoSwitch, # OAuth2 开关
|
| 243 |
+
loginDomain, minEmailPrefix, projectLink
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
#### B.1.9 其他
|
| 247 |
+
|
| 248 |
+
| 路由前缀 | 方法集 | 鉴权 | 备注 |
|
| 249 |
+
| ---------------- | -------------------- | ---- | ---------------------------------------------------------- |
|
| 250 |
+
| `/init/:secret` | GET | 白名单 | 首次部署初始化 DB |
|
| 251 |
+
| `/oauth/*` | GET/POST | 白名单 | LinuxDo / 第三方 OAuth 登录 |
|
| 252 |
+
| `/webhooks/*` | POST | 白名单 | 收件 webhook 入口(Cloudflare email routing 触发) |
|
| 253 |
+
| `/oss/*` | POST/DELETE | 白名单 | 附件 OSS(R2)上传 |
|
| 254 |
+
| `/r2/*` | POST/DELETE | 需 token | 附件管理(管理员视角) |
|
| 255 |
+
| `/regKey/*` | POST/GET/DELETE | 需 token | 注册码管理 |
|
| 256 |
+
| `/star/*` | POST/DELETE/GET | 需 token | 邮件星标 |
|
| 257 |
+
| `/my/*` | GET/PUT | 需 token | 当前用户配置 |
|
| 258 |
+
| `/resend/*` | POST/GET | 需 token | Resend token 管理(发邮件凭证) |
|
| 259 |
+
| `/analysis/*` | GET | 需 token | 数据可视化 |
|
| 260 |
+
| `/telegram/*` | POST | 白名单 | Telegram bot 入口 |
|
| 261 |
+
| `/test/*` | GET | 白名单 | 健康检查 |
|
| 262 |
+
|
| 263 |
+
### B.2 推测 / 未确认接口
|
| 264 |
+
|
| 265 |
+
| 推测点 | 是否需补查源码 | 备注 |
|
| 266 |
+
| --------------------------------------------------------- | -------------- | --------------------------------------------------------------- |
|
| 267 |
+
| `/oauth/linuxdo/callback` 具体回调字段 | 是 | `oauth-api.js` 未抓全文,可能影响后续做"用 linuxdo OAuth 验证归属" |
|
| 268 |
+
| `/init/:secret` 的 `secret` 来源 | 是 | 部署期生成,文档未明示;集成不需要 |
|
| 269 |
+
| `/setting/websiteConfig` 中 `domainList` 是否随租户隔离 | 是 | maillab 单实例多租户?设计文档未明,但 RBAC 看 user.userId 走表 |
|
| 270 |
+
| 短期是否会出 `/api/v2/*` 路由 | 否(暂时) | 现仓库 `mail-worker` 单工程,无版本前缀 |
|
| 271 |
+
| Turnstile 启用后 `/login` 是否要 `token` 字段 | 是 | maillab.py 已注释 TODO(maillab-verify),实施前 e2e 验证 |
|
| 272 |
+
| `/account/add` 创建时的 `addVerify` 行为 | 是 | 当 `addVerifyOpen=true` 时是否要带 captcha token,需先查 service |
|
| 273 |
+
|
| 274 |
+
### B.3 鉴权与刷新机制
|
| 275 |
+
|
| 276 |
+
源码:`mail-worker/src/security/security.js`
|
| 277 |
+
|
| 278 |
+
```javascript
|
| 279 |
+
// 全局中间件
|
| 280 |
+
app.use('*', async (c, next) => {
|
| 281 |
+
// 1. 白名单直通
|
| 282 |
+
if (exclude.some(p => path.startsWith(p))) return next();
|
| 283 |
+
|
| 284 |
+
// 2. 取 token
|
| 285 |
+
const jwt = c.req.header(constant.TOKEN_HEADER); // header = "Authorization"
|
| 286 |
+
if (!jwt) throw 'unauthorized';
|
| 287 |
+
|
| 288 |
+
// 3. 验签 + 检查会话
|
| 289 |
+
const result = await jwtUtils.verifyToken(c, jwt);
|
| 290 |
+
const authInfo = await getAuthInfo(c, result.userId);
|
| 291 |
+
if (!authInfo.tokens.includes(jwt)) throw 'session expired';
|
| 292 |
+
|
| 293 |
+
// 4. RBAC: requirePerms 数组里的端点会��权限检查
|
| 294 |
+
if (requirePerms.includes(path)) {
|
| 295 |
+
const permKeys = await permService.userPermKeys(c, authInfo.user.userId);
|
| 296 |
+
const userPaths = permKeyToPaths(permKeys);
|
| 297 |
+
const userPermIndex = userPaths.indexOf(path);
|
| 298 |
+
// 5. admin 邮箱直通(`c.env.admin` 是部署期配置)
|
| 299 |
+
if (userPermIndex === -1 && authInfo.user.email !== c.env.admin) throw 'no permission';
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// 6. 每日 TTL 刷新
|
| 303 |
+
if (today != lastRefresh) await refreshAuthInfoTTL(c, authInfo);
|
| 304 |
+
});
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
**关键点 — 集成 AutoTeam 必须知道**:
|
| 308 |
+
|
| 309 |
+
1. **header 名 = `Authorization`**(常量 `TOKEN_HEADER`),**裸 JWT**,**不加 `Bearer ` 前缀**。
|
| 310 |
+
这与一般 OAuth 习惯不同,`maillab.py:91-97` 已正确处理。
|
| 311 |
+
2. **Session 是 server-side 持久化**(KV `authInfo.tokens`),所以 token 续命不是靠 JWT 过期时间,而是看 KV;
|
| 312 |
+
本地 token 缓存可以长期有效,但不能跨重启假定。
|
| 313 |
+
3. **`c.env.admin` 是部署期配置的管理员邮箱白名单**,这位用户调用 `/public/genToken` 才能拿到 token。
|
| 314 |
+
4. **TTL 刷新是被动的**(每日首次调用触发),不是主动 keep-alive。
|
| 315 |
+
5. **没有 refresh_token 概念** — token 失效后必须重新 `/login`。AutoTeam 需要在 401 时调用 `_ensure_login()` 自愈
|
| 316 |
+
(现 `maillab.py` 没做,详见 §F)。
|
| 317 |
+
|
| 318 |
+
---
|
| 319 |
+
|
| 320 |
+
## C. 设计:邮箱归属验证 UI
|
| 321 |
+
|
| 322 |
+
### C.1 用户故事
|
| 323 |
+
|
| 324 |
+
> 作为管理员,我在 AutoTeam 的 Setup / Settings 页填写"CloudMail 域名"时,
|
| 325 |
+
> **不应**靠手敲一个字符串然后再用 `/api/config/register-domain` 试探;
|
| 326 |
+
> **应该**直接从 maillab 服务端拉到"我能用的域名清单",
|
| 327 |
+
> 并在保存前**确认我对该域名拥有写权限**(我能在这个 domain 下创建临时邮箱,
|
| 328 |
+
> 且 maillab 后端没启用我没法过的 captcha)。
|
| 329 |
+
>
|
| 330 |
+
> 同时,如果我换了 maillab 实例 base_url,前端要在我点"保存"前就告诉我:
|
| 331 |
+
> 1. 这个 base_url 是 maillab 后端吗?(指纹探测 — 现在已经有 `_sniff_provider_mismatch`,但仅在启动验证阶段跑,前端没有)
|
| 332 |
+
> 2. 这个 base_url 允许哪些 domain?(从 `/setting/websiteConfig.domainList` 拿)
|
| 333 |
+
> 3. 我用我填的 username/password 真的能登进去吗?(`/login`)
|
| 334 |
+
> 4. 这个 domain 在我登录后真的能创建邮箱吗?(`/account/add` 试探,然后立即 `/account/delete` 回收 — 现 `register-domain` PUT 已经有这逻辑)
|
| 335 |
+
|
| 336 |
+
### C.2 流程图(setup wizard 与 settings 共用)
|
| 337 |
+
|
| 338 |
+
```text
|
| 339 |
+
用户填 base_url + username + password + (可选)domain
|
| 340 |
+
│
|
| 341 |
+
▼
|
| 342 |
+
┌─────────────────────────────────────────────────┐
|
| 343 |
+
│ Step 1. 路由指纹探测(无需鉴权) │
|
| 344 |
+
│ GET {base_url}/setting/websiteConfig │
|
| 345 |
+
│ ├─ 200 + 含 domainList → 是 maillab │
|
| 346 |
+
│ ├─ 200 但缺 domainList → 可能是 cf_temp_email,提示切 provider │
|
| 347 |
+
│ ├─ 404 / 5xx → base_url 错 │
|
| 348 |
+
│ └─ 网络错 → 提示连接性 │
|
| 349 |
+
└─────────────────────────────────────────────────┘
|
| 350 |
+
│ ok
|
| 351 |
+
▼
|
| 352 |
+
┌─────────────────────────────────────────────────┐
|
| 353 |
+
│ Step 2. 域名展示与选择 │
|
| 354 |
+
│ 将 websiteConfig.domainList 渲染为下拉框 │
|
| 355 |
+
│ ├─ 只有 1 个域 → 自动选中 │
|
| 356 |
+
│ ├─ 多个域 → 让用户选(默认第一个) │
|
| 357 |
+
│ └─ 0 个域 → 报错"该实例未配置任何邮箱域名" │
|
| 358 |
+
│ 同时把 addVerifyOpen / registerVerify 的开关 │
|
| 359 |
+
│ 值返回给前端,如果开了就警告"该实例需要 captcha,│
|
| 360 |
+
│ AutoTeam 自动化路径暂不支持,请到 maillab 后台关闭"│
|
| 361 |
+
└─────────────────────────────────────────────────┘
|
| 362 |
+
│ ok
|
| 363 |
+
▼
|
| 364 |
+
┌─────────────────────────────────────────────────┐
|
| 365 |
+
│ Step 3. 凭据校验(归属第一关) │
|
| 366 |
+
│ POST {base_url}/login │
|
| 367 |
+
│ body {email: username, password} │
|
| 368 |
+
│ ├─ code:200, data.token → 拿到 jwt │
|
| 369 |
+
│ │ 解析 JWT payload,展示 `email` `userType` │
|
| 370 |
+
│ │ 如果 email ≠ c.env.admin → 警告"非管理员,│
|
| 371 |
+
│ │ 仅能创建临时邮箱,无法管理用户" │
|
| 372 |
+
│ ├─ code:401 → "凭据错误" │
|
| 373 |
+
│ └─ 其他 → 透传 message │
|
| 374 |
+
└─────────────────────────────────────────────────┘
|
| 375 |
+
│ ok
|
| 376 |
+
▼
|
| 377 |
+
┌─────────────────────────────────────────────────┐
|
| 378 |
+
│ Step 4. 域名归属确认(写权限验证) │
|
| 379 |
+
│ 带 Authorization: <jwt> │
|
| 380 |
+
│ POST {base_url}/account/add │
|
| 381 |
+
│ body {email: "probe-{ts}@{domain}"} │
|
| 382 |
+
│ ├─ code:200, data.accountId → 归属确认 │
|
| 383 |
+
│ │ 随后 DELETE /account/delete?accountId=N │
|
| 384 |
+
│ │ 立即回收探测邮箱 │
|
| 385 |
+
│ ├─ code:401 → "登录已过期"(罕见,刚拿的 jwt)│
|
| 386 |
+
│ ├─ code:403 → "无权在该域下创建" → 归属失败 │
|
| 387 |
+
│ └─ code:其他 + message 含 "domain" → 归属失败 │
|
| 388 |
+
└─────────────────────────────────────────────────┘
|
| 389 |
+
│ ok
|
| 390 |
+
▼
|
| 391 |
+
┌─────────────────────────────────────────────────┐
|
| 392 |
+
│ Step 5. 保存配置 │
|
| 393 |
+
│ POST /api/setup/save 写入: │
|
| 394 |
+
│ MAIL_PROVIDER=maillab │
|
| 395 |
+
│ MAILLAB_API_URL={base_url} │
|
| 396 |
+
│ MAILLAB_USERNAME={username} │
|
| 397 |
+
│ MAILLAB_PASSWORD={password} │
|
| 398 |
+
│ MAILLAB_DOMAIN=@{domain} │
|
| 399 |
+
│ 后端再用 _verify_cloudmail() 跑一遍 e2e │
|
| 400 |
+
└─────────────────────────────────────────────────┘
|
| 401 |
+
```
|
| 402 |
+
|
| 403 |
+
### C.3 API 契约(后端新增端点)
|
| 404 |
+
|
| 405 |
+
> **关键设计抉择**:不要让前端直接连 maillab 跨域,**全部通过 AutoTeam 后端代理**,理由:
|
| 406 |
+
> 1. 用户填的 base_url 多半是 HTTP / 自签证书,浏览器跨域会复杂
|
| 407 |
+
> 2. 凭据(password / jwt)如果在前端 fetch,F12 直接看到
|
| 408 |
+
> 3. 后端代理后续可以加重试 / 错误归一化 / 日志
|
| 409 |
+
|
| 410 |
+
**新增 `/api/mail-provider/probe`** (POST,无需鉴权 — 与 setup/save 一致;实际进了面板后再加 API_KEY)
|
| 411 |
+
|
| 412 |
+
```yaml
|
| 413 |
+
request:
|
| 414 |
+
provider: "maillab" # 或 "cf_temp_email"
|
| 415 |
+
base_url: "https://mail.example.com"
|
| 416 |
+
# cf_temp_email
|
| 417 |
+
password?: "admin_pwd" # cf 的 admin password
|
| 418 |
+
domain?: "@example.com"
|
| 419 |
+
# maillab
|
| 420 |
+
username?: "admin@example.com"
|
| 421 |
+
password?: "user_pwd"
|
| 422 |
+
domain?: "@example.com" # 可选,用户已选中的域
|
| 423 |
+
step: "fingerprint" | "credentials" | "domain_ownership" # 分步执行,前端按用户进度调
|
| 424 |
+
|
| 425 |
+
response (step=fingerprint):
|
| 426 |
+
ok: true
|
| 427 |
+
detected_provider: "maillab"
|
| 428 |
+
domainList: ["@example.com", "@another.com"]
|
| 429 |
+
addVerifyOpen: false
|
| 430 |
+
registerVerifyOpen: false
|
| 431 |
+
warnings: [] # 例:"检测到 base_url 像 cf_temp_email,但 MAIL_PROVIDER=maillab"
|
| 432 |
+
|
| 433 |
+
response (step=credentials):
|
| 434 |
+
ok: true
|
| 435 |
+
is_admin: true # email == c.env.admin?
|
| 436 |
+
token_preview: "eyJ..." # 仅用于前端展示鉴权成功,不长期存
|
| 437 |
+
user_email: "admin@example.com"
|
| 438 |
+
|
| 439 |
+
response (step=domain_ownership):
|
| 440 |
+
ok: true
|
| 441 |
+
probe_email: "probe-1714123456@example.com"
|
| 442 |
+
probe_account_id: 12345
|
| 443 |
+
cleaned: true # 探测邮箱已删除
|
| 444 |
+
|
| 445 |
+
response (any step, error):
|
| 446 |
+
ok: false
|
| 447 |
+
error_code: "ROUTE_NOT_FOUND" | "UNAUTHORIZED" | "FORBIDDEN_DOMAIN" | "NETWORK" | ...
|
| 448 |
+
message: "原始 maillab 响应 message,或本地翻译"
|
| 449 |
+
hint?: "建议用户怎么改" # 如:把 MAIL_PROVIDER 改成 cf_temp_email
|
| 450 |
+
```
|
| 451 |
+
|
| 452 |
+
**前端 SetupPage 改造点**(`web/src/components/SetupPage.vue`):
|
| 453 |
+
|
| 454 |
+
1. 字段从平铺改为**分组卡片**:
|
| 455 |
+
- 卡片 1:Mail Provider(单选 cf_temp_email / maillab,选中后只显示对应字段)
|
| 456 |
+
- 卡片 2:base_url + 凭�� + "测试连接"按钮(调 step=fingerprint + step=credentials)
|
| 457 |
+
- 卡片 3:domain 下拉框(从 step=fingerprint 拉到的 domainList 渲染) + "验证归属"按钮(调 step=domain_ownership)
|
| 458 |
+
- 卡片 4:CPA / API_KEY / 代理(原样保留)
|
| 459 |
+
2. 步骤式 UX:每步必须通过才能进下一步,失败显示原始 error message + hint
|
| 460 |
+
3. step=domain_ownership 通过后,"保存配置"按钮才可点;后端再做一次完整 `_verify_cloudmail()`
|
| 461 |
+
|
| 462 |
+
---
|
| 463 |
+
|
| 464 |
+
## D. 401 根因分析 (setup_wizard 创建邮箱失败)
|
| 465 |
+
|
| 466 |
+
### D.1 截图证据
|
| 467 |
+
|
| 468 |
+
```
|
| 469 |
+
[CloudMail] 管理员鉴权通过
|
| 470 |
+
[验证] CloudMail (cf_temp_email) 登录成功
|
| 471 |
+
[验证] CloudMail (cf_temp_email) 创建邮箱失败:
|
| 472 |
+
创建邮箱失败:响应缺少 address 字段:{'code': 401, 'message': '身份认证失效,请重新登录'}
|
| 473 |
+
[验证] 请检查 CLOUDMAIL_DOMAIN 是否正确
|
| 474 |
+
[验证] CloudMail 配置有误,请修改 .env 后重新启动
|
| 475 |
+
```
|
| 476 |
+
|
| 477 |
+
### D.2 根因(直接,可证伪)
|
| 478 |
+
|
| 479 |
+
**协议错配 — 用户填了 maillab 服务器地址,但 `MAIL_PROVIDER` 仍走默认 `cf_temp_email`**。
|
| 480 |
+
|
| 481 |
+
证据链:
|
| 482 |
+
|
| 483 |
+
1. `_verify_cloudmail()` 走的是 `cf_temp_email` 分支(label = "cf_temp_email"),意味着 `MAIL_PROVIDER` 没设或设成 cf_temp_email。
|
| 484 |
+
2. `cf_temp_email.login()`(`mail/cf_temp_email.py:86-116`)实际只 `GET /admin/address?limit=1` 验证 admin 密码:
|
| 485 |
+
```python
|
| 486 |
+
r = self._admin_get("/admin/address", params={"limit": 1, "offset": 0})
|
| 487 |
+
```
|
| 488 |
+
maillab 服务器**有 catch-all 路由**(Hono 默认 404,但部分部署/反向代理把 404 也返成 200 + 自定义错误体);
|
| 489 |
+
或者 maillab `/admin/address` 路径**未匹配任何路由,被 Hono 内置 fallback 处理**,响应不一定是 maillab 风格 `{code, ...}`,
|
| 490 |
+
也可能是 `200 OK + {}`,这时 `cf_temp_email.login` 看到 200 + 不含 `code/data` → 误以为成功(L106-112 的嗅探仅在响应像 maillab 时才抛错)。
|
| 491 |
+
3. 走到 `create_temp_email()`(L120-188)时,`POST /admin/new_address` 触发了 maillab 真正路由(可能是 `/admin/*` 通配的 401 拒绝,也可能是 worker 的 unmatched route fallback 直接返 maillab 风格 `{code:401, message:"身份认证失效,请重新登录"}`)。
|
| 492 |
+
4. 代码 L154-159 的 maillab 风格响应嗅探**确实捕获到了**这个错误并打印,但截图上看到的最终 message 是 `创建邮箱失败:响应缺少 address 字段:{'code': 401, 'message': '身份认证失效, 请重新登录'}`,
|
| 493 |
+
**错误信息被错误地折叠回 `响应缺少 address 字段` 分支**(L182,因为 if 条件不严:`"address" not in data and ("code" in data and "message" in data)` 只在响应同时含 code+message 时才走更友好的 maillab-mismatch 分支,
|
| 494 |
+
实际响应 dict 是 `{code:401, message:'...'}` 应该会命中 — 但截图说"响应缺少 address 字段"出在 L182,意味着**实际响应可能不是这个结构**,例如:
|
| 495 |
+
- `{code:401, message:'...', success:false}` (额外字段)→ 命中 L154 OK,但 message 拼接组装时被 L155-159 的 raise 抛出
|
| 496 |
+
- **或者响应是 `{code:401, message:'...'}` 直接命中 L182 的"缺少 address"分支**(可能是 cf_temp_email 路由在 maillab 上被 worker 默认 unmatched 处理,直接吐了一个像 `{code:401, ...}` 的 JSON 而 dict 没有 `data` 字段)
|
| 497 |
+
|
| 498 |
+
**结论**:
|
| 499 |
+
|
| 500 |
+
- **直接原因**:用户的 base_url(`https://apimail.icoulsy.asia`,看 `.env` L2)是 maillab 服务器,但 `.env` 里**没设 `MAIL_PROVIDER`**(默认走 cf_temp_email),触发 §B.3 描述的"半成功"现象。
|
| 501 |
+
- **附带原因(代码 bug)**:`cf_temp_email.login()` 的指纹嗅探(L106-112)条件**过于宽松** — 只在响应 dict 出现 `code` 或 `data` 字段时才抛错,但 maillab 真实的 `/admin/address` 在某些 worker 部署下可能返回 200 + `{}`(empty body)而非 maillab 标准 `{code,...}` 结构,
|
| 502 |
+
导致 login 假成功,直到 create 才暴露。
|
| 503 |
+
- **次要原因(UX 缺口)**:setup_wizard 的 `_sniff_provider_mismatch`(setup_wizard.py:173-220)是**启动时跑**的,
|
| 504 |
+
Web 面板 `post_setup_save` 不调 `_sniff_provider_mismatch`,只调 `_verify_cloudmail` → 用户在 Web 端首次填错时**得不到指纹错配的早期 warning**。
|
| 505 |
+
|
| 506 |
+
### D.3 修复方向(分层)
|
| 507 |
+
|
| 508 |
+
| 层级 | 修复点 | 优先级 |
|
| 509 |
+
| ------------------ | -------------------------------------------------------------------------------------------------------------- | ------ |
|
| 510 |
+
| **立即(用户)** | 在 `.env` 加 `MAIL_PROVIDER=maillab`,把 `CLOUDMAIL_*` 改 `MAILLAB_*`(domain 复用 `CLOUDMAIL_DOMAIN`) | P0 |
|
| 511 |
+
| **代码(嗅探)** | `cf_temp_email.login()` L106-112:在 `r.status_code==200` 且**响应 dict 缺少 `results` key**时一律抛错(空 body 也错配) | P0 |
|
| 512 |
+
| **代码(嗅探)** | `setup_wizard._sniff_provider_mismatch` 移到 `_verify_cloudmail` 第一步(目前是第二步),并在错配时 `return False` 而不是 warning | P0 |
|
| 513 |
+
| **代码(SetupConfig)** | `api.py:SetupConfig` Pydantic 模型加 `MAIL_PROVIDER` / `MAILLAB_API_URL` / `MAILLAB_USERNAME` / `MAILLAB_PASSWORD` / `MAILLAB_DOMAIN` 字段 | P0 |
|
| 514 |
+
| **代码(向导)** | `setup_wizard.REQUIRED_CONFIGS` 加 maillab 字段,根据 `MAIL_PROVIDER` 选项动态显示 | P0 |
|
| 515 |
+
| **UI(SetupPage)** | 实现 §C.2 的分步流程,把"填错 → 启动崩"前移到"填时即知" | P1 |
|
| 516 |
+
| **错误信息** | `create_temp_email` L156 的 raise 文案加上"如果你看到这条错误是从 SetupPage 来的,先点'重新探测后端类型'" | P1 |
|
| 517 |
+
| **代码(自愈)** | `MaillabClient` 在 401 时尝试 `_ensure_login()` 重登一次再重试一次(对短期 token 失效自愈) | P2 |
|
| 518 |
+
|
| 519 |
+
---
|
| 520 |
+
|
| 521 |
+
## E. 文档改写清单
|
| 522 |
+
|
| 523 |
+
| 文档 | 章节 | 改动类型 | 内容 |
|
| 524 |
+
| ------------------------------ | ----------------------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
|
| 525 |
+
| `docs/getting-started.md` | 「准备工作 / 1. 搭建临时邮箱」 | 改写 | 把"两个临时邮箱后端二选一"的语境从"cf_temp_email 默认推荐"改为**按用户实际部署选择**;突出 `MAIL_PROVIDER` 是必填 |
|
| 526 |
+
| `docs/getting-started.md` | 「第二步:配置 / 直接部署」 | 改写 | 配置示例增补完整 maillab 字段;说明 Web 面板 SetupPage 已支持选 provider(实施 §C 后) |
|
| 527 |
+
| `docs/getting-started.md` | (新增章节)「第二点五步:验证邮箱后端归属」 | 新增 | 说明 SetupPage 的 4 步验证流程(指纹 → 凭据 → 域名 → 保存),提示用户哪些情况会报错及对应 hint |
|
| 528 |
+
| `docs/configuration.md` | 「`.env` 配置项」表 | 改写 | `MAIL_PROVIDER` 列从"否"改成"**是**";`MAILLAB_*` 各字段说明改为"**Web 面板 Setup/Settings 中可填**" |
|
| 529 |
+
| `docs/configuration.md` | 「Mail Provider 切换」 | 改写 | 推荐顺序逆转:**maillab 优先推荐**(国内访问/skymail 一键部署友好);cf_temp_email 改为"Cloudflare Workers 用户"备选 |
|
| 530 |
+
| `docs/configuration.md` | (新增章节)「邮箱归属验证」 | 新增 | 阐明 `/setting/websiteConfig` 拉 domainList 的机制;说明 addVerify/registerVerify 启用时不被 AutoTeam 支持的边界 |
|
| 531 |
+
| `docs/configuration.md` | 「⚠️ 协议错配排查」 | 改写 | 把 issue#1 的截图与最终 fix 步骤显式列出;链接到 SetupPage 的"重新探测后端类型"按钮(§C 实施后) |
|
| 532 |
+
| `docs/mail-provider-design.md` | §6「cloud-mail 实现的未知项」 | 改写 | 5 项 TODO 标记**已通过 GitHub 源码全部确认**(login JWT header / domain 列表 API / createTime 单位 / 创建端点 / accountId 类型),状态从"未确认"改为"已验证 with reference" |
|
| 533 |
+
| `docs/mail-provider-design.md` | (新增章节)§7「skymail.ink API 全表」 | 新增 | 把本文件 §B.1.1-B.1.9 表格整理后入档;作为 cloudmail.py 长期维护参考 |
|
| 534 |
+
| `docs/troubleshooting.md` | 「Web 面板相关」 | 增补 | 加"配置保存后 401 创建邮箱失败"条目,链到 §D 的修复表 |
|
| 535 |
+
| `docs/troubleshooting.md` | (新增章节)「邮箱后端」 | 新增 | 列出常见问题:base_url 写错 / domain 错配 / token 失效 / Turnstile 启用 / 单实例多域选错 |
|
| 536 |
+
| `docs/api.md` | 「初始配置 API」 | 增补 | 文档化 `/api/mail-provider/probe`(实施 §C 后);明确 `/api/setup/save` 的 SetupConfig 已扩展 maillab 字段 |
|
| 537 |
+
| `docs/api.md` | 「域名管理 API」 | 改写 | `/api/config/register-domain` 的 verify 探测改为"会调用 §C step=domain_ownership 共享逻辑" |
|
| 538 |
+
| `docs/architecture.md` | (如有 mail 章节) | 增补 | 把"`MailProvider` ABC + 工厂分发"图列出;skymail.ink 集成方式作为 reference impl |
|
| 539 |
+
| `README.md` | 主要 Features | 增补 | 加"原生支持 maillab/cloud-mail (skymail.ink) 全量集成,含归属验证" |
|
| 540 |
+
| `.env.example` | 注释 | 改写 | `MAIL_PROVIDER=` 上面写"**强烈推荐显式设置**,不设默认 cf_temp_email 可能与你的 maillab 部署错配" |
|
| 541 |
+
|
| 542 |
+
---
|
| 543 |
+
|
| 544 |
+
## F. 风险与未决问题
|
| 545 |
+
|
| 546 |
+
### F.1 实施风险
|
| 547 |
+
|
| 548 |
+
| 风险 | 缓解 | 严重度 |
|
| 549 |
+
| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------ |
|
| 550 |
+
| `/setting/websiteConfig` 的 `domainList` 字段在 maillab v2.x 之前可能不存在(skymail.ink 当前 v2.8.0)| 前端兼容:domainList 缺失时降级为 free-input,后端探测改为"试创建一次邮箱" | 中 |
|
| 551 |
+
| maillab 部署启用 Turnstile (`registerVerifyOpen=true`)时,AutoTeam 自动化路径无法过 captcha | 探测响应显式告诉用户;在 docs/troubleshooting.md 写明"请到 maillab 后台关闭 captcha" | 高(用户体验) |
|
| 552 |
+
| `/public/genToken` 返回的 UUID token 与 `/login` 返回的 JWT 是否互通 — service 内部可能两套 KV | 现 maillab.py 走 /login 路径,**不动 /public/genToken**;只有当用户明确想用 public API 时再加 | 低 |
|
| 553 |
+
| 新 `/api/mail-provider/probe` 端点暴露后,被外部用作端口扫描(用户填任意 base_url 跑探测) | 加 rate limit + 仅在 setup/save 阶段开放(进面板后用 API_KEY 鉴权) | 中 |
|
| 554 |
+
| `cf_temp_email.login()` 嗅探收紧后,对**一些第三方 fork** (例如 `unsendapp/cloudflare_temp_email` 等)误伤 | 错误信息提示用户可以 `AUTOTEAM_SKIP_VERIFY=1` 绕过;长期看是收益>风险 | 低 |
|
| 555 |
+
| `MaillabClient.list_accounts` 单页 cap 30 是**当前版本**约束,未来 maillab 可能改 | 代码注释已写来源 `account-service.js`;CI 加一个 maillab e2e mock 测试翻页边界 | 低 |
|
| 556 |
+
| Web 面板 SetupPage 改造大,前后端字段都要改 | 分阶段 PR:第 1 阶段只补 SetupConfig + REQUIRED_CONFIGS(P0);第 2 阶段做分步 UX(P1) | 中 |
|
| 557 |
+
|
| 558 |
+
### F.2 未决问题(需 PRD 阶段决议)
|
| 559 |
+
|
| 560 |
+
1. **maillab 是否要支持非 admin 用户**?当前 `_verify_cloudmail` 隐式假设登录的就是平台 admin(因为 `c.env.admin` 才能调 `/public/genToken`);但 `/account/add` 普通用户也能调。是否对 SetupPage 的 username 做严格性检查?
|
| 561 |
+
2. **是否暴露 `/account/setName / setAllReceive / setAsTop`** 给 AutoTeam UI?目前业务流程只用 add/delete/list,这些写操作虽然 maillab 有但**业务上没刚需**;
|
| 562 |
+
保守做法:不实现,只在 `MaillabClient` 留接口骨架。
|
| 563 |
+
3. **附件支持**(`/email/attList`)— ChatGPT 邀请邮件**没有附件**,此功能与 AutoTeam 无关;暂不集成。
|
| 564 |
+
4. **`/email/send`** — AutoTeam 不需要发邮件(只读验证码),不集成;但用户在 maillab 控制台已经能用,文档可以不提。
|
| 565 |
+
5. **`/oauth/linuxdo/*`** — 未来如果 AutoTeam 想让"管理员通过 LinuxDo OAuth 绑定 maillab 账户"自动获取 token,
|
| 566 |
+
需要扩展。当前**不在范围**,但 setup_wizard 应当探测到 linuxdoSwitch=true 时给个 hint。
|
| 567 |
+
6. **多 maillab 实例**?当前 AutoTeam 只支持一个 base_url;skymail.ink 用户可能有 prod / staging 两套。
|
| 568 |
+
是否做多实例切换?**保守:不做**,留给 PRD 阶段权衡。
|
| 569 |
+
7. **register-domain 探测路径与 §C step=domain_ownership 是否合并**?现 `api.py:1631-1673` 已实现一次探测,
|
| 570 |
+
§C 设计应当把它**重构成共享 helper**,SetupPage 与 Settings 页 register-domain 调同一个底层函数。
|
| 571 |
+
|
| 572 |
+
---
|
| 573 |
+
|
| 574 |
+
## 附录 A:本研究查证过的源码 / 文档证据
|
| 575 |
+
|
| 576 |
+
| 来源 | 用途 |
|
| 577 |
+
| ----------------------------------------------------------------------------------- | ----------------------------- |
|
| 578 |
+
| `D:/Desktop/AutoTeam/src/autoteam/cloudmail.py` | 确认 stub re-export |
|
| 579 |
+
| `D:/Desktop/AutoTeam/src/autoteam/mail/__init__.py` | 确认工厂分发 |
|
| 580 |
+
| `D:/Desktop/AutoTeam/src/autoteam/mail/base.py` | 确认 ABC 公开方法集 |
|
| 581 |
+
| `D:/Desktop/AutoTeam/src/autoteam/mail/cf_temp_email.py` | 确认 cf 实现 + 嗅探条件 |
|
| 582 |
+
| `D:/Desktop/AutoTeam/src/autoteam/mail/maillab.py` | 确认 maillab 实现 + 已知 TODO |
|
| 583 |
+
| `D:/Desktop/AutoTeam/src/autoteam/setup_wizard.py` | 确认 _verify_cloudmail / 嗅探 |
|
| 584 |
+
| `D:/Desktop/AutoTeam/src/autoteam/api.py:70-149` | 确认 SetupConfig 缺 maillab 字段|
|
| 585 |
+
| `D:/Desktop/AutoTeam/src/autoteam/api.py:1617-1673` | 确认 register-domain 探测 |
|
| 586 |
+
| `D:/Desktop/AutoTeam/src/autoteam/runtime_config.py` | 确认 register_domain 持久化 |
|
| 587 |
+
| `D:/Desktop/AutoTeam/web/src/components/SetupPage.vue` | 确认 UI 是平铺字段表单 |
|
| 588 |
+
| `D:/Desktop/AutoTeam/web/src/components/Settings.vue` | 确认无 mail provider 切换 UI |
|
| 589 |
+
| `D:/Desktop/AutoTeam/web/src/api.js` | 确认前后端契约 |
|
| 590 |
+
| `D:/Desktop/AutoTeam/.env` / `.env.example` | 确认配置项现状 |
|
| 591 |
+
| `D:/Desktop/AutoTeam/docs/mail-provider-design.md` | 确认设计文档与已知未知项 |
|
| 592 |
+
| `https://doc.skymail.ink/api/api-doc.html` | 确认对外公布的 3 个 public API|
|
| 593 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/login-api.js` | 确认 /login /register /logout |
|
| 594 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/account-api.js`| 确认 /account/* 6 路由 |
|
| 595 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/email-api.js` | 确认 /email/* 6 路由 |
|
| 596 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/setting-api.js`| 确认 /setting/* 5 路由 |
|
| 597 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/init-api.js` | 确认 /init/:secret |
|
| 598 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/user-api.js` | 确认 /user/* 10 路由 |
|
| 599 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/role-api.js` | 确认 /role/* 7 路由 |
|
| 600 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/all-email-api.js`| 确认 /allEmail/* 4 路由 |
|
| 601 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/reg-key-api.js`| 确认 /regKey/* 5 路由 |
|
| 602 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/api/public-api.js` | 确认 /public/* 3 路由 |
|
| 603 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/security/security.js` | 确认白名单 + 鉴权流程 |
|
| 604 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/service/setting-service.js` | 确认 websiteConfig 字段集 |
|
| 605 |
+
| `https://github.com/maillab/cloud-mail/blob/main/mail-worker/src/service/public-service.js` | 确认 genToken 仅 admin 可调|
|
| 606 |
+
|
| 607 |
+
## 附录 B:本研究**未**进行的事项(留给 PRD/spec/test_reports)
|
| 608 |
+
|
| 609 |
+
- 没有改任何源代码(按任务要求,纯调研)
|
| 610 |
+
- 没有联系 skymail.ink 官方确认 v2.8.0 之外的版本兼容性
|
| 611 |
+
- 没有在真实 maillab 实例上跑 e2e 测试(`mail-provider-design.md` §6 提示 implementer 必须做)
|
| 612 |
+
- 没有提供 §C "新增 `/api/mail-provider/probe` 端点" 的具体 Pydantic 模型 / 单元测试用例(spec 阶段产出)
|
| 613 |
+
- 没有提供前端 SetupPage.vue 改造的具体 diff(test_reports 阶段产出)
|
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Issue#2 — 席位策略与套餐 / 免费号删除 研究报告
|
| 2 |
+
|
| 3 |
+
## A. 用户报告解构
|
| 4 |
+
|
| 5 |
+
用户提出 4 条诉求:
|
| 6 |
+
|
| 7 |
+
1. **默认走 codex 席位**:目前生成免费号 / Team 邀请号默认拿"完整 ChatGPT 席位",用户希望默认只占 codex 席位(让 ChatGPT 主席位留给真人/管理员)。
|
| 8 |
+
2. **免费号无法移除**:被标记为 personal(已退出 Team 走个人 free OAuth)的子号,从 UI 上点删除/批量删除拿不到结果。
|
| 9 |
+
3. **`self_serve_business_usage_based` 套餐识别**:OpenAI 在某些 workspace 下把 `chatgpt_plan_type` 标成这种新值,目前代码只匹配 `team` / `free`,其它一律走 fallback 分支,实际效果是"号能注册成功但 Codex 不可用"。
|
| 10 |
+
4. **席位策略可配置**:相比写死 default 邀请,用户希望管理员可以选择策略。
|
| 11 |
+
|
| 12 |
+
## B. 代码现状
|
| 13 |
+
|
| 14 |
+
### B.1 关键符号清单
|
| 15 |
+
|
| 16 |
+
| 文件:行号 | 签名/常量 | 作用 |
|
| 17 |
+
|---|---|---|
|
| 18 |
+
| `src/autoteam/accounts.py:23-25` | `SEAT_CHATGPT="chatgpt"` / `SEAT_CODEX="codex"` / `SEAT_UNKNOWN="unknown"` | 落盘到 `accounts.json` 的 `seat_type` 字段 |
|
| 19 |
+
| `src/autoteam/accounts.py:58` | `add_account(email, password, cloudmail_account_id=None, seat_type=SEAT_UNKNOWN, workspace_account_id=None)` | 默认 SEAT_UNKNOWN,邀请/注册流程会显式覆盖 |
|
| 20 |
+
| `src/autoteam/invite.py:39-44` | `_seat_label_from_raw(raw_seat)` | 把 ChatGPT API 返回的 `_seat_type` 字面量(`chatgpt` / `usage_based` / `unknown`)翻译成 `SEAT_*` 常量 |
|
| 21 |
+
| `src/autoteam/invite.py:496` | `chatgpt.invite_member(email, seat_type="default")` | **默认邀请入口写死 default(完整 ChatGPT 席位)** |
|
| 22 |
+
| `src/autoteam/chatgpt_api.py:1365-1411` | `invite_member(email, seat_type="usage_based")` + `_invite_member_with_fallback` | 自带 default → usage_based 兜底,只翻一次 |
|
| 23 |
+
| `src/autoteam/chatgpt_api.py:1414-1511` | `_invite_member_once(email, seat_type)` | POST /invites + POST 200 后 PATCH 升级到 default |
|
| 24 |
+
| `src/autoteam/chatgpt_api.py:1487-1510` | `data["_seat_type"] = "usage_based" / "chatgpt"` 写入 | usage_based 入口 + PATCH 全失败 → 标 SEAT_CODEX;default 入口 + 200 → 直接标 SEAT_CHATGPT |
|
| 25 |
+
| `src/autoteam/manual_account.py:233-237` | `seat_label = "chatgpt" if plan_type == "team" else "codex"` | 手动添加(用户粘贴 OAuth 回调)按 plan_type 反推 seat_type |
|
| 26 |
+
| `src/autoteam/manager.py:1463-1486` | `_run_post_register_oauth` 收尾段 | `seat_label = "chatgpt" if bundle_plan == "team" else "codex"` |
|
| 27 |
+
| `src/autoteam/codex_auth.py:111` | `bundle["plan_type"] = auth_claims.get("chatgpt_plan_type", "unknown")` | id_token JWT 直接提取,任何新值都会原样落盘 |
|
| 28 |
+
| `src/autoteam/cpa_sync.py:132-140` | plan_type 推断回退链 | 当 bundle 缺失时根据文件名 `-team-` / `-plus-` / `-free-` 反推 |
|
| 29 |
+
| `src/autoteam/api.py:1268-1303` | `DELETE /api/accounts/{email}` | 单点删除 |
|
| 30 |
+
| `src/autoteam/api.py:1306-1404` | `POST /api/accounts/delete-batch` | 批量删除 |
|
| 31 |
+
| `src/autoteam/account_ops.py:40-162` | `delete_managed_account(email, ...)` | 实际执行器:Team 远端删 + 本地 auth_file + cpa + cloudmail |
|
| 32 |
+
| `web/src/components/Dashboard.vue:133-142` | 删除按钮 `v-if="!acc.is_main_account"` | 只屏蔽主号,personal/standby/auth_invalid 都渲染 |
|
| 33 |
+
|
| 34 |
+
### B.2 调用链/数据流
|
| 35 |
+
|
| 36 |
+
#### 邀请席位决策链(用户 → `accounts.json`)
|
| 37 |
+
|
| 38 |
+
```
|
| 39 |
+
invite.run() / cmd_fill_team
|
| 40 |
+
└─ ChatGPTTeamAPI.invite_member(email, seat_type="default") ← 入口写死
|
| 41 |
+
└─ _invite_member_with_fallback(seat_type="default", allow_fallback=True)
|
| 42 |
+
├─ _invite_member_once("default") → POST /invites
|
| 43 |
+
│ └─ 200 + 无 errored → data["_seat_type"]="chatgpt" ← 完整 ChatGPT 席位
|
| 44 |
+
│ └─ 200 + errored / 非 200 → 触发兜底
|
| 45 |
+
└─ _invite_member_with_fallback(seat_type="usage_based", allow_fallback=False)
|
| 46 |
+
└─ _invite_member_once("usage_based")
|
| 47 |
+
├─ POST /invites 拿 invite_id 列表
|
| 48 |
+
└─ 对每个 invite_id PATCH seat_type="default"
|
| 49 |
+
├─ 全失败 → data["_seat_type"]="usage_based" ← SEAT_CODEX
|
| 50 |
+
└─ 任意成功 → data["_seat_type"]="chatgpt"
|
| 51 |
+
↓
|
| 52 |
+
invite.run() 用 _seat_label_from_raw() 转成 SEAT_* 常量
|
| 53 |
+
└─ add_account(..., seat_type=seat_label, ...) ← 落盘
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
**关键观察**:目前的 fallback 是**单向的"先升级,失败保留 codex"**,根本不存在"从一开始就只要 codex 席位、不尝试 PATCH"的入口。`chatgpt_api.py:1487-1499` 在 seat_type=="usage_based" 的分支里**永远**会调 `_update_invite_seat_type(invite_id, "default")` 试图升级。
|
| 57 |
+
|
| 58 |
+
#### 注册后回写席位的链(plan_type → seat_type)
|
| 59 |
+
|
| 60 |
+
```
|
| 61 |
+
_run_post_register_oauth (manager.py:1463)
|
| 62 |
+
└─ login_codex_via_browser → 拿 bundle (含 plan_type)
|
| 63 |
+
└─ bundle.plan_type == "team" → seat_label="chatgpt"
|
| 64 |
+
└─ bundle.plan_type ∈ {free,plus,unknown,self_serve_*,...} → seat_label="codex"
|
| 65 |
+
↑ 这里决定的 seat_type 与 invite 阶段决定的 seat_type 可能不一致,
|
| 66 |
+
update_account 会覆写 invite 阶段的值。
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
#### 删除 personal 子号链
|
| 70 |
+
|
| 71 |
+
```
|
| 72 |
+
DELETE /api/accounts/{email} (api.py:1268)
|
| 73 |
+
└─ acquire _playwright_lock (BLOCKING_FALSE)
|
| 74 |
+
└─ _pw_executor.run(delete_managed_account, email)
|
| 75 |
+
└─ delete_managed_account(email, remove_remote=True) (account_ops.py:40)
|
| 76 |
+
└─ if remote_state is None:
|
| 77 |
+
└─ ChatGPTTeamAPI().start() ← 主号 session 失效 → 抛
|
| 78 |
+
└─ fetch_team_state() 获取 members + invites
|
| 79 |
+
├─ member_matches = [] (personal 不在 Team)
|
| 80 |
+
├─ invite_matches = []
|
| 81 |
+
├─ 删本地 auth_file (codex-{email}-*.json)
|
| 82 |
+
├─ 删 CPA 文件
|
| 83 |
+
├─ 删本地 accounts.json 记录
|
| 84 |
+
└─ 删 CloudMail 邮箱
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## C. 根因分析
|
| 88 |
+
|
| 89 |
+
### C.1 "默认走 codex 席位"无法直接配置
|
| 90 |
+
|
| 91 |
+
**优先级 P0** — 缺乏配置开关。`invite.py:496` 的 `seat_type="default"` 是一行硬编码,且 `_invite_member_once` 在 usage_based 路径里**永远**会试图 PATCH 升级到 default(line 1498),即便管理员手动改 invite.py 入口为 `usage_based`,实际效果也是"先 usage_based 占席,再 PATCH 抢 ChatGPT 席位",而不是"只占 codex 席位"。
|
| 92 |
+
|
| 93 |
+
候选根因(按可能性排序):
|
| 94 |
+
1. **设计选择问题**:历史上 default 升级是为了让 ChatGPT 主席位用尽时仍能拿到 codex,作者把 fallback 写死成单向。
|
| 95 |
+
2. **缺少 runtime_config 字段**:对照 `runtime_config.py` / `config.py` 是否有 `PREFERRED_SEAT_TYPE` 的 hint,目前没有。
|
| 96 |
+
3. **PATCH 升级是无条件的**:`_invite_member_once` line 1489-1506 完全没有 opt-out。
|
| 97 |
+
|
| 98 |
+
### C.2 "免费号无法移除"
|
| 99 |
+
|
| 100 |
+
**优先级 P1** — 多个候选互相叠加:
|
| 101 |
+
|
| 102 |
+
候选根因(按可能性排序):
|
| 103 |
+
1. **`actionDisabled` 锁死按钮(P1 主犯)**:`Dashboard.vue:330` 的 `actionDisabled = !!props.runningTask || !adminReady.value`。如果用户**没登录主号**或**有任何任务在跑**,删除按钮会变灰失能。用户测试过程中很可能正在跑 fill 任务 / 主号 session 已过期但 UI 没及时刷新 `adminReady`。
|
| 104 |
+
2. **批量删除强依赖主号 session**:`api.py:1349-1354` 即便要删的全是 personal 子号(都不在 Team),也强制 `ChatGPTTeamAPI().start()` + `fetch_team_state()`。主号 session 失效时整批 500,没有"全为 personal 时跳过 Team 拉取"的优化。
|
| 105 |
+
3. **后端 _playwright_lock 409**:`api.py:1271-1283` 一旦 `_playwright_lock.acquire(blocking=False)` 失败就 409,前端表现为"点击没反应",但有 message 弹窗。
|
| 106 |
+
4. **CPA 文件名错位导致清理不彻底**:`account_ops.py:120` 用 `AUTH_DIR.glob(f"codex-{email}-*.json")`,但 personal 号是 `codex-{email}-free-*.json`,glob 能命中。然后 `cpa_sync.list_cpa_files / delete_from_cpa` 是按 name + email 双 key 删除,personal 号文件名是 free-,理论上能匹配。这一支不是主因,但需要在测试时确认。
|
| 107 |
+
5. **不是真删失败,而是 UI 不刷新**:删除完成后没有触发账号列表 reload(检查 Dashboard.vue 的 `removeAccount` 方法,line 568-584 region)。
|
| 108 |
+
|
| 109 |
+
### C.3 `self_serve_business_usage_based` 套餐处理
|
| 110 |
+
|
| 111 |
+
**优先级 P0** — 完全无识别。
|
| 112 |
+
|
| 113 |
+
候选根因:
|
| 114 |
+
1. **plan_type 检查只匹配 "team"**:`manual_account.py:234`、`manager.py:1468`、`manager.py:2490` 都是 `if plan_type == "team": ... else: codex / standby / 拒绝复用`。`self_serve_business_usage_based` 全部落入 else 分支,被标 SEAT_CODEX,但**实际上**这种 plan 的 workspace 根本没有 codex 配额。
|
| 115 |
+
2. **wham/usage 不能区分 quota=0 与 quota=正常**:`codex_auth.py:1582` 用 `primary_pct >= 100` 判定耗尽,`self_serve_business_usage_based` 这种新计费 workspace 实际返回的可能是 `used_percent=0` + `total=0` —— 后者无法表达,代码当作 ok + 显示 100% 剩余。这就是用户报告"不显示额度的那种,实际也没额度,调用什么都是 429 没额度"的根因。
|
| 116 |
+
3. **`reinvite_account` 直接判 plan_type != "team" 失败回收(manager.py:2490)**:任何 self_serve_* plan 一进来就被 `_cleanup_team_leftover` 踢回 standby — 死循环。
|
| 117 |
+
|
| 118 |
+
## D. 修复方向建议(只是方向,不写代码)
|
| 119 |
+
|
| 120 |
+
### D.1 必须改的点
|
| 121 |
+
|
| 122 |
+
1. **加 PREFERRED_SEAT_TYPE 配置开关**(P0)
|
| 123 |
+
- 位置:`runtime_config.py` + Web Settings 页
|
| 124 |
+
- 取值:`chatgpt`(默认升级,保留现行) / `codex`(默认 usage_based 不升级)
|
| 125 |
+
- 改动点:
|
| 126 |
+
- `invite.py:496` 改成读 config 的 `PREFERRED_SEAT_TYPE`,映射到 `seat_type=` 入参
|
| 127 |
+
- `chatgpt_api.py:1487-1506` 在 usage_based 分支里,只有 `PREFERRED_SEAT_TYPE=="chatgpt"` 时才执行 PATCH 升级,反之直接保留 usage_based
|
| 128 |
+
- `_invite_member_with_fallback` 的兜底也要读 config:codex 偏好下,default → usage_based 兜底改成"default 直接失败不再尝试 usage_based"或反��
|
| 129 |
+
|
| 130 |
+
2. **识别异常 plan_type**(P0)
|
| 131 |
+
- 位置:`codex_auth.py` 在 `_exchange_auth_code` 拿 bundle 后,新增一个常量集 `SUPPORTED_PLAN_TYPES = {"team", "free", "plus", "pro"}`
|
| 132 |
+
- 任何不在白名单的 plan(如 `self_serve_business_usage_based`)直接日志告警 + bundle["plan_type_unsupported"]=True
|
| 133 |
+
- 下游 `_run_post_register_oauth` / `manual_account._finalize_account` 看到 unsupported 标记时,选项:a) 拒绝接收 bundle(类似 codex_auth.py:920-930 的 personal 模式校验),b) 接收但标 STATUS_AUTH_INVALID + 在 UI 显示"不支持的套餐"
|
| 134 |
+
|
| 135 |
+
3. **wham/usage 增强:识别 quota 总额=0**(P0)
|
| 136 |
+
- 位置:`codex_auth.py:1685-1700`
|
| 137 |
+
- 解析 `rate_limit.primary_window` 时,除了 `used_percent` 也读 `limit` / `total`(如果接口返回了)
|
| 138 |
+
- 总额=0 或 reset_at=0 → 直接归为 "exhausted" 或新增 "no_quota"
|
| 139 |
+
- 上游标 STATUS_EXHAUSTED + 写一个明确的 quota_info `{"reason": "no_quota_assigned"}`,UI 显示"无配额"而不是"100% 剩余"
|
| 140 |
+
|
| 141 |
+
4. **personal 删除链解耦主号 session**(P1)
|
| 142 |
+
- 位置:`account_ops.py:73-84`
|
| 143 |
+
- 检查 acc.status:如果是 STATUS_PERSONAL / STATUS_AUTH_INVALID(确认不在 Team),跳过 `fetch_team_state()`,直接删本地资源
|
| 144 |
+
- 单点和批量入口都加这个 short-circuit
|
| 145 |
+
- `api.py:1306-1404` 批量删除前先扫一遍状态,如果全是 personal,完全不起 ChatGPTTeamAPI
|
| 146 |
+
|
| 147 |
+
5. **UI 删除失败需要明确 toast**(P1)
|
| 148 |
+
- 位置:`Dashboard.vue:566-585`(removeAccount)
|
| 149 |
+
- 失败原因(actionDisabled / 409 / 500)需要 message.value 不同提示,避免"按钮没反应"误解
|
| 150 |
+
- 删除成功后立即 `emit('refresh')` 触发账号列表 reload
|
| 151 |
+
|
| 152 |
+
### D.2 可选优化
|
| 153 |
+
|
| 154 |
+
1. **invite_member 加 dry_run 预览**:管理员可以在 web 上看 default vs usage_based 的预期结果。
|
| 155 |
+
2. **seat_type 漂移检测**:`_reconcile_team_members` 第二轮里,检查每个成员的实际 seat_type 是否与本地 `accounts.json` 一致(GET /users 返回的成员对象有 seat_type 字段),漂移就告警。
|
| 156 |
+
3. **plan_type=self_serve_business 转 personal 模式**:既然这种 plan workspace 没 codex 配额,直接用 `_run_post_register_oauth(leave_workspace=True)` 走 personal 流程能不能拿到 free plan?需要实际测试。
|
| 157 |
+
4. **删除批量:全 personal 时不需要 fetch_team_state**:跳过整个 ChatGPTTeamAPI 启动,加速删除。
|
| 158 |
+
|
| 159 |
+
### D.3 测试要点
|
| 160 |
+
|
| 161 |
+
- [ ] PREFERRED_SEAT_TYPE=codex 时,invite 流程返回的 seat_type 应该稳定=usage_based,且 PATCH 不被调用(看日志中 `修改邀请 seat_type` 是否出现)
|
| 162 |
+
- [ ] PREFERRED_SEAT_TYPE=chatgpt 保持现有行为不变(回归测试)
|
| 163 |
+
- [ ] 注册一个 self_serve_business_usage_based workspace 子号(可以临时用一个商用 workspace),确认 codex_auth.py 拒绝接收 bundle / 标 unsupported
|
| 164 |
+
- [ ] 创建一个 personal 子号(`fill-personal`),用 web UI 删除按钮,确认按钮可点 + 删除成功 + 列表刷新
|
| 165 |
+
- [ ] 批量删除 5 个 personal 子号,主号 session 故意先注销,确认不报 ChatGPTTeamAPI 启动失败
|
| 166 |
+
- [ ] wham/usage 返回 used_percent=0 但实际无配额的场景:本地是否能正确显示"无配额"
|
| 167 |
+
|
| 168 |
+
## E. 影响面 / 爆炸半径
|
| 169 |
+
|
| 170 |
+
- **PREFERRED_SEAT_TYPE 配置改动**:扩散到 `invite.py`、`chatgpt_api.py`、`runtime_config.py`、`web/Settings.vue`,4 个文件;最大风险是 fallback 链向后兼容(老的 default 入口 + PATCH 行为是否仍可用作"失败兜底")。需要对 `_invite_member_with_fallback` 做 unit test。
|
| 171 |
+
- **plan_type 白名单**:扩散到 `codex_auth.py` + `manual_account.py` + `manager.py`(_run_post_register_oauth、reinvite_account)+ `api.py`(login),5 个文件。注意 `cpa_sync.py:132-140` 的回退链不能漏改。
|
| 172 |
+
- **wham/usage 解析增强**:只动 `codex_auth.py:check_codex_quota / get_quota_exhausted_info`,影响所有调用方(manager / api 多处)的 status_str 分类,需要确认 "no_quota" 不会被误归 "auth_error"。
|
| 173 |
+
- **personal 删除短路**:只动 `account_ops.py` + `api.py:delete_accounts_batch`,不会影响 active 子号删除链。
|
| 174 |
+
- **UI toast 改动**:仅 Dashboard.vue,无后端影响。
|
| 175 |
+
|
| 176 |
+
## F. 风险与未决问题
|
| 177 |
+
|
| 178 |
+
1. **`self_serve_business_usage_based` 是否真的存在**:用户报告中提及,但 OpenAI 公开 API 文档没有这个值。需要从用户实际 bundle 取证(`auths/codex-*.json` 中 plan_type 字段)确认字面量,可能是 `chatgpt_business_usage_based` 或 `self_serve_team` 之类变体。
|
| 179 |
+
2. **PATCH 升级失败的统计**:目前 chatgpt_api.py:1503 只 log error,没有 metric。改默认策略前应该先看真实失败率 — 如果失败率 <5%,默认 ChatGPT 升级是合理的;如果 >30%,默认 codex 才合理。
|
| 180 |
+
3. **删除按钮 actionDisabled 是否有副作用**:adminReady=false 时禁删除是安全设计(避免 fetch_team_state 起 chatgpt_api 失败),不能简单去掉。修复方向应是"personal 子号不依赖 adminReady"。
|
| 181 |
+
4. **plan_type 白名单是否需要兼容大写**:`codex_auth.py:111` 直接读 JWT claim,OpenAI 后端可能某些版本返回大写 `Team`。所有判定建议 `.lower()` 后再比。
|
| 182 |
+
5. **如果用户希望"全部用 codex 席位"**,主号 ChatGPT 席位会闲置吗?需要确认主号的 codex 配额来自哪里(似乎来自主号本身的 ChatGPT 主席位,不需要 PATCH)。
|
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Issue#3 list_accounts ImportError 全量扫荡
|
| 2 |
+
|
| 3 |
+
> 调研时间:2026-04-26
|
| 4 |
+
> 调研范围:整个 AutoTeam 仓库 + Dockerfile / docker-compose / docker-entrypoint
|
| 5 |
+
> 结论:**源代码已干净 — docker 镜像未重建是 100% 真因**
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## A. 当前 accounts.py 真实导出
|
| 10 |
+
|
| 11 |
+
文件路径:`D:/Desktop/AutoTeam/src/autoteam/accounts.py`
|
| 12 |
+
|
| 13 |
+
**accounts.py 没有 `__all__`**,所以"导出"以模块顶层 `def`/常量为准:
|
| 14 |
+
|
| 15 |
+
| 类别 | 名称 |
|
| 16 |
+
|---|---|
|
| 17 |
+
| **状态常量** | `STATUS_ACTIVE` / `STATUS_EXHAUSTED` / `STATUS_STANDBY` / `STATUS_PENDING` / `STATUS_PERSONAL` / `STATUS_AUTH_INVALID` / `STATUS_ORPHAN` |
|
| 18 |
+
| **席位常量** | `SEAT_CHATGPT` / `SEAT_CODEX` / `SEAT_UNKNOWN` |
|
| 19 |
+
| **路径常量** | `PROJECT_ROOT` / `ACCOUNTS_FILE` |
|
| 20 |
+
| **私有辅助** | `_normalized_email` / `_is_main_account_email` |
|
| 21 |
+
| **核心 IO 函数** | `load_accounts` ← (line 36) |
|
| 22 |
+
| | `save_accounts` (line 45) |
|
| 23 |
+
| **业务函数** | `find_account` / `add_account` / `update_account` / `delete_account` |
|
| 24 |
+
| **筛选函数** | `get_active_accounts` / `get_personal_accounts` / `get_standby_accounts` / `get_next_reusable_account` |
|
| 25 |
+
|
| 26 |
+
**关键判定**:
|
| 27 |
+
- 模块**从未定义** `def list_accounts`
|
| 28 |
+
- 模块**从未定义** `list_accounts = load_accounts` 别名
|
| 29 |
+
- `accounts.py` 在 git 历史里也从未有过这个名字 — `list_accounts` 是 `api.py` 那处 typo 凭空引入的不存在符号
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## B. 全项目 list_accounts 引用清单
|
| 34 |
+
|
| 35 |
+
> 命令:`grep -rn "list_accounts" D:/Desktop/AutoTeam`
|
| 36 |
+
|
| 37 |
+
### B.1 与"账号池(autoteam.accounts)"相关的引用 — **0 处**
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
$ grep -rn "from autoteam.accounts import.*list_accounts" D:/Desktop/AutoTeam
|
| 41 |
+
# (0 行,完全干净)
|
| 42 |
+
|
| 43 |
+
$ grep -rn "accounts\.list_accounts" D:/Desktop/AutoTeam/src
|
| 44 |
+
# (0 行,完全干净)
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
✅ commit cf2f7d3 之后,**autoteam.accounts 这一侧的 list_accounts 引用已清零**。
|
| 48 |
+
|
| 49 |
+
### B.2 残留的 list_accounts 引用全部归属"邮箱 provider 子系统",与 issue 无关
|
| 50 |
+
|
| 51 |
+
| 文件 | 行号 | 上下文 | 是否 bug |
|
| 52 |
+
|---|---|---|---|
|
| 53 |
+
| `docs/mail-provider-design.md` | 68, 308 | 设计文档:邮箱 provider 抽象 `def list_accounts(self, size: int = 200)` | ❌ 无关(文档) |
|
| 54 |
+
| `src/autoteam/mail/base.py` | 177 | 邮箱 provider 抽象基类的方法签名 `def list_accounts(self, size: int = 200) -> list[dict]` | ❌ 无关(`MailProvider` 类方法) |
|
| 55 |
+
| `src/autoteam/mail/cf_temp_email.py` | 190 | `CfTempEmailClient.list_accounts(self, size=200)` | ❌ 无关(provider 实现) |
|
| 56 |
+
| `src/autoteam/mail/maillab.py` | 219 | `MaillabClient.list_accounts(self, size: int = 200)` | ❌ 无关(provider 实现) |
|
| 57 |
+
| `src/autoteam/mail/maillab.py` | 309, 322 | `for row in self.list_accounts(size=500)` 内部调用 | ❌ 无关 |
|
| 58 |
+
| `tests/unit/test_maillab.py` | 216, 236, 240, 253, 258, 279 | 测试 maillab provider 的 `list_accounts` 翻页/截断行为 | ❌ 无关(测试) |
|
| 59 |
+
| `CHANGELOG.md` | 11, 134 | 历史记录条目(本 issue 修复说明 + 旧版 maillab.list_accounts 上限补丁) | ❌ 无关(文档) |
|
| 60 |
+
| `prompts/issues1.md` | 77, 78 | 用户报告的报错原文 | ❌ 无关(报告) |
|
| 61 |
+
|
| 62 |
+
**结论**:`list_accounts` 这个名字在仓库里**只剩"邮箱 provider 的方法名"** — 跟 `autoteam.accounts` 模块没有任何 import 关系,**不会被 Python 解释器拿来跟 accounts 模块对接**。
|
| 63 |
+
|
| 64 |
+
### B.3 commit cf2f7d3 之前的现场还原(供对照)
|
| 65 |
+
|
| 66 |
+
`api.py:1942` 历史代码:
|
| 67 |
+
```python
|
| 68 |
+
# 修复前(已是历史)
|
| 69 |
+
from autoteam.accounts import STATUS_ACTIVE, STATUS_EXHAUSTED, list_accounts
|
| 70 |
+
^^^^^^^^^^^^^
|
| 71 |
+
# ← 这里就是 typo
|
| 72 |
+
|
| 73 |
+
in_team_local = sum(1 for a in list_accounts() if a.get("status") in ...)
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
`api.py:1944` 当前代码(已修):
|
| 77 |
+
```python
|
| 78 |
+
from autoteam.accounts import STATUS_ACTIVE, STATUS_EXHAUSTED, load_accounts
|
| 79 |
+
in_team_local = sum(1 for a in load_accounts() if a.get("status") in ...)
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
git log 里 `cf2f7d3 fix(round-3)` 第一条就是这条修复,文件指纹一致。
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## C. Docker 镜像 / 挂载现状
|
| 87 |
+
|
| 88 |
+
### C.1 Dockerfile 关键事实
|
| 89 |
+
|
| 90 |
+
```dockerfile
|
| 91 |
+
# 第 23-25 行
|
| 92 |
+
COPY src/ src/
|
| 93 |
+
COPY web/ web/
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
**源代码是 `COPY` 进镜像的 — 不是 volume 挂载**。
|
| 97 |
+
|
| 98 |
+
### C.2 docker-compose.yml 挂载策略
|
| 99 |
+
|
| 100 |
+
```yaml
|
| 101 |
+
volumes:
|
| 102 |
+
- ./data:/app/data # 仅挂数据目录
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
**没有挂 `./src:/app/src`** — 也就是说源代码改动**必须 rebuild image** 才会进入容器。
|
| 106 |
+
|
| 107 |
+
### C.3 docker-entrypoint.sh 软链规则
|
| 108 |
+
|
| 109 |
+
入口脚本只把 `/app/data/{.env, accounts.json, state.json, auths/, screenshots/}` 软链到工作目录,**不动源码**:
|
| 110 |
+
```bash
|
| 111 |
+
for f in .env accounts.json state.json; do
|
| 112 |
+
[ -f "/app/data/$f" ] || touch "/app/data/$f"
|
| 113 |
+
rm -f "/app/$f"
|
| 114 |
+
ln -s "/app/data/$f" "/app/$f"
|
| 115 |
+
done
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
`/app/src/autoteam/api.py` 是 `COPY` 时刻被烤进镜像的快照,不会随宿主机文件变动。
|
| 119 |
+
|
| 120 |
+
### C.4 推断:用户的 docker 镜像还是旧版
|
| 121 |
+
|
| 122 |
+
既然 commit cf2f7d3 已��� git,但 11:04 容器内还在报同样的 `ImportError: cannot import name 'list_accounts' from 'autoteam.accounts'`,只剩三种可能:
|
| 123 |
+
|
| 124 |
+
1. **(99%)镜像未重建** — 源码改了,`docker build` 没重跑
|
| 125 |
+
2. **(<1%)镜像已建但容器未替换** — 老 container 没 stop/restart
|
| 126 |
+
3. **(0%)有别处也写了 typo** — 已经 grep 全仓库扫干净,不存在
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## D. 推荐修复方案
|
| 131 |
+
|
| 132 |
+
### 方案 A:仅 docker rebuild(对应 99% 真因)
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
cd D:/Desktop/AutoTeam
|
| 136 |
+
|
| 137 |
+
# 1. 确认源码确实是新版
|
| 138 |
+
git log --oneline src/autoteam/api.py | head -3
|
| 139 |
+
git diff cf2f7d3~1 cf2f7d3 -- src/autoteam/api.py | head -20
|
| 140 |
+
|
| 141 |
+
# 2. 停掉旧容器
|
| 142 |
+
docker compose down
|
| 143 |
+
|
| 144 |
+
# 3. 强制重建镜像(--no-cache 防意外缓存命中)
|
| 145 |
+
docker compose build --no-cache autoteam
|
| 146 |
+
|
| 147 |
+
# 4. 重新启动
|
| 148 |
+
docker compose up -d
|
| 149 |
+
|
| 150 |
+
# 5. 进容器验证
|
| 151 |
+
docker compose exec autoteam python -c "from autoteam.accounts import load_accounts; print('OK', len(load_accounts()))"
|
| 152 |
+
|
| 153 |
+
# 6. 也可以同时确认 typo 已不在镜像里
|
| 154 |
+
docker compose exec autoteam grep -n "list_accounts" /app/src/autoteam/api.py || echo "干净"
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
**预期**:第 5 步打印 `OK <n>`,第 6 步打印 `干净`。
|
| 158 |
+
|
| 159 |
+
### 方案 B:在 accounts.py 加 `list_accounts = load_accounts` 别名(防御层 / 不推荐)
|
| 160 |
+
|
| 161 |
+
```python
|
| 162 |
+
# src/autoteam/accounts.py 末尾追加
|
| 163 |
+
list_accounts = load_accounts # 向后兼容,允许 typo 不炸
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
**评估**:
|
| 167 |
+
- 优点:即便有人重新写错也不会 ImportError
|
| 168 |
+
- 缺点:**掩盖 typo,长期维护更糟** — 团队不知道哪个名字才是规范的;静态分析工具看到两个绑定也会困惑
|
| 169 |
+
- **不推荐**:typo 应该让 lint / CI 抓住,而不是用别名兜底
|
| 170 |
+
|
| 171 |
+
### 方案 C:CI / pre-commit 防回归(强烈推荐组合)
|
| 172 |
+
|
| 173 |
+
虽然这条 issue 当前位点没残留 typo,但同类 typo 容易复发(`list_accounts` 写起来比 `load_accounts` 更顺手)。建议在 `ruff.toml` 或 pre-commit 增加一条 grep 守卫:
|
| 174 |
+
|
| 175 |
+
```bash
|
| 176 |
+
# scripts/lint-no-list-accounts.sh
|
| 177 |
+
#!/usr/bin/env bash
|
| 178 |
+
set -e
|
| 179 |
+
if grep -rn "from autoteam\.accounts import.*list_accounts\b" src tests; then
|
| 180 |
+
echo "Found typo: list_accounts (use load_accounts)"
|
| 181 |
+
exit 1
|
| 182 |
+
fi
|
| 183 |
+
if grep -rn "autoteam\.accounts\.list_accounts\b" src tests; then
|
| 184 |
+
echo "Found typo: autoteam.accounts.list_accounts (use load_accounts)"
|
| 185 |
+
exit 1
|
| 186 |
+
fi
|
| 187 |
+
echo "no list_accounts typo"
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
接到 pre-commit / CI:任何人再写错立刻拒绝 commit。
|
| 191 |
+
|
| 192 |
+
### 方案对比
|
| 193 |
+
|
| 194 |
+
| 方案 | 解决根因 | 落地成本 | 推荐度 |
|
| 195 |
+
|---|---|---|---|
|
| 196 |
+
| **A** docker rebuild | ✅ 完全解决当前 issue | 低(2 条命令) | ★★★★★ **必须做** |
|
| 197 |
+
| **B** 别名兜底 | ❌ 治标不治本 | 极低 | ★ 不推荐 |
|
| 198 |
+
| **C** lint 防回归 | ✅ 防未来同类 typo | 低(1 个 shell) | ★★★★ **推荐配套** |
|
| 199 |
+
|
| 200 |
+
**建议组合**:**A + C** — 立刻把镜像重建,同时埋一道 lint 防线。
|
| 201 |
+
|
| 202 |
+
---
|
| 203 |
+
|
| 204 |
+
## E. 防回归测试要点
|
| 205 |
+
|
| 206 |
+
### E.1 单元测试层
|
| 207 |
+
|
| 208 |
+
确认 api.py 里所有 `from autoteam.accounts import ...` 路径都不再误用 `list_accounts`:
|
| 209 |
+
|
| 210 |
+
```python
|
| 211 |
+
# tests/unit/test_no_list_accounts_typo.py
|
| 212 |
+
import re
|
| 213 |
+
from pathlib import Path
|
| 214 |
+
|
| 215 |
+
SRC = Path(__file__).parent.parent.parent / "src"
|
| 216 |
+
|
| 217 |
+
def test_no_list_accounts_in_accounts_namespace():
|
| 218 |
+
"""list_accounts 是 mail provider 的方法名,不应在 autoteam.accounts 命名空间出现"""
|
| 219 |
+
pattern = re.compile(
|
| 220 |
+
r"from\s+autoteam\.accounts\s+import[^#\n]*\blist_accounts\b"
|
| 221 |
+
r"|autoteam\.accounts\.list_accounts\b"
|
| 222 |
+
)
|
| 223 |
+
bad = []
|
| 224 |
+
for p in SRC.rglob("*.py"):
|
| 225 |
+
text = p.read_text(encoding="utf-8")
|
| 226 |
+
for i, line in enumerate(text.splitlines(), 1):
|
| 227 |
+
if pattern.search(line):
|
| 228 |
+
bad.append(f"{p}:{i}: {line.strip()}")
|
| 229 |
+
assert not bad, "list_accounts typo:\n" + "\n".join(bad)
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
### E.2 集成测试层
|
| 233 |
+
|
| 234 |
+
加一条对"生成免费号"按钮(`POST /api/tasks/fill {leave_workspace: true}`)的最小冒烟:
|
| 235 |
+
|
| 236 |
+
```python
|
| 237 |
+
# tests/integration/test_post_fill_no_import_error.py
|
| 238 |
+
def test_post_fill_personal_does_not_raise_import_error(client, monkeypatch):
|
| 239 |
+
# 桩掉 cmd_fill 避免真的开 Playwright,只校验 import 阶段不炸
|
| 240 |
+
from autoteam import manager
|
| 241 |
+
monkeypatch.setattr(manager, "cmd_fill", lambda **kw: [])
|
| 242 |
+
monkeypatch.setattr(manager, "TEAM_SUB_ACCOUNT_HARD_CAP", 999)
|
| 243 |
+
|
| 244 |
+
resp = client.post("/api/tasks/fill", json={"leave_workspace": True})
|
| 245 |
+
# 关键:不能是 500(ImportError 会变 500)
|
| 246 |
+
assert resp.status_code in (202, 409)
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
### E.3 部署测试层
|
| 250 |
+
|
| 251 |
+
`docker-entrypoint.sh` 启动末端加一行 self-check,把 typo 扼杀在容器启动期:
|
| 252 |
+
|
| 253 |
+
```bash
|
| 254 |
+
# 末尾追加
|
| 255 |
+
python -c "from autoteam.accounts import load_accounts" || {
|
| 256 |
+
echo "FATAL: autoteam.accounts.load_accounts import failed"
|
| 257 |
+
exit 1
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
exec uv run autoteam "$@"
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
如此一来:任何 typo 类 ImportError 在 `docker compose up` 时就**立刻 crash-loop**,不会再让用户在 Web 面板按按钮才发现。
|
| 264 |
+
|
| 265 |
+
### E.4 镜像版本指纹
|
| 266 |
+
|
| 267 |
+
建议在 Dockerfile 加一行 `LABEL org.opencontainers.image.revision=<git-sha>`,便于排查"当前镜像到底是哪个 commit":
|
| 268 |
+
|
| 269 |
+
```dockerfile
|
| 270 |
+
ARG GIT_SHA=unknown
|
| 271 |
+
LABEL org.opencontainers.image.revision=$GIT_SHA
|
| 272 |
+
ENV AUTOTEAM_GIT_SHA=$GIT_SHA
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
构建命令:
|
| 276 |
+
```bash
|
| 277 |
+
docker compose build --build-arg GIT_SHA=$(git rev-parse --short HEAD)
|
| 278 |
+
```
|
| 279 |
+
|
| 280 |
+
容器内可通过 `echo $AUTOTEAM_GIT_SHA` 立即知道是哪个 commit 烤的镜像 — 这次 issue#3 排错时就能直接看到镜像 SHA 是不是 cf2f7d3 之前的版本。
|
| 281 |
+
|
| 282 |
+
---
|
| 283 |
+
|
| 284 |
+
## 总结
|
| 285 |
+
|
| 286 |
+
| 维度 | 结论 |
|
| 287 |
+
|---|---|
|
| 288 |
+
| 源代码层是否还残留 typo? | ❌ 不残留(commit cf2f7d3 已修干净) |
|
| 289 |
+
| 是否需要在 accounts.py 加别名? | ❌ 不需要(掩盖 typo,反而坏事) |
|
| 290 |
+
| 真因? | **Docker 镜像未重建** — 用户 11:04 看到的容器还在跑旧 image |
|
| 291 |
+
| 立刻该做? | `docker compose down && docker compose build --no-cache && docker compose up -d` |
|
| 292 |
+
| 长期该做? | 加 lint 守卫 + entrypoint 启动期 self-check + 镜像 git-sha 标签 |
|
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Issue#4 OAuth 凭证获取失败 + 再次登录爆 add phone — 研究报告
|
| 2 |
+
|
| 3 |
+
> 作者:claude(OAuth/SSO 流程调研工程师) · 时间:2026-04-26
|
| 4 |
+
>
|
| 5 |
+
> 用户原话:"修复在使用中报错经常出现的:免费号凭证无法通过OAuth获取,再次登录时爆add phone的问题——不要脱离原本的流程太多"
|
| 6 |
+
>
|
| 7 |
+
> **本报告只调研、不改代码**。所有结论以代码事实为准,引用形式 `path:line` 全部经过 Read/Grep 校验。
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## TL;DR(给开看的人)
|
| 12 |
+
|
| 13 |
+
1. **现象一(凭证获取不到)** = `login_codex_via_browser` 末尾 `if not auth_code: return None` 静默失败。callback 没回来的根因被吞掉(超时 / consent 循环卡死 / **未识别的 add-phone 阻塞页**),上层只能写 `oauth_failed`。
|
| 14 |
+
2. **现象二(再次登录爆 add phone)** = OAuth 流程中**完全没有** add-phone 检测器。`invite.py:106 detect_phone_verification` 只在新账号注册的 4 个提交节点用,**所有 6 处 `login_codex_via_browser` 调用点(注册成功后 OAuth、生成免费号 personal OAuth、补登录、轮转复用、定点替换、`api.py /api/accounts/login`)都不调用它**。
|
| 15 |
+
3. **最小侵入修复方向**:把 `assert_not_blocked(page, step)` 接进 `codex_auth.py` OAuth 主循环 → 命中 add-phone 时抛 `RegisterBlocked(is_phone=True)` → 上层按"账号需要人工"分类(新增 `STATUS_PHONE_REQUIRED` 或复用 `STATUS_AUTH_INVALID` + register_failures `phone_blocked`)。**不重写 OAuth、不绕 add-phone**,与用户"不要脱离原本流程太多"的硬要求一致。
|
| 16 |
+
4. **与 issue#6 的边界**:#4 覆盖**所有**经过 `login_codex_via_browser` 的入口;#6 是 reinvite_account 这个特定子集的二次表现(踢出后再 OAuth 风控更高)。**修了 #4 的 add-phone 检测器,#6 的"还要手机"会被拦截**,只是分类语义不同(#4 失败时账号刚 kick 出去就直接放弃,#6 失败时账号已经在 Team 占席位需要立即清理)。
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## A. 当前 OAuth 流程地图
|
| 21 |
+
|
| 22 |
+
### A.1 关键文件清单
|
| 23 |
+
|
| 24 |
+
| 文件 | 行数 | 职责 |
|
| 25 |
+
|------|------|------|
|
| 26 |
+
| `src/autoteam/codex_auth.py` | 1728 | **OAuth 主战场** — `login_codex_via_browser`、`login_codex_via_session`、`SessionCodexAuthFlow`、`MainCodexSyncFlow`、`save_auth_file`、`check_codex_quota`、`refresh_access_token` |
|
| 27 |
+
| `src/autoteam/auth_storage.py` | 35 | `auths/` 目录 + 文件权限 0o666 修复 |
|
| 28 |
+
| `src/autoteam/manager.py` | 4137 | 6 处 `login_codex_via_browser` 调用点中的 4 处:`_run_post_register_oauth`(L1431/L1463)、`reinvite_account`(L2466)、`_check_pending_invites`(L1057) |
|
| 29 |
+
| `src/autoteam/api.py` | - | `/api/accounts/login` 单账号 web 触发(L1479) |
|
| 30 |
+
| `src/autoteam/manual_account.py` | 309 | 用户在自己浏览器走 OAuth → 提交回调 URL,本地不开 Playwright,**不会撞 add-phone**(由用户人眼处理) |
|
| 31 |
+
| `src/autoteam/invite.py` | - | `detect_phone_verification`(L106)、`assert_not_blocked`(L138)、`RegisterBlocked`(L53) — **add-phone 检测器只在这里被定义和使用** |
|
| 32 |
+
| `src/autoteam/accounts.py` | 156 | 状态常量 `STATUS_*`、`SEAT_*`,**没有 phone-required 状态** |
|
| 33 |
+
| `src/autoteam/register_failures.py` | 99 | `record_failure(category=...)`,已有 `phone_blocked` 类目但**只在注册阶段触发** |
|
| 34 |
+
|
| 35 |
+
### A.2 调用链(手工构建,无 gitnexus 索引)
|
| 36 |
+
|
| 37 |
+
```
|
| 38 |
+
入口层
|
| 39 |
+
├── /api/accounts/login (api.py:1450 post_account_login)
|
| 40 |
+
├── /api/tasks/fill (leave=False) (api.py:1933 post_fill → cmd_fill)
|
| 41 |
+
├── /api/tasks/fill (leave=True) (api.py:1933 post_fill → cmd_fill_personal)
|
| 42 |
+
├── /api/tasks/check (manager.py:cmd_check)
|
| 43 |
+
├── /api/tasks/replace (manager.py:cmd_replace_one)
|
| 44 |
+
└── /api/tasks/rotate (manager.py:cmd_rotate)
|
| 45 |
+
|
| 46 |
+
OAuth 中枢
|
| 47 |
+
login_codex_via_browser(use_personal=False/True)
|
| 48 |
+
┌─────────────────────────┬─────────────────────────┬────────────────────────┐
|
| 49 |
+
▼ ▼ ▼ ▼
|
| 50 |
+
_run_post_register_oauth _run_post_register_oauth reinvite_account /api/accounts/login
|
| 51 |
+
(L1463 Team) (L1431 personal) (L2466 standby 复用) (api.py:1479)
|
| 52 |
+
│ │ │ │
|
| 53 |
+
▼ ▼ ▼ ▼
|
| 54 |
+
save_auth_file save_auth_file save_auth_file save_auth_file
|
| 55 |
+
(codex-{email}-{plan}-{hash}.json)
|
| 56 |
+
│
|
| 57 |
+
▼
|
| 58 |
+
sync_to_cpa() (cpa_sync.py 把 auths/ 同步到 CLIProxyAPI)
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
主号 session 复用:`login_codex_via_session` / `SessionCodexAuthFlow` / `MainCodexSyncFlow` 用主号 `__Secure-next-auth.session-token` 注 cookie 直跳 `auth_url`,**也不检测 add-phone**(主号通常已绑定手机,但极端场景仍可能触发)。
|
| 62 |
+
|
| 63 |
+
### A.3 凭证文件路径与生命周期
|
| 64 |
+
|
| 65 |
+
```
|
| 66 |
+
auths/ (PROJECT_ROOT/auths,bind mount 到 docker 容器)
|
| 67 |
+
├── codex-{email}-{team|free|plus}-{8 字节 md5}.json
|
| 68 |
+
└── codex-main-{account_id}.json (主号专用,不进 accounts.json)
|
| 69 |
+
|
| 70 |
+
文件结构(_write_auth_file @ codex_auth.py:119):
|
| 71 |
+
{
|
| 72 |
+
"type": "codex",
|
| 73 |
+
"id_token": "...",
|
| 74 |
+
"access_token": "...",
|
| 75 |
+
"refresh_token": "...",
|
| 76 |
+
"account_id": "...",
|
| 77 |
+
"email": "...",
|
| 78 |
+
"expired": "ISO8601",
|
| 79 |
+
"last_refresh": "ISO8601"
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
权限:0o666(docker 容器写,宿主机用户读写,见 auth_storage.py:9)
|
| 83 |
+
|
| 84 |
+
生命周期:
|
| 85 |
+
1. login_codex_via_browser → _exchange_auth_code → 拿到 bundle
|
| 86 |
+
2. save_auth_file(bundle) → 删同 email 旧文件 → 写新文件
|
| 87 |
+
3. update_account(email, auth_file=路径)
|
| 88 |
+
4. sync_to_cpa() 反向同步给 CPA 服务
|
| 89 |
+
5. 后续每次 check_codex_quota 直接读文件里的 access_token,401/403 → status=AUTH_INVALID
|
| 90 |
+
6. refresh_access_token 用 refresh_token 换新 at,写回原文件
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
## B. "凭证获取失败"症状归因
|
| 96 |
+
|
| 97 |
+
### B.1 失败位点候选(按 codex_auth.py 流程顺序)
|
| 98 |
+
|
| 99 |
+
| # | 位置 | 表现 | 当前处理 | 是否被识别 |
|
| 100 |
+
|---|------|------|----------|------------|
|
| 101 |
+
| 1 | `login_codex_via_browser` step-0 ChatGPT 预登录(Team 模式)| Cloudflare "verify you are human" 不退 | 12 次循环每 5s 试一次,失败仍继续(L327-330) | 否,继续推进可能在后面崩 |
|
| 102 |
+
| 2 | step-0 OTP 等待主号验证码 | 主号未触发 OTP,但 `mail_client.search_emails_by_recipient` 收不到 | 静默 except,继续(L399) | 否 |
|
| 103 |
+
| 3 | step-0 workspace 选择失败 | 用户 `CHATGPT_WORKSPACE_NAME` 配错 | fallback 选第二个非"个人"选项(L417-432) | 否,可能选错 workspace |
|
| 104 |
+
| 4 | OAuth 邮箱/密码页"误跳 Google" | 邮箱碰到 SSO 自动跳 accounts.google.com | 检测到则 `page.go_back` 重试 1 次(L484-489) | **是**(`_is_google_redirect`) |
|
| 105 |
+
| 5 | 验证码邮件取不到 | 120s 内 `mail_client.search_emails_by_recipient` 没拿到 OPENAI 发的 OTP | warning 后继续主循环,实际 consent 步会卡(L564) | 部分,`_wait_for_otp_submit_result` 可识别 invalid/pending |
|
| 106 |
+
| 6 | about-you 失败 | 注册新账号 OAuth 时 `about-you` 页缺 spinbutton/age input | except 吞掉,继续 consent 循环(L608) | 否 |
|
| 107 |
+
| 7 | 10 次 consent 循环里 workspace/Continue 都点不出 | personal/Team 切换冲突、workspace 名称不存在、按钮被遮挡 | break 出 for(L880-882) | 否,只 logger.info |
|
| 108 |
+
| 8 | **add-phone 拦截页(URL `/verify-phone`、`/add-phone`、`/phone-number`)** | OpenAI 风控强制要求绑定手机号 | **完全不检测** | **否** ❗ |
|
| 109 |
+
| 9 | 等 callback 30s 没回来 | OAuth 中途任何卡死 / consent 走偏 | warning,return None(L902-910) | 否,根因被吞 |
|
| 110 |
+
| 10 | personal 模式 plan_type != free | 子号刚 kick 出 Team,workspace default 还指 Team | 拒收 bundle,返回 None(L920-930) | **是**(commit `e760be9`) |
|
| 111 |
+
|
| 112 |
+
**关键事实**:位点 8 和位点 9 是同一根因的两副面孔。OAuth 走到 add-phone 页 → 卡死 → callback 不回来 → 写"未获取到 auth code"。**这个流程对调用方不可区分**:都是 `bundle is None`。
|
| 113 |
+
|
| 114 |
+
### B.2 错误日志关键词扫描(grep `oauth_failed|no_bundle|未获取到 auth`)
|
| 115 |
+
|
| 116 |
+
```
|
| 117 |
+
codex_auth.py:909 "[Codex] OAuth 登录失败: 未获取到 authorization code" ← 兜底,根因黑盒
|
| 118 |
+
codex_auth.py:904 "[Codex] 未获取到 auth code,当前 URL: %s" ← 唯一能反推 add-phone 的线索:URL
|
| 119 |
+
codex_auth.py:1104 "[Codex] 主号未获取到 auth code,当前 URL: %s"
|
| 120 |
+
|
| 121 |
+
manager.py:1455 record_failure(email, "oauth_failed", "已退出 Team 但 personal Codex OAuth 登录未返回 bundle")
|
| 122 |
+
manager.py:1459 out_outcome("oauth_failed", reason="personal Codex OAuth 未返回 bundle")
|
| 123 |
+
manager.py:2485 _cleanup_team_leftover("no_bundle")
|
| 124 |
+
manager.py:1480 team_auth_missing ("已入 Team 席位但 Codex OAuth 未返回 bundle,需要补登录")
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
**用户的 register_failures.json 实际样本(11 条)**:
|
| 128 |
+
- 1 条 `oauth_failed`(`0d5d56a045@icoulsy.cloud` "personal Codex OAuth 未返回 bundle")— **这就是"凭证无法通过 OAuth 获取"的真实命中**
|
| 129 |
+
- 8 条 `exception`(net::ERR_ABORTED / Page.content / Page.screenshot 上下文已关闭) — 不在 add-phone 范畴,是 Playwright 浏览器异常
|
| 130 |
+
- 1 条 `kick_failed`、1 条 `register_failed`
|
| 131 |
+
|
| 132 |
+
证据有限但与现象吻合。`oauth_failed` 没有更细的子分类,无法区分"add-phone 阻塞" / "consent 循环走偏" / "callback 网络抖动"。
|
| 133 |
+
|
| 134 |
+
### B.3 兜底逻辑现状
|
| 135 |
+
|
| 136 |
+
```
|
| 137 |
+
login_codex_via_browser 内:
|
| 138 |
+
├─ 邮箱步误跳 Google → page.go_back 重试 1 次 ✅
|
| 139 |
+
├─ 密码步误跳 Google → page.go_back 重试 1 次 ✅
|
| 140 |
+
├─ OTP submit 不通过 → _wait_for_otp_submit_result accepted/invalid/pending 三态 ✅
|
| 141 |
+
├─ consent 循环最多 10 次 ✅
|
| 142 |
+
├─ 等 callback 30s ✅
|
| 143 |
+
├─ personal 模式 plan_type != free 拒收 ✅(commit e760be9)
|
| 144 |
+
└─ add-phone 拦截 ❌ 完全没检测
|
| 145 |
+
|
| 146 |
+
调用方兜底(reinvite_account L2483-2487):
|
| 147 |
+
bundle is None → _cleanup_team_leftover("no_bundle") → STATUS_STANDBY
|
| 148 |
+
这套兜底假设"OAuth 失败是一过性的,下次再试可能就好了"。
|
| 149 |
+
但 add-phone 是确定性风控,等下次 retry 还会撞同样页面 → 死循环消耗 invite/kick 配额
|
| 150 |
+
|
| 151 |
+
调用方兜底(_run_post_register_oauth L1431 personal 模式):
|
| 152 |
+
bundle is None → delete_account(email) + record_failure("oauth_failed", "personal OAuth 未返回 bundle")
|
| 153 |
+
这套对 add-phone 倒是"对的"(账号已 kick 出去,删干净),但**用户失去了"知道为啥失败"的能力**:
|
| 154 |
+
到底是 add-phone 触发了,还是临时网络问题,还是 workspace default 没同步,日志里都看不出来。
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
---
|
| 158 |
+
|
| 159 |
+
## C. add phone 拦截深度调研
|
| 160 |
+
|
| 161 |
+
### C.1 触发条件(综合外部资料 + 代码注释)
|
| 162 |
+
|
| 163 |
+
参考 `invite.py:69-87` 注释 + `manager.py:1393` 上下文 + grok web 检索结果(2026-04 仍在生效):
|
| 164 |
+
|
| 165 |
+
| 类别 | 触发场景 | 在 AutoTeam 上下文里的对应 |
|
| 166 |
+
|------|----------|---------------------------|
|
| 167 |
+
| **行为风控** | 短时间多账号同 IP/UA 注册 | 一个母号一晚上批量生产免费号 → fill-personal |
|
| 168 |
+
| **设备信任** | 新设备/新指纹首次登录 | docker 容器每次开 Playwright 都是"新设备",`oai-did` cookie 没复用 |
|
| 169 |
+
| **地理风控** | VPN / IP 漂移 | 用户跨国部署 / `PLAYWRIGHT_PROXY_URL` 切换 |
|
| 170 |
+
| **账号孤立** | 账号无 cookie/history 直接 OAuth | personal 模式注册后 5s kick 出 Team 立刻 OAuth(cookie 几乎是空的) |
|
| 171 |
+
| **Arkose 缺失** | 浏览器扩展屏蔽 Arkose Labs | docker headless chromium 默认不带扩展,但若 `PLAYWRIGHT_PROXY_URL` 指向某些被 Arkose 黑名单 IP 段会被强制 add-phone |
|
| 172 |
+
| **手机号已复用** | burner 号被多个账号绑过 → "this phone number is already linked to the maximum number of accounts" | 历史问题,与 OAuth 无关但表现类似 |
|
| 173 |
+
|
| 174 |
+
**"再次登录"特别触发条件**(用户原话:"再次登录时爆 add phone"):
|
| 175 |
+
- 一个账号在某个 session 已经验证过手机 → cookie 清掉再登(docker 重启浏览器、新 context、不复用 storage_state)→ OpenAI 看不到 device 信任,**重新走风控**
|
| 176 |
+
- 短时间多次 OAuth 同一邮箱(reinvite_account 复用 standby → kick 后下一轮再 invite → 再 OAuth)→ 命中"频次"风控
|
| 177 |
+
|
| 178 |
+
### C.2 检测方式(URL/DOM)
|
| 179 |
+
|
| 180 |
+
`invite.py:69-87` 已经提供完整检测器,可直接复用:
|
| 181 |
+
|
| 182 |
+
```python
|
| 183 |
+
_PHONE_URL_HINTS = ("verify-phone", "add-phone", "/phone", "phone_verification", "phone-number")
|
| 184 |
+
_PHONE_TEXT_HINTS = (
|
| 185 |
+
"verify your phone", "add your phone", "verify phone",
|
| 186 |
+
"verification code to your phone", "add a phone number",
|
| 187 |
+
"add a phone", "enter your phone", "phone verification",
|
| 188 |
+
"we'll text you", "请输入手机号", "手机号码", "验证手机", "添加手机",
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
def detect_phone_verification(page):
|
| 192 |
+
url = (page.url or "").lower()
|
| 193 |
+
if any(hint in url for hint in _PHONE_URL_HINTS):
|
| 194 |
+
return True # URL 命中 → 强信号
|
| 195 |
+
body = page.inner_text("body")[:1500].lower()
|
| 196 |
+
if not any(hint in body for hint in _PHONE_TEXT_HINTS):
|
| 197 |
+
return False
|
| 198 |
+
tel_input = page.locator(
|
| 199 |
+
'input[type="tel"], input[name*="phone" i], input[autocomplete*="tel" i]'
|
| 200 |
+
).first
|
| 201 |
+
if tel_input.is_visible(timeout=500): # 文本命中 + 电话输入框 → 强信号
|
| 202 |
+
return True
|
| 203 |
+
return False # 否则当未阻塞
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
设计意图(注释里写了):URL 强信号优先;文本必须配合电话输入框,避免注册帮助区"phone number"短语误报。**这套规则已经在生产被验证过半年(从 2025-09 开始用)**,可以直接拿到 OAuth 路径用。
|
| 207 |
+
|
| 208 |
+
### C.3 当前代码处理现状
|
| 209 |
+
|
| 210 |
+
**注册阶段(invite.py + manager.py 直接注册)**:✅ 完整覆盖
|
| 211 |
+
|
| 212 |
+
```
|
| 213 |
+
invite.py:247 assert_not_blocked(page, "email_submit") ← 邮箱提交后
|
| 214 |
+
invite.py:282 assert_not_blocked(page, "password_submit") ← 密码提交后
|
| 215 |
+
invite.py:364 assert_not_blocked(page, "code_submit") ← OTP 提交后
|
| 216 |
+
invite.py:446 assert_not_blocked(page, "profile_submit") ← about-you 提交后
|
| 217 |
+
manager.py:1954 assert_not_blocked(page, "about_you_submit") ← 直接注册 about-you 后
|
| 218 |
+
manager.py:2117 assert_not_blocked(page, "email_submit") ← 直接注册 email 后
|
| 219 |
+
manager.py:2184 assert_not_blocked(page, "password_submit") ← 直接注册 password 后
|
| 220 |
+
manager.py:2228 assert_not_blocked(page, "code_submit") ← 直接注册 OTP 后
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
捕获 `RegisterBlocked.is_phone=True` → `record_failure(category="phone_blocked")` + 删邮箱 + 整个账号放弃(`manager.py:2320-2332`)。
|
| 224 |
+
|
| 225 |
+
**OAuth 阶段(codex_auth.py)**:❌ 零覆盖
|
| 226 |
+
|
| 227 |
+
```
|
| 228 |
+
codex_auth.py 全文:
|
| 229 |
+
detect_phone_verification: 0 处
|
| 230 |
+
assert_not_blocked: 0 处
|
| 231 |
+
RegisterBlocked: 0 处
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
**关键空白点**:
|
| 235 |
+
- L568 `if "about-you" in page.url:` ← 后面填了 name/age 就 continue,**这一刻最可能撞 add-phone**(注册流程提交完 about-you 后 OpenAI 经常拉一次 add-phone)
|
| 236 |
+
- L612 consent 10 次循环 ← 每一步都可能跳到 add-phone 页,但只看 workspace/consent button,看不到 add-phone
|
| 237 |
+
- L884 等 callback 30s 死循环 ← add-phone 页就是"callback 永远不来"的根因
|
| 238 |
+
|
| 239 |
+
### C.4 绕过/规避策略对比
|
| 240 |
+
|
| 241 |
+
用户原话:"**不要脱离原本的流程太多**"。所有"绕过 add-phone"的方案直接淘汰。可选方向按"侵入度"从小到大:
|
| 242 |
+
|
| 243 |
+
| # | 策略 | 改动量 | 风险 | 用户接受度 |
|
| 244 |
+
|---|------|--------|------|------------|
|
| 245 |
+
| **A** | **检测到 add-phone 立即放弃 + 标记账号需要人工** | **极小**(invite.py 已现成 detect 函数,在 codex_auth.py 4 处插一行) | **零**,纯防御性 | **高**(用户已在 invite 阶段接受相同处置:"不绕,直接放弃") |
|
| 246 |
+
| B | A + 重置 cookie/storage_state 重试一次 | 中(要在 codex_auth.py 加 storage_state 持久化逻辑,失败重新打开 context) | 低(只是重试,不绕过) | 中(用户没说要重试,但符合"最大可能性修复") |
|
| 247 |
+
| C | A + 切换 PLAYWRIGHT_PROXY_URL 重试 | 大(需要 IP 池管理) | 高(运维负担) | 低(脱离原流程) |
|
| 248 |
+
| D | 自动绑定 SMS pool 上的手机号 | 极大(集成 sms-activate / mobilesms.io) | 极高(违反 OpenAI ToS,封号风险) | 极低 ❌ |
|
| 249 |
+
|
| 250 |
+
**推荐 A**(可附带 B 作为可配置开关,默认关闭)。
|
| 251 |
+
|
| 252 |
+
---
|
| 253 |
+
|
| 254 |
+
## D. 修复方向(最小侵入)
|
| 255 |
+
|
| 256 |
+
### D.1 必改点(按文件)
|
| 257 |
+
|
| 258 |
+
#### 1. `src/autoteam/codex_auth.py` — 接入 add-phone 检测
|
| 259 |
+
|
| 260 |
+
**改动 1:统一抛 RegisterBlocked**
|
| 261 |
+
- 在 `login_codex_via_browser` 主流程的 4 个关键节点插入 `assert_not_blocked(page, step)`:
|
| 262 |
+
- L568 about-you 入口前(`if "about-you" in page.url:` 之前)
|
| 263 |
+
- L612 consent 循环每次 step 开头(`for step in range(10):` 内、`if auth_code: break` 后)
|
| 264 |
+
- L884 等 callback 前(`for _ in range(30):` 之前)
|
| 265 |
+
- L932 personal 拒收 bundle 之前(plan_type 校验之前)
|
| 266 |
+
|
| 267 |
+
- 在 `SessionCodexAuthFlow._detect_step` 加一个 `phone_required` 状态,作为 `email_required` / `password_required` / `code_required` 的并列项:命中 add-phone 时返回 `("phone_required", page.url)` 让 `_advance` 立即终止主流程并触发上层处置。
|
| 268 |
+
|
| 269 |
+
**改动 2:`bundle is None` 携带失败原因**
|
| 270 |
+
- 改 `login_codex_via_browser` 返回签名:`-> bundle | None` 改成 `-> bundle | dict({"error": "phone_required", "url": ...})`,或直接抛 `RegisterBlocked` 让上层 catch。
|
| 271 |
+
- 推荐**抛异常**,理由:`record_failure` 已经按 `RegisterBlocked.is_phone` 分类成熟,新增 `phone_required` 抛异常路径与现有 `oauth_failed` return None 路径解耦,迁移成本最低。
|
| 272 |
+
|
| 273 |
+
#### 2. `src/autoteam/manager.py` — 6 处调用点统一处理
|
| 274 |
+
|
| 275 |
+
每个调用点 wrap 一层 try/except RegisterBlocked:
|
| 276 |
+
|
| 277 |
+
```
|
| 278 |
+
try:
|
| 279 |
+
bundle = login_codex_via_browser(...)
|
| 280 |
+
except RegisterBlocked as blocked:
|
| 281 |
+
if blocked.is_phone:
|
| 282 |
+
record_failure(email, "phone_blocked",
|
| 283 |
+
f"OAuth 阶段触发 add-phone (step={blocked.step})",
|
| 284 |
+
step=blocked.step, source="oauth")
|
| 285 |
+
# 处置策略按调用点不同:
|
| 286 |
+
# - _run_post_register_oauth(personal): delete_account + 退出
|
| 287 |
+
# - _run_post_register_oauth(team): update_account(STATUS_PHONE_REQUIRED 或 AUTH_INVALID)
|
| 288 |
+
# - reinvite_account: _cleanup_team_leftover + STATUS_STANDBY
|
| 289 |
+
# - api.py /api/accounts/login: 返回 409 + "需要人工处理"
|
| 290 |
+
...
|
| 291 |
+
raise
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
#### 3. `src/autoteam/accounts.py` — 新增状态(可选)
|
| 295 |
+
|
| 296 |
+
```
|
| 297 |
+
STATUS_PHONE_REQUIRED = "phone_required" # OAuth 时被风控强制 add-phone,等人工或自动重试
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
或复用 `STATUS_AUTH_INVALID`(commit cf2f7d3 已有)+ register_failures category=`phone_blocked` 区分。**复用现有状态成本更低**,但 UI 显示"auth invalid"语义不准。
|
| 301 |
+
|
| 302 |
+
#### 4. `src/autoteam/register_failures.py` — 扩 category 枚举注释
|
| 303 |
+
|
| 304 |
+
```python
|
| 305 |
+
category: 'phone_blocked' / 'duplicate_exhausted' / 'register_failed' / 'oauth_failed'
|
| 306 |
+
/ 'kick_failed' / 'team_oauth_failed' / 'exception'
|
| 307 |
+
/ 'oauth_phone_blocked' ← 新增,与注册阶段 phone_blocked 区分,便于统计
|
| 308 |
+
```
|
| 309 |
+
|
| 310 |
+
#### 5. `src/autoteam/web` 前端(可选)
|
| 311 |
+
|
| 312 |
+
`web/src/components/Dashboard.vue:381-403` `statusClass / dotClass / statusLabel` 白名单加 `phone_required`(若新增状态),否则跳过此项。
|
| 313 |
+
|
| 314 |
+
### D.2 测试用例(给后续 implement 参考)
|
| 315 |
+
|
| 316 |
+
- **单测**:`tests/unit/test_codex_auth_phone_detection.py`
|
| 317 |
+
- mock `page.url` 为 `https://chat.openai.com/auth/add-phone` → `_advance` 必须返回 `phone_required`
|
| 318 |
+
- mock body 含 "verify your phone" + tel input 可见 → 同上
|
| 319 |
+
- mock body 含 "phone number" 但无 tel input → 不阻断(避免误报,与 invite.py 保持一致)
|
| 320 |
+
- **集成测试**:`tests/integration/test_oauth_phone_blocked_flow.py`
|
| 321 |
+
- mock `login_codex_via_browser` 抛 `RegisterBlocked(is_phone=True)`
|
| 322 |
+
- 验证 `_run_post_register_oauth(leave_workspace=True)` 调用 `delete_account` + `record_failure(category="oauth_phone_blocked")`
|
| 323 |
+
- 验证 `reinvite_account` 调用 `_cleanup_team_leftover` + `record_failure`,**不**留 standby 假态
|
| 324 |
+
- 验证 `/api/accounts/login` 返回 409 + 明确错误体
|
| 325 |
+
- **回归测试**:`tests/unit/test_codex_oauth_no_phone_false_positive.py`
|
| 326 |
+
- 模拟正常 consent 页(含 "ChatGPT" / "Personal" 等文本)→ `detect_phone_verification` 必须返回 False
|
| 327 |
+
- 模拟 about-you 页(含 birthday spinbutton)→ 不触发误报
|
| 328 |
+
|
| 329 |
+
### D.3 与 Issue#6 的边界
|
| 330 |
+
|
| 331 |
+
| 维度 | Issue#4 | Issue#6 |
|
| 332 |
+
|------|---------|---------|
|
| 333 |
+
| 触发场景 | 任意账号 OAuth 阶段被 add-phone 拦截 | 已被踢出 Team 的账号 reinvite 时触发 add-phone |
|
| 334 |
+
| 涉及调用点 | 全部 6 处 `login_codex_via_browser` | 仅 `reinvite_account`(L2466) |
|
| 335 |
+
| 账号当前位置 | 不确定(可能在 Team / 已 kick / 个人池) | **已 kick 出 Team**,且本地 `STATUS_STANDBY` |
|
| 336 |
+
| 处置紧迫度 | 看场景定 | **高**(账号刚 invite 进 Team 又触发,席位被占用,容易死循环) |
|
| 337 |
+
| 修复在哪一层 | codex_auth.py 检测 + 6 处调用方分类处置 | 同 #4 检测,但 reinvite_account 处置必须立即 kick + 锁定该账号不再被 standby 复用 |
|
| 338 |
+
|
| 339 |
+
**结论**:#4 修好后,#6 是 #4 在 `reinvite_account` 这一支的特化分类。建议**两个 issue 在同一 PR 里改完**,避免:
|
| 340 |
+
1. 只修 #4 不管 #6 → reinvite_account 命中后未 kick → 假 standby
|
| 341 |
+
2. 只修 #6 不管 #4 → 其他 5 处调用点继续黑盒失败
|
| 342 |
+
|
| 343 |
+
实施顺序:
|
| 344 |
+
1. `codex_auth.py` 加检测(奠定基础)
|
| 345 |
+
2. `manager._run_post_register_oauth` 处置(personal 路径,最高频)
|
| 346 |
+
3. `manager.reinvite_account` 处置(issue#6 直接收尾)
|
| 347 |
+
4. `manager._check_pending_invites` / `api.post_account_login` 处置
|
| 348 |
+
5. 主号路径 `login_codex_via_session` / `SessionCodexAuthFlow`(可选,主号通常已绑定手机)
|
| 349 |
+
|
| 350 |
+
---
|
| 351 |
+
|
| 352 |
+
## E. 风险与未决问题
|
| 353 |
+
|
| 354 |
+
### E.1 设计层未决
|
| 355 |
+
|
| 356 |
+
1. **是否新增 `STATUS_PHONE_REQUIRED` 状态?**
|
| 357 |
+
- 推荐方案 A:复用 `STATUS_AUTH_INVALID` + `register_failures.category="oauth_phone_blocked"`,改动量最小
|
| 358 |
+
- 风险:语义混淆,前端 UI 显示"auth_invalid"会误导
|
| 359 |
+
- 替代方案 B:新增独立状态,改动 7-8 个文件(accounts.py / api.py status summary / web/Dashboard.vue / reconcile / sync_account_states / standby probe / cmd_check / 测试)。**Round-3 batch 已经新增过 `STATUS_AUTH_INVALID` 和 `STATUS_ORPHAN`,前端白名单和 reconcile 逻辑还没补全(CHANGELOG round-3 backlog 有提)**,不建议本次再加状态;先用 A 方案 + 后续随状态扩展统一补
|
| 360 |
+
2. **reinvite_account 命中 add-phone 时,是否要把账号永久标"不再复用"?**
|
| 361 |
+
- 当前 standby 池设计:24h 内已探测过的跳过(commit d6082ad)
|
| 362 |
+
- 风险:phone-block 是确定性风控,24h 后再试还是会撞;但完全黑名单又可能误杀(临时风控窗口过去后该账号其实可用)
|
| 363 |
+
- 建议:第一次撞 add-phone → STATUS_AUTH_INVALID,**让 reconcile 处理**(reconcile_anomalies 已有 auth_invalid → KICK 分支);不进 standby 池
|
| 364 |
+
3. **检测器误报怎么办?**
|
| 365 |
+
- `invite.py:69-87` 的检测规则已半年生产验证,但 OAuth 流程页面文本与注册流程**不完全相同**(consent 页可能含 "phone" 提示文字)
|
| 366 |
+
- 建议先全量审查 OAuth 流程的截图(`screenshots/codex_04_*.png`)是否含"phone"文本,若有需要在 `_PHONE_TEXT_HINTS` 上加 OAuth 专属规则
|
| 367 |
+
- 风险等级:低(URL 命中是强信号,文本命中要 tel input 配合,假阳概率小)
|
| 368 |
+
|
| 369 |
+
### E.2 实施层风险
|
| 370 |
+
|
| 371 |
+
1. **`SessionCodexAuthFlow._advance` 的 phone_required 处置链路**
|
| 372 |
+
- 该 Flow 用于主号,目前没有"中断主号 OAuth"的概念,只有 email/password/code required 三个用户输入态
|
| 373 |
+
- 如果主号撞 add-phone,UI 怎么提示?当前没有"主号需要绑手机"的展示路径
|
| 374 |
+
- 建议:主号路径暂时只在日志里 ERROR + 抛异常,不接 UI;主号绑过手机后通常不会再撞,这个 case 极罕见
|
| 375 |
+
2. **重试逻辑会不会进一步触发风控?**
|
| 376 |
+
- 当前 reinvite_account 失败 → STATUS_STANDBY → 24h 后又被选中 reinvite
|
| 377 |
+
- 加 add-phone 检测 + STATUS_AUTH_INVALID 后,reconcile 会 KICK,不再循环 → **降低**风控触发频率
|
| 378 |
+
3. **detect_phone_verification 在 docker headless 浏览器下的可靠性**
|
| 379 |
+
- `page.inner_text("body")` 和 `page.locator(...).is_visible` 在 headless 模式应该一致,但 OpenAI 偶尔 A/B test 不同 add-phone 页面布局
|
| 380 |
+
- 建议:命中后立即调 `_screenshot(page, f"codex_phone_blocked_{step}.png")` 留证据,便于后续回放调试
|
| 381 |
+
4. **fix-personal 的"已退出 Team"账号撞 add-phone**
|
| 382 |
+
- 这是**最严重**的死路径:账号已 kick → personal OAuth 撞 add-phone → bundle None → 当前流程 `delete_account` + `record_failure("oauth_failed")`
|
| 383 |
+
- 修复后:`record_failure("oauth_phone_blocked")` 分类,但**账号还是删**(kick 已经发生,留下来没意���)
|
| 384 |
+
- 风险:用户做 fill-personal 时若一批 4 个号全撞 add-phone,4 个邮箱 + 4 个 invite quota 直接打水漂。建议加**早停**:连续 2 个号撞 add-phone → 整批暂停,提示用户检查 IP/proxy
|
| 385 |
+
|
| 386 |
+
### E.3 数据层未决
|
| 387 |
+
|
| 388 |
+
- `register_failures.json` 当前只有 1 例 oauth_failed,样本不足以确认 add-phone 命中率
|
| 389 |
+
- 建议先把检测器加上、跑 1 周生产观察 `oauth_phone_blocked` 计数,再决定要不要加重试/IP 切换逻辑
|
| 390 |
+
- **当前 11 条记录中 8 条是 `Page.goto: net::ERR_ABORTED` / `Page.content: navigating` / `Page.screenshot: Target closed`**,这是 Playwright 浏览器异常**不是 OAuth 失败本身**,跟 add-phone 无关,但说明用户运行环境的浏览器稳定性也有问题(可能是另一个独立 issue)
|
| 391 |
+
|
| 392 |
+
---
|
| 393 |
+
|
| 394 |
+
## 附录:本次调研工具使用情况
|
| 395 |
+
|
| 396 |
+
| 工具 | 使用情况 | 备注 |
|
| 397 |
+
|------|----------|------|
|
| 398 |
+
| serena | 项目未注册到 serena, 退化用 Grep/Read | 可后续 `mcp__metamcp__serena__activate_project` 时手动加 |
|
| 399 |
+
| abcoder | AutoTeam 不在已索引仓库列表(只有 Inkforge/bentodesk/devhub/web) | 同上 |
|
| 400 |
+
| gitnexus | 未配置该项目的图谱索引 | 调用链需手工 grep 构建 |
|
| 401 |
+
| exa | **额度耗尽** ❗(`web_search_exa error 402: exceeded your credits limit`),改用 grok-search | 影响外部资料调研深度,但 grok 给出了 OpenAI Community + reddit + help.openai.com 6 个高质量来源 |
|
| 402 |
+
| grok-search | ✅ 命中 6 篇(2 个 sessions) | 关键发现:OpenAI 用 Arkose Labs,广告拦截器/IP 黑名单会触发 enforcement_failed |
|
| 403 |
+
| Read/Grep/Glob | 主力 | 全文档 100% 走代码事实,无臆测 |
|
| 404 |
+
|
| 405 |
+
调研涉及文件(主要):
|
| 406 |
+
- `D:/Desktop/AutoTeam/src/autoteam/codex_auth.py`
|
| 407 |
+
- `D:/Desktop/AutoTeam/src/autoteam/manager.py`
|
| 408 |
+
- `D:/Desktop/AutoTeam/src/autoteam/manual_account.py`
|
| 409 |
+
- `D:/Desktop/AutoTeam/src/autoteam/api.py`
|
| 410 |
+
- `D:/Desktop/AutoTeam/src/autoteam/invite.py`
|
| 411 |
+
- `D:/Desktop/AutoTeam/src/autoteam/accounts.py`
|
| 412 |
+
- `D:/Desktop/AutoTeam/src/autoteam/auth_storage.py`
|
| 413 |
+
- `D:/Desktop/AutoTeam/src/autoteam/register_failures.py`
|
| 414 |
+
- `D:/Desktop/AutoTeam/src/autoteam/setup_wizard.py`
|
| 415 |
+
- `D:/Desktop/AutoTeam/CHANGELOG.md`
|
| 416 |
+
- `D:/Desktop/AutoTeam/register_failures.json`(11 条历史失败记录)
|
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Issue#5 Playwright async/sync 一致性审计
|
| 2 |
+
|
| 3 |
+
> 调研时间:2026-04-26
|
| 4 |
+
> 调研范围:全项目 Playwright 使用面 + context7 拉取的官方 async/sync 边界
|
| 5 |
+
> 结论:**用户描述的"async 函数里大量误用 sync API"在 AutoTeam 源代码层不成立** — 项目根本没用 asyncio,sync_playwright 全部跑在普通同步线程里,完全合规
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## A. Playwright 官方 API 对比(context7 输出摘要)
|
| 10 |
+
|
| 11 |
+
来源:`/websites/playwright_dev_python` + `/microsoft/playwright-python`(Context7 高分 doc)
|
| 12 |
+
|
| 13 |
+
### A.1 Sync API 关键函数
|
| 14 |
+
|
| 15 |
+
| API | 调用形式 | 入口 |
|
| 16 |
+
|---|---|---|
|
| 17 |
+
| `sync_playwright()` | 上下文管理器 / `.start()` | `from playwright.sync_api import sync_playwright` |
|
| 18 |
+
| `BrowserType.launch()` | 阻塞返回 `Browser` | `p.chromium.launch()` |
|
| 19 |
+
| `Browser.new_context()` | 阻塞返回 `BrowserContext` | `browser.new_context(...)` |
|
| 20 |
+
| `BrowserContext.new_page()` | 阻塞返回 `Page` | `context.new_page()` |
|
| 21 |
+
| `Page.goto / click / fill / locator / evaluate / wait_for_*` | 全阻塞,无 `await` | 同上 |
|
| 22 |
+
|
| 23 |
+
```python
|
| 24 |
+
# 官方 sync 范式
|
| 25 |
+
from playwright.sync_api import sync_playwright
|
| 26 |
+
with sync_playwright() as p:
|
| 27 |
+
browser = p.chromium.launch()
|
| 28 |
+
page = browser.new_page()
|
| 29 |
+
page.goto("https://playwright.dev")
|
| 30 |
+
print(page.title())
|
| 31 |
+
browser.close()
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
### A.2 Async API 关键函数
|
| 35 |
+
|
| 36 |
+
| API | 调用形式 | 入口 |
|
| 37 |
+
|---|---|---|
|
| 38 |
+
| `async_playwright()` | `async with` / `await ....start()` | `from playwright.async_api import async_playwright` |
|
| 39 |
+
| `await BrowserType.launch()` | 协程 | `await p.chromium.launch()` |
|
| 40 |
+
| `await Browser.new_context()` | 协程 | |
|
| 41 |
+
| `await Page.goto / click / fill / evaluate / ...` | 全部协程 | |
|
| 42 |
+
|
| 43 |
+
```python
|
| 44 |
+
# 官方 async 范式
|
| 45 |
+
import asyncio
|
| 46 |
+
from playwright.async_api import async_playwright
|
| 47 |
+
|
| 48 |
+
async def main():
|
| 49 |
+
async with async_playwright() as p:
|
| 50 |
+
browser = await p.chromium.launch()
|
| 51 |
+
page = await browser.new_page()
|
| 52 |
+
await page.goto("https://playwright.dev")
|
| 53 |
+
print(await page.title())
|
| 54 |
+
await browser.close()
|
| 55 |
+
|
| 56 |
+
asyncio.run(main())
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### A.3 不可混用的边界(官方明确警告)
|
| 60 |
+
|
| 61 |
+
> *"Playwright supports two variations of the API: synchronous and asynchronous. **If your modern project uses asyncio, you should use async API.**"*
|
| 62 |
+
> — playwright.dev/python/docs/library
|
| 63 |
+
|
| 64 |
+
> *"Playwright's API is not thread-safe. If you are using Playwright in a multi-threaded environment, you should create a Playwright instance per thread."*
|
| 65 |
+
> — playwright.dev/python/docs/library (Threading 小节)
|
| 66 |
+
|
| 67 |
+
**核心约束矩阵**:
|
| 68 |
+
|
| 69 |
+
| 调用上下文 | 允许 sync_playwright? | 允许 async_playwright? | 备注 |
|
| 70 |
+
|---|---|---|---|
|
| 71 |
+
| 普通 `def` 函数(无 asyncio loop) | ✅ | ❌ | 标准 sync 用法 |
|
| 72 |
+
| 子线程(无 loop) | ✅ 但**每线程独立实例** | ❌ | 不能跨线程共享 page/browser |
|
| 73 |
+
| `async def` 函数(asyncio loop 内) | ❌ **会抛 SyncBecauseInsideAsyncLoop** | ✅ | 这是用户描述的"那个错" |
|
| 74 |
+
| FastAPI `def` 路由 | ✅(FastAPI 把 sync 路由转线程池) | ❌ | 路由本身在 anyio 工作线程跑,不在主 loop |
|
| 75 |
+
| FastAPI `async def` 路由 | ❌ | ✅ | 直接在 loop 里 |
|
| 76 |
+
| `threading.Thread(target=fn)` 里的 fn | ✅ | ⚠ 需新建 loop | AutoTeam 当前用法 |
|
| 77 |
+
|
| 78 |
+
**典型错误信号**:
|
| 79 |
+
```
|
| 80 |
+
Error: It looks like you are using Playwright Sync API inside the asyncio loop.
|
| 81 |
+
Please use the Async API instead.
|
| 82 |
+
```
|
| 83 |
+
触发条件:`asyncio.run() / loop.run_until_complete()` 调用栈里出现 `sync_playwright()`。
|
| 84 |
+
|
| 85 |
+
---
|
| 86 |
+
|
| 87 |
+
## B. 全项目 Playwright 使用现状
|
| 88 |
+
|
| 89 |
+
### B.1 import 清单(完整,5 处)
|
| 90 |
+
|
| 91 |
+
| 文件 | 行 | 形式 | 上下文 |
|
| 92 |
+
|---|---|---|---|
|
| 93 |
+
| `src/autoteam/chatgpt_api.py` | 12 | 顶层 `from playwright.sync_api import sync_playwright` | 模块顶层 import |
|
| 94 |
+
| `src/autoteam/codex_auth.py` | 13 | 顶层 `from playwright.sync_api import sync_playwright` | 模块顶层 import |
|
| 95 |
+
| `src/autoteam/invite.py` | 24 | 顶层 `from playwright.sync_api import sync_playwright` | 模块顶层 import |
|
| 96 |
+
| `src/autoteam/manager.py` | 1491 | **函数内** `from playwright.sync_api import sync_playwright` | `_complete_registration` 里 |
|
| 97 |
+
| `src/autoteam/manager.py` | 1969 | **函数内** `from playwright.sync_api import sync_playwright` | `_register_direct_once` 里 |
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
$ grep -rn "from playwright" D:/Desktop/AutoTeam/src
|
| 101 |
+
src/autoteam/chatgpt_api.py:12: from playwright.sync_api import sync_playwright
|
| 102 |
+
src/autoteam/codex_auth.py:13: from playwright.sync_api import sync_playwright
|
| 103 |
+
src/autoteam/invite.py:24: from playwright.sync_api import sync_playwright
|
| 104 |
+
src/autoteam/manager.py:1491: from playwright.sync_api import sync_playwright
|
| 105 |
+
src/autoteam/manager.py:1969: from playwright.sync_api import sync_playwright
|
| 106 |
+
|
| 107 |
+
$ grep -rn "from playwright.async_api" D:/Desktop/AutoTeam
|
| 108 |
+
# (0 行 — 项目根本没引用 async_api)
|
| 109 |
+
|
| 110 |
+
$ grep -rn "async_playwright" D:/Desktop/AutoTeam
|
| 111 |
+
# (0 行)
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### B.2 async / sync 各模块归属
|
| 115 |
+
|
| 116 |
+
#### B.2.1 `async def` 函数清单(完整,1 处)
|
| 117 |
+
|
| 118 |
+
```bash
|
| 119 |
+
$ grep -rn "^async def" D:/Desktop/AutoTeam/src
|
| 120 |
+
src/autoteam/api.py:35: async def auth_middleware(request: Request, call_next):
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
**唯一一处 `async def`** 是 FastAPI 鉴权中间件:
|
| 124 |
+
```python
|
| 125 |
+
@app.middleware("http")
|
| 126 |
+
async def auth_middleware(request: Request, call_next):
|
| 127 |
+
path = request.url.path
|
| 128 |
+
if not path.startswith("/api/") or path in _AUTH_SKIP_PATHS:
|
| 129 |
+
return await call_next(request)
|
| 130 |
+
if not API_KEY:
|
| 131 |
+
return await call_next(request)
|
| 132 |
+
auth_header = request.headers.get("authorization", "")
|
| 133 |
+
if auth_header.startswith("Bearer "):
|
| 134 |
+
token = auth_header[7:]
|
| 135 |
+
else:
|
| 136 |
+
token = request.query_params.get("key", "")
|
| 137 |
+
if token != API_KEY:
|
| 138 |
+
return JSONResponse(status_code=401, content={"detail": "未授权,请提供有效的 API Key"})
|
| 139 |
+
return await call_next(request)
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
**该函数体里没有 sync_playwright / async_playwright 任何引用** — 只是处理 Header 字符串、调 `call_next`。完全合规。
|
| 143 |
+
|
| 144 |
+
#### B.2.2 `async with` / `asyncio` 全项目搜索
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
$ grep -rn "async with" D:/Desktop/AutoTeam/src # 0 行
|
| 148 |
+
$ grep -rn "\basyncio\b" D:/Desktop/AutoTeam/src # 0 行
|
| 149 |
+
$ grep -rn "import asyncio" D:/Desktop/AutoTeam # 0 行(包括 tests)
|
| 150 |
+
$ grep -rn "loop\.run_until_complete" D:/Desktop/AutoTeam # 0 行
|
| 151 |
+
$ grep -rn "asyncio\.run" D:/Desktop/AutoTeam # 0 行
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
**项目压根没用 asyncio**。FastAPI 也是用 sync 路由,uvicorn 起 loop 但路由全跑在 anyio 工作线程。
|
| 155 |
+
|
| 156 |
+
#### B.2.3 FastAPI 路由全部是 sync `def`
|
| 157 |
+
|
| 158 |
+
```bash
|
| 159 |
+
$ grep -nE "^@app\.(get|post|put|delete|patch)" src/autoteam/api.py | wc -l
|
| 160 |
+
50
|
| 161 |
+
|
| 162 |
+
$ grep -nE "^async def (post|get|put|delete|patch|check|set|serve)" src/autoteam/api.py | wc -l
|
| 163 |
+
0
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
**50 个路由,0 个 async** — 全是 `def` 同步函数。FastAPI 会自动把 sync 路由调度到 anyio threadpool,不在 asyncio 主 loop 上执行,因此 `sync_playwright` 在路由里调用**完全合规**。
|
| 167 |
+
|
| 168 |
+
#### B.2.4 Playwright 调用上下文(全部 5 处)
|
| 169 |
+
|
| 170 |
+
| 文件:行 | 上下文 | 是否合规 |
|
| 171 |
+
|---|---|---|
|
| 172 |
+
| `chatgpt_api.py:118` | `class ChatGPTAPI._launch_browser` 实例方法,被 sync 路由 / 后台 thread 调用 | ✅ 合规 |
|
| 173 |
+
| `codex_auth.py:271` | `with sync_playwright() as p:` 在 sync `def` 函数 `_run_oauth_flow` 内 | ✅ 合规 |
|
| 174 |
+
| `invite.py:552` | `with sync_playwright() as p:` 在 sync `def register_with_invite` 内 | ✅ 合规 |
|
| 175 |
+
| `manager.py:1496` | `with sync_playwright() as p:` 在 sync `def _complete_registration` 内 | ✅ 合规 |
|
| 176 |
+
| `manager.py:1976` | `with sync_playwright() as p:` 在 sync `def _register_direct_once` 内 | ✅ 合规 |
|
| 177 |
+
|
| 178 |
+
**所有 5 处全部在 sync 函数中**,且这些函数最终被 `_PlaywrightExecutor` 专用线程或后台任务线程调用 — **不接触 asyncio loop**。
|
| 179 |
+
|
| 180 |
+
#### B.2.5 项目自身的 Playwright 线程模型(api.py:155-229)
|
| 181 |
+
|
| 182 |
+
`src/autoteam/api.py:173-227` 实现了一个 `_PlaywrightExecutor` 单例:
|
| 183 |
+
|
| 184 |
+
```python
|
| 185 |
+
class _PlaywrightExecutor:
|
| 186 |
+
"""将 Playwright 操作派发到专用线程执行,避免跨线程错误"""
|
| 187 |
+
|
| 188 |
+
def __init__(self):
|
| 189 |
+
self._queue: _queue.Queue = _queue.Queue()
|
| 190 |
+
self._thread: threading.Thread | None = None
|
| 191 |
+
|
| 192 |
+
def _worker(self):
|
| 193 |
+
while True:
|
| 194 |
+
item = self._queue.get()
|
| 195 |
+
if item is None: break
|
| 196 |
+
func, args, kwargs, result_event, result_holder = item
|
| 197 |
+
try:
|
| 198 |
+
result_holder["result"] = func(*args, **kwargs)
|
| 199 |
+
except Exception as e:
|
| 200 |
+
result_holder["error"] = e
|
| 201 |
+
finally:
|
| 202 |
+
result_event.set()
|
| 203 |
+
|
| 204 |
+
def run_with_timeout(self, timeout, func, *args, **kwargs):
|
| 205 |
+
# 提交到专用线程,主线程 wait 结果
|
| 206 |
+
self._queue.put((func, args, kwargs, result_event, result_holder))
|
| 207 |
+
...
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
加上 `_playwright_lock = threading.Lock()` 串行所有任务,这套**正是 Playwright 官方文档建议的"sync API + 单线程串行"模式** — 不仅没踩坑,反而是教科书级实现。
|
| 211 |
+
|
| 212 |
+
#### B.2.6 `page.evaluate(""" async () => { ... } """)` 的"假阳性"
|
| 213 |
+
|
| 214 |
+
这是审计里**最容易误判的点**:
|
| 215 |
+
|
| 216 |
+
```bash
|
| 217 |
+
$ grep -rn '"""async' D:/Desktop/AutoTeam/src
|
| 218 |
+
chatgpt_api.py:957: data = self.page.evaluate("""async (accessToken) => {...}""")
|
| 219 |
+
chatgpt_api.py:1195: result = self.page.evaluate("""async ([accountId, accessToken]) => {...}""")
|
| 220 |
+
chatgpt_api.py:1230: result = self.page.evaluate("""async () => { const resp = await fetch(...); ... }""")
|
| 221 |
+
chatgpt_api.py:1287: js_code = """async ([method, url, headers, body]) => {...}"""
|
| 222 |
+
api.py:709: "async () => { const r = await fetch(...); ... }"
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
**这些 `async () => { ... }` 全部是字符串内容**,通过 `page.evaluate(js_string)` 注入到**浏览器侧 JavaScript 上下文**执行 — 不是 Python 协程。Python 这一侧 `page.evaluate(...)` 仍然是同步阻塞调用(返回 JS Promise resolve 后的 value)。
|
| 226 |
+
|
| 227 |
+
这是 sync_playwright 的标准用法 — 浏览器 `fetch()` 本来就是 JS Promise/async,在浏览器里写 `async/await` 才能 `await fetch()`,Python 这边等 evaluate 返回即可。
|
| 228 |
+
|
| 229 |
+
**完全合规,不是 bug。** 但用户口头描述"async 里用 sync API"很可能就是看到这种 evaluate 字符串误判的。
|
| 230 |
+
|
| 231 |
+
#### B.2.7 用户提到的不存在文件
|
| 232 |
+
|
| 233 |
+
用户原话:"重点文件:src/autoteam/chatgpt_session.py, codex_oauth.py, login_*.py, browser_manager.py 等"。
|
| 234 |
+
|
| 235 |
+
**实际盘点 src/autoteam/ 全部 .py 文件**:
|
| 236 |
+
```
|
| 237 |
+
__init__.py account_ops.py accounts.py admin_state.py
|
| 238 |
+
api.py auth_storage.py cancel_signal.py chatgpt_api.py
|
| 239 |
+
cloudmail.py codex_auth.py config.py cpa_sync.py
|
| 240 |
+
display.py identity.py invite.py manager.py
|
| 241 |
+
manual_account.py register_failures.py runtime_config.py
|
| 242 |
+
setup_wizard.py textio.py __main__.py
|
| 243 |
+
mail/{base.py, cf_temp_email.py, maillab.py}
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
**`chatgpt_session.py` / `browser_manager.py` / `login_*.py` 不存在**(可能是用户记错文件名,或者在描述其它项目)。最接近的是 `chatgpt_api.py` + `codex_auth.py` + `invite.py`,已在上文逐一审计。
|
| 247 |
+
|
| 248 |
+
---
|
| 249 |
+
|
| 250 |
+
## C. 误用清单(file:line + 当前 → 应改)
|
| 251 |
+
|
| 252 |
+
> **结果:0 处误用**
|
| 253 |
+
|
| 254 |
+
| # | file:line | 当前代码 | 应改为 | 优先级 |
|
| 255 |
+
|---|---|---|---|---|
|
| 256 |
+
| — | — | — | — | — |
|
| 257 |
+
|
| 258 |
+
经过全量扫描:
|
| 259 |
+
- 0 处 `async_playwright` 引用
|
| 260 |
+
- 0 处 `asyncio` import
|
| 261 |
+
- 0 处 `async with`
|
| 262 |
+
- 1 处 `async def`(`auth_middleware`,内部不调 playwright)
|
| 263 |
+
- 5 处 `sync_playwright` 调用,全部在 sync `def` 上下文 + 已有专用线程隔离
|
| 264 |
+
|
| 265 |
+
**没有需要改的位点**。
|
| 266 |
+
|
| 267 |
+
---
|
| 268 |
+
|
| 269 |
+
## D. 修复策略
|
| 270 |
+
|
| 271 |
+
### D.1 优先级
|
| 272 |
+
|
| 273 |
+
**Priority 0 - 不需要修复**
|
| 274 |
+
|
| 275 |
+
源代码层不存在 async/sync 混用。但有几条**预防性硬化**值得做(下面 D.2、D.3)。
|
| 276 |
+
|
| 277 |
+
### D.2 重构步骤(预防性,非必须)
|
| 278 |
+
|
| 279 |
+
虽然现状没问题,但有 3 项可选增强:
|
| 280 |
+
|
| 281 |
+
#### D.2.1 把 manager.py 的函数内 import 上提到模块顶层
|
| 282 |
+
|
| 283 |
+
```python
|
| 284 |
+
# 现状(manager.py:1491, 1969)
|
| 285 |
+
def _complete_registration(...):
|
| 286 |
+
from playwright.sync_api import sync_playwright # ← 函数内 import
|
| 287 |
+
...
|
| 288 |
+
def _register_direct_once(...):
|
| 289 |
+
from playwright.sync_api import sync_playwright # ← 函数内 import
|
| 290 |
+
...
|
| 291 |
+
|
| 292 |
+
# 建议(顶层 import,与其它 4 个文件风格一致)
|
| 293 |
+
from playwright.sync_api import sync_playwright
|
| 294 |
+
|
| 295 |
+
def _complete_registration(...):
|
| 296 |
+
...
|
| 297 |
+
def _register_direct_once(...):
|
| 298 |
+
...
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
收益:风格一致,IDE 跳转更顺,启动时一次性失败比"按按钮才发现 import 错"更好。
|
| 302 |
+
风险:可能因为循环 import 才被推到函数内的 — 需先 grep 确认 `manager.py` 不会被 `playwright` 间接 import 反向触发。
|
| 303 |
+
|
| 304 |
+
#### D.2.2 在 `_PlaywrightExecutor` 加官方文档式断言
|
| 305 |
+
|
| 306 |
+
```python
|
| 307 |
+
class _PlaywrightExecutor:
|
| 308 |
+
def _worker(self):
|
| 309 |
+
# 启动时打一句日志,便于运维确认线程隔离正常
|
| 310 |
+
import threading
|
| 311 |
+
logger.info("[Playwright] worker thread started: %s", threading.current_thread().name)
|
| 312 |
+
...
|
| 313 |
+
```
|
| 314 |
+
|
| 315 |
+
#### D.2.3 引入 `playwright_in_async_loop` 守卫(终极防御)
|
| 316 |
+
|
| 317 |
+
如果未来有人手贱写了 `async def` 路由 + 内部直接 `sync_playwright`,我们应当让它在第一次执行时炸响,而不是悄悄触发 `Sync API inside the asyncio loop`:
|
| 318 |
+
|
| 319 |
+
```python
|
| 320 |
+
# autoteam/_playwright_guard.py(新文件)
|
| 321 |
+
import asyncio
|
| 322 |
+
import functools
|
| 323 |
+
from playwright.sync_api import sync_playwright as _orig_sync_playwright
|
| 324 |
+
|
| 325 |
+
@functools.wraps(_orig_sync_playwright)
|
| 326 |
+
def sync_playwright():
|
| 327 |
+
try:
|
| 328 |
+
loop = asyncio.get_running_loop()
|
| 329 |
+
except RuntimeError:
|
| 330 |
+
loop = None
|
| 331 |
+
if loop is not None:
|
| 332 |
+
raise RuntimeError(
|
| 333 |
+
"禁止在 asyncio loop 内调用 sync_playwright — "
|
| 334 |
+
"改用 await asyncio.to_thread(...) 或重构为 async_playwright"
|
| 335 |
+
)
|
| 336 |
+
return _orig_sync_playwright()
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
然后在 5 个调用点统一 `from autoteam._playwright_guard import sync_playwright`。出现误用立刻抛错,堆栈一目了然。
|
| 340 |
+
|
| 341 |
+
### D.3 测试要点
|
| 342 |
+
|
| 343 |
+
#### D.3.1 静态守卫测试
|
| 344 |
+
|
| 345 |
+
```python
|
| 346 |
+
# tests/unit/test_no_playwright_in_async_def.py
|
| 347 |
+
import ast
|
| 348 |
+
from pathlib import Path
|
| 349 |
+
|
| 350 |
+
SRC = Path(__file__).parent.parent.parent / "src"
|
| 351 |
+
|
| 352 |
+
def _walk(node, parent_async):
|
| 353 |
+
if isinstance(node, ast.AsyncFunctionDef):
|
| 354 |
+
parent_async = True
|
| 355 |
+
if parent_async and isinstance(node, (ast.Call, ast.Attribute, ast.Name)):
|
| 356 |
+
text = ast.unparse(node)
|
| 357 |
+
if "sync_playwright" in text:
|
| 358 |
+
yield node.lineno, text
|
| 359 |
+
for child in ast.iter_child_nodes(node):
|
| 360 |
+
yield from _walk(child, parent_async)
|
| 361 |
+
|
| 362 |
+
def test_no_sync_playwright_inside_async_def():
|
| 363 |
+
bad = []
|
| 364 |
+
for p in SRC.rglob("*.py"):
|
| 365 |
+
try:
|
| 366 |
+
tree = ast.parse(p.read_text(encoding="utf-8"))
|
| 367 |
+
except SyntaxError:
|
| 368 |
+
continue
|
| 369 |
+
for lineno, text in _walk(tree, False):
|
| 370 |
+
bad.append(f"{p}:{lineno} {text}")
|
| 371 |
+
assert not bad, "async def 函数内调用了 sync_playwright:\n" + "\n".join(bad)
|
| 372 |
+
```
|
| 373 |
+
|
| 374 |
+
#### D.3.2 运行时守卫测试
|
| 375 |
+
|
| 376 |
+
```python
|
| 377 |
+
# tests/unit/test_playwright_guard.py
|
| 378 |
+
import asyncio
|
| 379 |
+
import pytest
|
| 380 |
+
|
| 381 |
+
from autoteam._playwright_guard import sync_playwright
|
| 382 |
+
|
| 383 |
+
@pytest.mark.asyncio
|
| 384 |
+
async def test_sync_playwright_blocked_in_async_loop():
|
| 385 |
+
with pytest.raises(RuntimeError, match="禁止在 asyncio loop"):
|
| 386 |
+
sync_playwright()
|
| 387 |
+
|
| 388 |
+
def test_sync_playwright_works_in_sync_context():
|
| 389 |
+
# 仅做 import / 调用前置检查不抛即可,不真启 browser
|
| 390 |
+
pass
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
#### D.3.3 集成 smoke
|
| 394 |
+
|
| 395 |
+
`/api/tasks/fill` `/api/tasks/check` 等触发 sync_playwright 的端点已有现存测试;只需补一条:**确认从 FastAPI sync 路由到 _PlaywrightExecutor 的链路在 uvicorn loop 下不会抛 "Sync API inside asyncio loop"**。可以通过 `httpx.AsyncClient` 调一次 `/api/auth/check`(轻量端点)+ `/api/tasks/check` 完成。
|
| 396 |
+
|
| 397 |
+
---
|
| 398 |
+
|
| 399 |
+
## E. 回归风险
|
| 400 |
+
|
| 401 |
+
### E.1 当前不动代码的风险:0
|
| 402 |
+
|
| 403 |
+
不修任何代码 = 当前架构合规,无回归。
|
| 404 |
+
|
| 405 |
+
### E.2 应用 D.2 / D.3 的风险评估
|
| 406 |
+
|
| 407 |
+
| 改动 | 风险等级 | 主要风险点 |
|
| 408 |
+
|---|---|---|
|
| 409 |
+
| **D.2.1** 顶层 import | 🟡 低 | 循环 import 导致启动失败 — 需先 grep 验证 |
|
| 410 |
+
| **D.2.2** 启动日志 | 🟢 极低 | 多一行 log 而已 |
|
| 411 |
+
| **D.2.3** runtime guard | 🟡 中 | 必须 5 处全部替换 — 漏一处就回到老路;另需在 tests 里 unstub `_orig_sync_playwright` |
|
| 412 |
+
| **D.3.1** AST 静态测试 | 🟢 极低 | 误报概率低,纯静态 |
|
| 413 |
+
| **D.3.2** runtime guard 测试 | 🟢 低 | 需要 pytest-asyncio 依赖 |
|
| 414 |
+
|
| 415 |
+
### E.3 用户描述与现实的偏差
|
| 416 |
+
|
| 417 |
+
用户说"在 playwright 中大量出现一堆 async 里用 sync 的 api 的问题",**这描述与 AutoTeam 当前代码不符**。可能性:
|
| 418 |
+
1. 用户看的是**某次运行时报错日志**,但那段日志可能来自其他库(比如 anyio 内部、或 FastAPI 框架自身)
|
| 419 |
+
2. 用户把 `page.evaluate("""async () => {...}""")` 的 JS async 字符串误读成 Python async 函数
|
| 420 |
+
3. 用户看的是**别的项目** / 历史版本(本仓库 git log 也没出现过 async_playwright)
|
| 421 |
+
4. 用户准备好了"接下来要做的事"(比如想引入并发抓取),提前打预防针
|
| 422 |
+
|
| 423 |
+
**建议下一步**:让用户提供具体的报错堆栈或文件:行号 — 凭描述无法定位,代码层面是干净的。
|
| 424 |
+
|
| 425 |
+
---
|
| 426 |
+
|
| 427 |
+
## 总结
|
| 428 |
+
|
| 429 |
+
| 维度 | 结论 |
|
| 430 |
+
|---|---|
|
| 431 |
+
| 全项目 `async_playwright` 引用数 | **0** |
|
| 432 |
+
| 全项目 `asyncio` 引用数 | **0** |
|
| 433 |
+
| 全项目 `async def` 数 | **1**(FastAPI auth_middleware,与 playwright 无关) |
|
| 434 |
+
| 全项目 `sync_playwright` 调用数 | **5**,全部在 sync `def` + 专用线程 |
|
| 435 |
+
| 误用 / 混用位点 | **0** |
|
| 436 |
+
| 当前是否需要修复? | **❌ 不需要** |
|
| 437 |
+
| 建议增强(可选) | 顶层 import 统一 + runtime guard + AST 静态测试三连 |
|
| 438 |
+
| 用户描述与现实差距 | 可能误读 `page.evaluate("async () => ...")` JS 字符串;请提供具体堆栈定位 |
|
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Issue#6 — 邀请→进 workspace→quota=0→踢出 401→重 OAuth add phone 研究报告
|
| 2 |
+
|
| 3 |
+
## A. 用户报告解构
|
| 4 |
+
|
| 5 |
+
用户原话(摘录):
|
| 6 |
+
> "手动试了下在 team 里邀请,然后邮件点击链接完成注册,收个邮箱验证码倒是能进 team 了。CPA OAuth 登录再收个验证码登录子号没让选个人还是工作空间,直接就是工作空间了。(还是不显示额度的那种,实际也没额度,调用什么都是 429 没额度)team 踢掉之后,子号就 401 了,重新 OAuth 还是需要手机"
|
| 7 |
+
|
| 8 |
+
拆为 4 子症状:
|
| 9 |
+
|
| 10 |
+
| 编号 | 症状 | 严重程度 |
|
| 11 |
+
|---|---|---|
|
| 12 |
+
| **A** | CPA OAuth 没让选 personal/workspace,直接进 workspace | 中(可能是设计意图) |
|
| 13 |
+
| **B** | workspace 模式下 quota=0,调用 429 | 高(账号入池但完全不可用) |
|
| 14 |
+
| **C** | team 踢出后子号 401 | 高(状态机没正确识别"被人踢") |
|
| 15 |
+
| **D** | 重新 OAuth 时强制 add phone | 高(踢出+重 OAuth 风控) |
|
| 16 |
+
|
| 17 |
+
## B. 代码现状
|
| 18 |
+
|
| 19 |
+
### B.1 关键符号清单
|
| 20 |
+
|
| 21 |
+
| 文件:行号 | 签名 | 作用 |
|
| 22 |
+
|---|---|---|
|
| 23 |
+
| `src/autoteam/codex_auth.py:250-932` | `login_codex_via_browser(email, password, mail_client=None, *, use_personal=False)` | OAuth 主流程,包含 step-0 注入 `_account` cookie + step-4 workspace 选择循环 |
|
| 24 |
+
| `src/autoteam/codex_auth.py:262-263` | `chatgpt_account_id = "" if use_personal else get_chatgpt_account_id()` | use_personal 决定要不要注入 Team workspace cookie |
|
| 25 |
+
| `src/autoteam/codex_auth.py:295-318` | step-0:Team 模式注入 `_account` cookie 引导 OAuth 进 Team workspace | personal 模式跳过 |
|
| 26 |
+
| `src/autoteam/codex_auth.py:618-733` | step-4 循环:检测"选择一个工作空间"页面,自动点击 Team / Personal | use_personal 时点 Personal,否则点 Team workspace_name |
|
| 27 |
+
| `src/autoteam/codex_auth.py:916-930` | personal 模式 plan_type 强校验 | plan_type != "free" 时拒收 bundle(防止 Team 被默认选中颁发 Team token) |
|
| 28 |
+
| `src/autoteam/codex_auth.py:1618-1700` | `check_codex_quota(access_token, account_id=None)` | 调 wham/usage,返回 ok / exhausted / auth_error / network_error |
|
| 29 |
+
| `src/autoteam/codex_auth.py:1572-1615` | `get_quota_exhausted_info(quota_info, *, limit_reached=False)` | 判定耗尽:`primary_pct >= 100 or weekly_pct >= 100 or limit_reached` |
|
| 30 |
+
| `src/autoteam/codex_auth.py:1665-1671` | `if resp.status_code in (401, 403): return "auth_error"` / `429` 归 network_error | 401 token 失效与 429 限流分流 |
|
| 31 |
+
| `src/autoteam/manager.py:1386-1486` | `_run_post_register_oauth(email, password, mail_client, leave_workspace=False, ...)` | 注册收尾,走 Team 或 personal 分支,但**不调 check_codex_quota** |
|
| 32 |
+
| `src/autoteam/manager.py:1463-1486` | `bundle = login_codex_via_browser(...)` 后直接 `update_account(status=STATUS_ACTIVE)` | **没有 quota probe** |
|
| 33 |
+
| `src/autoteam/manager.py:476-600` | `sync_account_states(chatgpt_api=None)` | Team 成员对照本地状态;`active 但不在 Team` 直接刷 standby |
|
| 34 |
+
| `src/autoteam/manager.py:526-541` | `elif not in_team and acc["status"] == STATUS_ACTIVE: ... acc["status"] = STATUS_STANDBY` | **不区分"被踢出"和"正常待机"** |
|
| 35 |
+
| `src/autoteam/manager.py:161-471` | `_reconcile_team_members` | 检测 ghost / orphan / 错位,KICK 后会标 STATUS_AUTH_INVALID(line 312、339) |
|
| 36 |
+
| `src/autoteam/manager.py:2446-2630` | `reinvite_account(chatgpt_api, mail_client, acc)` | standby 复用主流程:重 OAuth → 验 plan_type=team → check_codex_quota → 假恢复检测 |
|
| 37 |
+
| `src/autoteam/manager.py:2466` | `bundle = login_codex_via_browser(email, password, mail_client=mail_client)` | 默认 use_personal=False |
|
| 38 |
+
| `src/autoteam/manager.py:2489-2494` | `if plan_type != "team": _cleanup_team_leftover; return False` | self_serve_business_* 等 plan 直接被拒 |
|
| 39 |
+
| `src/autoteam/invite.py:106-145` | `detect_phone_verification(page)` + `assert_not_blocked(page, step)` | **只在 invite.register_with_invite 流程里调用,login_codex_via_browser 完全没引用** |
|
| 40 |
+
| `src/autoteam/api.py:1450-1525` | `POST /api/accounts/{email}/login`(补登录) | 调 login_codex_via_browser,无 add-phone 探针保护 |
|
| 41 |
+
|
| 42 |
+
### B.2 调用链/数据流
|
| 43 |
+
|
| 44 |
+
#### 子症状 A:OAuth workspace 选择链
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
用户在 Web 点"补登录" / "fill-team"
|
| 48 |
+
└─ login_codex_via_browser(email, pwd, use_personal=False)
|
| 49 |
+
├─ step-0: 注入 _account cookie = chatgpt_account_id ← 强制 Team workspace
|
| 50 |
+
├─ 浏览器访问 auth_url (auth.openai.com/authorize)
|
| 51 |
+
├─ 邮箱 + OTP 验证
|
| 52 |
+
└─ step-4 loop (10 轮):
|
| 53 |
+
├─ 检测"选择一个工作空间"页 → JS click workspace_name
|
| 54 |
+
├─ 点继续按钮
|
| 55 |
+
└─ 拿 auth_code → exchange → bundle (含 plan_type=team)
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
**整个流程没有"让用户选"的交互点**:cookie 注入决定 default workspace,JS 自动点击决定具体选项。这是设计意图(自动化场景),但与 user 期望"先问我"冲突。
|
| 59 |
+
|
| 60 |
+
#### 子症状 B:quota=0 进 active 链
|
| 61 |
+
|
| 62 |
+
```
|
| 63 |
+
_run_post_register_oauth(...) (manager.py:1463)
|
| 64 |
+
├─ login_codex_via_browser → bundle.plan_type
|
| 65 |
+
├─ save_auth_file(bundle) → auth_file
|
| 66 |
+
└─ update_account(status=STATUS_ACTIVE, seat_type, auth_file, ...)
|
| 67 |
+
↑ 此处缺一道 check_codex_quota:
|
| 68 |
+
如果 wham/usage 返回 quota=0 / limit_reached / 401,
|
| 69 |
+
应该立刻分流到 STATUS_EXHAUSTED 或 STATUS_AUTH_INVALID。
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
对比 `manual_account._finalize_account`(`manual_account.py:227-286`)— 手动添加路径**有** check_codex_quota(line 263-272),会按结果标 STATUS_EXHAUSTED。但**邀请→注册路径**走的是 `_run_post_register_oauth`,这条**没有 quota probe**。这是直接 bug。
|
| 73 |
+
|
| 74 |
+
更致命的是 `get_quota_exhausted_info`(codex_auth.py:1582)只看 `primary_pct >= 100`。如果 wham/usage 返回 `used_percent=0` 但**总额本身是 0**(self_serve_business_usage_based workspace 没分配 codex 配额的情况),代码当作 ok 状态 + 显示"100% 剩余",但下游 CPA 调用 codex API 立刻 429 — 这就是用户报告的"显示额度但实际没额度"。
|
| 75 |
+
|
| 76 |
+
#### 子症状 C:team 踢出后 401 链
|
| 77 |
+
|
| 78 |
+
```
|
| 79 |
+
管理员在 ChatGPT 后台手动踢子号(不走 AutoTeam)
|
| 80 |
+
↓
|
| 81 |
+
sync_account_states (manager.py:476)
|
| 82 |
+
├─ GET /backend-api/accounts/{id}/users → 不见该 email
|
| 83 |
+
└─ acc["status"] = STATUS_STANDBY ← 没有标 AUTH_INVALID
|
| 84 |
+
↓
|
| 85 |
+
下次 rotation 选中该 standby
|
| 86 |
+
└─ reinvite_account (manager.py:2446)
|
| 87 |
+
├─ login_codex_via_browser → bundle (此时会拿到 free / personal plan)
|
| 88 |
+
│ ↑ 因为 cookie 注入失败/workspace 默认变了,
|
| 89 |
+
│ bundle.plan_type 不是 "team"
|
| 90 |
+
├─ if plan_type != "team": _cleanup_team_leftover ← line 2490
|
| 91 |
+
└─ return False(死循环)
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
子号在 ChatGPT 后端被 user_id revoke,但 OAuth refresh_token 走的是 chatgpt_account_id 维度 — 一旦该 user_id 在 workspace 不存在,任何 backend-api 调用都 401。**关键缺陷**:`sync_account_states:540` 把"被踢"当成"待机"处理。
|
| 95 |
+
|
| 96 |
+
#### 子症状 D:重 OAuth add phone 链
|
| 97 |
+
|
| 98 |
+
```
|
| 99 |
+
踢出立即(< 5 分钟)重新跑 reinvite_account
|
| 100 |
+
└─ login_codex_via_browser
|
| 101 |
+
└─ Playwright 浏览器:
|
| 102 |
+
├─ 邮箱 + OTP
|
| 103 |
+
├─ ChatGPT 后端检测"刚刚被踢且立即重登" → 触发风控
|
| 104 |
+
└─ 跳转 add-phone 页面
|
| 105 |
+
↑ 这里 codex_auth.py 完全没 detect_phone_verification!
|
| 106 |
+
step-4 循环见不到 add-phone,只看到 OTP/workspace/consent 选项,
|
| 107 |
+
全部 timeout 后 auth_code 为 None,bundle 失败。
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
**对比** `invite.register_with_invite`(invite.py:190-477):每个步骤之间都调 `assert_not_blocked(page, step)`,命中 add-phone 立即 raise RegisterBlocked + 上层放弃账号。这套 detect 在 `login_codex_via_browser` 完全没接入。
|
| 111 |
+
|
| 112 |
+
## C. 根因分析
|
| 113 |
+
|
| 114 |
+
### C.1 子症状 B:quota=0 进 active 池
|
| 115 |
+
|
| 116 |
+
**优先级 P0**(用户痛点最直接 — 进池号不可用)
|
| 117 |
+
|
| 118 |
+
候选根因:
|
| 119 |
+
1. `_run_post_register_oauth` 缺 quota probe(主因) — manual_account 有,这里没,接口不对称。
|
| 120 |
+
2. `get_quota_exhausted_info` 不能识别"总额=0"(次因) — 即便加 probe,也只判 used_percent=100,无配额 workspace 仍会被认 ok。
|
| 121 |
+
3. `chatgpt_plan_type` 白名单缺失 — `self_serve_business_usage_based` 进来被打 codex 席位,但 codex 在该 workspace 根本不可用。
|
| 122 |
+
|
| 123 |
+
### C.2 子症状 C:踢出后状态错位
|
| 124 |
+
|
| 125 |
+
**优先级 P0**
|
| 126 |
+
|
| 127 |
+
候选根因:
|
| 128 |
+
1. `sync_account_states:540` 不区分"被踢"vs"自然待机"(主因) — 应该用 auth_file 实测一次 wham/usage,401 → STATUS_AUTH_INVALID,而不是无脑 standby。
|
| 129 |
+
2. `reinvite_account:2490` 对 plan_type != team 的处理简单粗暴(次因) — 此时 token 已经 revoke,正确处置是清掉 auth_file + 标 AUTH_INVALID,而不是回 standby 等下一轮(下一轮还是 401 死循环)。
|
| 130 |
+
3. 缺少 standby 准入探测 — manager.py:1166 段有但有 24h 去重,新被踢的号要等 24h 才会被 wham 探测。
|
| 131 |
+
|
| 132 |
+
### C.3 子症状 D:add-phone 真空区
|
| 133 |
+
|
| 134 |
+
**优先级 P0**
|
| 135 |
+
|
| 136 |
+
候选根因:
|
| 137 |
+
1. `login_codex_via_browser` 没接入 `detect_phone_verification` 探针(主因) — invite.py 的探针没被复用。
|
| 138 |
+
2. 踢出 → 立即重 OAuth 时间太近(次因) — `_run_post_register_oauth:1428` 有 8s 等待,但 reinvite_account 没有类似等待。
|
| 139 |
+
3. `_used_email_ids` 状态污染 — `codex_auth.py:260` 每轮新建,不会跨轮污染。但浏览器 context 是新建的,不该带 cookie 残留。
|
| 140 |
+
|
| 141 |
+
### C.4 子症状 A:workspace 不让选
|
| 142 |
+
|
| 143 |
+
**优先级 P2**(更像 UX 缺失而非 bug)
|
| 144 |
+
|
| 145 |
+
候选根因:
|
| 146 |
+
1. cookie 注入 + JS 自动点击的设计就是为了无人值守自动化 — 与"让用户选"的交互需求矛盾。
|
| 147 |
+
2. 缺少前端 OAuth 模式选择菜单 — 当前 web UI 的"补登录"按钮没有 use_personal 开关,API `POST /accounts/{email}/login` 也是按 acc.status 自动决定 use_personal(api.py:1475)。
|
| 148 |
+
|
| 149 |
+
## D. 修复方向建议(只是方向,不写代码)
|
| 150 |
+
|
| 151 |
+
### D.1 必须改的点
|
| 152 |
+
|
| 153 |
+
1. **`_run_post_register_oauth` 加 quota probe**(P0,issue#6 主因 B)
|
| 154 |
+
- 位置:`manager.py:1463-1486`
|
| 155 |
+
- 拿到 bundle 后立即调 `check_codex_quota(bundle.access_token, bundle.account_id)`
|
| 156 |
+
- 结果分流:
|
| 157 |
+
- `ok` + `quota_info` 写入 last_quota,标 STATUS_ACTIVE(现有行为)
|
| 158 |
+
- `exhausted` 标 STATUS_EXHAUSTED + quota_exhausted_at(参考 manual_account.py:266-272)
|
| 159 |
+
- `auth_error` / `network_error` 写日志,但**不**标 active(避免假入池)— 标 STATUS_AUTH_INVALID,等 reconcile 处理
|
| 160 |
+
|
| 161 |
+
2. **`check_codex_quota` 识别"总额=0"**(P0,issue#2 / #6 共因)
|
| 162 |
+
- 位置:`codex_auth.py:1685-1700`
|
| 163 |
+
- 解析 rate_limit.primary_window 时除了 `used_percent` 也读 `limit` / `total` / `remaining`
|
| 164 |
+
- 总额=0 / remaining=0 / reset_at=0 → 新增分类 `"no_quota"` 或归到 `"exhausted"` + window=`no_quota`
|
| 165 |
+
|
| 166 |
+
3. **`sync_account_states` 区分"被踢"vs"自然待机"**(P0,issue#6 主因 C)
|
| 167 |
+
- 位置:`manager.py:526-541`
|
| 168 |
+
- 当 `not in_team and status == STATUS_ACTIVE` 时,如果 acc.auth_file 存在,实测一次 wham/usage:
|
| 169 |
+
- 401/403 → 标 STATUS_AUTH_INVALID(token 已 revoke,等 reconcile 清 auth_file)
|
| 170 |
+
- 200 + ok → 真的是被踢但 token 还在(罕见,可能 OpenAI 缓存延迟),标 STATUS_STANDBY 让 reinvite_account 重新拉
|
| 171 |
+
- network_error → 暂保持 STATUS_ACTIVE 等下轮(避免网络抖动误标)
|
| 172 |
+
|
| 173 |
+
4. **`login_codex_via_browser` 接入 add-phone 探针**(P0,issue#6 主因 D)
|
| 174 |
+
- 位置:`codex_auth.py:612` 起的 step-4 循环里,每轮 try 块开头 import `from autoteam.invite import detect_phone_verification`
|
| 175 |
+
- 命中 add-phone 直接 break + 设置 `auth_code = None` + log error
|
| 176 |
+
- 函数返回 None,上游 `_run_post_register_oauth` / `reinvite_account` 走"OAuth 失败"分支
|
| 177 |
+
- **额外加** `record_failure(email, "add_phone_at_oauth", "OAuth 流程命中 add-phone 风控")`,UI 失败明细能看到
|
| 178 |
+
|
| 179 |
+
5. **`reinvite_account` 在踢出后等待 OpenAI 同步**(P1,issue#6 D 次因)
|
| 180 |
+
- 位置:`manager.py:2466` 之前
|
| 181 |
+
- 如果 acc 的状态从 standby 进入 reinvite,且 `last_active_at` < 5 分钟前(刚被踢),先 sleep 8-15s 让 ChatGPT 后端 default workspace 切换生效
|
| 182 |
+
- 与 `_run_post_register_oauth:1427-1429` 的 8s 等待保持对称
|
| 183 |
+
|
| 184 |
+
6. **`reinvite_account` plan_type != team 时的二次处理**(P1,issue#6 C 次因)
|
| 185 |
+
- 位置:`manager.py:2489-2494`
|
| 186 |
+
- 不仅 `_cleanup_team_leftover` + return False,还要 `update_account(status=STATUS_AUTH_INVALID, auth_file=None)`
|
| 187 |
+
- 让该号下一轮被 reconcile 直接清掉,而不是反复在 standby 池里轮转
|
| 188 |
+
|
| 189 |
+
### D.2 可选优化
|
| 190 |
+
|
| 191 |
+
1. **OAuth 模式选择 UI**(issue#6 A)
|
| 192 |
+
- Web Dashboard 的"补登录"按钮加下拉:Team / Personal / Auto(根据 status)
|
| 193 |
+
- API `POST /accounts/{email}/login` 接受 `mode` 参数
|
| 194 |
+
|
| 195 |
+
2. **standby 探测去重的 cool-down 缩短**(issue#6 C)
|
| 196 |
+
- `manager.py:1166` 24h 去重对新被踢号太长,改成"被踢后 30 分钟即可再探测"
|
| 197 |
+
- 用 acc 的 `last_kicked_at` 字段(新增)而不是 `last_quota_check_at` 判断
|
| 198 |
+
|
| 199 |
+
3. **add-phone 命中后清理 auth_file**
|
| 200 |
+
- 一旦 OAuth 命中 add-phone,该账号短期内重 OAuth 还是会被风控,清掉 auth_file 标 STATUS_AUTH_INVALID 让用户介入
|
| 201 |
+
- 比反复重试更友好
|
| 202 |
+
|
| 203 |
+
4. **batch 失败明细**:`failures` 表新增 add_phone_at_oauth 类别,UI 上区分"邀请阶段 add-phone"和"OAuth 阶段 add-phone"
|
| 204 |
+
|
| 205 |
+
### D.3 测试要点
|
| 206 |
+
|
| 207 |
+
- [ ] 注册一个新号,通过 invite 流程进 Team,确认 `_run_post_register_oauth` 拿到 bundle 后立刻打 wham/usage
|
| 208 |
+
- [ ] 模拟一个 quota=0 的 workspace(可以临时改 wham/usage 返回值)— 确认账号被标 EXHAUSTED 而不是 ACTIVE
|
| 209 |
+
- [ ] 在 ChatGPT 后台**手动**踢一个 active 子号 → 等下次 sync_account_states 跑 → 确认状态变 AUTH_INVALID(不是 STANDBY)
|
| 210 |
+
- [ ] 主动 kick 一个号后立刻 reinvite,确认 add-phone 风控被探测到 + 失败明细写入 register_failures.json
|
| 211 |
+
- [ ] reinvite_account 第一次失败标 AUTH_INVALID 后,确认下一轮 reconcile 清掉 auth_file,不再被 standby 池选中
|
| 212 |
+
- [ ] OAuth 模式选择 UI:在补登录时手动选"Team",确认 `_account` cookie 注入 + workspace 选 Team;选"Personal"确认 use_personal=True 路径
|
| 213 |
+
|
| 214 |
+
## E. 影响面 / 爆炸半径
|
| 215 |
+
|
| 216 |
+
- **`_run_post_register_oauth` 加 probe**:扩散到 `manager.py:1386-1486` 单点,但调用方多(_complete_registration、create_account_direct、_check_pending_invites)。最大风险是 probe 异常时不能堵死注册流程 — 必须保持"probe 失败不影响 bundle 已成功"的弱化逻辑。
|
| 217 |
+
- **`check_codex_quota` 加 no_quota 分类**:扩散到所有 6+ 调用点(manager 多处、api 多处、cpa_sync),需要每个调用点都 handle 新分类,否则会被当成 ok 默认值掉进死循环。
|
| 218 |
+
- **`sync_account_states` 加 wham 探测**:这是个高频调用函数(rotation 每轮都会触发),如果探测延时叠加,可能拖慢整个轮转。需要给每个 active 子号探测加并发 + 超时上限。
|
| 219 |
+
- **`login_codex_via_browser` 加 add-phone 探针**:仅 codex_auth.py 内部,但���为变化是"OAuth 提前失败"— 调用方都已有 bundle=None 兜底逻辑,影响小。
|
| 220 |
+
- **`reinvite_account` 调整 plan_type != team 的清理**:扩散到 manager.py 多个 reinvite 调用点(line 2719 / 3041 / 3391),需要确认它们都依赖"return False 时不动 acc.auth_file"的旧约定。
|
| 221 |
+
|
| 222 |
+
## F. 风险与未决问题
|
| 223 |
+
|
| 224 |
+
1. **wham/usage 返回 schema 是否真有 limit / total**:目前代码只读 `used_percent` / `reset_at` / `limit_reached`,如果 OpenAI 没在 self_serve_business 上返回 limit 字段,"总额=0"的识别只能依赖间接信号(reset_at=0 + used_percent=0 + 调用立刻 429)。需要从用户 bundle 实测 wham/usage 响应。
|
| 225 |
+
2. **手动踢 vs API 踢的区分**:OpenAI 后端 /users 的 GET 返回不会告诉我们"成员是被踢还是自然离开",只能靠"上次见过 + 这次没见到"推断。AutoTeam 自己 kick 的会有 _reconcile_team_members 主动标 AUTH_INVALID(line 312、339),但**人工 kick** 走 sync_account_states,目前缺识别。
|
| 226 |
+
3. **add-phone 探针误报**:invite.py 的 detect_phone_verification 主要靠 URL hint + body 文本,在 OAuth 流程中(auth.openai.com 域)可能误命中 OAuth 同意页里的 phone 帮助链接。需要为 OAuth 流程定制更严格的探针(必须有可见 input[type=tel] 且不在 footer 区)。
|
| 227 |
+
4. **reinvite 后 8-15s 等待是否够**:OpenAI 后端的 default workspace 切换延迟可能是 8s 也可能是 60s+,固定等待可能不够。建议 polling auth.openai.com 的 /me 接口确认 workspace 已切换才继续。
|
| 228 |
+
5. **加 quota probe 后 _run_post_register_oauth 失败率会上升**:因为之前 quota=0 的号会被认为成功,加 probe 后会被打回失败。这可能让 fill-team 的"成功率"指标看起来变差,但实际是更准确。需要在变更说明中提醒用户这是正向变化。
|
| 229 |
+
6. **issue#2 与 issue#6 的修复有重叠**:`check_codex_quota` 增强、`plan_type` 白名单、quota probe 都是两个 issue 共因,实施时应统一规划而不是分两次改。
|
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Shared SPEC: 账号状态机
|
| 2 |
+
|
| 3 |
+
## 0. 元数据 + 引用方
|
| 4 |
+
|
| 5 |
+
| 字段 | 内容 |
|
| 6 |
+
|---|---|
|
| 7 |
+
| 名称 | 账号 7 状态完整状态机与转移规则 |
|
| 8 |
+
| 版本 | v1.0 (2026-04-26) |
|
| 9 |
+
| 主题归属 | `accounts.py` STATUS_* 常量 + 各转移点的触发函数 + 不变量 |
|
| 10 |
+
| 引用方 | PRD-2 / spec-2-account-lifecycle.md / FR-D1~D4、FR-E1~E4、FR-H1~H3 |
|
| 11 |
+
| 共因 | synthesis §1 共因 D、E + Issue#6 |
|
| 12 |
+
| 不在范围 | seat_type 字段(见 PRD-2 FR-F1~F6)、cpa_sync 同步细节(参考 `cpa_sync.py`) |
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 1. 概念定义
|
| 17 |
+
|
| 18 |
+
| 术语 | 定义 |
|
| 19 |
+
|---|---|
|
| 20 |
+
| `account state` | 落盘到 `accounts.json` 的 `status` 字段,7 个枚举之一 |
|
| 21 |
+
| `transition` | 由代码事件(同步/注册/踢出/探测)触发的状态变化 |
|
| 22 |
+
| `terminal state` | 没有自动转出 transition 的状态,需要人工介入或 reconcile 接管 |
|
| 23 |
+
| 被踢(kicked) | 在 ChatGPT Team 后端不可见但本地仍 active 的账号;wham/usage 401/403 时确认 |
|
| 24 |
+
| 自然待机(natural standby) | 账号 quota 耗尽后被本系统主动 kick,等待 5h reset 自然恢复 |
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 2. 完整数据契约
|
| 29 |
+
|
| 30 |
+
### 2.1 状态枚举(已存在,在 `accounts.py:13-20`)
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
# src/autoteam/accounts.py 已有:
|
| 34 |
+
STATUS_ACTIVE = "active" # 在 team 中,额度可用
|
| 35 |
+
STATUS_EXHAUSTED = "exhausted" # 在 team 中,额度用完
|
| 36 |
+
STATUS_STANDBY = "standby" # 已移出 team,等待额度恢复
|
| 37 |
+
STATUS_PENDING = "pending" # 已邀请,等待注册完成
|
| 38 |
+
STATUS_PERSONAL = "personal" # 已主动退出 team,走个人号 Codex OAuth
|
| 39 |
+
STATUS_AUTH_INVALID = "auth_invalid" # auth_file token 已不可用,待 reconcile 清理或重登
|
| 40 |
+
STATUS_ORPHAN = "orphan" # 在 workspace 占席位但本地无 auth_file
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### 2.2 Pydantic AccountRecord(完整契约,本 spec 引入)
|
| 44 |
+
|
| 45 |
+
```python
|
| 46 |
+
from typing import Literal, Optional
|
| 47 |
+
from pydantic import BaseModel, Field
|
| 48 |
+
|
| 49 |
+
AccountStatus = Literal[
|
| 50 |
+
"active", "exhausted", "standby", "pending", "personal", "auth_invalid", "orphan",
|
| 51 |
+
]
|
| 52 |
+
SeatType = Literal["chatgpt", "codex", "unknown"]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class QuotaSnapshot(BaseModel):
|
| 56 |
+
"""详见 ./quota-classification.md §2.1"""
|
| 57 |
+
primary_pct: int = 0
|
| 58 |
+
primary_resets_at: int = 0
|
| 59 |
+
primary_total: Optional[int] = None
|
| 60 |
+
primary_remaining: Optional[int] = None
|
| 61 |
+
weekly_pct: int = 0
|
| 62 |
+
weekly_resets_at: int = 0
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class AccountRecord(BaseModel):
|
| 66 |
+
email: str
|
| 67 |
+
password: str
|
| 68 |
+
cloudmail_account_id: Optional[str] = None
|
| 69 |
+
status: AccountStatus
|
| 70 |
+
seat_type: SeatType = "unknown"
|
| 71 |
+
workspace_account_id: Optional[str] = None
|
| 72 |
+
auth_file: Optional[str] = None
|
| 73 |
+
quota_exhausted_at: Optional[float] = None
|
| 74 |
+
quota_resets_at: Optional[float] = None
|
| 75 |
+
last_quota_check_at: Optional[float] = None # FR-E3 探测去重
|
| 76 |
+
last_quota: Optional[QuotaSnapshot] = None
|
| 77 |
+
last_active_at: Optional[float] = None
|
| 78 |
+
created_at: float
|
| 79 |
+
plan_supported: Optional[bool] = None # 新增,见 ./plan-type-whitelist.md
|
| 80 |
+
plan_type_raw: Optional[str] = None # 新增,记录原始 OAuth 字面量
|
| 81 |
+
last_kicked_at: Optional[float] = None # 新增,被踢识别时间戳;reconcile 用
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
### 2.3 状态-字段不变量
|
| 85 |
+
|
| 86 |
+
| 状态 | 必备字段 | 禁用字段 |
|
| 87 |
+
|---|---|---|
|
| 88 |
+
| `pending` | `email`, `password`, `created_at` | `auth_file` 必须为 None(注册未完成) |
|
| 89 |
+
| `active` | `email`, `auth_file`(非 None) | — |
|
| 90 |
+
| `exhausted` | `email`, `auth_file`, `quota_exhausted_at`, `quota_resets_at` | — |
|
| 91 |
+
| `standby` | `email` | — |
|
| 92 |
+
| `personal` | `email`, `auth_file`(非 None,personal 专属 plan_type=free) | — |
|
| 93 |
+
| `auth_invalid` | `email` | — |
|
| 94 |
+
| `orphan` | `email`(在 workspace 占席位) | `auth_file` 必须为 None |
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## 3. 状态机图(ASCII)
|
| 99 |
+
|
| 100 |
+
### 3.1 完整状态机
|
| 101 |
+
|
| 102 |
+
```
|
| 103 |
+
┌──────────────┐
|
| 104 |
+
│ PENDING │ 注册中,等收邮件
|
| 105 |
+
└──────┬───────┘
|
| 106 |
+
│
|
| 107 |
+
注册成功+OAuth+quota ok
|
| 108 |
+
│
|
| 109 |
+
▼
|
| 110 |
+
┌──────────────────────────┐
|
| 111 |
+
│ ACTIVE │ ◄────┐ reinvite 验证 ok
|
| 112 |
+
│ 在 Team,可调 Codex │ │
|
| 113 |
+
└──┬───────────────────────┘ │
|
| 114 |
+
│ │
|
| 115 |
+
quota │ 100% ┌──────────────────┤
|
| 116 |
+
▼ │ │
|
| 117 |
+
┌──────────┐ 本系统kick ┌────────────┐
|
| 118 |
+
│EXHAUSTED │ ────────────► │ STANDBY │ ◄── reinvite plan!=team(旧路径)
|
| 119 |
+
│ (lock 5h)│ │ 等待 reset │ (新路径推 AUTH_INVALID)
|
| 120 |
+
└────┬─────┘ └────┬───────┘
|
| 121 |
+
│ │
|
| 122 |
+
reset│ 5h 后 reinvite │ reinvite 验证
|
| 123 |
+
└──────────────────────────┴────────► 返 ACTIVE
|
| 124 |
+
或 fall through
|
| 125 |
+
(见下方 fail_reason 分支)
|
| 126 |
+
|
| 127 |
+
┌─────────────┐
|
| 128 |
+
人工 leave_workspace ──► │ PERSONAL │ (终态,不参与 Team 轮转)
|
| 129 |
+
└─────────────┘
|
| 130 |
+
|
| 131 |
+
sync 探测 wham 401/403 ┌────────────────┐ reconcile.KICK
|
| 132 |
+
──────────────────────► │ AUTH_INVALID │ ──────────────────► 删本地或保留人工介入
|
| 133 |
+
注册收尾 wham no_quota │ token 已失效 │
|
| 134 |
+
reinvite plan_drift │ │
|
| 135 |
+
add-phone 命中(Team) └────────────────┘
|
| 136 |
+
|
| 137 |
+
_reconcile 发现 ghost ┌──────────┐ 人工 KICK
|
| 138 |
+
──────────────────────► │ ORPHAN │ ──────────────────► 删
|
| 139 |
+
└──────────┘
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
### 3.2 状态分类
|
| 143 |
+
|
| 144 |
+
| 类别 | 状态 |
|
| 145 |
+
|---|---|
|
| 146 |
+
| 工作池(轮转参与) | `active`、`exhausted`、`standby`、`pending` |
|
| 147 |
+
| 终态(不参与轮转) | `personal`、`auth_invalid`、`orphan` |
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## 4. 状态/分类规则
|
| 152 |
+
|
| 153 |
+
### 4.1 触发函数 → 转移矩阵
|
| 154 |
+
|
| 155 |
+
| 触发函数 | 文件:行号 | 触发条件 | from → to |
|
| 156 |
+
|---|---|---|---|
|
| 157 |
+
| `add_account` | `accounts.py:58` | 新增账号(invite 成功) | (none) → PENDING |
|
| 158 |
+
| `_run_post_register_oauth` Team 分支 | `manager.py:1463` | bundle ok + quota ok | PENDING → ACTIVE |
|
| 159 |
+
| `_run_post_register_oauth` Team 分支 | `manager.py:1463` | bundle ok + quota exhausted(新) | PENDING → EXHAUSTED |
|
| 160 |
+
| `_run_post_register_oauth` Team 分支 | `manager.py:1463` | bundle ok + quota no_quota(新) | PENDING → AUTH_INVALID |
|
| 161 |
+
| `_run_post_register_oauth` Team 分支 | `manager.py:1463` | bundle ok + quota auth_error(新) | PENDING → AUTH_INVALID |
|
| 162 |
+
| `_run_post_register_oauth` Team 分支 | `manager.py:1463` | bundle 但 plan_supported=False(新) | PENDING → AUTH_INVALID |
|
| 163 |
+
| `_run_post_register_oauth` Team 分支 | `manager.py:1463` | bundle 失败但已 invite | PENDING → ACTIVE(team_auth_missing,旧行为保留) |
|
| 164 |
+
| `_run_post_register_oauth` Team 分支 | `manager.py:1463` | RegisterBlocked(is_phone=True)(新) | PENDING → AUTH_INVALID |
|
| 165 |
+
| `_run_post_register_oauth` personal 分支 | `manager.py:1431` | bundle ok + plan=free | PENDING → PERSONAL |
|
| 166 |
+
| `_run_post_register_oauth` personal 分支 | `manager.py:1431` | bundle 失败 / plan != free | PENDING → deleted(record_failure) |
|
| 167 |
+
| `_run_post_register_oauth` personal 分支 | `manager.py:1431` | RegisterBlocked(is_phone=True)(新) | PENDING → deleted + record_failure |
|
| 168 |
+
| `sync_account_states` | `manager.py:520` | active 在 Team 中(同步成功) | ACTIVE → ACTIVE(无变化) |
|
| 169 |
+
| `sync_account_states` | `manager.py:520` | standby/pending 在 Team 中 | STANDBY/PENDING → ACTIVE |
|
| 170 |
+
| `sync_account_states` | `manager.py:540` | active 不在 Team + workspace_account_id 不一致 | ACTIVE → ACTIVE(母号切换守卫,旧行为保留) |
|
| 171 |
+
| `sync_account_states` | `manager.py:540`(新) | active 不在 Team + wham 401/403 | ACTIVE → AUTH_INVALID |
|
| 172 |
+
| `sync_account_states` | `manager.py:540`(新) | active 不在 Team + wham ok / network_error | ACTIVE → STANDBY(自然待机,保留旧行为) |
|
| 173 |
+
| `cmd_check` quota 探测 | `manager.py:715/748/760` | wham 状态变化 | active → exhausted/auth_invalid/standby |
|
| 174 |
+
| `reinvite_account` | `manager.py:2466` | OAuth 成功 + plan=team + quota verified | STANDBY → ACTIVE |
|
| 175 |
+
| `reinvite_account` | `manager.py:2466`(新) | OAuth 成功 + plan != team / plan_supported=False | STANDBY → AUTH_INVALID(plan_drift) |
|
| 176 |
+
| `reinvite_account` | `manager.py:2466`(新) | OAuth 失败 RegisterBlocked(is_phone=True) | STANDBY → AUTH_INVALID(oauth_phone_blocked) |
|
| 177 |
+
| `reinvite_account` | `manager.py:2466`(旧) | OAuth 成功但 quota_low / exhausted | STANDBY → STANDBY(锁 5h) |
|
| 178 |
+
| `reinvite_account` | `manager.py:2466`(旧) | OAuth 成功但 quota auth_error / network_error / exception | STANDBY → STANDBY(不锁 5h) |
|
| 179 |
+
| `reinvite_account` | `manager.py:2466`(旧) | OAuth 失败 bundle=None | STANDBY → STANDBY(_cleanup_team_leftover) |
|
| 180 |
+
| `_reconcile_team_members` | `manager.py:312/339` | 发现 ghost / orphan 错位 | ACTIVE → AUTH_INVALID |
|
| 181 |
+
| `_reconcile_team_members` | `manager.py:312/339` | workspace 占席位但本地无 auth_file | (任意) → ORPHAN |
|
| 182 |
+
| `_replace_single` kick | `manager.py:2626` | 主动定点替换 | active → STANDBY |
|
| 183 |
+
| `delete_managed_account` | `account_ops.py:40` | 用户单点 / 批量删除 | (任意) → deleted |
|
| 184 |
+
| `manual_account._finalize_account` | `manual_account.py:227` | 用户粘贴 OAuth callback | (none) → STANDBY(team) / ACTIVE(team+plus 之外) / AUTH_INVALID(plan_unsupported,新) |
|
| 185 |
+
|
| 186 |
+
### 4.2 "被踢" vs "自然待机" 识别规则(FR-E1)
|
| 187 |
+
|
| 188 |
+
```
|
| 189 |
+
sync_account_states 看到 acc.status == ACTIVE 且 email 不在当前 workspace_team_emails:
|
| 190 |
+
├─ acc.workspace_account_id ≠ 当前 account_id → 母号切换遗留,保留 ACTIVE(旧行为)
|
| 191 |
+
├─ acc.auth_file 存在 → 用 access_token 调一次 wham/usage(并发限制 5,超时 5s)
|
| 192 |
+
│ ├─ ("auth_error", _) → STATUS_AUTH_INVALID + last_kicked_at=now ★被踢
|
| 193 |
+
│ ├─ ("ok", info) → STATUS_STANDBY + last_quota=info ★自然待机(罕见,可能 OpenAI 缓存延迟)
|
| 194 |
+
│ ├─ ("exhausted", info) → STATUS_STANDBY + quota_exhausted_at ★自然待机
|
| 195 |
+
│ ├─ ("no_quota", info) → STATUS_AUTH_INVALID(无配额且不在 Team,不会自动恢复)
|
| 196 |
+
│ └─ ("network_error", _) → 保持 ACTIVE 等下轮(避免抖动误标)
|
| 197 |
+
└─ acc.auth_file 缺失 → STATUS_STANDBY(降级,无法验证)
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
### 4.3 reinvite_account fail_reason 分支(扩 FR-H1)
|
| 201 |
+
|
| 202 |
+
```
|
| 203 |
+
reinvite_account 拿到 bundle 后:
|
| 204 |
+
├─ bundle == None
|
| 205 |
+
│ ├─ 由 RegisterBlocked(is_phone=True) 引发 → STATUS_AUTH_INVALID(新,FR-C3)
|
| 206 |
+
│ └─ 其他 → _cleanup_team_leftover("no_bundle") + STATUS_STANDBY(旧)
|
| 207 |
+
├─ plan_supported == False → STATUS_AUTH_INVALID + record_failure("plan_unsupported")(新)
|
| 208 |
+
├─ plan_type != "team" → STATUS_AUTH_INVALID + record_failure("plan_drift")(新,替代旧 STATUS_STANDBY)
|
| 209 |
+
└─ plan_type == "team":
|
| 210 |
+
├─ quota verified ok → STATUS_ACTIVE(旧)
|
| 211 |
+
├─ quota fail_reason in (exhausted, quota_low) → STATUS_STANDBY + 锁 5h(旧)
|
| 212 |
+
└─ quota fail_reason in (auth_error, network_error, exception) → STATUS_STANDBY 不锁 5h(旧)
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
### 4.4 反向不变量
|
| 216 |
+
|
| 217 |
+
| 不变量 | 说明 |
|
| 218 |
+
|---|---|
|
| 219 |
+
| `auth_file 存在 ⇒ status ∈ {active, exhausted, standby_with_token, personal, auth_invalid}` | orphan / pending 必无 auth_file |
|
| 220 |
+
| `status == personal ⇒ plan_type_raw == "free"` | personal 路径强校验(`codex_auth.py:920-930`) |
|
| 221 |
+
| `status == active ⇒ workspace_account_id 与当前一致 OR workspace_account_id is None` | sync 守卫(`manager.py:531-538`) |
|
| 222 |
+
| `last_kicked_at != None ⇒ status in {auth_invalid, deleted}` | 被踢标记的语义边界 |
|
| 223 |
+
|
| 224 |
+
---
|
| 225 |
+
|
| 226 |
+
## 5. 调用方处置规范(状态消费方)
|
| 227 |
+
|
| 228 |
+
### 5.1 工作池筛选
|
| 229 |
+
|
| 230 |
+
```python
|
| 231 |
+
# accounts.py 已存在
|
| 232 |
+
def get_active_accounts():
|
| 233 |
+
"""status == active 且非主号"""
|
| 234 |
+
return [a for a in load_accounts() if a["status"] == STATUS_ACTIVE]
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def get_personal_accounts():
|
| 238 |
+
"""status == personal 且非主号"""
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def get_standby_accounts():
|
| 242 |
+
"""status == standby,按 quota_recovered 排序"""
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# 新增推荐(便于 UI 与 reconcile)
|
| 246 |
+
def get_terminal_accounts():
|
| 247 |
+
"""auth_invalid + orphan,需要人工介入或 reconcile 清理"""
|
| 248 |
+
return [a for a in load_accounts()
|
| 249 |
+
if a["status"] in (STATUS_AUTH_INVALID, STATUS_ORPHAN)]
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
### 5.2 reconcile 处置(`manager.py:_reconcile_team_members` 旧 + 新)
|
| 253 |
+
|
| 254 |
+
| 输入状态 | reconcile 行为 |
|
| 255 |
+
|---|---|
|
| 256 |
+
| `auth_invalid` + 在 workspace 占席位 | KICK + 保留本地记录 + 等用户/批量删除 |
|
| 257 |
+
| `auth_invalid` + 不在 workspace | 保留本地记录(已自然清理),等用户决定是否删 |
|
| 258 |
+
| `orphan` + 在 workspace 占席位 | KICK |
|
| 259 |
+
| 其他状态 | 沿用现有处理 |
|
| 260 |
+
|
| 261 |
+
### 5.3 UI 显示规范
|
| 262 |
+
|
| 263 |
+
| 状态 | UI 文案 | 操作按钮 |
|
| 264 |
+
|---|---|---|
|
| 265 |
+
| `active` | 工作中 | 强制下线 / 删除 |
|
| 266 |
+
| `exhausted` | 已耗尽,X 小时后恢复 | 删除(灰显:auto check 已锁) |
|
| 267 |
+
| `standby` | 待机中(quota 已恢复 / 未恢复) | 立即重用 / 删除 |
|
| 268 |
+
| `pending` | 注册中... | (无) |
|
| 269 |
+
| `personal` | 个人 free 号 | 删除(短路 fetch_team_state,见 PRD-2 FR-G1) |
|
| 270 |
+
| `auth_invalid` | **token 失效,已退出 Team** | 删除(短路 fetch_team_state) |
|
| 271 |
+
| `orphan` | **席位异常,等待清理** | KICK + 删除 |
|
| 272 |
+
|
| 273 |
+
---
|
| 274 |
+
|
| 275 |
+
## 6. 单元测试 fixture 与样本数据
|
| 276 |
+
|
| 277 |
+
### 6.1 状态机 transition 表(yaml)
|
| 278 |
+
|
| 279 |
+
```yaml
|
| 280 |
+
# tests/fixtures/state_transitions.yaml
|
| 281 |
+
- name: register_team_success
|
| 282 |
+
from: pending
|
| 283 |
+
trigger: _run_post_register_oauth(leave_workspace=False, bundle.plan="team", quota="ok")
|
| 284 |
+
to: active
|
| 285 |
+
expected_fields:
|
| 286 |
+
auth_file: not_null
|
| 287 |
+
seat_type: chatgpt | codex
|
| 288 |
+
last_active_at: not_null
|
| 289 |
+
|
| 290 |
+
- name: register_team_quota_no_quota
|
| 291 |
+
from: pending
|
| 292 |
+
trigger: _run_post_register_oauth(leave_workspace=False, bundle.plan="team", quota="no_quota")
|
| 293 |
+
to: auth_invalid
|
| 294 |
+
expected_register_failure:
|
| 295 |
+
category: no_quota_assigned
|
| 296 |
+
|
| 297 |
+
- name: register_team_plan_unsupported
|
| 298 |
+
from: pending
|
| 299 |
+
trigger: _run_post_register_oauth(bundle.plan_supported=False)
|
| 300 |
+
to: auth_invalid
|
| 301 |
+
expected_register_failure:
|
| 302 |
+
category: plan_unsupported
|
| 303 |
+
|
| 304 |
+
- name: register_team_phone_blocked
|
| 305 |
+
from: pending
|
| 306 |
+
trigger: _run_post_register_oauth raises RegisterBlocked(is_phone=True)
|
| 307 |
+
to: auth_invalid
|
| 308 |
+
expected_register_failure:
|
| 309 |
+
category: oauth_phone_blocked
|
| 310 |
+
stage: run_post_register_oauth_team
|
| 311 |
+
|
| 312 |
+
- name: sync_active_kicked_by_admin
|
| 313 |
+
from: active
|
| 314 |
+
trigger: sync_account_states(not in_team) + wham 401
|
| 315 |
+
to: auth_invalid
|
| 316 |
+
expected_fields:
|
| 317 |
+
last_kicked_at: not_null
|
| 318 |
+
|
| 319 |
+
- name: sync_active_natural_standby
|
| 320 |
+
from: active
|
| 321 |
+
trigger: sync_account_states(not in_team) + wham exhausted
|
| 322 |
+
to: standby
|
| 323 |
+
|
| 324 |
+
- name: sync_active_workspace_drift
|
| 325 |
+
from: active
|
| 326 |
+
trigger: sync_account_states(not in_team) + workspace_account_id 不一致
|
| 327 |
+
to: active # 保留(母号切换遗留)
|
| 328 |
+
|
| 329 |
+
- name: reinvite_plan_drift
|
| 330 |
+
from: standby
|
| 331 |
+
trigger: reinvite_account(bundle.plan="free")
|
| 332 |
+
to: auth_invalid
|
| 333 |
+
expected_register_failure:
|
| 334 |
+
category: plan_drift
|
| 335 |
+
|
| 336 |
+
- name: reinvite_plan_unsupported
|
| 337 |
+
from: standby
|
| 338 |
+
trigger: reinvite_account(bundle.plan_supported=False)
|
| 339 |
+
to: auth_invalid
|
| 340 |
+
expected_register_failure:
|
| 341 |
+
category: plan_unsupported
|
| 342 |
+
|
| 343 |
+
- name: reinvite_phone_blocked
|
| 344 |
+
from: standby
|
| 345 |
+
trigger: reinvite_account raises RegisterBlocked(is_phone=True)
|
| 346 |
+
to: auth_invalid
|
| 347 |
+
expected_register_failure:
|
| 348 |
+
category: oauth_phone_blocked
|
| 349 |
+
stage: reinvite_account
|
| 350 |
+
|
| 351 |
+
- name: reinvite_team_quota_low
|
| 352 |
+
from: standby
|
| 353 |
+
trigger: reinvite_account(bundle.plan="team", quota="ok" but pct<threshold)
|
| 354 |
+
to: standby # 锁 5h(旧行为)
|
| 355 |
+
expected_fields:
|
| 356 |
+
quota_exhausted_at: not_null
|
| 357 |
+
|
| 358 |
+
- name: reinvite_team_success
|
| 359 |
+
from: standby
|
| 360 |
+
trigger: reinvite_account(bundle.plan="team", quota verified)
|
| 361 |
+
to: active
|
| 362 |
+
```
|
| 363 |
+
|
| 364 |
+
### 6.2 单测代码
|
| 365 |
+
|
| 366 |
+
```python
|
| 367 |
+
# tests/unit/test_state_machine.py
|
| 368 |
+
import pytest
|
| 369 |
+
import yaml
|
| 370 |
+
from pathlib import Path
|
| 371 |
+
from autoteam.accounts import (
|
| 372 |
+
STATUS_ACTIVE, STATUS_STANDBY, STATUS_PENDING, STATUS_AUTH_INVALID,
|
| 373 |
+
STATUS_PERSONAL, STATUS_ORPHAN, STATUS_EXHAUSTED, AccountRecord,
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
TRANSITIONS = yaml.safe_load(Path("tests/fixtures/state_transitions.yaml").read_text())
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
@pytest.mark.parametrize("case", TRANSITIONS)
|
| 380 |
+
def test_state_transition(case, mock_factory):
|
| 381 |
+
"""每个 transition 跑一遍,验证 from / to / 失败记录字段"""
|
| 382 |
+
acc = mock_factory.account(status=case["from"])
|
| 383 |
+
mock_factory.fire_trigger(case["trigger"], acc)
|
| 384 |
+
final = mock_factory.reload(acc.email)
|
| 385 |
+
assert final.status == case["to"]
|
| 386 |
+
if "expected_register_failure" in case:
|
| 387 |
+
rec = mock_factory.last_failure(acc.email)
|
| 388 |
+
for k, v in case["expected_register_failure"].items():
|
| 389 |
+
assert rec[k] == v
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def test_state_field_invariants():
|
| 393 |
+
"""每个 status 的 必备/禁用 字段不变量"""
|
| 394 |
+
cases = [
|
| 395 |
+
# (status, must_have_keys, must_be_none_keys)
|
| 396 |
+
(STATUS_PENDING, ["email", "password", "created_at"], ["auth_file"]),
|
| 397 |
+
(STATUS_ACTIVE, ["email", "auth_file"], []),
|
| 398 |
+
(STATUS_EXHAUSTED, ["email", "auth_file", "quota_exhausted_at", "quota_resets_at"], []),
|
| 399 |
+
(STATUS_PERSONAL, ["email", "auth_file"], []),
|
| 400 |
+
(STATUS_ORPHAN, ["email"], ["auth_file"]),
|
| 401 |
+
]
|
| 402 |
+
for status, must_have, must_none in cases:
|
| 403 |
+
acc = AccountRecord(...) # 用工厂构造
|
| 404 |
+
for k in must_have:
|
| 405 |
+
assert getattr(acc, k) is not None
|
| 406 |
+
for k in must_none:
|
| 407 |
+
assert getattr(acc, k) is None
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def test_pydantic_account_record_round_trip():
|
| 411 |
+
"""JSON 序列化 / 反序列化后字段不丢失"""
|
| 412 |
+
acc = AccountRecord(
|
| 413 |
+
email="t@example.com", password="x", status="active",
|
| 414 |
+
seat_type="codex", auth_file="/auths/codex-t.json",
|
| 415 |
+
plan_supported=True, plan_type_raw="team",
|
| 416 |
+
created_at=1714000000.0,
|
| 417 |
+
)
|
| 418 |
+
js = acc.model_dump_json()
|
| 419 |
+
acc2 = AccountRecord.model_validate_json(js)
|
| 420 |
+
assert acc2.status == "active"
|
| 421 |
+
assert acc2.plan_supported is True
|
| 422 |
+
```
|
| 423 |
+
|
| 424 |
+
### 6.3 完整 accounts.json 样本
|
| 425 |
+
|
| 426 |
+
```json
|
| 427 |
+
[
|
| 428 |
+
{
|
| 429 |
+
"email": "alice-team@example.com",
|
| 430 |
+
"password": "abc",
|
| 431 |
+
"cloudmail_account_id": "cm-1",
|
| 432 |
+
"status": "active",
|
| 433 |
+
"seat_type": "chatgpt",
|
| 434 |
+
"workspace_account_id": "ws-100",
|
| 435 |
+
"auth_file": "/abs/auths/codex-alice-team-team-deadbeef.json",
|
| 436 |
+
"quota_exhausted_at": null,
|
| 437 |
+
"quota_resets_at": null,
|
| 438 |
+
"last_quota_check_at": 1714050000.0,
|
| 439 |
+
"last_quota": {
|
| 440 |
+
"primary_pct": 35,
|
| 441 |
+
"primary_resets_at": 1714060000,
|
| 442 |
+
"primary_total": 100,
|
| 443 |
+
"primary_remaining": 65,
|
| 444 |
+
"weekly_pct": 10,
|
| 445 |
+
"weekly_resets_at": 1714600000
|
| 446 |
+
},
|
| 447 |
+
"last_active_at": 1714050000.0,
|
| 448 |
+
"created_at": 1714000000.0,
|
| 449 |
+
"plan_supported": true,
|
| 450 |
+
"plan_type_raw": "team",
|
| 451 |
+
"last_kicked_at": null
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
"email": "bob-self-serve@example.com",
|
| 455 |
+
"password": "xyz",
|
| 456 |
+
"cloudmail_account_id": "cm-2",
|
| 457 |
+
"status": "auth_invalid",
|
| 458 |
+
"seat_type": "codex",
|
| 459 |
+
"workspace_account_id": "ws-100",
|
| 460 |
+
"auth_file": null,
|
| 461 |
+
"quota_exhausted_at": null,
|
| 462 |
+
"quota_resets_at": null,
|
| 463 |
+
"last_quota_check_at": null,
|
| 464 |
+
"last_quota": null,
|
| 465 |
+
"last_active_at": null,
|
| 466 |
+
"created_at": 1714000000.0,
|
| 467 |
+
"plan_supported": false,
|
| 468 |
+
"plan_type_raw": "self_serve_business_usage_based",
|
| 469 |
+
"last_kicked_at": null
|
| 470 |
+
},
|
| 471 |
+
{
|
| 472 |
+
"email": "charlie-kicked@example.com",
|
| 473 |
+
"password": "def",
|
| 474 |
+
"cloudmail_account_id": "cm-3",
|
| 475 |
+
"status": "auth_invalid",
|
| 476 |
+
"seat_type": "chatgpt",
|
| 477 |
+
"workspace_account_id": "ws-100",
|
| 478 |
+
"auth_file": "/abs/auths/codex-charlie-team-team-cafe1234.json",
|
| 479 |
+
"quota_exhausted_at": null,
|
| 480 |
+
"quota_resets_at": null,
|
| 481 |
+
"last_quota_check_at": 1714050000.0,
|
| 482 |
+
"last_quota": null,
|
| 483 |
+
"last_active_at": 1714045000.0,
|
| 484 |
+
"created_at": 1714000000.0,
|
| 485 |
+
"plan_supported": true,
|
| 486 |
+
"plan_type_raw": "team",
|
| 487 |
+
"last_kicked_at": 1714050000.0
|
| 488 |
+
}
|
| 489 |
+
]
|
| 490 |
+
```
|
| 491 |
+
|
| 492 |
+
---
|
| 493 |
+
|
| 494 |
+
## 7. 不变量(Invariants)
|
| 495 |
+
|
| 496 |
+
- **I1**:任何状态变更必须经过 `update_account` 入口,**禁止**直接 dict 改写后 `save_accounts`(避免漏触发持久化)
|
| 497 |
+
- **I2**:`STATUS_ACTIVE` 必须有 `auth_file`(注册收尾或 reinvite 验证后写入);任何把 active 设回但不写 auth_file 的代码都是 bug
|
| 498 |
+
- **I3**:`STATUS_AUTH_INVALID` 不能有 `quota_exhausted_at`(因为不会自然恢复);写时必须清空
|
| 499 |
+
- **I4**:`STATUS_PERSONAL` 是终态,不能回到 active(转换路径只有 manual_account 重新注册或新 OAuth bundle)
|
| 500 |
+
- **I5**:`STATUS_ORPHAN` 必须 `auth_file == None`(定义:占席位但本地无凭证);`auth_file` 存在的应判 active / standby / auth_invalid
|
| 501 |
+
- **I6**:`last_kicked_at` 字段一旦写入,后续状态转移不能清掉(用于 reconcile 历史回放;只在 delete_account 时随记录一起删)
|
| 502 |
+
- **I7**:reconcile_anomalies(`manager.py:161-471`)对 `auth_invalid` 的 KICK 行为必须保持幂等(重复 KICK 不抛异常,`kick_status="already_absent"` 视为成功)
|
| 503 |
+
- **I8**:状态白名单变更(新增枚举)需要全局检查 4 处:`Dashboard.vue` statusClass / `cpa_sync.py` 同步规则 / `sync_account_states` 处置 / 本 spec §4.2
|
| 504 |
+
|
| 505 |
+
---
|
| 506 |
+
|
| 507 |
+
## 附录 A:状态机变更历史
|
| 508 |
+
|
| 509 |
+
| 版本 | 时间 | 变更 |
|
| 510 |
+
|---|---|---|
|
| 511 |
+
| v0.1 | round-1 | 初始 4 状态(active/exhausted/standby/pending) |
|
| 512 |
+
| v0.2 | round-2 | 加 personal |
|
| 513 |
+
| v0.3 | round-3 | 加 auth_invalid + orphan(commit cf2f7d3) |
|
| 514 |
+
| v1.0 | 2026-04-26 PRD-2 | 加 last_kicked_at / plan_supported / plan_type_raw 字段;补全转移规则;不新增 STATUS_PHONE_REQUIRED(复用 auth_invalid + register_failures) |
|
| 515 |
+
|
| 516 |
+
---
|
| 517 |
+
|
| 518 |
+
**文档结束。** 工程师据此可直接编写 7 状态 + 转移点的代码改造、Pydantic 模型、单测,不需额外决策。
|
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Shared SPEC: OAuth add-phone 探针
|
| 2 |
+
|
| 3 |
+
## 0. 元数据 + 引用方
|
| 4 |
+
|
| 5 |
+
| 字段 | 内容 |
|
| 6 |
+
|---|---|
|
| 7 |
+
| 名称 | OAuth 流程 add-phone 探针接入与异常分类 |
|
| 8 |
+
| 版本 | v1.0 (2026-04-26) |
|
| 9 |
+
| 主题归属 | `login_codex_via_browser` 中的 4 个探针接入点 + 5 个调用方分类处置 |
|
| 10 |
+
| 引用方 | PRD-2 / spec-2-account-lifecycle.md / FR-C1~C5 |
|
| 11 |
+
| 共因 | synthesis §1 共因 C;Issue#4 + Issue#6 |
|
| 12 |
+
| 不在范围 | invite 注册阶段的 add-phone 检测(已实现,见 `invite.py:106-145`)、SMS pool 自动绑定(违反 ToS,非目标) |
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 1. 概念定义
|
| 17 |
+
|
| 18 |
+
| 术语 | 定义 |
|
| 19 |
+
|---|---|
|
| 20 |
+
| `add-phone` | OpenAI 在 OAuth 流程中触发的强制手机号验证页(URL 含 `add-phone` / `verify-phone` 等),命中后用户必须绑定手机才能继续 |
|
| 21 |
+
| `RegisterBlocked` | 现有异常类(`invite.py:53-66`),用于注册流程被风控阻断时上抛;含 `is_phone` / `is_duplicate` / `step` / `reason` 字段 |
|
| 22 |
+
| `assert_not_blocked` | 现有探针函数(`invite.py:138-145`),命中 add-phone 或 duplicate 立刻 raise |
|
| 23 |
+
| `detect_phone_verification` | 现有检测函数(`invite.py:106-126`),URL 强信号 + 文本+输入框组合信号 |
|
| 24 |
+
| `oauth_phone_blocked` | 新增 register_failures category,与注册阶段的 `phone_blocked` 区分(便于统计 OAuth vs 注册的命中率) |
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 2. 完整数据契约
|
| 29 |
+
|
| 30 |
+
### 2.1 现有契约(直接复用,不改动)
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
# src/autoteam/invite.py L53-66 已存在
|
| 34 |
+
class RegisterBlocked(Exception):
|
| 35 |
+
"""
|
| 36 |
+
注册流程被风控或确定性错误阻断时抛出;调用方按 reason 做分流处理:
|
| 37 |
+
- is_phone=True: OpenAI 要求手机验证,当前账号放弃(用户明确不绕过)
|
| 38 |
+
- is_duplicate=True: 邮箱已被占用,当前账号放弃,换邮箱重来
|
| 39 |
+
- 其他: 单步逻辑错误,按现有 retry 流程处理
|
| 40 |
+
"""
|
| 41 |
+
def __init__(self, step: str, reason: str, *, is_phone: bool = False, is_duplicate: bool = False):
|
| 42 |
+
super().__init__(f"[{step}] {reason}")
|
| 43 |
+
self.step: str = step
|
| 44 |
+
self.reason: str = reason
|
| 45 |
+
self.is_phone: bool = is_phone
|
| 46 |
+
self.is_duplicate: bool = is_duplicate
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# src/autoteam/invite.py L106 已存在
|
| 50 |
+
def detect_phone_verification(page) -> bool:
|
| 51 |
+
"""若当前页面要求手机验证返回 True。URL 命中优先;文本命中需配合电话输入框。"""
|
| 52 |
+
...
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# src/autoteam/invite.py L138 已存在
|
| 56 |
+
def assert_not_blocked(page, step: str) -> None:
|
| 57 |
+
"""任何步骤后调用,检测到阻断项立刻 raise RegisterBlocked。"""
|
| 58 |
+
...
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### 2.2 新增契约(本 spec 引入)
|
| 62 |
+
|
| 63 |
+
```python
|
| 64 |
+
# src/autoteam/register_failures.py 文档化扩 category(只是注释,不改代码逻辑)
|
| 65 |
+
def record_failure(email: str, category: str, reason: str, **extra) -> None:
|
| 66 |
+
"""
|
| 67 |
+
category: 'phone_blocked' 注册阶段命中 add-phone(invite.register_with_invite)
|
| 68 |
+
'oauth_phone_blocked' OAuth 阶段命中 add-phone(login_codex_via_browser) ★新增
|
| 69 |
+
'duplicate_exhausted' 邮箱重复
|
| 70 |
+
'register_failed' 通用注册失败
|
| 71 |
+
'oauth_failed' OAuth 通用失败(无 phone 子分类时)
|
| 72 |
+
'kick_failed' kick 失败
|
| 73 |
+
'team_oauth_failed' Team 阶段 OAuth 失败
|
| 74 |
+
'exception' 浏览器/网络异常
|
| 75 |
+
'plan_unsupported' plan_type 不在白名单(见 plan-type-whitelist.md) ★新增
|
| 76 |
+
'no_quota_assigned' wham/usage 返回 no_quota(见 quota-classification.md) ★新增
|
| 77 |
+
'plan_drift' reinvite 拿到非 team plan(见 spec-2 §3.3) ★新增
|
| 78 |
+
'auth_error_at_oauth' 注册收尾 wham 401/403 ★新增
|
| 79 |
+
'quota_probe_network_error' 注册收尾 wham 网络异常 ★新增
|
| 80 |
+
"""
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### 2.3 OAuth 域定制规则(差异于注册阶段)
|
| 84 |
+
|
| 85 |
+
```python
|
| 86 |
+
# src/autoteam/codex_auth.py 顶部新增(本地常量,不动 invite.py)
|
| 87 |
+
# OAuth consent 页可能含 "phone" 帮助链接 / footer 文字,但**没有**可见 input[type=tel]。
|
| 88 |
+
# 对 OAuth 域采用更严格规则:URL 命中是强信号(直接判 True);文本命中**必须**配合
|
| 89 |
+
# 可见 tel input,且 tel input 不能在 footer / aside 等次要区域。
|
| 90 |
+
_OAUTH_PHONE_URL_HINTS = ("verify-phone", "add-phone", "/phone", "phone_verification", "phone-number")
|
| 91 |
+
_OAUTH_PHONE_TEXT_HINTS = ( # 严格子集,移除 "phone verification" 这种容易误命中 consent 页解释文案的
|
| 92 |
+
"verify your phone",
|
| 93 |
+
"add your phone",
|
| 94 |
+
"verification code to your phone",
|
| 95 |
+
"add a phone number",
|
| 96 |
+
"add a phone",
|
| 97 |
+
"enter your phone",
|
| 98 |
+
"we'll text you",
|
| 99 |
+
"请输入手机号",
|
| 100 |
+
"手机号码",
|
| 101 |
+
"验证手机",
|
| 102 |
+
"添加手机",
|
| 103 |
+
)
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## 3. 行为契约
|
| 109 |
+
|
| 110 |
+
### 3.1 函数签名(所有现有,本 spec 仅约束调用方)
|
| 111 |
+
|
| 112 |
+
```python
|
| 113 |
+
def detect_phone_verification(page) -> bool: ...
|
| 114 |
+
def assert_not_blocked(page, step: str) -> None:
|
| 115 |
+
"""命中 add-phone → raise RegisterBlocked(step, "add-phone 手机验证", is_phone=True)
|
| 116 |
+
命中 duplicate → raise RegisterBlocked(step, "duplicate email", is_duplicate=True)
|
| 117 |
+
未命中 → 返回 None
|
| 118 |
+
"""
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
### 3.2 前置条件
|
| 122 |
+
|
| 123 |
+
- `page` 是已加载的 Playwright Page 对象(可读 `.url` / `.inner_text` / `.locator`)
|
| 124 |
+
- `step` 是非空字符串,表示触发探针的位点名称(用于异常 `step` 字段 + 失败截图命名)
|
| 125 |
+
- 探针函数本身**不抛**业务异常;Playwright 异常被内部 try/except 吞掉(注释中已说明)
|
| 126 |
+
|
| 127 |
+
### 3.3 后置条件
|
| 128 |
+
|
| 129 |
+
- `assert_not_blocked` 返回 None 表示页面未阻塞,主流程可继续
|
| 130 |
+
- 抛出 `RegisterBlocked` 后,调用方必须按 `is_phone` / `is_duplicate` 分类处置(见 §5)
|
| 131 |
+
- 命中后由调用方负责截图(`_screenshot(page, f"codex_phone_blocked_{step}.png")`)
|
| 132 |
+
|
| 133 |
+
### 3.4 异常传播
|
| 134 |
+
|
| 135 |
+
```
|
| 136 |
+
detect_phone_verification(page)
|
| 137 |
+
└─ 任何 Playwright 异常 → logger.debug + return False(不阻塞)
|
| 138 |
+
└─ 命中 → return True
|
| 139 |
+
|
| 140 |
+
assert_not_blocked(page, step)
|
| 141 |
+
└─ detect_phone_verification(page) == True → raise RegisterBlocked(step, ..., is_phone=True)
|
| 142 |
+
└─ detect_duplicate_email(page) == True → raise RegisterBlocked(step, ..., is_duplicate=True)
|
| 143 |
+
└─ 其他 → return None
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## 4. 4 个探针接入点(精确位置)
|
| 149 |
+
|
| 150 |
+
### 4.1 接入位置图
|
| 151 |
+
|
| 152 |
+
```
|
| 153 |
+
login_codex_via_browser(email, password, mail_client, *, use_personal) # codex_auth.py:250
|
| 154 |
+
│
|
| 155 |
+
├─ step-0 ChatGPT 预登录 + _account cookie 注入(L295-440)
|
| 156 |
+
│ Cloudflare 等待 / 邮箱+OTP / workspace 选择
|
| 157 |
+
│
|
| 158 |
+
├─ step-1 goto auth_url → 邮箱表单(L443-488)
|
| 159 |
+
│ Google redirect 检测(已存在,不变)
|
| 160 |
+
│
|
| 161 |
+
├─ step-2 邮箱 + 密码 + OTP(L489-562)
|
| 162 |
+
│
|
| 163 |
+
├─ ★ C-P1: assert_not_blocked(page, "oauth_about_you") [L568 about-you 入口前]
|
| 164 |
+
│ 插入位置:`if "about-you" in page.url:` 这行**之前**
|
| 165 |
+
│ 理由:about-you 提交后 OpenAI 经常拉一次 add-phone,要在页面切到 about-you 前先确认
|
| 166 |
+
│
|
| 167 |
+
├─ step-3 about-you 填表 + 提交(L568-610)
|
| 168 |
+
│
|
| 169 |
+
├─ ★ C-P2: assert_not_blocked(page, f"oauth_consent_{step}") [L612 consent 循环每轮开头]
|
| 170 |
+
│ 插入位置:`for step in range(10):` 内,每次 try 块的**第一行**
|
| 171 |
+
│ 理由:consent 循环每一步都可能跳到 add-phone,只看 workspace/Continue 按钮看不到
|
| 172 |
+
│
|
| 173 |
+
├─ step-4 consent 10 次循环(L612-882)
|
| 174 |
+
│
|
| 175 |
+
├─ ★ C-P3: assert_not_blocked(page, "oauth_callback_wait") [L884 等 callback 前]
|
| 176 |
+
│ 插入位置:`for _ in range(30):` 这行**之前**
|
| 177 |
+
│ 理由:add-phone 页就是"callback 永远不来"的根因,等之前先拦
|
| 178 |
+
│
|
| 179 |
+
├─ step-5 等 30s callback(L884-906)
|
| 180 |
+
│
|
| 181 |
+
├─ ★ C-P4: assert_not_blocked(page, "oauth_personal_check") [L920 personal 拒收 bundle 之前]
|
| 182 |
+
│ 插入位置:`if use_personal:` 这行**之前**
|
| 183 |
+
│ 理由:防御性 — 通常 callback 前已拦截,但作为最后一道关卡
|
| 184 |
+
│
|
| 185 |
+
└─ _exchange_auth_code(auth_code) → bundle
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
### 4.2 实施代码片段
|
| 189 |
+
|
| 190 |
+
```python
|
| 191 |
+
# 位点 C-P1(在 codex_auth.py:568 之前)
|
| 192 |
+
from autoteam.invite import assert_not_blocked, RegisterBlocked # 文件顶部 import
|
| 193 |
+
|
| 194 |
+
# === 处理 about-you 页面(可能出现在 OAuth 流程中)===
|
| 195 |
+
assert_not_blocked(page, "oauth_about_you") # ★新增
|
| 196 |
+
if "about-you" in page.url:
|
| 197 |
+
logger.info("[Codex] 检测到 about-you 页面,填写个人信息...")
|
| 198 |
+
...
|
| 199 |
+
|
| 200 |
+
# 位点 C-P2(在 codex_auth.py:612 后的 for 循环每轮开头)
|
| 201 |
+
for consent_step in range(10):
|
| 202 |
+
try:
|
| 203 |
+
assert_not_blocked(page, f"oauth_consent_{consent_step}") # ★新增,作为 try 块第一行
|
| 204 |
+
# ... 原有 consent 循环代码 ...
|
| 205 |
+
except RegisterBlocked:
|
| 206 |
+
raise # 不在内层吞,让外层调用方处理
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.debug(...)
|
| 209 |
+
|
| 210 |
+
# 位点 C-P3(在 codex_auth.py:884 之前)
|
| 211 |
+
assert_not_blocked(page, "oauth_callback_wait") # ★新增
|
| 212 |
+
# 等待 redirect callback 获取 auth code
|
| 213 |
+
for _ in range(30):
|
| 214 |
+
if auth_code:
|
| 215 |
+
break
|
| 216 |
+
...
|
| 217 |
+
|
| 218 |
+
# 位点 C-P4(在 codex_auth.py:920 之前,personal 模式校验 plan_type 之前)
|
| 219 |
+
assert_not_blocked(page, "oauth_personal_check") # ★新增,防御性
|
| 220 |
+
if use_personal:
|
| 221 |
+
plan = (bundle.get("plan_type") or "").lower()
|
| 222 |
+
...
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
### 4.3 位点选择理由(摘自 issue#4 D.1.1)
|
| 226 |
+
|
| 227 |
+
| 位点 | 选择理由 |
|
| 228 |
+
|---|---|
|
| 229 |
+
| C-P1 (about-you 前) | OpenAI 在新账号注册的 about-you 页之后**最频繁**拉 add-phone。在切到 about-you 之前先拦,可在最早时间发现风控 |
|
| 230 |
+
| C-P2 (consent 循环每轮) | consent 循环 10 次,每一步都可能跳 add-phone;不在循环里拦,会被当作"workspace 没选好"反复重试 |
|
| 231 |
+
| C-P3 (callback 等待前) | add-phone 阻塞会让 callback 永远不来,30s 等待白白耗费;在等之前先判 |
|
| 232 |
+
| C-P4 (personal 拒收前) | 防御性:正常情况下前 3 个位点已拦下;万一漏过(consent 循环之外的页面),最后兜底 |
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## 5. 调用方处���规范(5 个 login_codex_via_browser 调用点)
|
| 237 |
+
|
| 238 |
+
### 5.1 调用点清单
|
| 239 |
+
|
| 240 |
+
| # | 文件:行号 | 上下文 | use_personal |
|
| 241 |
+
|---|---|---|---|
|
| 242 |
+
| 1 | `manager.py:1057` | `_check_pending_invites` 补登录 | False |
|
| 243 |
+
| 2 | `manager.py:1431` | `_run_post_register_oauth(leave_workspace=True)` 个人模式 | True |
|
| 244 |
+
| 3 | `manager.py:1463` | `_run_post_register_oauth` Team 注册收尾 | False |
|
| 245 |
+
| 4 | `manager.py:2466` | `reinvite_account` standby 复用 | False |
|
| 246 |
+
| 5 | `api.py:1479` | `/api/accounts/{email}/login` 用户触发补登录 | 视 acc.status 而定 |
|
| 247 |
+
|
| 248 |
+
### 5.2 5 个调用方处置矩阵
|
| 249 |
+
|
| 250 |
+
| # | catch RegisterBlocked(is_phone=True) 后 | record_failure 字段 |
|
| 251 |
+
|---|---|---|
|
| 252 |
+
| 1 | `delete_account(email)` + `record_failure(...)` | category="oauth_phone_blocked", stage="check_pending_invites" |
|
| 253 |
+
| 2 | `delete_account(email)` + `record_failure(...)` | category="oauth_phone_blocked", stage="run_post_register_oauth_personal" |
|
| 254 |
+
| 3 | `update_account(email, status=STATUS_AUTH_INVALID)` + `record_failure(...)` + 不删账号(已在 Team) | category="oauth_phone_blocked", stage="run_post_register_oauth_team" |
|
| 255 |
+
| 4 | `_cleanup_team_leftover("oauth_phone_blocked")` + `update_account(email, status=STATUS_AUTH_INVALID, auth_file=None)` | category="oauth_phone_blocked", stage="reinvite_account" |
|
| 256 |
+
| 5 | 转 HTTP 409 + body `{"error": "phone_required", "step": ..., "reason": ...}` + record_failure | category="oauth_phone_blocked", stage="api_login" |
|
| 257 |
+
|
| 258 |
+
### 5.3 通用 try/except 模板
|
| 259 |
+
|
| 260 |
+
```python
|
| 261 |
+
from autoteam.invite import RegisterBlocked
|
| 262 |
+
|
| 263 |
+
try:
|
| 264 |
+
bundle = login_codex_via_browser(email, password, mail_client=mail_client, use_personal=...)
|
| 265 |
+
except RegisterBlocked as blocked:
|
| 266 |
+
if blocked.is_phone:
|
| 267 |
+
record_failure(
|
| 268 |
+
email,
|
| 269 |
+
category="oauth_phone_blocked",
|
| 270 |
+
reason=f"OAuth 阶段触发 add-phone (step={blocked.step})",
|
| 271 |
+
step=blocked.step,
|
| 272 |
+
stage=<调用点名>,
|
| 273 |
+
url=getattr(blocked, "url", None),
|
| 274 |
+
)
|
| 275 |
+
# 调用点特定处置(见 §5.2 矩阵)
|
| 276 |
+
...
|
| 277 |
+
return None
|
| 278 |
+
elif blocked.is_duplicate:
|
| 279 |
+
# OAuth 阶段不应触发 duplicate(账号已注册成功);记 exception 兜底
|
| 280 |
+
record_failure(email, category="exception", reason=f"OAuth 阶段意外 duplicate: {blocked.reason}")
|
| 281 |
+
return None
|
| 282 |
+
raise # 其他 RegisterBlocked 子分类暂不可能,raise 让上层兜底
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
### 5.4 主号路径(不在范围)
|
| 286 |
+
|
| 287 |
+
- `login_codex_via_session` / `SessionCodexAuthFlow` / `MainCodexSyncFlow`(`codex_auth.py` 中段)用于主号 session 复用
|
| 288 |
+
- 主号通常已绑定手机,极罕见撞 add-phone
|
| 289 |
+
- **本 spec 不要求**给主号路径加探针(节省工作量);如未来需要,按 §4.2 模板独立扩展
|
| 290 |
+
|
| 291 |
+
---
|
| 292 |
+
|
| 293 |
+
## 6. 单元测试 fixture 与样本数据
|
| 294 |
+
|
| 295 |
+
### 6.1 mock 页面 fixture
|
| 296 |
+
|
| 297 |
+
```python
|
| 298 |
+
# tests/fixtures/oauth_phone_pages.py
|
| 299 |
+
from unittest.mock import MagicMock
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def make_phone_page(url="https://auth.openai.com/add-phone",
|
| 303 |
+
body_text="please add your phone number to continue",
|
| 304 |
+
has_tel_input=True):
|
| 305 |
+
page = MagicMock()
|
| 306 |
+
page.url = url
|
| 307 |
+
page.inner_text.return_value = body_text
|
| 308 |
+
tel_input = MagicMock()
|
| 309 |
+
tel_input.is_visible.return_value = has_tel_input
|
| 310 |
+
page.locator.return_value.first = tel_input
|
| 311 |
+
return page
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def make_consent_page(url="https://auth.openai.com/authorize/consent",
|
| 315 |
+
body_text="continue with your team workspace"):
|
| 316 |
+
return make_phone_page(url=url, body_text=body_text, has_tel_input=False)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def make_about_you_page(url="https://auth.openai.com/about-you",
|
| 320 |
+
body_text="tell us about yourself, full name, age"):
|
| 321 |
+
return make_phone_page(url=url, body_text=body_text, has_tel_input=False)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
# 误报测试:consent 页含 "phone number" 帮助文字 + footer tel input,但应判 False
|
| 325 |
+
def make_consent_with_phone_link():
|
| 326 |
+
page = MagicMock()
|
| 327 |
+
page.url = "https://auth.openai.com/authorize/consent"
|
| 328 |
+
page.inner_text.return_value = "if you have issues, call our phone support number"
|
| 329 |
+
tel_input = MagicMock()
|
| 330 |
+
tel_input.is_visible.return_value = False # 实际页上没有可见 tel input
|
| 331 |
+
page.locator.return_value.first = tel_input
|
| 332 |
+
return page
|
| 333 |
+
```
|
| 334 |
+
|
| 335 |
+
### 6.2 单测代码
|
| 336 |
+
|
| 337 |
+
```python
|
| 338 |
+
# tests/unit/test_oauth_phone_detection.py
|
| 339 |
+
import pytest
|
| 340 |
+
from autoteam.invite import RegisterBlocked, assert_not_blocked, detect_phone_verification
|
| 341 |
+
from tests.fixtures.oauth_phone_pages import (
|
| 342 |
+
make_phone_page, make_consent_page, make_about_you_page, make_consent_with_phone_link,
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def test_url_hint_strong_signal():
|
| 347 |
+
"""URL 含 add-phone → 立即判 True,不需要 tel input"""
|
| 348 |
+
page = make_phone_page(url="https://auth.openai.com/verify-phone", body_text="", has_tel_input=False)
|
| 349 |
+
assert detect_phone_verification(page) is True
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def test_text_hint_requires_tel_input():
|
| 353 |
+
"""文本命中但无 tel input → 不阻塞(避免帮助文字误报)"""
|
| 354 |
+
page = make_consent_with_phone_link()
|
| 355 |
+
assert detect_phone_verification(page) is False
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def test_text_hint_with_tel_input():
|
| 359 |
+
"""文本命中 + tel input 可见 → 判 True"""
|
| 360 |
+
page = make_phone_page(
|
| 361 |
+
url="https://auth.openai.com/authorize",
|
| 362 |
+
body_text="add your phone number to continue",
|
| 363 |
+
has_tel_input=True,
|
| 364 |
+
)
|
| 365 |
+
assert detect_phone_verification(page) is True
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def test_assert_not_blocked_raises_on_phone():
|
| 369 |
+
page = make_phone_page()
|
| 370 |
+
with pytest.raises(RegisterBlocked) as exc_info:
|
| 371 |
+
assert_not_blocked(page, "oauth_about_you")
|
| 372 |
+
assert exc_info.value.is_phone is True
|
| 373 |
+
assert exc_info.value.step == "oauth_about_you"
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def test_assert_not_blocked_passes_on_consent():
|
| 377 |
+
page = make_consent_page()
|
| 378 |
+
assert_not_blocked(page, "oauth_consent_0") # 不抛
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def test_about_you_page_does_not_trigger():
|
| 382 |
+
"""about-you 页应当通过(避免在过早位点误报)"""
|
| 383 |
+
page = make_about_you_page()
|
| 384 |
+
assert detect_phone_verification(page) is False
|
| 385 |
+
```
|
| 386 |
+
|
| 387 |
+
### 6.3 集成测试样本
|
| 388 |
+
|
| 389 |
+
```python
|
| 390 |
+
# tests/integration/test_oauth_phone_blocked_flow.py
|
| 391 |
+
from unittest.mock import patch
|
| 392 |
+
from autoteam.invite import RegisterBlocked
|
| 393 |
+
from autoteam.manager import _run_post_register_oauth
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def test_post_register_oauth_team_phone_blocked():
|
| 397 |
+
"""Team 模式 OAuth 命中 add-phone → 标 STATUS_AUTH_INVALID + record_failure"""
|
| 398 |
+
with patch("autoteam.manager.login_codex_via_browser") as mock_login:
|
| 399 |
+
mock_login.side_effect = RegisterBlocked("oauth_consent_2", "add-phone", is_phone=True)
|
| 400 |
+
with patch("autoteam.manager.record_failure") as mock_rec:
|
| 401 |
+
_run_post_register_oauth("test@example.com", "pwd", mail_client=None, leave_workspace=False)
|
| 402 |
+
mock_rec.assert_called_with(
|
| 403 |
+
"test@example.com",
|
| 404 |
+
category="oauth_phone_blocked",
|
| 405 |
+
reason=pytest.approx("OAuth 阶段触发 add-phone (step=oauth_consent_2)"),
|
| 406 |
+
step="oauth_consent_2",
|
| 407 |
+
stage="run_post_register_oauth_team",
|
| 408 |
+
url=None,
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def test_reinvite_account_phone_blocked_kicks_team_leftover():
|
| 413 |
+
"""reinvite 命中 add-phone → _cleanup_team_leftover + STATUS_AUTH_INVALID(不进 STANDBY)"""
|
| 414 |
+
# 略,见 spec-2-account-lifecycle.md §5
|
| 415 |
+
```
|
| 416 |
+
|
| 417 |
+
---
|
| 418 |
+
|
| 419 |
+
## 7. 不变量(Invariants)
|
| 420 |
+
|
| 421 |
+
- **I1**:`assert_not_blocked` 在 `login_codex_via_browser` 内被调用时,任何已有的 `try/except Exception` 块**禁止**吞掉 `RegisterBlocked`(必须显式 `except RegisterBlocked: raise`)
|
| 422 |
+
- **I2**:5 个 login_codex_via_browser 调用方**必须**显式 `except RegisterBlocked`,不允许让异常裸奔到 cmd_fill 等顶层
|
| 423 |
+
- **I3**:`record_failure` 中 `category="oauth_phone_blocked"` 的记录**必须**携带 `step` 和 `stage` 字段(用于事后统计)
|
| 424 |
+
- **I4**:add-phone 命中后,调用方**必须**先调 `_screenshot(page, f"codex_phone_blocked_{step}.png")`(在 raise 之前),否则页面 close 后无法回放
|
| 425 |
+
- **I5**:`detect_phone_verification` 不能改成"无 tel input 也判 True":会让 OAuth consent 页含 phone 帮助链接的场景大量误报
|
| 426 |
+
- **I6**:OAuth 流程的 RegisterBlocked 不重试 — 命中即放弃账号(用户硬要求"不要脱离原本流程太多");任何"撞了再试一次"的逻辑都属于范围外
|
| 427 |
+
- **I7**:`STATUS_PHONE_REQUIRED` 不新增到 `accounts.py`;复用 `STATUS_AUTH_INVALID` + `register_failures.category` 区分
|
| 428 |
+
|
| 429 |
+
---
|
| 430 |
+
|
| 431 |
+
## 附录 A:误报回放与运营校准
|
| 432 |
+
|
| 433 |
+
每次 `oauth_phone_blocked` 命中后:
|
| 434 |
+
1. 截图保存 `screenshots/codex_phone_blocked_{step}_{ts}.png`
|
| 435 |
+
2. `register_failures.json` 记录 `step` / `stage` / `url` / `email`
|
| 436 |
+
3. 运营定期(每周)抽样回放,确认未发生误报
|
| 437 |
+
4. 如发现误报集中在某 step:补充 `_OAUTH_PHONE_TEXT_HINTS` 排除规则,而非全局放宽
|
| 438 |
+
|
| 439 |
+
---
|
| 440 |
+
|
| 441 |
+
**文档结束。** 工程师据此可直接编写 4 处 `assert_not_blocked` 接入 + 5 处调用方 try/except + 单元/集成测试,无需额外决策。
|
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Shared SPEC: plan_type 白名单
|
| 2 |
+
|
| 3 |
+
## 0. 元数据 + 引用方
|
| 4 |
+
|
| 5 |
+
| 字段 | 内容 |
|
| 6 |
+
|---|---|
|
| 7 |
+
| 名称 | plan_type 白名单与不支持套餐处置 |
|
| 8 |
+
| 版本 | v1.0 (2026-04-26) |
|
| 9 |
+
| 主题归属 | OAuth bundle 解析 + 账号入池前置校验 |
|
| 10 |
+
| 引用方 | PRD-2 / spec-2-account-lifecycle.md / FR-A1~A5 |
|
| 11 |
+
| 共因 | synthesis §1 共因 A;Issue#2 + Issue#6 |
|
| 12 |
+
| 不在范围 | seat_type 决策(见 PRD-2 FR-F1~F6)、quota 分类(见 [`./quota-classification.md`](./quota-classification.md)) |
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 1. 概念定义
|
| 17 |
+
|
| 18 |
+
| 术语 | 定义 |
|
| 19 |
+
|---|---|
|
| 20 |
+
| `plan_type` | OpenAI 在 OAuth id_token JWT claim `https://api.openai.com/auth.chatgpt_plan_type` 中下发的字符串字面量,标识当前 workspace 的套餐类型 |
|
| 21 |
+
| `SUPPORTED_PLAN_TYPES` | 本系统能够正确处理(注册→入池→Codex 调用)的 plan_type 集合 |
|
| 22 |
+
| `unsupported plan_type` | 出现在 OAuth bundle 中但不在白名单内的字面量,例如 `self_serve_business_usage_based`、`enterprise` |
|
| 23 |
+
| `plan_type_raw` | 落盘到 `accounts.json` 的原始字面量(.lower() 后),用于事后排查 |
|
| 24 |
+
| `plan_supported` | 落盘到 `accounts.json` 的布尔值,反映当时 OAuth bundle 是否在白名单内 |
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 2. 完整数据契约
|
| 29 |
+
|
| 30 |
+
### 2.1 常量集(置于 `src/autoteam/accounts.py`)
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
# accounts.py 顶部新增
|
| 34 |
+
SUPPORTED_PLAN_TYPES: frozenset[str] = frozenset({
|
| 35 |
+
"team", # ChatGPT Team workspace,本系统主要工作池
|
| 36 |
+
"free", # 已退出 Team 的个人 free,personal 子号路径
|
| 37 |
+
"plus", # 个人付费,允许通过 manual_account 手动添加
|
| 38 |
+
"pro", # 个人 Pro,同上
|
| 39 |
+
})
|
| 40 |
+
"""
|
| 41 |
+
支持的 plan_type 白名单。
|
| 42 |
+
|
| 43 |
+
不在此集合内的字面量(如 self_serve_business_usage_based / enterprise / unknown)
|
| 44 |
+
均视为 unsupported,触发 STATUS_AUTH_INVALID + register_failures.category="plan_unsupported"。
|
| 45 |
+
|
| 46 |
+
新增字面量需经过测试验证(quota 是否能正常拉取、Codex 调用是否能用)后再加入白名单。
|
| 47 |
+
"""
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### 2.2 工具函数
|
| 51 |
+
|
| 52 |
+
```python
|
| 53 |
+
# accounts.py 紧随常量定义之后
|
| 54 |
+
def is_supported_plan(plan_type: str | None) -> bool:
|
| 55 |
+
"""判定 plan_type 是否在白名单内。
|
| 56 |
+
|
| 57 |
+
None / 空串 / "unknown" 一律返回 False。
|
| 58 |
+
比对前先 .lower().strip(),避免 OpenAI 后端大小写漂移。
|
| 59 |
+
"""
|
| 60 |
+
if not plan_type:
|
| 61 |
+
return False
|
| 62 |
+
return plan_type.strip().lower() in SUPPORTED_PLAN_TYPES
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def normalize_plan_type(plan_type: str | None) -> str:
|
| 66 |
+
"""归一化用于落盘 / 比对的 plan_type。
|
| 67 |
+
|
| 68 |
+
None → "unknown",其余统一 .lower().strip()。
|
| 69 |
+
"""
|
| 70 |
+
if not plan_type:
|
| 71 |
+
return "unknown"
|
| 72 |
+
return plan_type.strip().lower()
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### 2.3 OAuth bundle 字段扩展(`codex_auth.py:_exchange_auth_code` L100-116)
|
| 76 |
+
|
| 77 |
+
```python
|
| 78 |
+
# 现有
|
| 79 |
+
bundle = {
|
| 80 |
+
"access_token": ...,
|
| 81 |
+
"refresh_token": ...,
|
| 82 |
+
"id_token": ...,
|
| 83 |
+
"account_id": ...,
|
| 84 |
+
"email": ...,
|
| 85 |
+
"plan_type": auth_claims.get("chatgpt_plan_type", "unknown"),
|
| 86 |
+
"expired": ...,
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
# 新增字段(在已有字段之后追加,不破坏旧字段)
|
| 90 |
+
bundle["plan_type_raw"] = bundle["plan_type"] # 原始字面量,事后排查用
|
| 91 |
+
bundle["plan_type"] = normalize_plan_type(bundle["plan_type"]) # 归一化后的小写值
|
| 92 |
+
bundle["plan_supported"] = is_supported_plan(bundle["plan_type"])
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
## 3. 行为契约
|
| 98 |
+
|
| 99 |
+
### 3.1 函数签名
|
| 100 |
+
|
| 101 |
+
```python
|
| 102 |
+
def is_supported_plan(plan_type: str | None) -> bool: ...
|
| 103 |
+
def normalize_plan_type(plan_type: str | None) -> str: ...
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
### 3.2 前置条件
|
| 107 |
+
|
| 108 |
+
- 调用 `is_supported_plan` 不要求参数已 lower(函数内会处理)
|
| 109 |
+
- `SUPPORTED_PLAN_TYPES` 是 `frozenset`,运行时不可修改
|
| 110 |
+
- 任何对 `bundle["plan_type"]` 的判定**必须**先经过 `normalize_plan_type` 或 `is_supported_plan`
|
| 111 |
+
|
| 112 |
+
### 3.3 后置条件
|
| 113 |
+
|
| 114 |
+
- `normalize_plan_type(None) == "unknown"`,`normalize_plan_type("Team") == "team"`
|
| 115 |
+
- `is_supported_plan` 不抛异常(对任何输入都给出布尔值)
|
| 116 |
+
- bundle 含 `plan_supported` 字段后,所有下游消费方应只读该字段而非自己 .lower() 比对
|
| 117 |
+
|
| 118 |
+
### 3.4 异常类型
|
| 119 |
+
|
| 120 |
+
- 工具函数本身不抛异常
|
| 121 |
+
- 触发 unsupported 的下游处置依赖 `register_failures.record_failure`,该函数不向外抛异常
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
## 4. 决策矩阵
|
| 126 |
+
|
| 127 |
+
### 4.1 OAuth bundle 检查 → 处置矩阵
|
| 128 |
+
|
| 129 |
+
| 场景 | bundle["plan_type"] | bundle["plan_supported"] | 处置 |
|
| 130 |
+
|---|---|---|---|
|
| 131 |
+
| Team 注册成功 | "team" | True | 沿用现行流程 → STATUS_ACTIVE(配额另判,见 quota-classification.md) |
|
| 132 |
+
| Personal OAuth 成功 | "free" | True | 沿用现行流程 → STATUS_PERSONAL |
|
| 133 |
+
| 手动添加 Plus 号 | "plus" | True | manual_account 走 STATUS_STANDBY(plus 无 Team 调度) |
|
| 134 |
+
| 手动添加 Pro 号 | "pro" | True | 同 plus |
|
| 135 |
+
| **新计费 workspace** | "self_serve_business_usage_based" | False | **STATUS_AUTH_INVALID + record_failure("plan_unsupported")** |
|
| 136 |
+
| **企业版 workspace** | "enterprise" | False | 同上 |
|
| 137 |
+
| JWT 缺 claim | "unknown" | False | 同上(标 plan_unsupported,理由是无法判定) |
|
| 138 |
+
| **��写漂移** | "Team" → 归一为 "team" | True | 与 team 等价 |
|
| 139 |
+
|
| 140 |
+
### 4.2 6 个调用点处置矩阵
|
| 141 |
+
|
| 142 |
+
| 调用点 | 文件:行号 | 旧处置 | 新处置 |
|
| 143 |
+
|---|---|---|---|
|
| 144 |
+
| 1. OAuth bundle 落盘 | `codex_auth.py:111` | 直接落 `plan_type` | 落 `plan_type_raw` + `plan_type` + `plan_supported` |
|
| 145 |
+
| 2. 手动添加完成 | `manual_account.py:233` | `if plan_type == "team"` 二元分支 | `if not bundle["plan_supported"]` → STATUS_AUTH_INVALID + record_failure;否则按 plan_type 走原 seat_type 决策 |
|
| 146 |
+
| 3. Team 注册收尾 | `manager.py:1467-1478` | `seat_label = "chatgpt" if bundle_plan == "team" else "codex"` | 先判 `plan_supported`,False → STATUS_AUTH_INVALID + record_failure;True 时再判 plan_type 决定 seat |
|
| 147 |
+
| 4. personal 注册收尾 | `manager.py:1431-1460`(use_personal=True 分支) | `if plan_type != "free": delete + oauth_failed` | `if not plan_supported` 优先级最高 → record_failure("plan_unsupported");其次保留 plan != "free" 的拒收 |
|
| 148 |
+
| 5. reinvite_account 校验 | `manager.py:2489-2494` | `plan_type != "team": _cleanup + STANDBY` | 增加一道 `plan_supported` 判定:False → STATUS_AUTH_INVALID + record_failure("plan_unsupported");True 但 plan != "team" → 沿用 plan_drift 路径 |
|
| 149 |
+
| 6. cpa_sync 文件名推断 | `cpa_sync.py:132-140` | `team / plus / free` 子串 | 不改判定逻辑,但记录到的 plan_type 走 normalize_plan_type |
|
| 150 |
+
|
| 151 |
+
---
|
| 152 |
+
|
| 153 |
+
## 5. 调用方处置规范
|
| 154 |
+
|
| 155 |
+
### 5.1 共通原则
|
| 156 |
+
|
| 157 |
+
```python
|
| 158 |
+
# 任何 OAuth bundle 消费方都必须按这个顺序检查
|
| 159 |
+
if not bundle.get("plan_supported"):
|
| 160 |
+
record_failure(
|
| 161 |
+
email,
|
| 162 |
+
category="plan_unsupported",
|
| 163 |
+
reason=f"OAuth bundle plan_type={bundle.get('plan_type_raw')} 不在白名单",
|
| 164 |
+
plan_type=bundle.get("plan_type"),
|
| 165 |
+
plan_type_raw=bundle.get("plan_type_raw"),
|
| 166 |
+
stage=<调用点名称>,
|
| 167 |
+
)
|
| 168 |
+
update_account(email, status=STATUS_AUTH_INVALID, plan_type_raw=bundle.get("plan_type_raw"))
|
| 169 |
+
return None # 或上游约定的失败值
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
### 5.2 各调用方差异
|
| 173 |
+
|
| 174 |
+
| 调用方 | 差异处置 |
|
| 175 |
+
|---|---|
|
| 176 |
+
| `manual_account._finalize_account` | unsupported → 不写 last_quota / auth_file 不删(保留供人工排查) |
|
| 177 |
+
| `manager._run_post_register_oauth` (Team) | unsupported + bundle 已成功 → kick 该 email 出 Team(避免占席位)+ STATUS_AUTH_INVALID |
|
| 178 |
+
| `manager._run_post_register_oauth` (personal) | unsupported → delete_account + record_failure(因为已 leave_workspace,本地无价值) |
|
| 179 |
+
| `manager.reinvite_account` | unsupported → `_cleanup_team_leftover("plan_unsupported")` + STATUS_AUTH_INVALID + auth_file=None |
|
| 180 |
+
|
| 181 |
+
---
|
| 182 |
+
|
| 183 |
+
## 6. 单元测试 fixture 与样本数据
|
| 184 |
+
|
| 185 |
+
### 6.1 输入样本(yaml)
|
| 186 |
+
|
| 187 |
+
```yaml
|
| 188 |
+
# tests/fixtures/plan_type_samples.yaml
|
| 189 |
+
supported:
|
| 190 |
+
- input: "team"
|
| 191 |
+
expected_supported: true
|
| 192 |
+
expected_normalized: "team"
|
| 193 |
+
- input: "Team"
|
| 194 |
+
expected_supported: true
|
| 195 |
+
expected_normalized: "team"
|
| 196 |
+
- input: "FREE"
|
| 197 |
+
expected_supported: true
|
| 198 |
+
expected_normalized: "free"
|
| 199 |
+
- input: " plus "
|
| 200 |
+
expected_supported: true
|
| 201 |
+
expected_normalized: "plus"
|
| 202 |
+
- input: "pro"
|
| 203 |
+
expected_supported: true
|
| 204 |
+
expected_normalized: "pro"
|
| 205 |
+
|
| 206 |
+
unsupported:
|
| 207 |
+
- input: "self_serve_business_usage_based"
|
| 208 |
+
expected_supported: false
|
| 209 |
+
expected_normalized: "self_serve_business_usage_based"
|
| 210 |
+
- input: "chatgpt_business_usage_based"
|
| 211 |
+
expected_supported: false
|
| 212 |
+
expected_normalized: "chatgpt_business_usage_based"
|
| 213 |
+
- input: "enterprise"
|
| 214 |
+
expected_supported: false
|
| 215 |
+
expected_normalized: "enterprise"
|
| 216 |
+
- input: "unknown"
|
| 217 |
+
expected_supported: false
|
| 218 |
+
expected_normalized: "unknown"
|
| 219 |
+
- input: ""
|
| 220 |
+
expected_supported: false
|
| 221 |
+
expected_normalized: "unknown"
|
| 222 |
+
- input: null
|
| 223 |
+
expected_supported: false
|
| 224 |
+
expected_normalized: "unknown"
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
### 6.2 完整测试用例
|
| 228 |
+
|
| 229 |
+
```python
|
| 230 |
+
# tests/unit/test_plan_type_whitelist.py
|
| 231 |
+
import pytest
|
| 232 |
+
import yaml
|
| 233 |
+
from pathlib import Path
|
| 234 |
+
from autoteam.accounts import is_supported_plan, normalize_plan_type, SUPPORTED_PLAN_TYPES
|
| 235 |
+
|
| 236 |
+
FIXTURE = yaml.safe_load(Path("tests/fixtures/plan_type_samples.yaml").read_text())
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@pytest.mark.parametrize("case", FIXTURE["supported"])
|
| 240 |
+
def test_supported_plan_types(case):
|
| 241 |
+
assert is_supported_plan(case["input"]) is True
|
| 242 |
+
assert normalize_plan_type(case["input"]) == case["expected_normalized"]
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@pytest.mark.parametrize("case", FIXTURE["unsupported"])
|
| 246 |
+
def test_unsupported_plan_types(case):
|
| 247 |
+
assert is_supported_plan(case["input"]) is False
|
| 248 |
+
assert normalize_plan_type(case["input"]) == case["expected_normalized"]
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def test_whitelist_contains_only_lowercase():
|
| 252 |
+
"""SUPPORTED_PLAN_TYPES 不能含大写,否则 is_supported_plan 比对会失败"""
|
| 253 |
+
for plan in SUPPORTED_PLAN_TYPES:
|
| 254 |
+
assert plan == plan.lower(), f"{plan} 必须全小写"
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def test_whitelist_is_frozen():
|
| 258 |
+
"""SUPPORTED_PLAN_TYPES 必须是 frozenset,运行时不可变"""
|
| 259 |
+
assert isinstance(SUPPORTED_PLAN_TYPES, frozenset)
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
### 6.3 OAuth bundle 集成 fixture
|
| 263 |
+
|
| 264 |
+
```json
|
| 265 |
+
{
|
| 266 |
+
"bundle_team_supported": {
|
| 267 |
+
"access_token": "sk-***",
|
| 268 |
+
"refresh_token": "rt-***",
|
| 269 |
+
"id_token": "ey***",
|
| 270 |
+
"account_id": "acc-12345",
|
| 271 |
+
"email": "test@example.com",
|
| 272 |
+
"plan_type": "team",
|
| 273 |
+
"plan_type_raw": "team",
|
| 274 |
+
"plan_supported": true,
|
| 275 |
+
"expired": 1714123200.0
|
| 276 |
+
},
|
| 277 |
+
"bundle_self_serve_business": {
|
| 278 |
+
"access_token": "sk-***",
|
| 279 |
+
"refresh_token": "rt-***",
|
| 280 |
+
"id_token": "ey***",
|
| 281 |
+
"account_id": "acc-67890",
|
| 282 |
+
"email": "test2@example.com",
|
| 283 |
+
"plan_type": "self_serve_business_usage_based",
|
| 284 |
+
"plan_type_raw": "self_serve_business_usage_based",
|
| 285 |
+
"plan_supported": false,
|
| 286 |
+
"expired": 1714123200.0
|
| 287 |
+
},
|
| 288 |
+
"bundle_unknown": {
|
| 289 |
+
"access_token": "sk-***",
|
| 290 |
+
"refresh_token": "rt-***",
|
| 291 |
+
"id_token": "ey***",
|
| 292 |
+
"account_id": "acc-99999",
|
| 293 |
+
"email": "test3@example.com",
|
| 294 |
+
"plan_type": "unknown",
|
| 295 |
+
"plan_type_raw": "unknown",
|
| 296 |
+
"plan_supported": false,
|
| 297 |
+
"expired": 1714123200.0
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
+
|
| 304 |
+
## 7. 不变量(Invariants)
|
| 305 |
+
|
| 306 |
+
- **I1**:任何对 plan_type 的字符串判定必须经过 `normalize_plan_type` 或 `is_supported_plan`,**禁止**直接 `bundle["plan_type"] == "team"`
|
| 307 |
+
- **I2**:`bundle["plan_type"]` 落盘后必为小写或 "unknown",`plan_type_raw` 保留原始字面量
|
| 308 |
+
- **I3**:`SUPPORTED_PLAN_TYPES` 修改必须经过测试评审(新增字面量代表新的 quota / seat 行为分支)
|
| 309 |
+
- **I4**:任何 unsupported plan_type 的账号都不能进入 STATUS_ACTIVE,只能在 STATUS_AUTH_INVALID / STATUS_PENDING / STATUS_STANDBY 中
|
| 310 |
+
- **I5**:cpa_sync 文件名推断与 OAuth bundle 提取的 plan_type 应保持一致(同一字面量集合)
|
| 311 |
+
- **I6**:`plan_type_unsupported` 字段名**不**新增到 accounts.json(改用 `plan_supported` 反向语义,默认 None=旧记录)
|
| 312 |
+
|
| 313 |
+
---
|
| 314 |
+
|
| 315 |
+
## 附录 A:已知不支持的字面量(运营观测)
|
| 316 |
+
|
| 317 |
+
| 字面量 | 来源 | OpenAI 定义 |
|
| 318 |
+
|---|---|---|
|
| 319 |
+
| `self_serve_business_usage_based` | 用户报告 #2/#6 | 自助商用按量计费,无 codex 配额 |
|
| 320 |
+
| `chatgpt_business_usage_based` | 推测 | 同上变体 |
|
| 321 |
+
| `enterprise` | OpenAI 公开文档 | 企业版,通常需要单独签约 |
|
| 322 |
+
| `edu` | OpenAI 公开文档 | 教育版 |
|
| 323 |
+
| `unknown` | JWT claim 缺失 | 无法判定,默认拒收 |
|
| 324 |
+
|
| 325 |
+
---
|
| 326 |
+
|
| 327 |
+
**文档结束。** 工程师据此可直接编写常量集 + 工具函数 + 单测,无需额外决策。
|
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Shared SPEC: wham/usage 配额分类
|
| 2 |
+
|
| 3 |
+
## 0. 元数据 + 引用方
|
| 4 |
+
|
| 5 |
+
| 字段 | 内容 |
|
| 6 |
+
|---|---|
|
| 7 |
+
| 名称 | Codex 配额查询(wham/usage)五分类规则 |
|
| 8 |
+
| 版本 | v1.0 (2026-04-26) |
|
| 9 |
+
| 主题归属 | `check_codex_quota` 返回值契约 + 9 个调用方处置 |
|
| 10 |
+
| 引用方 | PRD-2 / spec-2-account-lifecycle.md / FR-B1~B4、FR-D1~D4、FR-E1 |
|
| 11 |
+
| 共因 | synthesis §1 共因 B、D;Issue#2 + Issue#6 |
|
| 12 |
+
| 不在范围 | refresh_token 刷新(`refresh_access_token`)、auth_file 写入(`save_auth_file`)、plan_type 白名单(见 [`./plan-type-whitelist.md`](./plan-type-whitelist.md)) |
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 1. 概念定义
|
| 17 |
+
|
| 18 |
+
| 术语 | 定义 |
|
| 19 |
+
|---|---|
|
| 20 |
+
| `wham/usage` | OpenAI 后端接口 `https://chatgpt.com/backend-api/wham/usage`,返回当前 access_token 对应账号的 Codex 配额状态 |
|
| 21 |
+
| `quota_status` | 本系统对 wham/usage 调用结果的 5 类归一化:`ok / exhausted / no_quota / auth_error / network_error` |
|
| 22 |
+
| `quota_info` / `QuotaSnapshot` | 落盘到 `accounts.json` 的配额快照,字段:`primary_pct`、`primary_resets_at`、`primary_total`、`primary_remaining`、`weekly_pct`、`weekly_resets_at` |
|
| 23 |
+
| `no_quota`(新) | "workspace 已分配但 codex 配额=0"形态 — used_percent=0 但 limit/total=0,UI 不应显示"剩余 100%" |
|
| 24 |
+
| `limit_reached` | OpenAI 返回 `rate_limit.limit_reached: true`,与 `primary_pct >= 100` 等价 |
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 2. 完整数据契约
|
| 29 |
+
|
| 30 |
+
### 2.1 类型定义(置于 `src/autoteam/codex_auth.py` 顶部 typing 区)
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
from typing import Literal, Optional, TypedDict, Union
|
| 34 |
+
|
| 35 |
+
QuotaStatus = Literal["ok", "exhausted", "no_quota", "auth_error", "network_error"]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class QuotaSnapshot(TypedDict, total=False):
|
| 39 |
+
"""落盘到 accounts.json 的 last_quota 字段结构。
|
| 40 |
+
|
| 41 |
+
所有字段都允许缺失(向后兼容旧记录),消费方需用 .get(...) 取值。
|
| 42 |
+
"""
|
| 43 |
+
primary_pct: int # 0..100,主配额已用百分比
|
| 44 |
+
primary_resets_at: int # epoch seconds,主配额重置时间;0 表示未知
|
| 45 |
+
primary_total: int # 主配额总额(令牌或请求数);0 表示 no_quota
|
| 46 |
+
primary_remaining: int # 主配额剩余值(若 OpenAI 返回)
|
| 47 |
+
weekly_pct: int # 周配额已用百分比
|
| 48 |
+
weekly_resets_at: int # 周配额重置时间
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class QuotaExhaustedInfo(TypedDict):
|
| 52 |
+
"""get_quota_exhausted_info 的返回结构。"""
|
| 53 |
+
window: Literal["primary", "weekly", "combined", "limit", "no_quota"]
|
| 54 |
+
resets_at: int # epoch seconds,何时可重试
|
| 55 |
+
quota_info: QuotaSnapshot
|
| 56 |
+
limit_reached: bool
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# check_codex_quota 返回类型
|
| 60 |
+
QuotaProbeResult = tuple[QuotaStatus, Optional[Union[QuotaSnapshot, QuotaExhaustedInfo]]]
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### 2.2 函数签名(实施期目标)
|
| 64 |
+
|
| 65 |
+
```python
|
| 66 |
+
def check_codex_quota(
|
| 67 |
+
access_token: str,
|
| 68 |
+
account_id: Optional[str] = None,
|
| 69 |
+
) -> QuotaProbeResult:
|
| 70 |
+
"""通过 wham/usage 探测 Codex 配额状态。
|
| 71 |
+
|
| 72 |
+
返回 (status, info):
|
| 73 |
+
("ok", QuotaSnapshot) — 200 + 配额可用
|
| 74 |
+
("exhausted", QuotaExhaustedInfo) — 200 + 主/周配额耗尽
|
| 75 |
+
("no_quota", QuotaExhaustedInfo) — 200 + 配额总额=0(workspace 未分配) ★新增
|
| 76 |
+
("auth_error", None) — 401/403,token/seat 失效
|
| 77 |
+
("network_error", None) — DNS/timeout/SSL/5xx/429/JSON 解析异常
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def get_quota_exhausted_info(
|
| 82 |
+
quota_info: QuotaSnapshot,
|
| 83 |
+
*,
|
| 84 |
+
limit_reached: bool = False,
|
| 85 |
+
) -> Optional[QuotaExhaustedInfo]:
|
| 86 |
+
"""根据 QuotaSnapshot 判断是否耗尽,返回耗尽详情或 None。
|
| 87 |
+
|
| 88 |
+
新增:primary_total == 0 → 返回 window="no_quota" 形态。
|
| 89 |
+
"""
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### 2.3 落盘字段扩展
|
| 93 |
+
|
| 94 |
+
```python
|
| 95 |
+
# accounts.json 中 acc.last_quota 的字段集(QuotaSnapshot)
|
| 96 |
+
{
|
| 97 |
+
"primary_pct": int,
|
| 98 |
+
"primary_resets_at": int,
|
| 99 |
+
"primary_total": int, # 新增,FR-B2
|
| 100 |
+
"primary_remaining": int, # 新增,FR-B2(可缺失)
|
| 101 |
+
"weekly_pct": int,
|
| 102 |
+
"weekly_resets_at": int
|
| 103 |
+
}
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## 3. 行为契约
|
| 109 |
+
|
| 110 |
+
### 3.1 前置条件
|
| 111 |
+
|
| 112 |
+
- `access_token` 非空字符串(空串短路返回 `("auth_error", None)`)
|
| 113 |
+
- `account_id` 可选;若为 None,内部从 `get_chatgpt_account_id()` 拉取
|
| 114 |
+
- 调用方在并发场景下需要自己控制并发数(本函数无 rate limit)
|
| 115 |
+
|
| 116 |
+
### 3.2 后置条件
|
| 117 |
+
|
| 118 |
+
- 返回元组永远 2 元素,第 1 元素永远是 5 个字面量之一
|
| 119 |
+
- `("ok", info)` 时,info 必为 QuotaSnapshot
|
| 120 |
+
- `("exhausted", info)` / `("no_quota", info)` 时,info 必为 QuotaExhaustedInfo
|
| 121 |
+
- `("auth_error", None)` / `("network_error", None)` 时,info 必为 None
|
| 122 |
+
- 函数永不抛异常(所有异常归为 `network_error`)
|
| 123 |
+
|
| 124 |
+
### 3.3 异常类型
|
| 125 |
+
|
| 126 |
+
| 实际异常 | 归类 |
|
| 127 |
+
|---|---|
|
| 128 |
+
| `requests.exceptions.ConnectionError` | network_error |
|
| 129 |
+
| `requests.exceptions.Timeout` | network_error |
|
| 130 |
+
| `requests.exceptions.SSLError` | network_error |
|
| 131 |
+
| `requests.exceptions.RequestException`(其它) | network_error |
|
| 132 |
+
| HTTP 401 / 403 | auth_error |
|
| 133 |
+
| HTTP 429 / 5xx | network_error |
|
| 134 |
+
| HTTP 4xx(非 401/403/429) | network_error |
|
| 135 |
+
| JSON 解析失败 | network_error |
|
| 136 |
+
| 任意 `Exception` 兜底 | network_error |
|
| 137 |
+
|
| 138 |
+
**不变量**:`auth_error` 与 `network_error` 严格区分,前者会触发"标记 AUTH_INVALID/重登"等破坏性流程,网络抖动绝不能落入该分支。
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## 4. 分类规则(决策矩阵)
|
| 143 |
+
|
| 144 |
+
### 4.1 HTTP 状态 → 分类
|
| 145 |
+
|
| 146 |
+
```
|
| 147 |
+
┌──────────────────┬──────────────────┐
|
| 148 |
+
│ HTTP / 异常 │ 分类 │
|
| 149 |
+
├──────────────────┼──────────────────┤
|
| 150 |
+
│ 200 OK + 解析成功 │ → 看下表 4.2 │
|
| 151 |
+
│ 401 / 403 │ auth_error │
|
| 152 |
+
│ 429 │ network_error │
|
| 153 |
+
│ 5xx │ network_error │
|
| 154 |
+
│ 其他 4xx │ network_error │
|
| 155 |
+
│ Connection/Timeout│ network_error │
|
| 156 |
+
│ JSON 解析异常 │ network_error │
|
| 157 |
+
│ 未知 Exception │ network_error(兜底)│
|
| 158 |
+
└──────────────────┴──────────────────┘
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
### 4.2 200 OK 内部分类(关键)
|
| 162 |
+
|
| 163 |
+
按**优先级从高到低**判定(命中即返回):
|
| 164 |
+
|
| 165 |
+
```
|
| 166 |
+
┌──────────────────────────────────────────────┬──────────────┐
|
| 167 |
+
│ 触发条件(任一命中) │ 分类 │
|
| 168 |
+
├──────────────────────────────────────────────┼──────────────┤
|
| 169 |
+
│ rate_limit 字段缺失或为空 │ no_quota │
|
| 170 |
+
│ rate_limit.primary_window 字段缺失或为空 │ no_quota │
|
| 171 |
+
│ primary.limit == 0(显式 0) │ no_quota │
|
| 172 |
+
│ primary.total == 0 │ no_quota │
|
| 173 |
+
│ primary.reset_at == 0 AND used_percent == 0 │ no_quota │
|
| 174 |
+
│ primary.remaining == 0 AND total == 0 │ no_quota │
|
| 175 |
+
│ rate_limit.limit_reached == true │ exhausted │
|
| 176 |
+
│ primary_pct >= 100 │ exhausted │
|
| 177 |
+
│ weekly_pct >= 100 │ exhausted │
|
| 178 |
+
│ 上述都不命中 │ ok │
|
| 179 |
+
└──────────────────────────────────────────────┴──────────────┘
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
### 4.3 `no_quota` 与 `exhausted` 的语义区分
|
| 183 |
+
|
| 184 |
+
| 维度 | exhausted | no_quota |
|
| 185 |
+
|---|---|---|
|
| 186 |
+
| 触发前提 | 配额本来有但已用完 | workspace 从未被分配配额 |
|
| 187 |
+
| primary_pct | 100 | 0(或缺失) |
|
| 188 |
+
| primary_total | > 0 | 0(或缺失) |
|
| 189 |
+
| 用户操作 | 等待 reset_at 后自然恢复 | 需要管理员介入(配额从来没有) |
|
| 190 |
+
| UI 文案 | "已耗尽,X 小时后恢复" | "无配额,联系管理员" |
|
| 191 |
+
| 是否 retry | 5h 后 retry | **不**自动 retry,直接终态 |
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
|
| 195 |
+
## 5. 调用方处置规范(9 个调用点)
|
| 196 |
+
|
| 197 |
+
### 5.1 调用点清单
|
| 198 |
+
|
| 199 |
+
| # | 文件:行号 | 函数/上下文 |
|
| 200 |
+
|---|---|---|
|
| 201 |
+
| 1 | `manual_account.py:263` | `_finalize_account` 手动添加完成 |
|
| 202 |
+
| 2 | `manager.py:715` | `cmd_check` 巡检 |
|
| 203 |
+
| 3 | `manager.py:748` | `cmd_check` 失败重试 |
|
| 204 |
+
| 4 | `manager.py:760` | `cmd_check` refresh_token 后重测 |
|
| 205 |
+
| 5 | `manager.py:2521` | `reinvite_account` 假恢复检测 |
|
| 206 |
+
| 6 | `manager.py:2683` | `_replace_single` 后续验证 |
|
| 207 |
+
| 7 | `manager.py:2964` | rotation 周期检查 |
|
| 208 |
+
| 8 | `api.py:1499` | `/api/accounts/{email}/login` 补登录 |
|
| 209 |
+
| 9 | `api.py:1558`、`api.py:2136` | API 端点直接验配额 |
|
| 210 |
+
| 10 | **新增** `manager.py:_run_post_register_oauth` | FR-D1 注册收尾 probe |
|
| 211 |
+
| 11 | **新增** `manager.py:sync_account_states` | FR-E1 被踢识别 |
|
| 212 |
+
|
| 213 |
+
### 5.2 9+2 调用方统一处置矩阵
|
| 214 |
+
|
| 215 |
+
| status | 处置 | last_quota 是否更新 | 状态变更 |
|
| 216 |
+
|---|---|---|---|
|
| 217 |
+
| `ok` | 沿用现有逻辑(STATUS_ACTIVE) | 写入 quota_info | 无变更或→ ACTIVE |
|
| 218 |
+
| `exhausted` | 锁 5h 等恢复 | 写入(从 info["quota_info"]) | → STATUS_EXHAUSTED + quota_exhausted_at + quota_resets_at |
|
| 219 |
+
| `no_quota`(新) | **不**锁 5h(不会自然恢复) | 写入 + 标 primary_total=0 | → STATUS_AUTH_INVALID + record_failure("no_quota_assigned") |
|
| 220 |
+
| `auth_error` | 触发重登 / KICK 路径 | 不更新 | → STATUS_AUTH_INVALID(reconcile 接管) |
|
| 221 |
+
| `network_error` | 不动 status,等下轮 | 不更新 | 保持原状态 |
|
| 222 |
+
|
| 223 |
+
### 5.3 各调用点差异
|
| 224 |
+
|
| 225 |
+
| 调用点 | 特殊处置 |
|
| 226 |
+
|---|---|
|
| 227 |
+
| `manual_account.py:263` | no_quota → `update_account(status=STATUS_AUTH_INVALID, last_quota=info["quota_info"])` + 不删 auth_file(保留供调试) |
|
| 228 |
+
| `manager.py:_run_post_register_oauth` (新) | no_quota → 与 manual_account 对称,但额外触发 kick(账号已在 Team) |
|
| 229 |
+
| `manager.py:sync_account_states` (新) | auth_error → `STATUS_AUTH_INVALID`(替换原 STATUS_STANDBY 行为) |
|
| 230 |
+
| `manager.py:reinvite_account:2521` | no_quota → 走 `_cleanup_team_leftover("no_quota_assigned")` + STATUS_AUTH_INVALID,不进 STANDBY 池(避免反复试) |
|
| 231 |
+
| `api.py:1499` | no_quota → 返回 HTTP 422 + body `{"error": "no_quota_assigned", "plan_type": ...}` |
|
| 232 |
+
|
| 233 |
+
### 5.4 auto-check 阈值与 quota_verified 联动
|
| 234 |
+
|
| 235 |
+
`reinvite_account:2528-2530` 用 `100 - primary_pct >= threshold`(默认 10%)判 quota_verified:
|
| 236 |
+
- `no_quota` 分支:不进入 quota_verified 判断,直接 fail_reason="no_quota_assigned",`_cleanup_team_leftover`
|
| 237 |
+
- `ok` 分支保持 threshold 判定不变
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
## 6. 单元测试 fixture 与样本数据
|
| 242 |
+
|
| 243 |
+
### 6.1 wham/usage 响应样本(json)
|
| 244 |
+
|
| 245 |
+
```json
|
| 246 |
+
// tests/fixtures/wham_usage_responses.json
|
| 247 |
+
{
|
| 248 |
+
"ok_normal": {
|
| 249 |
+
"status_code": 200,
|
| 250 |
+
"body": {
|
| 251 |
+
"rate_limit": {
|
| 252 |
+
"primary_window": {"used_percent": 35, "reset_at": 1714200000, "limit": 100, "remaining": 65},
|
| 253 |
+
"secondary_window": {"used_percent": 10, "reset_at": 1714780800}
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
},
|
| 257 |
+
"exhausted_primary": {
|
| 258 |
+
"status_code": 200,
|
| 259 |
+
"body": {
|
| 260 |
+
"rate_limit": {
|
| 261 |
+
"limit_reached": true,
|
| 262 |
+
"primary_window": {"used_percent": 100, "reset_at": 1714200000, "limit": 100, "remaining": 0},
|
| 263 |
+
"secondary_window": {"used_percent": 50, "reset_at": 1714780800}
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
},
|
| 267 |
+
"exhausted_weekly": {
|
| 268 |
+
"status_code": 200,
|
| 269 |
+
"body": {
|
| 270 |
+
"rate_limit": {
|
| 271 |
+
"primary_window": {"used_percent": 30, "reset_at": 1714200000, "limit": 100, "remaining": 70},
|
| 272 |
+
"secondary_window": {"used_percent": 100, "reset_at": 1714780800}
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
},
|
| 276 |
+
"no_quota_limit_zero": {
|
| 277 |
+
"status_code": 200,
|
| 278 |
+
"body": {
|
| 279 |
+
"rate_limit": {
|
| 280 |
+
"primary_window": {"used_percent": 0, "reset_at": 0, "limit": 0, "remaining": 0},
|
| 281 |
+
"secondary_window": {"used_percent": 0, "reset_at": 0}
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
},
|
| 285 |
+
"no_quota_empty_rate_limit": {
|
| 286 |
+
"status_code": 200,
|
| 287 |
+
"body": {}
|
| 288 |
+
},
|
| 289 |
+
"no_quota_missing_primary": {
|
| 290 |
+
"status_code": 200,
|
| 291 |
+
"body": {
|
| 292 |
+
"rate_limit": {
|
| 293 |
+
"secondary_window": {"used_percent": 0, "reset_at": 0}
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
},
|
| 297 |
+
"auth_error_401": {
|
| 298 |
+
"status_code": 401,
|
| 299 |
+
"body": {"error": {"code": "invalid_token"}}
|
| 300 |
+
},
|
| 301 |
+
"auth_error_403": {
|
| 302 |
+
"status_code": 403,
|
| 303 |
+
"body": {"error": {"code": "forbidden"}}
|
| 304 |
+
},
|
| 305 |
+
"rate_limited_429": {
|
| 306 |
+
"status_code": 429,
|
| 307 |
+
"body": {"error": "Too Many Requests"}
|
| 308 |
+
},
|
| 309 |
+
"server_error_500": {
|
| 310 |
+
"status_code": 500,
|
| 311 |
+
"body": {"error": "Internal Server Error"}
|
| 312 |
+
},
|
| 313 |
+
"json_invalid": {
|
| 314 |
+
"status_code": 200,
|
| 315 |
+
"body": "<html>Service Unavailable</html>"
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
```
|
| 319 |
+
|
| 320 |
+
### 6.2 单测代码
|
| 321 |
+
|
| 322 |
+
```python
|
| 323 |
+
# tests/unit/test_quota_classification.py
|
| 324 |
+
import json
|
| 325 |
+
import pytest
|
| 326 |
+
from unittest.mock import patch, MagicMock
|
| 327 |
+
from autoteam.codex_auth import check_codex_quota, get_quota_exhausted_info
|
| 328 |
+
|
| 329 |
+
FIXTURE = json.loads(Path("tests/fixtures/wham_usage_responses.json").read_text())
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
@pytest.mark.parametrize("name,expected_status", [
|
| 333 |
+
("ok_normal", "ok"),
|
| 334 |
+
("exhausted_primary", "exhausted"),
|
| 335 |
+
("exhausted_weekly", "exhausted"),
|
| 336 |
+
("no_quota_limit_zero", "no_quota"),
|
| 337 |
+
("no_quota_empty_rate_limit", "no_quota"),
|
| 338 |
+
("no_quota_missing_primary", "no_quota"),
|
| 339 |
+
("auth_error_401", "auth_error"),
|
| 340 |
+
("auth_error_403", "auth_error"),
|
| 341 |
+
("rate_limited_429", "network_error"),
|
| 342 |
+
("server_error_500", "network_error"),
|
| 343 |
+
("json_invalid", "network_error"),
|
| 344 |
+
])
|
| 345 |
+
def test_check_codex_quota_classification(name, expected_status):
|
| 346 |
+
sample = FIXTURE[name]
|
| 347 |
+
mock_resp = MagicMock()
|
| 348 |
+
mock_resp.status_code = sample["status_code"]
|
| 349 |
+
if isinstance(sample["body"], str):
|
| 350 |
+
mock_resp.json.side_effect = ValueError("not JSON")
|
| 351 |
+
mock_resp.text = sample["body"]
|
| 352 |
+
else:
|
| 353 |
+
mock_resp.json.return_value = sample["body"]
|
| 354 |
+
mock_resp.text = json.dumps(sample["body"])
|
| 355 |
+
|
| 356 |
+
with patch("autoteam.codex_auth.requests.get", return_value=mock_resp):
|
| 357 |
+
status, _ = check_codex_quota("test-token", account_id="acc-1")
|
| 358 |
+
assert status == expected_status
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def test_no_quota_includes_quota_info():
|
| 362 |
+
"""no_quota 分类必须返回 QuotaExhaustedInfo,info["window"] == "no_quota" """
|
| 363 |
+
sample = FIXTURE["no_quota_limit_zero"]
|
| 364 |
+
mock_resp = MagicMock()
|
| 365 |
+
mock_resp.status_code = 200
|
| 366 |
+
mock_resp.json.return_value = sample["body"]
|
| 367 |
+
with patch("autoteam.codex_auth.requests.get", return_value=mock_resp):
|
| 368 |
+
status, info = check_codex_quota("test-token")
|
| 369 |
+
assert status == "no_quota"
|
| 370 |
+
assert isinstance(info, dict)
|
| 371 |
+
assert info["window"] == "no_quota"
|
| 372 |
+
assert info["quota_info"]["primary_total"] == 0
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def test_exhausted_uses_existing_window_label():
|
| 376 |
+
"""exhausted 分支不能误标为 no_quota"""
|
| 377 |
+
sample = FIXTURE["exhausted_primary"]
|
| 378 |
+
mock_resp = MagicMock()
|
| 379 |
+
mock_resp.status_code = 200
|
| 380 |
+
mock_resp.json.return_value = sample["body"]
|
| 381 |
+
with patch("autoteam.codex_auth.requests.get", return_value=mock_resp):
|
| 382 |
+
status, info = check_codex_quota("test-token")
|
| 383 |
+
assert status == "exhausted"
|
| 384 |
+
assert info["window"] in ("primary", "combined")
|
| 385 |
+
```
|
| 386 |
+
|
| 387 |
+
---
|
| 388 |
+
|
| 389 |
+
## 7. 不变量(Invariants)
|
| 390 |
+
|
| 391 |
+
- **I1**:`check_codex_quota` 永不抛异常(任何异常归为 network_error 返回)
|
| 392 |
+
- **I2**:`auth_error` 与 `network_error` 严格区分,401/403 是 auth_error 唯一来源,**429 不能落入 auth_error**
|
| 393 |
+
- **I3**:`no_quota` 必须返回 `QuotaExhaustedInfo` 结构,`window` 字段值为 `"no_quota"`(便于上游分支判定)
|
| 394 |
+
- **I4**:`get_quota_exhausted_info` 中 `primary_total == 0` 优先级**高于** `primary_pct >= 100`(no_quota 优先于 exhausted)
|
| 395 |
+
- **I5**:`last_quota` 字段更新仅在 ok / exhausted / no_quota 情况发生;auth_error / network_error 不更新(避免脏数据)
|
| 396 |
+
- **I6**:`exhausted` 与 `no_quota` 的 reset_at 语义不同:exhausted 给真实重置时间,no_quota 给 `time.time() + 86400`(占位,不应被用作重试依据)
|
| 397 |
+
- **I7**:9+2 个调用点必须**显式**处理 `no_quota` 分支,不能让 `no_quota` 走默认 `else` 路径被当作 ok / exhausted
|
| 398 |
+
|
| 399 |
+
---
|
| 400 |
+
|
| 401 |
+
## 附录 A:wham/usage 真实 schema 待核实
|
| 402 |
+
|
| 403 |
+
PRD-2 Q-1 列出未决:`limit` / `total` / `remaining` 字段是否真实返回需要从用户实际抓包确认。**实施期约定**:
|
| 404 |
+
1. 先按本 spec 实现 4 个 no_quota 触发条件
|
| 405 |
+
2. 上线后通过 `register_failures.json` 的 `category="no_quota_assigned"` 计数,每条记录附 `raw_rate_limit` 字段以便回溯
|
| 406 |
+
3. 1-2 周后根据数据决议是否需要补充新的 no_quota 触发条件
|
| 407 |
+
|
| 408 |
+
---
|
| 409 |
+
|
| 410 |
+
**文档结束。** 工程师据此可直接编写 `check_codex_quota` 改造 + `get_quota_exhausted_info` 扩展 + 9+2 调用点的处置代码,无需额外决策。
|
|
@@ -0,0 +1,1045 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPEC-1: Mail Provider 全量化 实施规范
|
| 2 |
+
|
| 3 |
+
## 0. 元数据
|
| 4 |
+
|
| 5 |
+
| 字段 | 值 |
|
| 6 |
+
| ---------- | ----------------------------------------------------------------------------------- |
|
| 7 |
+
| 版本 | v1.0 |
|
| 8 |
+
| 日期 | 2026-04-26 |
|
| 9 |
+
| 主笔 | prd-mail |
|
| 10 |
+
| 对应 PRD | `prompts/0426/prd/prd-1-mail-provider.md` v1.0 |
|
| 11 |
+
| 状态 | Draft → Ready-for-Implementation |
|
| 12 |
+
| 关联 spec | (无依赖 PRD-2 shared spec;`spec/shared/account-state-machine.md` 仅作弱引用) |
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 1. 文件级修改清单
|
| 17 |
+
|
| 18 |
+
| 文件路径 | 修改类型 | 行数预估 | 涉及函数/类 |
|
| 19 |
+
| ----------------------------------------------------- | -------- | -------- | ---------------------------------------------------------------------------- |
|
| 20 |
+
| `src/autoteam/api.py` | 修改 | +180 | `SetupConfig`, `_AUTH_SKIP_PATHS`, `post_setup_save`, 新增 `post_mail_provider_probe`,重构 `put_register_domain_api` |
|
| 21 |
+
| `src/autoteam/mail/probe.py` | 新增 | ~250 | `probe_fingerprint()`, `probe_credentials()`, `probe_domain_ownership()`, `ProbeError`, `ProbeErrorCode` |
|
| 22 |
+
| `src/autoteam/mail/cf_temp_email.py` | 修改 | +12 / -4 | `login()` 嗅探条件, `create_temp_email()` 嗅探条件 |
|
| 23 |
+
| `src/autoteam/mail/maillab.py` | 修改 | +60 | `_with_login_retry`(新增), `_get`/`_post`/`_delete`/`_put`(改造调用), `MaillabAuthFailed`(新异常类) |
|
| 24 |
+
| `src/autoteam/setup_wizard.py` | 修改 | +35 / -8 | `REQUIRED_CONFIGS`(扩 4 字段), `_sniff_provider_mismatch`(强阻断), `_verify_cloudmail`(嗅探前置 + 错配即返 False) |
|
| 25 |
+
| `.env.example` | 修改 | +5 / -3 | `MAIL_PROVIDER` 注释强化, MAILLAB_* 取消注释 |
|
| 26 |
+
| `web/src/components/SetupPage.vue` | 重写 | ~250 | 由平铺字段重构为 `<MailProviderCard>` + 4 卡片 |
|
| 27 |
+
| `web/src/components/MailProviderCard.vue` | 新增 | ~280 | 3 步 wizard 状态机 |
|
| 28 |
+
| `web/src/components/Settings.vue` | 修改 | +60 | 新增「邮箱后端」区块 |
|
| 29 |
+
| `web/src/api.js` | 修改 | +6 | 新增 `probeMailProvider(payload)` |
|
| 30 |
+
| `tests/test_mail_cf_temp_email_sniff.py` | 新增 | ~80 | 4 个嗅探用例 |
|
| 31 |
+
| `tests/test_mail_maillab_self_heal.py` | 新增 | ~120 | 401 自愈 + 递归守卫 |
|
| 32 |
+
| `tests/test_mail_provider_probe.py` | 新增 | ~180 | 三步 probe + error_code 全覆盖 |
|
| 33 |
+
| `tests/test_mail_probe_helper.py` | 新增 | ~60 | `probe_domain_ownership` 共享 helper |
|
| 34 |
+
| `tests/test_setup_wizard_sniff_block.py` | 新增 | ~70 | `_verify_cloudmail` 错配阻断 |
|
| 35 |
+
| `tests/integration/test_setup_save_e2e.py` | 新增 | ~110 | maillab 字段写盘 + 重载 |
|
| 36 |
+
| `docs/getting-started.md` | 修改 | +50 | §1 / §2 / §2.5 章节 |
|
| 37 |
+
| `docs/configuration.md` | 修改 | +80 | MAIL_PROVIDER 必填化 + 错配排查 |
|
| 38 |
+
| `docs/mail-provider-design.md` | 修改 | +120 | §6 收口 + §7 API 全表 |
|
| 39 |
+
| `docs/troubleshooting.md` | 修改 | +60 | 邮箱后端 / 配置保存 401 |
|
| 40 |
+
| `docs/api.md` | 修改 | +90 | `/api/mail-provider/probe` 文档化 |
|
| 41 |
+
|
| 42 |
+
**总计**:21 个文件,约 +1900 行 / -15 行(以 SLOC 计;不含 vue 模板的 markup 行)。
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## 2. 数据契约
|
| 47 |
+
|
| 48 |
+
### 2.1 Pydantic 模型完整定义
|
| 49 |
+
|
| 50 |
+
> ���下代码可直接粘贴入 `src/autoteam/api.py`(SetupConfig 在原位修改;ProbeRequest/Response/ErrorCode 新增)。
|
| 51 |
+
|
| 52 |
+
```python
|
| 53 |
+
# ===== 修改:src/autoteam/api.py L70 =====
|
| 54 |
+
from typing import Literal
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class SetupConfig(BaseModel):
|
| 58 |
+
"""`/api/setup/save` 请求体。新增 5 个 mail 相关字段。"""
|
| 59 |
+
|
| 60 |
+
# mail provider 选择(默认 cf_temp_email,REQUIRED_CONFIGS 与之对齐)
|
| 61 |
+
MAIL_PROVIDER: Literal["cf_temp_email", "cloudflare_temp_email", "maillab"] = "cf_temp_email"
|
| 62 |
+
# cf_temp_email 字段
|
| 63 |
+
CLOUDMAIL_BASE_URL: str = ""
|
| 64 |
+
CLOUDMAIL_EMAIL: str = "" # 已废弃,保留兼容旧前端
|
| 65 |
+
CLOUDMAIL_PASSWORD: str = ""
|
| 66 |
+
CLOUDMAIL_DOMAIN: str = ""
|
| 67 |
+
# maillab 字段(新增)
|
| 68 |
+
MAILLAB_API_URL: str = ""
|
| 69 |
+
MAILLAB_USERNAME: str = ""
|
| 70 |
+
MAILLAB_PASSWORD: str = ""
|
| 71 |
+
MAILLAB_DOMAIN: str = ""
|
| 72 |
+
# 其他既有
|
| 73 |
+
CPA_URL: str = "http://127.0.0.1:8317"
|
| 74 |
+
CPA_KEY: str = ""
|
| 75 |
+
PLAYWRIGHT_PROXY_URL: str = ""
|
| 76 |
+
PLAYWRIGHT_PROXY_BYPASS: str = ""
|
| 77 |
+
API_KEY: str = ""
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class ProbeErrorCode(str, Enum):
|
| 81 |
+
ROUTE_NOT_FOUND = "ROUTE_NOT_FOUND"
|
| 82 |
+
PROVIDER_MISMATCH = "PROVIDER_MISMATCH"
|
| 83 |
+
UNAUTHORIZED = "UNAUTHORIZED"
|
| 84 |
+
FORBIDDEN_DOMAIN = "FORBIDDEN_DOMAIN"
|
| 85 |
+
CAPTCHA_REQUIRED = "CAPTCHA_REQUIRED"
|
| 86 |
+
NETWORK = "NETWORK"
|
| 87 |
+
TIMEOUT = "TIMEOUT"
|
| 88 |
+
EMPTY_DOMAIN_LIST = "EMPTY_DOMAIN_LIST"
|
| 89 |
+
UNKNOWN = "UNKNOWN"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class MailProviderProbeRequest(BaseModel):
|
| 93 |
+
provider: Literal["cf_temp_email", "maillab"]
|
| 94 |
+
step: Literal["fingerprint", "credentials", "domain_ownership"]
|
| 95 |
+
base_url: str = Field(..., min_length=1, max_length=512)
|
| 96 |
+
admin_password: str = "" # cf_temp_email 用
|
| 97 |
+
username: str = "" # maillab 用
|
| 98 |
+
password: str = "" # maillab 用
|
| 99 |
+
domain: str = "" # step=domain_ownership 必填
|
| 100 |
+
bearer_token: str = "" # 跨步 token 中转;后端不持久化
|
| 101 |
+
|
| 102 |
+
@field_validator("base_url")
|
| 103 |
+
@classmethod
|
| 104 |
+
def _normalize_base_url(cls, v: str) -> str:
|
| 105 |
+
v = v.strip().rstrip("/")
|
| 106 |
+
if not (v.startswith("http://") or v.startswith("https://")):
|
| 107 |
+
raise ValueError("base_url 必须以 http:// 或 https:// 开头")
|
| 108 |
+
return v
|
| 109 |
+
|
| 110 |
+
@field_validator("domain")
|
| 111 |
+
@classmethod
|
| 112 |
+
def _normalize_domain(cls, v: str) -> str:
|
| 113 |
+
v = (v or "").strip().lstrip("@")
|
| 114 |
+
return v
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class MailProviderProbeResponse(BaseModel):
|
| 118 |
+
ok: bool
|
| 119 |
+
step: Literal["fingerprint", "credentials", "domain_ownership"]
|
| 120 |
+
error_code: ProbeErrorCode | None = None
|
| 121 |
+
message: str | None = None
|
| 122 |
+
hint: str | None = None
|
| 123 |
+
warnings: list[str] = []
|
| 124 |
+
# fingerprint
|
| 125 |
+
detected_provider: Literal["cf_temp_email", "maillab", "unknown"] | None = None
|
| 126 |
+
domain_list: list[str] | None = None
|
| 127 |
+
add_verify_open: bool | None = None
|
| 128 |
+
register_verify_open: bool | None = None
|
| 129 |
+
# credentials
|
| 130 |
+
is_admin: bool | None = None
|
| 131 |
+
user_email: str | None = None
|
| 132 |
+
token_preview: str | None = None
|
| 133 |
+
# domain_ownership
|
| 134 |
+
probe_email: str | None = None
|
| 135 |
+
probe_account_id: int | None = None
|
| 136 |
+
cleaned: bool | None = None
|
| 137 |
+
leaked_probe: dict | None = None
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
### 2.2 .env 字段全表
|
| 141 |
+
|
| 142 |
+
| Key | 当前 .env.example 状态 | 新状态 | 必填条件 |
|
| 143 |
+
| ------------------------- | ---------------------- | ------------------------ | ---------------------------- |
|
| 144 |
+
| `MAIL_PROVIDER` | 已存在,值 `cf_temp_email` | 注释强化:**强烈推荐显式设置** | 始终必填(默认 cf_temp_email) |
|
| 145 |
+
| `CLOUDMAIL_BASE_URL` | 已存在 | 注释微调 | provider=cf_temp_email 时必填 |
|
| 146 |
+
| `CLOUDMAIL_PASSWORD` | 已存在 | 不变 | 同上 |
|
| 147 |
+
| `CLOUDMAIL_DOMAIN` | 已存在 | 不变 | 同上 |
|
| 148 |
+
| `CLOUDMAIL_EMAIL` | 已存在,空值 | 保持(已废弃) | 否 |
|
| 149 |
+
| `MAILLAB_API_URL` | 注释状态 | **取消注释,空值** | provider=maillab 时必填 |
|
| 150 |
+
| `MAILLAB_USERNAME` | 注释状态 | 取消注释,空值 | 同上 |
|
| 151 |
+
| `MAILLAB_PASSWORD` | 注释状态 | 取消注释,空值 | 同上 |
|
| 152 |
+
| `MAILLAB_DOMAIN` | 注释状态 | 取消注释,空值 | 否(缺省回落 CLOUDMAIL_DOMAIN) |
|
| 153 |
+
|
| 154 |
+
### 2.3 accounts.json / runtime_config.json 影响
|
| 155 |
+
|
| 156 |
+
**无影响**。`runtime_config.json.register_domain` 字段不变,继续被 `cf_temp_email.create_temp_email` 与 `maillab.create_temp_email` 同时读取(优先级 `显式参数 > runtime_config > 环境变量`),已在 round-2 实现并验证。
|
| 157 |
+
|
| 158 |
+
---
|
| 159 |
+
|
| 160 |
+
## 3. 函数签名规范
|
| 161 |
+
|
| 162 |
+
### 3.1 新建 `src/autoteam/mail/probe.py`
|
| 163 |
+
|
| 164 |
+
> 公开 5 个对象:3 个 helper 函数 + 1 个异常类 + 1 �� dataclass。
|
| 165 |
+
|
| 166 |
+
```python
|
| 167 |
+
"""mail/probe.py — `/api/mail-provider/probe` 与 `register-domain` 共享 helper。
|
| 168 |
+
|
| 169 |
+
每个 step 都有同名 helper,api.py 端点直接调用并把返回值封装为 ProbeResponse;
|
| 170 |
+
register-domain 复用 `probe_domain_ownership`。
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
from __future__ import annotations
|
| 174 |
+
|
| 175 |
+
from dataclasses import dataclass
|
| 176 |
+
from typing import Literal
|
| 177 |
+
|
| 178 |
+
import requests
|
| 179 |
+
|
| 180 |
+
from autoteam.mail.base import decode_jwt_payload
|
| 181 |
+
|
| 182 |
+
PROBE_TIMEOUT = 5 # 秒;每个 HTTP 调用上限
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@dataclass
|
| 186 |
+
class ProbeResult:
|
| 187 |
+
"""通用结果。step=fingerprint/credentials/domain_ownership 共用,字段按 step 填。"""
|
| 188 |
+
|
| 189 |
+
ok: bool
|
| 190 |
+
step: Literal["fingerprint", "credentials", "domain_ownership"]
|
| 191 |
+
error_code: str | None = None
|
| 192 |
+
message: str | None = None
|
| 193 |
+
hint: str | None = None
|
| 194 |
+
warnings: list[str] | None = None
|
| 195 |
+
# fingerprint
|
| 196 |
+
detected_provider: Literal["cf_temp_email", "maillab", "unknown"] | None = None
|
| 197 |
+
domain_list: list[str] | None = None
|
| 198 |
+
add_verify_open: bool | None = None
|
| 199 |
+
register_verify_open: bool | None = None
|
| 200 |
+
# credentials
|
| 201 |
+
is_admin: bool | None = None
|
| 202 |
+
user_email: str | None = None
|
| 203 |
+
token_preview: str | None = None
|
| 204 |
+
bearer_token: str | None = None # 内部传给下一步 helper,api 端点不返回给前端
|
| 205 |
+
# domain_ownership
|
| 206 |
+
probe_email: str | None = None
|
| 207 |
+
probe_account_id: int | None = None
|
| 208 |
+
cleaned: bool | None = None
|
| 209 |
+
leaked_probe: dict | None = None
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def probe_fingerprint(base_url: str, expected_provider: str) -> ProbeResult:
|
| 213 |
+
"""Step 1:无凭据指纹探测。
|
| 214 |
+
|
| 215 |
+
依次探测:
|
| 216 |
+
- GET {base_url}/setting/websiteConfig (maillab 独有,且通常含 domainList)
|
| 217 |
+
- GET {base_url}/admin/address (cf_temp_email 独有,401/403/200)
|
| 218 |
+
|
| 219 |
+
返回 detected_provider:
|
| 220 |
+
- "maillab" → /setting/websiteConfig 返回 200 + dict
|
| 221 |
+
- "cf_temp_email" → /admin/address 返回 200 (含 results) 或 401/403
|
| 222 |
+
- "unknown" → 两个都不像
|
| 223 |
+
|
| 224 |
+
与 expected_provider 不一致时,error_code=PROVIDER_MISMATCH。
|
| 225 |
+
"""
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def probe_credentials(
|
| 229 |
+
base_url: str,
|
| 230 |
+
provider: Literal["cf_temp_email", "maillab"],
|
| 231 |
+
*,
|
| 232 |
+
username: str = "",
|
| 233 |
+
password: str = "",
|
| 234 |
+
admin_password: str = "",
|
| 235 |
+
) -> ProbeResult:
|
| 236 |
+
"""Step 2:凭据校验。
|
| 237 |
+
|
| 238 |
+
cf_temp_email:GET {base_url}/admin/address with `x-admin-auth: <admin_password>` header
|
| 239 |
+
maillab:POST {base_url}/login body `{email, password}`,返回 JWT
|
| 240 |
+
|
| 241 |
+
返回 token_preview(前 10 字符)与 is_admin(maillab 解析 JWT payload `userType==1`)。
|
| 242 |
+
bearer_token 字段供下游 step 用。
|
| 243 |
+
"""
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def probe_domain_ownership(
|
| 247 |
+
base_url: str,
|
| 248 |
+
provider: Literal["cf_temp_email", "maillab"],
|
| 249 |
+
*,
|
| 250 |
+
bearer_token: str = "",
|
| 251 |
+
admin_password: str = "",
|
| 252 |
+
domain: str,
|
| 253 |
+
) -> ProbeResult:
|
| 254 |
+
"""Step 3:域名归属验证。
|
| 255 |
+
|
| 256 |
+
通用流程:
|
| 257 |
+
1. 在该 domain 下创建一个 probe-{ts} 邮箱
|
| 258 |
+
2. 立即删除回收(failed 时填 leaked_probe)
|
| 259 |
+
|
| 260 |
+
cf_temp_email:用 admin_password 直接调 /admin/new_address + /admin/delete_address
|
| 261 |
+
maillab:用 bearer_token 调 /account/add + /account/delete
|
| 262 |
+
"""
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
class ProbeError(Exception):
|
| 266 |
+
"""probe.* helper 内部抛错;api 层捕获后转成 ProbeResult(ok=False)。"""
|
| 267 |
+
|
| 268 |
+
def __init__(self, error_code: str, message: str, hint: str | None = None):
|
| 269 |
+
self.error_code = error_code
|
| 270 |
+
self.message = message
|
| 271 |
+
self.hint = hint
|
| 272 |
+
super().__init__(message)
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
**异常映射规则**(probe 内部统一捕获):
|
| 276 |
+
|
| 277 |
+
| 触发 | error_code |
|
| 278 |
+
| ----------------------------------------- | -------------------- |
|
| 279 |
+
| `requests.ConnectionError` | `NETWORK` |
|
| 280 |
+
| `requests.Timeout` 或 timeout=5 触发 | `TIMEOUT` |
|
| 281 |
+
| HTTP 404 + `/setting/websiteConfig` 不存在 + `/admin/address` 不存在 | `ROUTE_NOT_FOUND` |
|
| 282 |
+
| detected_provider != expected_provider | `PROVIDER_MISMATCH` |
|
| 283 |
+
| HTTP 401 / cf_temp_email admin 401 | `UNAUTHORIZED` |
|
| 284 |
+
| maillab `code:401` 在 step=credentials | `UNAUTHORIZED` |
|
| 285 |
+
| maillab `code:403` 或 message 含 `domain` | `FORBIDDEN_DOMAIN` |
|
| 286 |
+
| `addVerifyOpen` 或 `registerVerifyOpen` true 在 credentials 调用前 | `CAPTCHA_REQUIRED`(warning,不阻断) |
|
| 287 |
+
| domainList 为空数组 | `EMPTY_DOMAIN_LIST` |
|
| 288 |
+
| 兜底 | `UNKNOWN` |
|
| 289 |
+
|
| 290 |
+
### 3.2 cf_temp_email.login() 嗅探收紧
|
| 291 |
+
|
| 292 |
+
**改前** (`mail/cf_temp_email.py:106-112`):
|
| 293 |
+
|
| 294 |
+
```python
|
| 295 |
+
if isinstance(payload, dict) and "results" not in payload and ("code" in payload or "data" in payload):
|
| 296 |
+
raise Exception(...)
|
| 297 |
+
```
|
| 298 |
+
|
| 299 |
+
**改后**:
|
| 300 |
+
|
| 301 |
+
```python
|
| 302 |
+
# 正向白名单:cf_temp_email 的 /admin/address 必须返回含 `results` 字段的 dict;
|
| 303 |
+
# 任何缺 results 的 dict(空 {}、maillab 风格 {code,...}、null 等)都视为协议错配。
|
| 304 |
+
if not isinstance(payload, dict) or "results" not in payload:
|
| 305 |
+
raise Exception(
|
| 306 |
+
"CloudMail 登录响应不像 dreamhunter2333/cloudflare_temp_email"
|
| 307 |
+
f"(响应 {type(payload).__name__} 不含 `results` 字段)。"
|
| 308 |
+
f"你的 CLOUDMAIL_BASE_URL={self.base_url} 可能指向 maillab/cloud-mail 服务器或路由错。"
|
| 309 |
+
"请确认 .env 中 MAIL_PROVIDER=cf_temp_email 时填的是 dreamhunter2333 后端;"
|
| 310 |
+
"如果你部署的是 maillab,请改 MAIL_PROVIDER=maillab。"
|
| 311 |
+
)
|
| 312 |
+
```
|
| 313 |
+
|
| 314 |
+
**create_temp_email** (L154) 同步:
|
| 315 |
+
|
| 316 |
+
```python
|
| 317 |
+
# 改前
|
| 318 |
+
if isinstance(data, dict) and "address" not in data and ("code" in data and "message" in data):
|
| 319 |
+
|
| 320 |
+
# 改后(任何缺 address 字段就报错)
|
| 321 |
+
if not isinstance(data, dict) or "address" not in data:
|
| 322 |
+
raise Exception(
|
| 323 |
+
f"创建邮箱响应不像 cf_temp_email(收到 {data!r})。"
|
| 324 |
+
"请检查 MAIL_PROVIDER 与 base_url 是否对应。"
|
| 325 |
+
)
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
### 3.3 maillab.py 401 自愈
|
| 329 |
+
|
| 330 |
+
**新增类成员 + 装饰器**:
|
| 331 |
+
|
| 332 |
+
```python
|
| 333 |
+
# ===== mail/maillab.py 顶部 =====
|
| 334 |
+
import threading
|
| 335 |
+
import functools
|
| 336 |
+
|
| 337 |
+
class MaillabAuthFailed(Exception):
|
| 338 |
+
"""maillab 401 自愈失败 — 重 login 后仍 401。业务层不再重试。"""
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
_LOGIN_GUARD = threading.local()
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _with_login_retry(method):
|
| 345 |
+
"""装饰 _get/_post/_delete/_put;若响应 code=401,自动 re-login + 重试一次。
|
| 346 |
+
|
| 347 |
+
通过 thread-local guard `_LOGIN_GUARD.in_login` 阻止 login() 自身触发递归。
|
| 348 |
+
"""
|
| 349 |
+
@functools.wraps(method)
|
| 350 |
+
def wrapper(self, path, *args, **kwargs):
|
| 351 |
+
resp = method(self, path, *args, **kwargs)
|
| 352 |
+
if isinstance(resp, dict) and resp.get("code") == 401:
|
| 353 |
+
if getattr(_LOGIN_GUARD, "in_login", False):
|
| 354 |
+
# 我们在 login() 内部,不要递归
|
| 355 |
+
return resp
|
| 356 |
+
if getattr(_LOGIN_GUARD, "retried", False):
|
| 357 |
+
# 上一次重试还是 401 → 抛
|
| 358 |
+
raise MaillabAuthFailed(
|
| 359 |
+
f"maillab {path}: 重 login 后仍 401,请检查 MAILLAB_USERNAME/PASSWORD"
|
| 360 |
+
)
|
| 361 |
+
logger.warning("[maillab] %s 收到 code:401,自愈中...", path)
|
| 362 |
+
self.token = None
|
| 363 |
+
try:
|
| 364 |
+
_LOGIN_GUARD.retried = True
|
| 365 |
+
self.login() # login 内部 _post 会被 _LOGIN_GUARD.in_login 标记保护
|
| 366 |
+
resp = method(self, path, *args, **kwargs)
|
| 367 |
+
logger.info("[maillab] %s token 已自愈", path)
|
| 368 |
+
finally:
|
| 369 |
+
_LOGIN_GUARD.retried = False
|
| 370 |
+
return resp
|
| 371 |
+
return wrapper
|
| 372 |
+
```
|
| 373 |
+
|
| 374 |
+
**改造 4 个底层方法**(改前/改后):
|
| 375 |
+
|
| 376 |
+
```python
|
| 377 |
+
# 改前
|
| 378 |
+
def _get(self, path: str, params: dict | None = None) -> dict:
|
| 379 |
+
r = self.session.get(self._url(path), headers=self._headers(), params=params, timeout=30)
|
| 380 |
+
return self._parse_response(r, path)
|
| 381 |
+
|
| 382 |
+
# 改后
|
| 383 |
+
@_with_login_retry
|
| 384 |
+
def _get(self, path: str, params: dict | None = None) -> dict:
|
| 385 |
+
r = self.session.get(self._url(path), headers=self._headers(), params=params, timeout=30)
|
| 386 |
+
return self._parse_response(r, path)
|
| 387 |
+
```
|
| 388 |
+
|
| 389 |
+
`_post`/`_delete`/`_put` 同样加 `@_with_login_retry`。
|
| 390 |
+
|
| 391 |
+
**login() 加守卫**:
|
| 392 |
+
|
| 393 |
+
```python
|
| 394 |
+
def login(self) -> str:
|
| 395 |
+
_LOGIN_GUARD.in_login = True
|
| 396 |
+
try:
|
| 397 |
+
# ... 原有逻辑 ...
|
| 398 |
+
return token
|
| 399 |
+
finally:
|
| 400 |
+
_LOGIN_GUARD.in_login = False
|
| 401 |
+
```
|
| 402 |
+
|
| 403 |
+
### 3.4 setup_wizard 嗅探强阻断
|
| 404 |
+
|
| 405 |
+
**`_sniff_provider_mismatch` 改造**(`setup_wizard.py:173`):
|
| 406 |
+
|
| 407 |
+
```python
|
| 408 |
+
def _sniff_provider_mismatch(provider: str) -> tuple[bool, str]:
|
| 409 |
+
"""探测 base_url 与 MAIL_PROVIDER 的匹配性。
|
| 410 |
+
|
| 411 |
+
返回 (matched, reason):
|
| 412 |
+
- matched=True:可继续 login
|
| 413 |
+
- matched=False:reason 描述错配,_verify_cloudmail 直接 return False
|
| 414 |
+
|
| 415 |
+
探测策略升级:
|
| 416 |
+
- cf_temp_email 期望 → /admin/address 返 200 含 results / 401 / 403
|
| 417 |
+
+ /setting/websiteConfig 返 404
|
| 418 |
+
- maillab 期望 → /setting/websiteConfig 返 200 含 domainList
|
| 419 |
+
+ /admin/address 返 404 或 maillab 风格 {code,...}
|
| 420 |
+
"""
|
| 421 |
+
# ... 实施细节略;关键:增加 GET /setting/websiteConfig 探测
|
| 422 |
+
```
|
| 423 |
+
|
| 424 |
+
**`_verify_cloudmail` 流程调整**:
|
| 425 |
+
|
| 426 |
+
```python
|
| 427 |
+
def _verify_cloudmail():
|
| 428 |
+
provider = (os.environ.get("MAIL_PROVIDER") or "cf_temp_email").strip().lower()
|
| 429 |
+
# ... 原有 base_url/password/domain 完备性检查 ...
|
| 430 |
+
|
| 431 |
+
# ★ 新位置:嗅探在所有 client 实例化之前
|
| 432 |
+
if os.environ.get("AUTOTEAM_SKIP_PROVIDER_SNIFF") != "1":
|
| 433 |
+
matched, reason = _sniff_provider_mismatch(provider)
|
| 434 |
+
if not matched:
|
| 435 |
+
logger.error("[验证] 协议错配: %s", reason)
|
| 436 |
+
return False # ← 强阻断,不再 warning
|
| 437 |
+
|
| 438 |
+
# ... 后续 client.login() / create_temp_email() 不变 ...
|
| 439 |
+
```
|
| 440 |
+
|
| 441 |
+
**REQUIRED_CONFIGS 扩**(`setup_wizard.py:20`):
|
| 442 |
+
|
| 443 |
+
```python
|
| 444 |
+
REQUIRED_CONFIGS = [
|
| 445 |
+
("MAIL_PROVIDER", "Mail Provider(cf_temp_email | maillab)", "cf_temp_email", True),
|
| 446 |
+
("CLOUDMAIL_BASE_URL", "CloudMail API 地址(cf_temp_email)", "", False),
|
| 447 |
+
("CLOUDMAIL_PASSWORD", "CloudMail 管理员密码(cf_temp_email)", "", False),
|
| 448 |
+
("CLOUDMAIL_DOMAIN", "邮箱域名(如 @example.com)", "", False),
|
| 449 |
+
# ★ 新增 4 字段(maillab 用;optional=True 由前端按 provider 切换可见性)
|
| 450 |
+
("MAILLAB_API_URL", "Maillab API 地址", "", True),
|
| 451 |
+
("MAILLAB_USERNAME", "Maillab 管理员邮箱", "", True),
|
| 452 |
+
("MAILLAB_PASSWORD", "Maillab 管理员密码", "", True),
|
| 453 |
+
("MAILLAB_DOMAIN", "Maillab 邮箱域名(缺省回落 CLOUDMAIL_DOMAIN)", "", True),
|
| 454 |
+
# ... 其余不变
|
| 455 |
+
]
|
| 456 |
+
```
|
| 457 |
+
|
| 458 |
+
**注**:`/api/setup/status` 端点会按 provider 动态计算 `optional`,见 §3.6。
|
| 459 |
+
|
| 460 |
+
### 3.5 api.py 新增端点 + 鉴权策略
|
| 461 |
+
|
| 462 |
+
**`_AUTH_SKIP_PATHS` 扩展**(`api.py:31`):
|
| 463 |
+
|
| 464 |
+
```python
|
| 465 |
+
_AUTH_SKIP_PATHS = {
|
| 466 |
+
"/api/auth/check",
|
| 467 |
+
"/api/setup/status",
|
| 468 |
+
"/api/setup/save",
|
| 469 |
+
"/api/mail-provider/probe", # ← 新增;条件鉴权见下
|
| 470 |
+
}
|
| 471 |
+
```
|
| 472 |
+
|
| 473 |
+
**条件鉴权说明**:`auth_middleware` 已自动跳过 `_AUTH_SKIP_PATHS` 中的路径。但 `/api/mail-provider/probe` 在面板初始化后(API_KEY 已配置)需强制鉴权,通过手工二次校验:
|
| 474 |
+
|
| 475 |
+
```python
|
| 476 |
+
@app.post("/api/mail-provider/probe", response_model=MailProviderProbeResponse)
|
| 477 |
+
def post_mail_provider_probe(req: MailProviderProbeRequest, request: Request):
|
| 478 |
+
# 条件鉴权:API_KEY 已配置 → 强制 Bearer
|
| 479 |
+
from autoteam.config import API_KEY as _key
|
| 480 |
+
if _key:
|
| 481 |
+
auth_header = request.headers.get("authorization", "")
|
| 482 |
+
if not (auth_header.startswith("Bearer ") and auth_header[7:] == _key):
|
| 483 |
+
raise HTTPException(status_code=401, detail="API_KEY 已配置,请提供 Bearer token")
|
| 484 |
+
# 速率限制:setup 阶段(无 API_KEY)单 IP 60/min;有 API_KEY 时不限
|
| 485 |
+
if not _key:
|
| 486 |
+
_enforce_probe_rate_limit(request)
|
| 487 |
+
|
| 488 |
+
# 分发
|
| 489 |
+
from autoteam.mail import probe as mail_probe
|
| 490 |
+
|
| 491 |
+
try:
|
| 492 |
+
if req.step == "fingerprint":
|
| 493 |
+
result = mail_probe.probe_fingerprint(req.base_url, req.provider)
|
| 494 |
+
elif req.step == "credentials":
|
| 495 |
+
result = mail_probe.probe_credentials(
|
| 496 |
+
req.base_url, req.provider,
|
| 497 |
+
username=req.username, password=req.password,
|
| 498 |
+
admin_password=req.admin_password,
|
| 499 |
+
)
|
| 500 |
+
elif req.step == "domain_ownership":
|
| 501 |
+
result = mail_probe.probe_domain_ownership(
|
| 502 |
+
req.base_url, req.provider,
|
| 503 |
+
bearer_token=req.bearer_token,
|
| 504 |
+
admin_password=req.admin_password,
|
| 505 |
+
domain=req.domain,
|
| 506 |
+
)
|
| 507 |
+
except mail_probe.ProbeError as exc:
|
| 508 |
+
return MailProviderProbeResponse(
|
| 509 |
+
ok=False, step=req.step,
|
| 510 |
+
error_code=ProbeErrorCode(exc.error_code),
|
| 511 |
+
message=exc.message, hint=exc.hint,
|
| 512 |
+
)
|
| 513 |
+
# ProbeResult → ProbeResponse(忽略 bearer_token,前端用上一步的返回)
|
| 514 |
+
return MailProviderProbeResponse(**{k: v for k, v in vars(result).items() if k != "bearer_token"})
|
| 515 |
+
```
|
| 516 |
+
|
| 517 |
+
**`_enforce_probe_rate_limit`**(简单内存计数,生产可换 redis):
|
| 518 |
+
|
| 519 |
+
```python
|
| 520 |
+
_probe_rate_buckets: dict[str, list[float]] = {}
|
| 521 |
+
_probe_rate_lock = threading.Lock()
|
| 522 |
+
|
| 523 |
+
def _enforce_probe_rate_limit(request: Request, max_per_min: int = 60):
|
| 524 |
+
ip = request.client.host if request.client else "unknown"
|
| 525 |
+
now = time.time()
|
| 526 |
+
with _probe_rate_lock:
|
| 527 |
+
bucket = _probe_rate_buckets.setdefault(ip, [])
|
| 528 |
+
bucket[:] = [t for t in bucket if now - t < 60]
|
| 529 |
+
if len(bucket) >= max_per_min:
|
| 530 |
+
raise HTTPException(status_code=429, detail="probe 请求过频,请稍后再试")
|
| 531 |
+
bucket.append(now)
|
| 532 |
+
```
|
| 533 |
+
|
| 534 |
+
### 3.6 api.py /api/setup/save 新字段写盘
|
| 535 |
+
|
| 536 |
+
**`post_setup_save` 改造**(`api.py:99`):
|
| 537 |
+
|
| 538 |
+
```python
|
| 539 |
+
@app.post("/api/setup/save")
|
| 540 |
+
def post_setup_save(config: SetupConfig):
|
| 541 |
+
import secrets as _secrets
|
| 542 |
+
from autoteam.setup_wizard import REQUIRED_CONFIGS, _write_env
|
| 543 |
+
|
| 544 |
+
data = config.model_dump()
|
| 545 |
+
defaults = {key: default for key, _, default, _ in REQUIRED_CONFIGS}
|
| 546 |
+
|
| 547 |
+
if not data.get("CPA_URL"):
|
| 548 |
+
data["CPA_URL"] = defaults.get("CPA_URL", "http://127.0.0.1:8317")
|
| 549 |
+
if not data.get("API_KEY"):
|
| 550 |
+
data["API_KEY"] = _secrets.token_urlsafe(24)
|
| 551 |
+
|
| 552 |
+
# ★ 按 provider 选择性写入:cf_temp_email 时不写 MAILLAB_*,反之亦然
|
| 553 |
+
provider = data.get("MAIL_PROVIDER", "cf_temp_email")
|
| 554 |
+
if provider in ("cf_temp_email", "cloudflare_temp_email"):
|
| 555 |
+
skip_keys = {"MAILLAB_API_URL", "MAILLAB_USERNAME", "MAILLAB_PASSWORD", "MAILLAB_DOMAIN"}
|
| 556 |
+
elif provider == "maillab":
|
| 557 |
+
skip_keys = {"CLOUDMAIL_BASE_URL", "CLOUDMAIL_PASSWORD"} # CLOUDMAIL_DOMAIN 仍可作回落,保留
|
| 558 |
+
else:
|
| 559 |
+
skip_keys = set()
|
| 560 |
+
|
| 561 |
+
clearable_fields = {"PLAYWRIGHT_PROXY_URL", "PLAYWRIGHT_PROXY_BYPASS"}
|
| 562 |
+
for key, value in data.items():
|
| 563 |
+
if key in skip_keys:
|
| 564 |
+
continue
|
| 565 |
+
if value or key in clearable_fields:
|
| 566 |
+
_write_env(key, value)
|
| 567 |
+
os.environ[key] = value
|
| 568 |
+
|
| 569 |
+
# ... 后续重载与 _verify_cloudmail 不变
|
| 570 |
+
```
|
| 571 |
+
|
| 572 |
+
**`/api/setup/status` 按 provider 标 optional**:
|
| 573 |
+
|
| 574 |
+
```python
|
| 575 |
+
@app.get("/api/setup/status")
|
| 576 |
+
def get_setup_status():
|
| 577 |
+
from autoteam.setup_wizard import REQUIRED_CONFIGS, _read_env
|
| 578 |
+
|
| 579 |
+
env = _read_env()
|
| 580 |
+
provider = env.get("MAIL_PROVIDER") or os.environ.get("MAIL_PROVIDER", "cf_temp_email")
|
| 581 |
+
|
| 582 |
+
cf_keys = {"CLOUDMAIL_BASE_URL", "CLOUDMAIL_PASSWORD", "CLOUDMAIL_DOMAIN"}
|
| 583 |
+
ml_keys = {"MAILLAB_API_URL", "MAILLAB_USERNAME", "MAILLAB_PASSWORD", "MAILLAB_DOMAIN"}
|
| 584 |
+
|
| 585 |
+
fields = []
|
| 586 |
+
all_ok = True
|
| 587 |
+
for key, prompt, default, optional in REQUIRED_CONFIGS:
|
| 588 |
+
# 动态 optional:不属于当前 provider 的字段强制 optional=True
|
| 589 |
+
if provider == "maillab" and key in cf_keys:
|
| 590 |
+
optional = True
|
| 591 |
+
elif provider in ("cf_temp_email", "cloudflare_temp_email") and key in ml_keys:
|
| 592 |
+
optional = True
|
| 593 |
+
val = env.get(key, "") or os.environ.get(key, "")
|
| 594 |
+
ok = bool(val)
|
| 595 |
+
if not ok and not optional:
|
| 596 |
+
all_ok = False
|
| 597 |
+
fields.append({"key": key, "prompt": prompt, "default": default, "optional": optional, "configured": ok})
|
| 598 |
+
return {"configured": all_ok, "fields": fields, "provider": provider}
|
| 599 |
+
```
|
| 600 |
+
|
| 601 |
+
---
|
| 602 |
+
|
| 603 |
+
## 4. 前端契约
|
| 604 |
+
|
| 605 |
+
### 4.1 SetupPage 4 步状态机
|
| 606 |
+
|
| 607 |
+
```
|
| 608 |
+
┌──────────────────┐
|
| 609 |
+
│ State.PROVIDER │ 用户选 cf_temp_email / maillab
|
| 610 |
+
└────────┬─────────┘
|
| 611 |
+
│ provider 选定
|
| 612 |
+
▼
|
| 613 |
+
┌──────────────────┐
|
| 614 |
+
│ State.CONNECTION │ 填 base_url / 凭据,点击「测试连接」
|
| 615 |
+
│ probe.fingerprint
|
| 616 |
+
│ probe.credentials
|
| 617 |
+
└────────┬─────────┘
|
| 618 |
+
│ ok=true 进入下一步
|
| 619 |
+
失败──────│
|
| 620 |
+
显示 ▼
|
| 621 |
+
err ┌──────────────────┐
|
| 622 |
+
+hint │ State.DOMAIN │ 下拉/输入 domain,点击「验证归属」
|
| 623 |
+
灰显 │ probe.domain_ownership
|
| 624 |
+
下游 └────────┬─────────┘
|
| 625 |
+
│ ok=true
|
| 626 |
+
▼
|
| 627 |
+
┌──────────────────┐
|
| 628 |
+
│ State.SAVE │ 填 CPA / API_KEY / Proxy,点保存
|
| 629 |
+
│ /api/setup/save
|
| 630 |
+
└──────────────────┘
|
| 631 |
+
```
|
| 632 |
+
|
| 633 |
+
**状态转移**:任何一步失败时,State 不前进,但允许用户**修改前一步字段后重新点该步按钮**。State.PROVIDER 切换 provider 时,后续状态全部重置。
|
| 634 |
+
|
| 635 |
+
### 4.2 axios/fetch 调用契约
|
| 636 |
+
|
| 637 |
+
**新增 `web/src/api.js` 方法**:
|
| 638 |
+
|
| 639 |
+
```javascript
|
| 640 |
+
probeMailProvider: (payload) => request('POST', '/mail-provider/probe', payload),
|
| 641 |
+
```
|
| 642 |
+
|
| 643 |
+
**前端调用示例**(MailProviderCard.vue):
|
| 644 |
+
|
| 645 |
+
```javascript
|
| 646 |
+
async function testConnection() {
|
| 647 |
+
const fp = await api.probeMailProvider({
|
| 648 |
+
provider: form.provider,
|
| 649 |
+
step: 'fingerprint',
|
| 650 |
+
base_url: form.baseUrl,
|
| 651 |
+
})
|
| 652 |
+
if (!fp.ok) { showError(fp); return }
|
| 653 |
+
domainList.value = fp.domain_list || []
|
| 654 |
+
warnings.value = fp.warnings || []
|
| 655 |
+
|
| 656 |
+
const cred = await api.probeMailProvider({
|
| 657 |
+
provider: form.provider,
|
| 658 |
+
step: 'credentials',
|
| 659 |
+
base_url: form.baseUrl,
|
| 660 |
+
username: form.username,
|
| 661 |
+
password: form.password,
|
| 662 |
+
admin_password: form.adminPassword,
|
| 663 |
+
})
|
| 664 |
+
if (!cred.ok) { showError(cred); return }
|
| 665 |
+
state.value = 'DOMAIN'
|
| 666 |
+
bearerToken.value = cred.token_preview ? null : null // 前端实际只缓存上一步 cred 通过的 flag
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
async function verifyDomain() {
|
| 670 |
+
const own = await api.probeMailProvider({
|
| 671 |
+
provider: form.provider,
|
| 672 |
+
step: 'domain_ownership',
|
| 673 |
+
base_url: form.baseUrl,
|
| 674 |
+
bearer_token: '', // 后端再次走 login(短期 ttl 不缓存),实现简单
|
| 675 |
+
admin_password: form.adminPassword,
|
| 676 |
+
domain: form.domain,
|
| 677 |
+
})
|
| 678 |
+
if (!own.ok) { showError(own); return }
|
| 679 |
+
state.value = 'SAVE'
|
| 680 |
+
}
|
| 681 |
+
```
|
| 682 |
+
|
| 683 |
+
> 注:为了让后端无状态,前端**不持有 jwt**,step=domain_ownership 时后端**重新调一次 login**(maillab 单次 login 成本低)。这把 OQ-1 决策改为「后端在 step=domain_ownership 内部 re-login」,比前端缓存 token 更简单且更安全。
|
| 684 |
+
|
| 685 |
+
### 4.3 错误展示规则表
|
| 686 |
+
|
| 687 |
+
| error_code | 用户文案 | hint |
|
| 688 |
+
| -------------------- | --------------------------------------------------- | --------------------------------------------------- |
|
| 689 |
+
| `ROUTE_NOT_FOUND` | base_url 路由不可达 | 检查地址拼写,maillab 通常无 `/api` 前缀,cf_temp_email 通常带 `/api` |
|
| 690 |
+
| `PROVIDER_MISMATCH` | 选择的 provider 与服务器类型不一致 | 把 provider 改成「{detected_provider}」 |
|
| 691 |
+
| `UNAUTHORIZED` | 凭据错误 | 检查用户名 / 密码 |
|
| 692 |
+
| `FORBIDDEN_DOMAIN` | 该 domain 无写权限或不在白名单 | 联系 maillab 管理员把该 domain 加入 |
|
| 693 |
+
| `CAPTCHA_REQUIRED` | 服务端启用了 Turnstile / addVerify | 到 maillab 后台 → 设置 → 安全 关闭 captcha |
|
| 694 |
+
| `NETWORK` | 网络不可达 | 检查 base_url、防火墙、代理 |
|
| 695 |
+
| `TIMEOUT` | 服务器响应超时(>5s) | 检查 base_url 可达性 |
|
| 696 |
+
| `EMPTY_DOMAIN_LIST` | 该实例未配置任何邮箱域名 | 到 maillab 后台 → 设置 → 域名 配置 |
|
| 697 |
+
| `UNKNOWN` | 未知错误,见 message | 复制 message 反馈 |
|
| 698 |
+
|
| 699 |
+
---
|
| 700 |
+
|
| 701 |
+
## 5. 测试用例
|
| 702 |
+
|
| 703 |
+
### 5.1 单元测试清单
|
| 704 |
+
|
| 705 |
+
#### `tests/test_mail_cf_temp_email_sniff.py`
|
| 706 |
+
|
| 707 |
+
```python
|
| 708 |
+
import pytest
|
| 709 |
+
import responses
|
| 710 |
+
from autoteam.mail.cf_temp_email import CfTempEmailClient
|
| 711 |
+
|
| 712 |
+
@responses.activate
|
| 713 |
+
@pytest.mark.parametrize("body, should_raise", [
|
| 714 |
+
({"results": []}, False), # cf 正常空列表
|
| 715 |
+
({"results": [{"id": 1}]}, False), # cf 正常有数据
|
| 716 |
+
({}, True), # 空 dict — round-3 漏判,本次必须捕获
|
| 717 |
+
({"code": 401, "message": "auth"}, True), # maillab 风格
|
| 718 |
+
({"code": 200, "data": {}}, True), # maillab 风格 200
|
| 719 |
+
(None, True), # 非 dict
|
| 720 |
+
])
|
| 721 |
+
def test_login_sniff(body, should_raise, monkeypatch):
|
| 722 |
+
monkeypatch.setenv("CLOUDMAIL_BASE_URL", "https://example.com/api")
|
| 723 |
+
monkeypatch.setenv("CLOUDMAIL_PASSWORD", "secret")
|
| 724 |
+
responses.add(responses.GET, "https://example.com/api/admin/address",
|
| 725 |
+
json=body, status=200)
|
| 726 |
+
client = CfTempEmailClient()
|
| 727 |
+
if should_raise:
|
| 728 |
+
with pytest.raises(Exception, match="不像.*cloudflare_temp_email"):
|
| 729 |
+
client.login()
|
| 730 |
+
else:
|
| 731 |
+
client.login() # 应不抛
|
| 732 |
+
```
|
| 733 |
+
|
| 734 |
+
#### `tests/test_mail_maillab_self_heal.py`
|
| 735 |
+
|
| 736 |
+
```python
|
| 737 |
+
@responses.activate
|
| 738 |
+
def test_401_self_heal(monkeypatch):
|
| 739 |
+
monkeypatch.setenv("MAILLAB_API_URL", "https://m.example.com")
|
| 740 |
+
monkeypatch.setenv("MAILLAB_USERNAME", "admin@x.com")
|
| 741 |
+
monkeypatch.setenv("MAILLAB_PASSWORD", "p")
|
| 742 |
+
# 第 1 次 list 返回 401
|
| 743 |
+
responses.add(responses.GET, "https://m.example.com/account/list",
|
| 744 |
+
json={"code": 401, "message": "expired"}, status=200)
|
| 745 |
+
# 重 login 成功
|
| 746 |
+
responses.add(responses.POST, "https://m.example.com/login",
|
| 747 |
+
json={"code": 200, "data": {"token": "new-jwt"}}, status=200)
|
| 748 |
+
# 第 2 次 list 成功
|
| 749 |
+
responses.add(responses.GET, "https://m.example.com/account/list",
|
| 750 |
+
json={"code": 200, "data": []}, status=200)
|
| 751 |
+
from autoteam.mail.maillab import MaillabClient
|
| 752 |
+
client = MaillabClient()
|
| 753 |
+
client.token = "stale-jwt"
|
| 754 |
+
rows = client.list_accounts()
|
| 755 |
+
assert rows == []
|
| 756 |
+
# 验证调用顺序:list → login → list
|
| 757 |
+
assert [c.request.url for c in responses.calls] == [
|
| 758 |
+
"https://m.example.com/account/list?size=30",
|
| 759 |
+
"https://m.example.com/login",
|
| 760 |
+
"https://m.example.com/account/list?size=30",
|
| 761 |
+
]
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
@responses.activate
|
| 765 |
+
def test_401_repeated_raises(monkeypatch):
|
| 766 |
+
"""重 login 后仍 401 → 抛 MaillabAuthFailed,不无限循环。"""
|
| 767 |
+
# ... 类似上面,但第 2 次 list 仍 401
|
| 768 |
+
from autoteam.mail.maillab import MaillabAuthFailed
|
| 769 |
+
with pytest.raises(MaillabAuthFailed):
|
| 770 |
+
client.list_accounts()
|
| 771 |
+
|
| 772 |
+
|
| 773 |
+
def test_login_internal_no_recursion(monkeypatch):
|
| 774 |
+
"""login() 内部 _post 即便回 401,也不应触发外层 _with_login_retry,避免无限递归。"""
|
| 775 |
+
# mock /login 第一次回 {code:401},触发 wrapper 时 _LOGIN_GUARD.in_login=True 应阻止重试
|
| 776 |
+
# 期望:client.login() 直接抛 "maillab 登录失败",而不是循环调 login
|
| 777 |
+
```
|
| 778 |
+
|
| 779 |
+
#### `tests/test_mail_provider_probe.py`
|
| 780 |
+
|
| 781 |
+
```python
|
| 782 |
+
@responses.activate
|
| 783 |
+
def test_fingerprint_detect_maillab():
|
| 784 |
+
responses.add(responses.GET, "https://m.example.com/setting/websiteConfig",
|
| 785 |
+
json={"domainList": ["@a.com", "@b.com"], "addVerifyOpen": False, "registerVerifyOpen": False},
|
| 786 |
+
status=200)
|
| 787 |
+
from autoteam.mail.probe import probe_fingerprint
|
| 788 |
+
result = probe_fingerprint("https://m.example.com", "maillab")
|
| 789 |
+
assert result.ok
|
| 790 |
+
assert result.detected_provider == "maillab"
|
| 791 |
+
assert result.domain_list == ["@a.com", "@b.com"]
|
| 792 |
+
|
| 793 |
+
|
| 794 |
+
@responses.activate
|
| 795 |
+
def test_fingerprint_provider_mismatch():
|
| 796 |
+
"""base_url 是 maillab,但 provider 选了 cf_temp_email"""
|
| 797 |
+
responses.add(responses.GET, "https://m.example.com/setting/websiteConfig",
|
| 798 |
+
json={"domainList": ["@a.com"]}, status=200)
|
| 799 |
+
result = probe_fingerprint("https://m.example.com", "cf_temp_email")
|
| 800 |
+
assert not result.ok
|
| 801 |
+
assert result.error_code == "PROVIDER_MISMATCH"
|
| 802 |
+
assert result.detected_provider == "maillab"
|
| 803 |
+
|
| 804 |
+
|
| 805 |
+
def test_credentials_maillab_admin():
|
| 806 |
+
# mock /login 返回带 userType=1 的 JWT
|
| 807 |
+
# 断言 is_admin=True
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
def test_domain_ownership_forbidden():
|
| 811 |
+
# mock /account/add 返 {code:403, message:"domain not allowed"}
|
| 812 |
+
# 断言 error_code=FORBIDDEN_DOMAIN
|
| 813 |
+
```
|
| 814 |
+
|
| 815 |
+
#### `tests/test_setup_wizard_sniff_block.py`
|
| 816 |
+
|
| 817 |
+
```python
|
| 818 |
+
def test_verify_cloudmail_aborts_on_mismatch(monkeypatch, caplog):
|
| 819 |
+
monkeypatch.setenv("MAIL_PROVIDER", "cf_temp_email")
|
| 820 |
+
monkeypatch.setenv("CLOUDMAIL_BASE_URL", "https://m.example.com") # maillab 服务器
|
| 821 |
+
monkeypatch.setenv("CLOUDMAIL_PASSWORD", "x")
|
| 822 |
+
monkeypatch.setenv("CLOUDMAIL_DOMAIN", "@a.com")
|
| 823 |
+
# mock /setting/websiteConfig 返 200(maillab 指纹)、/admin/address 返 404
|
| 824 |
+
# ...
|
| 825 |
+
from autoteam.setup_wizard import _verify_cloudmail
|
| 826 |
+
assert _verify_cloudmail() is False
|
| 827 |
+
assert "协议错配" in caplog.text
|
| 828 |
+
# 关键:CloudMailClient 不应被实例化
|
| 829 |
+
```
|
| 830 |
+
|
| 831 |
+
### 5.2 集成测试
|
| 832 |
+
|
| 833 |
+
```python
|
| 834 |
+
def test_setup_save_writes_maillab_fields(client, tmp_env):
|
| 835 |
+
payload = {
|
| 836 |
+
"MAIL_PROVIDER": "maillab",
|
| 837 |
+
"MAILLAB_API_URL": "https://m.example.com",
|
| 838 |
+
"MAILLAB_USERNAME": "admin@x.com",
|
| 839 |
+
"MAILLAB_PASSWORD": "p",
|
| 840 |
+
"MAILLAB_DOMAIN": "@x.com",
|
| 841 |
+
"CLOUDMAIL_DOMAIN": "@x.com", # 回落用
|
| 842 |
+
"CPA_URL": "http://127.0.0.1:8317",
|
| 843 |
+
"CPA_KEY": "k",
|
| 844 |
+
"API_KEY": "test-key",
|
| 845 |
+
}
|
| 846 |
+
# mock _verify_cloudmail/_verify_cpa 返 True
|
| 847 |
+
resp = client.post("/api/setup/save", json=payload)
|
| 848 |
+
assert resp.status_code == 200
|
| 849 |
+
env = read_env_file()
|
| 850 |
+
assert env["MAIL_PROVIDER"] == "maillab"
|
| 851 |
+
assert env["MAILLAB_API_URL"] == "https://m.example.com"
|
| 852 |
+
# cf_temp_email 字段被跳过
|
| 853 |
+
assert "CLOUDMAIL_BASE_URL" not in env or env["CLOUDMAIL_BASE_URL"] == ""
|
| 854 |
+
```
|
| 855 |
+
|
| 856 |
+
### 5.3 回归测试(避免破坏 cf_temp_email 用户)
|
| 857 |
+
|
| 858 |
+
```python
|
| 859 |
+
def test_existing_cf_user_not_broken(monkeypatch, mock_cf_server):
|
| 860 |
+
"""老用户不改 .env(无 MAIL_PROVIDER 字段)启动,行为应与之前完全一致。"""
|
| 861 |
+
# 不设 MAIL_PROVIDER → 默认 cf_temp_email
|
| 862 |
+
# mock_cf_server 返合法 results
|
| 863 |
+
from autoteam.setup_wizard import _verify_cloudmail
|
| 864 |
+
assert _verify_cloudmail() is True
|
| 865 |
+
```
|
| 866 |
+
|
| 867 |
+
### 5.4 测试数据(mock 样本)
|
| 868 |
+
|
| 869 |
+
**maillab `/setting/websiteConfig` 完整响应**:
|
| 870 |
+
|
| 871 |
+
```json
|
| 872 |
+
{
|
| 873 |
+
"register": true,
|
| 874 |
+
"title": "SkyMail",
|
| 875 |
+
"manyEmail": true,
|
| 876 |
+
"addEmail": true,
|
| 877 |
+
"autoRefresh": 30,
|
| 878 |
+
"addEmailVerify": false,
|
| 879 |
+
"registerVerify": false,
|
| 880 |
+
"send": false,
|
| 881 |
+
"domainList": ["@example.com", "@another.com"],
|
| 882 |
+
"siteKey": "0x4...",
|
| 883 |
+
"regKey": "0x4...",
|
| 884 |
+
"r2Domain": "https://r2.example.com",
|
| 885 |
+
"background": "",
|
| 886 |
+
"loginOpacity": 0.8,
|
| 887 |
+
"regVerifyOpen": false,
|
| 888 |
+
"addVerifyOpen": false,
|
| 889 |
+
"noticeTitle": "",
|
| 890 |
+
"noticeContent": "",
|
| 891 |
+
"linuxdoSwitch": false,
|
| 892 |
+
"minEmailPrefix": 3,
|
| 893 |
+
"projectLink": "https://skymail.ink"
|
| 894 |
+
}
|
| 895 |
+
```
|
| 896 |
+
|
| 897 |
+
**maillab `/login` 成功响应**:
|
| 898 |
+
|
| 899 |
+
```json
|
| 900 |
+
{"code": 200, "data": {"token": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsInVzZXJUeXBlIjoxLCJlbWFpbCI6ImFkbWluQHguY29tIn0.sig"}}
|
| 901 |
+
```
|
| 902 |
+
|
| 903 |
+
**maillab `/account/add` 403**:
|
| 904 |
+
|
| 905 |
+
```json
|
| 906 |
+
{"code": 403, "message": "domain @forbidden.com not in white list"}
|
| 907 |
+
```
|
| 908 |
+
|
| 909 |
+
**cf_temp_email `/admin/address` 正常**:
|
| 910 |
+
|
| 911 |
+
```json
|
| 912 |
+
{"results": [{"id": 1, "name": "test@example.com"}], "count": 1}
|
| 913 |
+
```
|
| 914 |
+
|
| 915 |
+
---
|
| 916 |
+
|
| 917 |
+
## 6. 文档落地清单(章节级 diff 大纲)
|
| 918 |
+
|
| 919 |
+
### `docs/getting-started.md`
|
| 920 |
+
- §1 「准备工作 / 1. 搭建临时邮箱」:用 admonition 强调「先决定后端,再决定填哪一组配置」
|
| 921 |
+
- §2 「第二步:配置 / 直接部署」:增补 maillab 完整字段示例;明确 SetupPage 已支持 provider 选择
|
| 922 |
+
- 新增 §2.5 「邮箱后端归属验证」:截图(可后补)+ 4 步流程文字说明
|
| 923 |
+
|
| 924 |
+
### `docs/configuration.md`
|
| 925 |
+
- 「`.env` 配置项」表:`MAIL_PROVIDER` 必填列由 `否` 改 `是(默认 cf_temp_email)`;MAILLAB_* 4 字段 「Web 面板可填」标记
|
| 926 |
+
- 「Mail Provider 切换」:**推荐顺序**改为先讲 maillab(国内 + skymail 一键部署),后讲 cf_temp_email
|
| 927 |
+
- 新增 「邮箱归属验证」:`/setting/websiteConfig.domainList` + addVerify 边界
|
| 928 |
+
- 「⚠️ 协议错配排查」:把 issue#1 截图 + 修复路径(选 provider → 测试连接)显式列
|
| 929 |
+
|
| 930 |
+
### `docs/mail-provider-design.md`
|
| 931 |
+
- §6 「未知项 5 项」状态全部改 ✅(login JWT header / domain 列表 API / createTime / 创建端点 / accountId 类型),引用 GitHub 源码 url
|
| 932 |
+
- 新增 §7 「skymail.ink API 全表」:从 issue-1-cloudmail.md §B 整理入档
|
| 933 |
+
- 新增 §8 「401 自愈策略」:本 SPEC §3.3 设计入档
|
| 934 |
+
|
| 935 |
+
### `docs/troubleshooting.md`
|
| 936 |
+
- 「Web 面板相关」:加「配置保存后 401 创建邮箱失败」 → 链接 issue#1 修复
|
| 937 |
+
- 新增 「邮箱后端」 章节:5 个常见错配场景 + 对应 error_code
|
| 938 |
+
|
| 939 |
+
### `docs/api.md`
|
| 940 |
+
- 「初始配置 API」:文档化 `/api/mail-provider/probe`(请求/响应 schema、3 个 step 示例、错误码表)
|
| 941 |
+
- 「域名管理 API」:`/api/config/register-domain` 添加「内部使用 `probe_domain_ownership` helper」说明
|
| 942 |
+
|
| 943 |
+
### `.env.example`
|
| 944 |
+
- L1-5:MAIL_PROVIDER 上方加大段注释「**强烈推荐显式设置**;不设默认 cf_temp_email,maillab 用户会撞 issue#1 错配」
|
| 945 |
+
- MAILLAB_* 4 行取消 `#` 注释
|
| 946 |
+
|
| 947 |
+
---
|
| 948 |
+
|
| 949 |
+
## 7. 实施顺序与依赖
|
| 950 |
+
|
| 951 |
+
### DAG
|
| 952 |
+
|
| 953 |
+
```
|
| 954 |
+
┌─────────────────────────────────────────┐
|
| 955 |
+
│ Phase 1 (后端基础,无 UI 风险) │
|
| 956 |
+
│ │
|
| 957 |
+
│ ST-101 SetupConfig 扩字段 │
|
| 958 |
+
│ │ │
|
| 959 |
+
│ ├─ ST-104 嗅探强阻断 ─┐ │
|
| 960 |
+
│ │ │ │
|
| 961 |
+
│ ST-102 cf 嗅探收紧 ──────┤ │
|
| 962 |
+
│ ST-103 maillab 401 自愈 ─┤ │
|
| 963 |
+
│ │ │ │
|
| 964 |
+
│ ST-105 .env.example ────┤ │
|
| 965 |
+
│ │ │ │
|
| 966 |
+
│ ST-107 单元测试 ─────────┘ │
|
| 967 |
+
│ │ │
|
| 968 |
+
│ ST-106 文档(部分) │
|
| 969 |
+
└─────────────────────────────────────────┘
|
| 970 |
+
│
|
| 971 |
+
▼
|
| 972 |
+
┌─────────────────────────────────────────┐
|
| 973 |
+
│ Phase 2 (probe 端点 + UI) │
|
| 974 |
+
│ │
|
| 975 |
+
│ ST-201 mail/probe.py ─┐ │
|
| 976 |
+
│ │ │ │
|
| 977 |
+
│ ▼ │ │
|
| 978 |
+
│ ST-202 /api/mail-provider/probe │
|
| 979 |
+
│ │ │ │
|
| 980 |
+
│ ST-203 register-domain 复用 ←┘ │
|
| 981 |
+
│ │ │
|
| 982 |
+
│ ST-204 MailProviderCard.vue │
|
| 983 |
+
│ ST-205 SetupPage 重构 │
|
| 984 |
+
│ ST-206 api.js 方法 │
|
| 985 |
+
│ │ │
|
| 986 |
+
│ ST-207 集成 + E2E │
|
| 987 |
+
└─────────────────────────────────────────┘
|
| 988 |
+
│
|
| 989 |
+
▼
|
| 990 |
+
┌─────────────────────────────────────────┐
|
| 991 |
+
│ Phase 3 (Settings 页 UI) │
|
| 992 |
+
│ ST-301 → ST-302 → ST-303 │
|
| 993 |
+
└─────────────────────────────────────────┘
|
| 994 |
+
```
|
| 995 |
+
|
| 996 |
+
### Phase 边界
|
| 997 |
+
|
| 998 |
+
- **Phase 1 完整可独立合并** — 不依赖任何 UI 改动,可单独发版,issue#1 错配阻断已生效。
|
| 999 |
+
- **Phase 2 强依赖 Phase 1** — `/api/mail-provider/probe` 需要 SetupConfig 已扩字段才能写盘;`probe.probe_credentials` 复用 `MaillabClient` 401 自愈守卫的语义。
|
| 1000 |
+
- **Phase 3 强依赖 Phase 2** — Settings.vue 复用 MailProviderCard.vue 组件。
|
| 1001 |
+
|
| 1002 |
+
---
|
| 1003 |
+
|
| 1004 |
+
## 8. 验收清单(Implementer Checklist)
|
| 1005 |
+
|
| 1006 |
+
| # | 验收项 | yes/no |
|
| 1007 |
+
| --- | ------------------------------------------------------------------------------------------------------- | ------ |
|
| 1008 |
+
| 1 | SetupConfig 包含 MAIL_PROVIDER + MAILLAB_* 4 字段,Pydantic 模型 model_dump() 含 9 个 mail 字段 | ☐ |
|
| 1009 |
+
| 2 | `/api/setup/save` POST `MAIL_PROVIDER=maillab` 后,`.env` 含 MAILLAB_* 4 字段且不含 CLOUDMAIL_BASE_URL | ☐ |
|
| 1010 |
+
| 3 | `cf_temp_email.login()` 在响应 `{}` 时抛错(包含 "不像.*cloudflare_temp_email") | ☐ |
|
| 1011 |
+
| 4 | `MaillabClient` 业务方法收 `code:401`,自动 re-login + 重试一次,日志含 `[maillab] token 已自愈` | ☐ |
|
| 1012 |
+
| 5 | `MaillabClient.login()` 内部 `/login` 自身回 401 时,**不**触发递归(thread-local guard 生效) | ☐ |
|
| 1013 |
+
| 6 | `_verify_cloudmail` 在 provider/base_url 错配时直接 return False,不实例化 client | ☐ |
|
| 1014 |
+
| 7 | `_verify_cloudmail` 在 `AUTOTEAM_SKIP_PROVIDER_SNIFF=1` 时跳过嗅探 | ☐ |
|
| 1015 |
+
| 8 | `/api/mail-provider/probe` step=fingerprint 对 maillab base_url 返 detected_provider=maillab + domain_list 非空 | ☐ |
|
| 1016 |
+
| 9 | step=fingerprint provider 与 detected 不一致时返 error_code=PROVIDER_MISMATCH | ☐ |
|
| 1017 |
+
| 10 | step=credentials 凭据错时返 UNAUTHORIZED;maillab 启用 captcha 时返 CAPTCHA_REQUIRED warning | ☐ |
|
| 1018 |
+
| 11 | step=domain_ownership 成功时探测邮箱已被 DELETE,leaked_probe=null | ☐ |
|
| 1019 |
+
| 12 | `PUT /api/config/register-domain` 与 step=domain_ownership 调用同一个 `probe_domain_ownership` helper | ☐ |
|
| 1020 |
+
| 13 | `_AUTH_SKIP_PATHS` 含 `/api/mail-provider/probe`;API_KEY 已配置时强制 Bearer 鉴权 | ☐ |
|
| 1021 |
+
| 14 | setup 阶段 probe 端点 60 req/min 速率限制生效(超过返 429) | ☐ |
|
| 1022 |
+
| 15 | SetupPage 切 provider 时,后续状态全部重置(domain_list 清空、State 回到 PROVIDER) | ☐ |
|
| 1023 |
+
| 16 | SetupPage 任一步失败时,下游卡片灰显 disabled | ☐ |
|
| 1024 |
+
| 17 | Settings.vue 切换 mail provider 后保存,toast 显示「重启服务后生效」 | ☐ |
|
| 1025 |
+
| 18 | docs/getting-started.md / configuration.md / mail-provider-design.md / troubleshooting.md / api.md 全部 sync | ☐ |
|
| 1026 |
+
| 19 | `.env.example` MAIL_PROVIDER 上方含强引导注释,MAILLAB_* 不再注释状态 | ☐ |
|
| 1027 |
+
| 20 | 19 处 `from autoteam.cloudmail import CloudMailClient` 调用零改动,业务回归测试 100% 通过 | ☐ |
|
| 1028 |
+
| 21 | issue#1 复现场景(maillab 服务器 + cf_temp_email 选项)在 Web 面板内 step=fingerprint 即报错 | ☐ |
|
| 1029 |
+
|
| 1030 |
+
---
|
| 1031 |
+
|
| 1032 |
+
## 附:对 PRD 的批判审查与边界补完
|
| 1033 |
+
|
| 1034 |
+
> 实施过程中发现 PRD 未完全交代的 4 个边界,SPEC 已补:
|
| 1035 |
+
|
| 1036 |
+
1. **PRD §FR-004 鉴权策略未定** — PRD 仅说「setup 阶段无鉴权,进入面板后 API_KEY」,SPEC §3.5 落实为:`_AUTH_SKIP_PATHS` 加白 + 路由内手工二次校验,API_KEY 配置时强制 Bearer。
|
| 1037 |
+
2. **PRD §OQ-1 token 跨步存储** — PRD 留作 OQ,SPEC §4.2 直接决策:**前端不持有 token**,step=domain_ownership 后端再调一次 login,降低复杂度且更安全。
|
| 1038 |
+
3. **PRD §FR-003 login() 内部递归** — PRD 提到 thread-local guard 但未说细节,SPEC §3.3 给出双 guard:`in_login`(login 内部不触发自愈)+ `retried`(单次重试限制)。
|
| 1039 |
+
4. **PRD §FR-001 互斥写入** — PRD 说「按 provider 跳过无关字段」,SPEC §3.6 落实为 `skip_keys` 集合,且保留 `CLOUDMAIL_DOMAIN` 作为 maillab 的 fallback(因 maillab.py 已实现该回落,删了反而破坏)。
|
| 1040 |
+
|
| 1041 |
+
> SPEC 已与 PRD §13 OQ-1 / OQ-3 / OQ-6 决策对齐:
|
| 1042 |
+
> - OQ-1:不让前端持 token(SPEC §4.2)
|
| 1043 |
+
> - OQ-3:`AUTOTEAM_SKIP_PROVIDER_SNIFF=1` 逃生口写入 SPEC §3.4
|
| 1044 |
+
> - OQ-6:domainList 空时 error_code=`EMPTY_DOMAIN_LIST`(SPEC §3.1 异常表)
|
| 1045 |
+
|
|
@@ -0,0 +1,1018 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPEC-2: 账号生命周期与配额加固 实施规范
|
| 2 |
+
|
| 3 |
+
## 0. 元数据
|
| 4 |
+
|
| 5 |
+
| 字段 | 内容 |
|
| 6 |
+
|---|---|
|
| 7 |
+
| 编号 | SPEC-2 |
|
| 8 |
+
| 名称 | 账号生命周期与配额加固 实施规范 |
|
| 9 |
+
| 主笔 | prd-lifecycle |
|
| 10 |
+
| 时间 | 2026-04-26 |
|
| 11 |
+
| 关联 PRD | [`../prd/prd-2-account-lifecycle.md`](../prd/prd-2-account-lifecycle.md) |
|
| 12 |
+
| 引用 shared spec | [`./shared/plan-type-whitelist.md`](./shared/plan-type-whitelist.md) · [`./shared/quota-classification.md`](./shared/quota-classification.md) · [`./shared/add-phone-detection.md`](./shared/add-phone-detection.md) · [`./shared/account-state-machine.md`](./shared/account-state-machine.md) |
|
| 13 |
+
| 覆盖 FR | A1~A5 / B1~B4 / C1~C5 / D1~D4 / E1~E4 / F1~F6 / G1~G4 / H1~H3 |
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 1. 文件级修改清单
|
| 18 |
+
|
| 19 |
+
| 文件 | 预估改动行数 | 涉及函数/对象 |
|
| 20 |
+
|---|---|---|
|
| 21 |
+
| `src/autoteam/accounts.py` | +35 | 新增 `SUPPORTED_PLAN_TYPES` / `is_supported_plan` / `normalize_plan_type` / 扩 `add_account` 默认字段 |
|
| 22 |
+
| `src/autoteam/codex_auth.py` | ~80 | `_exchange_auth_code`(扩 bundle 字段)/ `login_codex_via_browser`(4 处探针)/ `check_codex_quota`(no_quota 分支)/ `get_quota_exhausted_info`(no_quota 短路) |
|
| 23 |
+
| `src/autoteam/manager.py` | ~250 | `_run_post_register_oauth`(quota probe + 5 类处置 + RegisterBlocked catch)/ `sync_account_states`(被踢识别 + 并发探测)/ `reinvite_account`(plan_drift / phone_blocked 兜底)/ `_check_pending_invites`(L1057 RegisterBlocked catch) |
|
| 24 |
+
| `src/autoteam/chatgpt_api.py` | ~30 | `_invite_member_once`(allow_patch_upgrade 参数)/ `_invite_member_with_fallback`(分支调整) |
|
| 25 |
+
| `src/autoteam/invite.py` | ~5 | `run` / `cmd_fill_team` 入口读 `PREFERRED_SEAT_TYPE`(L496) |
|
| 26 |
+
| `src/autoteam/runtime_config.py` | +30 | 新增 `get_preferred_seat_type` / `set_preferred_seat_type` / `get_sync_probe_concurrency` 等 |
|
| 27 |
+
| `src/autoteam/api.py` | ~50 | `/api/accounts/{email}/login`(409 phone_required)/ `delete_accounts_batch`(全 personal 短路) |
|
| 28 |
+
| `src/autoteam/account_ops.py` | ~25 | `delete_managed_account`(short_circuit 逻辑) |
|
| 29 |
+
| `src/autoteam/manual_account.py` | ~20 | `_finalize_account`(plan_supported 检查 + no_quota 处置) |
|
| 30 |
+
| `src/autoteam/register_failures.py` | +5 | docstring 扩 6 个新 category |
|
| 31 |
+
| `web/src/views/Settings.vue` | +60 | 邀请席位偏好下拉 / 探测并发 / 探测去重 |
|
| 32 |
+
| `web/src/components/Dashboard.vue` | ~40 | removeAccount toast / quota 显示识别 no_quota / status 文案 |
|
| 33 |
+
| `web/src/api.ts` | +20 | 解析 409 phone_required + 422 no_quota |
|
| 34 |
+
|
| 35 |
+
**总计**:13 个文件,预计 +650 行 / -120 行。
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## 2. 引用的 shared specs
|
| 40 |
+
|
| 41 |
+
本主 spec 不重复定义以下契约,实施时请直接打开对应 shared:
|
| 42 |
+
|
| 43 |
+
| shared spec | 提供 |
|
| 44 |
+
|---|---|
|
| 45 |
+
| [`./shared/plan-type-whitelist.md`](./shared/plan-type-whitelist.md) | `SUPPORTED_PLAN_TYPES` 常量、`is_supported_plan` / `normalize_plan_type` 工具函数、bundle 字段扩展、6 调用点处置矩阵 |
|
| 46 |
+
| [`./shared/quota-classification.md`](./shared/quota-classification.md) | `check_codex_quota` 5 分类签名、QuotaSnapshot / QuotaExhaustedInfo 类型、9+2 调用点处置 |
|
| 47 |
+
| [`./shared/add-phone-detection.md`](./shared/add-phone-detection.md) | RegisterBlocked / assert_not_blocked 复用契约、4 探针接入点位置、5 调用方处置模板 |
|
| 48 |
+
| [`./shared/account-state-machine.md`](./shared/account-state-machine.md) | 7 状态完整状态机、AccountRecord Pydantic 模型、转移矩阵、不变量 |
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## 3. 函数级修改详情
|
| 53 |
+
|
| 54 |
+
### 3.1 `_run_post_register_oauth` 加 quota probe 与 RegisterBlocked catch
|
| 55 |
+
|
| 56 |
+
**文件**:`src/autoteam/manager.py:1386-1486`
|
| 57 |
+
**FR**:D1~D4 + C3(Team / personal 调用点)
|
| 58 |
+
|
| 59 |
+
#### 3.1.1 改造前(精简版,见 §1463-1486)
|
| 60 |
+
|
| 61 |
+
```python
|
| 62 |
+
bundle = login_codex_via_browser(email, password, mail_client=mail_client)
|
| 63 |
+
if bundle:
|
| 64 |
+
auth_file = save_auth_file(bundle)
|
| 65 |
+
bundle_plan = (bundle.get("plan_type") or "").lower()
|
| 66 |
+
seat_label = "chatgpt" if bundle_plan == "team" else "codex"
|
| 67 |
+
update_account(email, status=STATUS_ACTIVE, seat_type=seat_label, auth_file=auth_file, ...)
|
| 68 |
+
return email
|
| 69 |
+
update_account(email, status=STATUS_ACTIVE, ...) # team_auth_missing
|
| 70 |
+
return email
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
#### 3.1.2 改造后(Team 分支完整 diff)
|
| 74 |
+
|
| 75 |
+
```python
|
| 76 |
+
# manager.py:1462 起,Team 分支完整改造
|
| 77 |
+
from autoteam.invite import RegisterBlocked
|
| 78 |
+
from autoteam.accounts import is_supported_plan
|
| 79 |
+
from autoteam.codex_auth import check_codex_quota, get_quota_exhausted_info
|
| 80 |
+
# (上述 import 应集中放到 manager.py 文件顶部)
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
bundle = login_codex_via_browser(email, password, mail_client=mail_client)
|
| 84 |
+
except RegisterBlocked as blocked:
|
| 85 |
+
if blocked.is_phone:
|
| 86 |
+
record_failure(
|
| 87 |
+
email,
|
| 88 |
+
category="oauth_phone_blocked",
|
| 89 |
+
reason=f"OAuth 阶段触发 add-phone (step={blocked.step})",
|
| 90 |
+
step=blocked.step,
|
| 91 |
+
stage="run_post_register_oauth_team",
|
| 92 |
+
)
|
| 93 |
+
# Team 模式下账号已成功 invite,不能 delete_account(席位仍占着);标 AUTH_INVALID 让 reconcile ��管
|
| 94 |
+
update_account(
|
| 95 |
+
email,
|
| 96 |
+
status=STATUS_AUTH_INVALID,
|
| 97 |
+
workspace_account_id=get_chatgpt_account_id() or None,
|
| 98 |
+
)
|
| 99 |
+
_record_outcome("oauth_phone_blocked", reason="OAuth 阶段触发 add-phone")
|
| 100 |
+
return None
|
| 101 |
+
# is_duplicate 在 OAuth 阶段不应出现,记 exception 兜底
|
| 102 |
+
record_failure(email, "exception", f"OAuth 意外 RegisterBlocked: {blocked.reason}")
|
| 103 |
+
_record_outcome("oauth_failed", reason=f"unexpected RegisterBlocked: {blocked.reason}")
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
if not bundle:
|
| 107 |
+
# 旧路径:bundle=None,team_auth_missing,保留 ACTIVE 等用户补登录
|
| 108 |
+
update_account(email, status=STATUS_ACTIVE, workspace_account_id=get_chatgpt_account_id() or None)
|
| 109 |
+
logger.warning("[注册] 账号已加入 Team 但 Codex 登录失败,需要补登录: %s", email)
|
| 110 |
+
_record_outcome("team_auth_missing", reason="已入 Team 席位但 Codex OAuth 未返回 bundle,需要补登录")
|
| 111 |
+
return email
|
| 112 |
+
|
| 113 |
+
auth_file = save_auth_file(bundle)
|
| 114 |
+
bundle_plan = bundle.get("plan_type", "unknown") # 已被 _exchange_auth_code 归一化为小写
|
| 115 |
+
plan_supported = bundle.get("plan_supported", is_supported_plan(bundle_plan))
|
| 116 |
+
|
| 117 |
+
# 新增 FR-A4:plan_type 不支持 → AUTH_INVALID
|
| 118 |
+
if not plan_supported:
|
| 119 |
+
record_failure(
|
| 120 |
+
email,
|
| 121 |
+
category="plan_unsupported",
|
| 122 |
+
reason=f"OAuth bundle plan_type={bundle.get('plan_type_raw') or bundle_plan} 不在白名单",
|
| 123 |
+
plan_type=bundle_plan,
|
| 124 |
+
plan_type_raw=bundle.get("plan_type_raw"),
|
| 125 |
+
stage="run_post_register_oauth_team",
|
| 126 |
+
)
|
| 127 |
+
update_account(
|
| 128 |
+
email,
|
| 129 |
+
status=STATUS_AUTH_INVALID,
|
| 130 |
+
seat_type="codex",
|
| 131 |
+
auth_file=auth_file, # 保留 auth_file 供调试
|
| 132 |
+
plan_type_raw=bundle.get("plan_type_raw"),
|
| 133 |
+
workspace_account_id=get_chatgpt_account_id() or None,
|
| 134 |
+
)
|
| 135 |
+
_record_outcome("plan_unsupported", plan=bundle_plan)
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
# FR-D1~D4:quota probe(对称 manual_account._finalize_account)
|
| 139 |
+
seat_label = "chatgpt" if bundle_plan == "team" else "codex"
|
| 140 |
+
access_token = bundle.get("access_token")
|
| 141 |
+
account_id = bundle.get("account_id")
|
| 142 |
+
|
| 143 |
+
update_fields = {
|
| 144 |
+
"status": STATUS_ACTIVE,
|
| 145 |
+
"seat_type": seat_label,
|
| 146 |
+
"auth_file": auth_file,
|
| 147 |
+
"last_active_at": time.time(),
|
| 148 |
+
"workspace_account_id": get_chatgpt_account_id() or None,
|
| 149 |
+
"plan_type_raw": bundle.get("plan_type_raw"),
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
if access_token:
|
| 153 |
+
try:
|
| 154 |
+
quota_status, quota_info = check_codex_quota(access_token, account_id=account_id)
|
| 155 |
+
except Exception as exc:
|
| 156 |
+
# FR-D4: probe 异常吞掉,降级 ACTIVE + 记录
|
| 157 |
+
record_failure(email, "quota_probe_network_error", f"quota probe exception: {exc}",
|
| 158 |
+
stage="run_post_register_oauth_team")
|
| 159 |
+
quota_status, quota_info = "network_error", None
|
| 160 |
+
|
| 161 |
+
if quota_status == "ok" and isinstance(quota_info, dict):
|
| 162 |
+
update_fields["last_quota"] = quota_info
|
| 163 |
+
elif quota_status == "exhausted":
|
| 164 |
+
snapshot = quota_info.get("quota_info") if isinstance(quota_info, dict) else None
|
| 165 |
+
if snapshot:
|
| 166 |
+
update_fields["last_quota"] = snapshot
|
| 167 |
+
update_fields["status"] = STATUS_EXHAUSTED
|
| 168 |
+
update_fields["quota_exhausted_at"] = time.time()
|
| 169 |
+
update_fields["quota_resets_at"] = (
|
| 170 |
+
quota_info.get("resets_at") if isinstance(quota_info, dict) else int(time.time() + 18000)
|
| 171 |
+
)
|
| 172 |
+
elif quota_status == "no_quota":
|
| 173 |
+
snapshot = quota_info.get("quota_info") if isinstance(quota_info, dict) else None
|
| 174 |
+
if snapshot:
|
| 175 |
+
update_fields["last_quota"] = snapshot
|
| 176 |
+
update_fields["status"] = STATUS_AUTH_INVALID
|
| 177 |
+
record_failure(email, "no_quota_assigned",
|
| 178 |
+
"wham/usage 返回 no_quota(workspace 未分配 codex 配额)",
|
| 179 |
+
plan_type=bundle_plan, stage="run_post_register_oauth_team")
|
| 180 |
+
elif quota_status == "auth_error":
|
| 181 |
+
update_fields["status"] = STATUS_AUTH_INVALID
|
| 182 |
+
record_failure(email, "auth_error_at_oauth",
|
| 183 |
+
"wham/usage 返回 401/403,token 失效",
|
| 184 |
+
stage="run_post_register_oauth_team")
|
| 185 |
+
elif quota_status == "network_error":
|
| 186 |
+
# 网络抖动:保留 ACTIVE,但记录一次失败便于运营观察
|
| 187 |
+
record_failure(email, "quota_probe_network_error",
|
| 188 |
+
"wham/usage 网络异常,ACTIVE 状态由下轮 cmd_check 校准",
|
| 189 |
+
stage="run_post_register_oauth_team")
|
| 190 |
+
|
| 191 |
+
update_account(email, **update_fields)
|
| 192 |
+
_record_outcome("success" if update_fields["status"] == STATUS_ACTIVE else "quota_issue",
|
| 193 |
+
plan=bundle_plan, status=update_fields["status"])
|
| 194 |
+
return email if update_fields["status"] == STATUS_ACTIVE else None
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
#### 3.1.3 personal 分支(L1431-1460)的对称改造
|
| 198 |
+
|
| 199 |
+
```python
|
| 200 |
+
# manager.py:1431 起
|
| 201 |
+
try:
|
| 202 |
+
bundle = login_codex_via_browser(email, password, mail_client=mail_client, use_personal=True)
|
| 203 |
+
except RegisterBlocked as blocked:
|
| 204 |
+
if blocked.is_phone:
|
| 205 |
+
record_failure(
|
| 206 |
+
email,
|
| 207 |
+
category="oauth_phone_blocked",
|
| 208 |
+
reason=f"personal OAuth 触发 add-phone (step={blocked.step})",
|
| 209 |
+
step=blocked.step,
|
| 210 |
+
stage="run_post_register_oauth_personal",
|
| 211 |
+
)
|
| 212 |
+
delete_account(email) # personal 已 leave_workspace,本地无价值
|
| 213 |
+
_record_outcome("oauth_phone_blocked", reason="personal OAuth 触发 add-phone")
|
| 214 |
+
return None
|
| 215 |
+
record_failure(email, "exception", f"personal OAuth RegisterBlocked: {blocked.reason}")
|
| 216 |
+
delete_account(email)
|
| 217 |
+
return None
|
| 218 |
+
|
| 219 |
+
if bundle:
|
| 220 |
+
plan_supported = bundle.get("plan_supported", is_supported_plan(bundle.get("plan_type", "")))
|
| 221 |
+
if not plan_supported:
|
| 222 |
+
record_failure(
|
| 223 |
+
email, "plan_unsupported",
|
| 224 |
+
f"personal OAuth bundle plan_type={bundle.get('plan_type_raw')} 不在白名单",
|
| 225 |
+
plan_type=bundle.get("plan_type"),
|
| 226 |
+
plan_type_raw=bundle.get("plan_type_raw"),
|
| 227 |
+
stage="run_post_register_oauth_personal",
|
| 228 |
+
)
|
| 229 |
+
delete_account(email)
|
| 230 |
+
_record_outcome("plan_unsupported", plan=bundle.get("plan_type"))
|
| 231 |
+
return None
|
| 232 |
+
|
| 233 |
+
auth_file = save_auth_file(bundle)
|
| 234 |
+
update_fields = {
|
| 235 |
+
"status": STATUS_PERSONAL,
|
| 236 |
+
"seat_type": "codex",
|
| 237 |
+
"auth_file": auth_file,
|
| 238 |
+
"last_active_at": time.time(),
|
| 239 |
+
"plan_type_raw": bundle.get("plan_type_raw"),
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
# personal 分支也加 quota probe(对称),确认 free plan 真有 codex 配额
|
| 243 |
+
access_token = bundle.get("access_token")
|
| 244 |
+
if access_token:
|
| 245 |
+
try:
|
| 246 |
+
quota_status, quota_info = check_codex_quota(access_token, account_id=bundle.get("account_id"))
|
| 247 |
+
if quota_status == "ok" and isinstance(quota_info, dict):
|
| 248 |
+
update_fields["last_quota"] = quota_info
|
| 249 |
+
elif quota_status == "no_quota":
|
| 250 |
+
# personal 拿到无配额 → 保留 PERSONAL 但记一笔(用户可以决定删不删)
|
| 251 |
+
record_failure(email, "no_quota_assigned",
|
| 252 |
+
"personal free plan 无 codex 配额",
|
| 253 |
+
stage="run_post_register_oauth_personal")
|
| 254 |
+
except Exception:
|
| 255 |
+
pass # personal probe 失败不阻塞
|
| 256 |
+
|
| 257 |
+
update_account(email, **update_fields)
|
| 258 |
+
_record_outcome("success", plan=bundle.get("plan_type"))
|
| 259 |
+
return email
|
| 260 |
+
|
| 261 |
+
# bundle is None — 旧路径保留
|
| 262 |
+
delete_account(email)
|
| 263 |
+
record_failure(email, "oauth_failed",
|
| 264 |
+
"已退出 Team 但 personal Codex OAuth 登录未返回 bundle",
|
| 265 |
+
stage="post_leave_workspace")
|
| 266 |
+
_record_outcome("oauth_failed", reason="personal Codex OAuth 未返回 bundle")
|
| 267 |
+
return None
|
| 268 |
+
```
|
| 269 |
+
|
| 270 |
+
### 3.2 `sync_account_states` 分支扩展(被踢识别 + 并发)
|
| 271 |
+
|
| 272 |
+
**文件**:`src/autoteam/manager.py:476-541`
|
| 273 |
+
**FR**:E1~E4
|
| 274 |
+
|
| 275 |
+
#### 3.2.1 改造点
|
| 276 |
+
|
| 277 |
+
```python
|
| 278 |
+
# manager.py:526-541 改造后
|
| 279 |
+
import concurrent.futures
|
| 280 |
+
|
| 281 |
+
def _probe_kicked_account(acc):
|
| 282 |
+
"""单账号探测:wham 401/403 → 被踢;否则返回 None."""
|
| 283 |
+
auth_file = acc.get("auth_file")
|
| 284 |
+
if not auth_file:
|
| 285 |
+
return None # 无 token 无法判定,降级 STANDBY
|
| 286 |
+
try:
|
| 287 |
+
bundle = load_auth_file(auth_file) # 假设 codex_auth 提供
|
| 288 |
+
access_token = bundle.get("access_token")
|
| 289 |
+
if not access_token:
|
| 290 |
+
return None
|
| 291 |
+
status, _ = check_codex_quota(access_token)
|
| 292 |
+
return status
|
| 293 |
+
except Exception:
|
| 294 |
+
return None
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# 在 sync_account_states 函数体内,替换 L526-541 的现行分支
|
| 298 |
+
# 先收集需要探测的 acc,然后用 ThreadPoolExecutor 并发
|
| 299 |
+
need_probe = []
|
| 300 |
+
for acc in accounts:
|
| 301 |
+
email = acc["email"].lower()
|
| 302 |
+
in_team = email in team_emails
|
| 303 |
+
|
| 304 |
+
if in_team and acc["status"] in (STATUS_STANDBY, STATUS_PENDING):
|
| 305 |
+
acc["status"] = STATUS_ACTIVE
|
| 306 |
+
if account_id:
|
| 307 |
+
acc["workspace_account_id"] = account_id
|
| 308 |
+
changed = True
|
| 309 |
+
elif not in_team and acc["status"] == STATUS_ACTIVE:
|
| 310 |
+
acc_ws = acc.get("workspace_account_id")
|
| 311 |
+
if acc_ws and account_id and acc_ws != account_id:
|
| 312 |
+
logger.warning("[同步] %s workspace 漂移,保留 active 不 flip", acc["email"])
|
| 313 |
+
continue
|
| 314 |
+
|
| 315 |
+
# FR-E3 探测去重:30 分钟内不重复探测
|
| 316 |
+
last_check = acc.get("last_quota_check_at") or 0
|
| 317 |
+
cooldown = get_runtime("sync_probe_cooldown_minutes", 30) * 60
|
| 318 |
+
if time.time() - last_check < cooldown:
|
| 319 |
+
acc["status"] = STATUS_STANDBY
|
| 320 |
+
changed = True
|
| 321 |
+
continue
|
| 322 |
+
|
| 323 |
+
need_probe.append(acc)
|
| 324 |
+
|
| 325 |
+
# FR-E2 并发探测
|
| 326 |
+
concurrency = get_runtime("sync_probe_concurrency", 5)
|
| 327 |
+
if need_probe:
|
| 328 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
|
| 329 |
+
future_map = {ex.submit(_probe_kicked_account, acc): acc for acc in need_probe}
|
| 330 |
+
for fut in concurrent.futures.as_completed(future_map, timeout=concurrency * 6):
|
| 331 |
+
acc = future_map[fut]
|
| 332 |
+
try:
|
| 333 |
+
status_str = fut.result(timeout=5)
|
| 334 |
+
except Exception:
|
| 335 |
+
status_str = None
|
| 336 |
+
now = time.time()
|
| 337 |
+
acc["last_quota_check_at"] = now
|
| 338 |
+
|
| 339 |
+
if status_str == "auth_error":
|
| 340 |
+
acc["status"] = STATUS_AUTH_INVALID
|
| 341 |
+
acc["last_kicked_at"] = now
|
| 342 |
+
logger.warning("[同步] %s wham 401/403,判定被人工踢出 → AUTH_INVALID", acc["email"])
|
| 343 |
+
elif status_str == "no_quota":
|
| 344 |
+
acc["status"] = STATUS_AUTH_INVALID # 不会自然恢复
|
| 345 |
+
logger.warning("[同步] %s wham no_quota → AUTH_INVALID", acc["email"])
|
| 346 |
+
else:
|
| 347 |
+
# ok / exhausted / network_error / None → 保持自然待机语义
|
| 348 |
+
acc["status"] = STATUS_STANDBY
|
| 349 |
+
changed = True
|
| 350 |
+
```
|
| 351 |
+
|
| 352 |
+
#### 3.2.2 关键设计决策
|
| 353 |
+
|
| 354 |
+
- **并发上限 5**(可配):防止 N 个 active 号串行 wham 让 sync 周期超 30s(NFR-1)
|
| 355 |
+
- **单调用超时 5s** + **整体超时 30s**(`concurrency * 6` 上限)
|
| 356 |
+
- **去重 30 分钟**:同 email 不重复探测,避免同一 active 号在多次 sync 间被反复探测
|
| 357 |
+
- **任何探测异常吞掉**:保持 STATUS_STANDBY 旧行为,避免一次抖动批量误标 AUTH_INVALID
|
| 358 |
+
- **`load_auth_file` 工具函数**:codex_auth.py 中应已有(读 auth json),实施时核对;无则在该模块新增小工具
|
| 359 |
+
|
| 360 |
+
### 3.3 `reinvite_account` 兜底改造
|
| 361 |
+
|
| 362 |
+
**文件**:`src/autoteam/manager.py:2466-2585`
|
| 363 |
+
**FR**:H1~H3 + C3(reinvite 调用点)
|
| 364 |
+
|
| 365 |
+
#### 3.3.1 改造点(L2466 包 try/except + L2489 替换 STATUS_STANDBY → AUTH_INVALID)
|
| 366 |
+
|
| 367 |
+
```python
|
| 368 |
+
# manager.py:2466 起
|
| 369 |
+
try:
|
| 370 |
+
bundle = login_codex_via_browser(email, password, mail_client=mail_client)
|
| 371 |
+
except RegisterBlocked as blocked:
|
| 372 |
+
if blocked.is_phone:
|
| 373 |
+
record_failure(
|
| 374 |
+
email,
|
| 375 |
+
category="oauth_phone_blocked",
|
| 376 |
+
reason=f"reinvite OAuth 触发 add-phone (step={blocked.step})",
|
| 377 |
+
step=blocked.step,
|
| 378 |
+
stage="reinvite_account",
|
| 379 |
+
)
|
| 380 |
+
_cleanup_team_leftover("oauth_phone_blocked")
|
| 381 |
+
update_account(
|
| 382 |
+
email,
|
| 383 |
+
status=STATUS_AUTH_INVALID,
|
| 384 |
+
auth_file=None,
|
| 385 |
+
quota_exhausted_at=None,
|
| 386 |
+
quota_resets_at=None,
|
| 387 |
+
)
|
| 388 |
+
return False
|
| 389 |
+
record_failure(email, "exception", f"reinvite RegisterBlocked: {blocked.reason}")
|
| 390 |
+
_cleanup_team_leftover("exception")
|
| 391 |
+
update_account(email, status=STATUS_AUTH_INVALID, auth_file=None)
|
| 392 |
+
return False
|
| 393 |
+
|
| 394 |
+
# (现有 _cleanup_team_leftover 闭包定义保留)
|
| 395 |
+
|
| 396 |
+
if not bundle:
|
| 397 |
+
logger.warning("[轮转] 旧账号 OAuth 登录失败,保持 standby: %s", email)
|
| 398 |
+
_cleanup_team_leftover("no_bundle")
|
| 399 |
+
update_account(email, status=STATUS_STANDBY)
|
| 400 |
+
return False
|
| 401 |
+
|
| 402 |
+
# FR-H1 + plan_supported 检查:plan_supported=False → AUTH_INVALID;plan != team 也走 AUTH_INVALID
|
| 403 |
+
plan_type = (bundle.get("plan_type") or "").lower()
|
| 404 |
+
plan_supported = bundle.get("plan_supported", is_supported_plan(plan_type))
|
| 405 |
+
|
| 406 |
+
if not plan_supported:
|
| 407 |
+
logger.warning("[轮转] 旧账号 plan=%s 不在白名单,推 AUTH_INVALID: %s",
|
| 408 |
+
bundle.get("plan_type_raw"), email)
|
| 409 |
+
_cleanup_team_leftover(f"plan_unsupported_{plan_type or 'unknown'}")
|
| 410 |
+
record_failure(
|
| 411 |
+
email, "plan_unsupported",
|
| 412 |
+
f"reinvite bundle plan_type={bundle.get('plan_type_raw')} 不在白名单",
|
| 413 |
+
plan_type=plan_type, plan_type_raw=bundle.get("plan_type_raw"),
|
| 414 |
+
stage="reinvite_account",
|
| 415 |
+
)
|
| 416 |
+
update_account(
|
| 417 |
+
email,
|
| 418 |
+
status=STATUS_AUTH_INVALID,
|
| 419 |
+
auth_file=None, # 清掉错误 plan 的 token
|
| 420 |
+
plan_type_raw=bundle.get("plan_type_raw"),
|
| 421 |
+
quota_exhausted_at=None,
|
| 422 |
+
quota_resets_at=None,
|
| 423 |
+
)
|
| 424 |
+
return False
|
| 425 |
+
|
| 426 |
+
if plan_type != "team":
|
| 427 |
+
logger.warning("[轮转] 旧账号 plan=%s,plan_drift,推 AUTH_INVALID: %s", plan_type, email)
|
| 428 |
+
_cleanup_team_leftover(f"plan_drift_{plan_type or 'unknown'}")
|
| 429 |
+
record_failure(
|
| 430 |
+
email, "plan_drift",
|
| 431 |
+
f"reinvite 拿到 plan={plan_type or 'unknown'} != team",
|
| 432 |
+
plan_type=plan_type, stage="reinvite_account",
|
| 433 |
+
)
|
| 434 |
+
update_account(
|
| 435 |
+
email,
|
| 436 |
+
status=STATUS_AUTH_INVALID, # 不再回 STANDBY
|
| 437 |
+
auth_file=None,
|
| 438 |
+
plan_type_raw=bundle.get("plan_type_raw"),
|
| 439 |
+
quota_exhausted_at=None,
|
| 440 |
+
quota_resets_at=None,
|
| 441 |
+
)
|
| 442 |
+
return False
|
| 443 |
+
|
| 444 |
+
# (剩余 quota verify 段保留 L2496-2595 现行实现,只在 fail_reason 分支补 no_quota 处置)
|
| 445 |
+
auth_file = save_auth_file(bundle)
|
| 446 |
+
...
|
| 447 |
+
# 在 quota verified 判断中增加:
|
| 448 |
+
elif status_str == "no_quota":
|
| 449 |
+
fail_reason = "no_quota_assigned"
|
| 450 |
+
logger.warning("[轮转] %s reinvite 后 wham no_quota,判定 token 风控", email)
|
| 451 |
+
# 后续 quota_verified=False 分支已经会处理 _cleanup_team_leftover
|
| 452 |
+
```
|
| 453 |
+
|
| 454 |
+
#### 3.3.2 批判 R-7:不会误伤 personal 转化路径
|
| 455 |
+
|
| 456 |
+
**结论**:`reinvite_account` 仅由 `_rotate_round` / `_replace_single` 等场景从 STATUS_STANDBY 池选中调用。用户主动转 personal 的入口是 `cmd_fill_personal` → `_run_post_register_oauth(leave_workspace=True)`,**完全不进 reinvite_account**。因此 reinvite 拿到 free plan 永远是异常("Team workspace 同步异常 / token 漂移"),推 AUTH_INVALID 无误伤。
|
| 457 |
+
|
| 458 |
+
### 3.4 `PREFERRED_SEAT_TYPE` 在 invite_member / _invite_member_once 的应用
|
| 459 |
+
|
| 460 |
+
**文件**:`src/autoteam/invite.py:496` + `src/autoteam/chatgpt_api.py:1414-1511` + `runtime_config.py`
|
| 461 |
+
**FR**:F1~F6
|
| 462 |
+
|
| 463 |
+
#### 3.4.1 `runtime_config.py` 新增
|
| 464 |
+
|
| 465 |
+
```python
|
| 466 |
+
# runtime_config.py 末尾追加
|
| 467 |
+
|
| 468 |
+
_VALID_SEAT_TYPES = frozenset({"chatgpt", "codex"})
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def get_preferred_seat_type() -> str:
|
| 472 |
+
"""返回邀请席位偏好。chatgpt(默认):走 default 席位,PATCH 升级;codex:usage_based,不升级."""
|
| 473 |
+
raw = (get("preferred_seat_type") or "chatgpt").strip().lower()
|
| 474 |
+
return raw if raw in _VALID_SEAT_TYPES else "chatgpt"
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def set_preferred_seat_type(value: str) -> str:
|
| 478 |
+
cleaned = (value or "").strip().lower()
|
| 479 |
+
if cleaned not in _VALID_SEAT_TYPES:
|
| 480 |
+
raise ValueError(f"preferred_seat_type 必须是 {_VALID_SEAT_TYPES} 之一")
|
| 481 |
+
set_value("preferred_seat_type", cleaned)
|
| 482 |
+
return cleaned
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def get_runtime(key: str, default):
|
| 486 |
+
"""通用读取(供 sync_account_states 等使用)"""
|
| 487 |
+
return get(key, default)
|
| 488 |
+
```
|
| 489 |
+
|
| 490 |
+
#### 3.4.2 `invite.py:496` 改造
|
| 491 |
+
|
| 492 |
+
```python
|
| 493 |
+
# invite.py:493-496 之间插入
|
| 494 |
+
from autoteam.runtime_config import get_preferred_seat_type
|
| 495 |
+
|
| 496 |
+
preferred = get_preferred_seat_type()
|
| 497 |
+
seat_type_param = "default" if preferred == "chatgpt" else "usage_based"
|
| 498 |
+
status, data = chatgpt.invite_member(email, seat_type=seat_type_param)
|
| 499 |
+
```
|
| 500 |
+
|
| 501 |
+
#### 3.4.3 `chatgpt_api.py:_invite_member_once` 加 `allow_patch_upgrade` 参数
|
| 502 |
+
|
| 503 |
+
```python
|
| 504 |
+
# chatgpt_api.py:1414
|
| 505 |
+
def _invite_member_once(self, email, seat_type, *, allow_patch_upgrade=True):
|
| 506 |
+
...
|
| 507 |
+
# 现行 L1487-1506
|
| 508 |
+
data["_seat_type"] = "usage_based"
|
| 509 |
+
if seat_type == "usage_based":
|
| 510 |
+
if not allow_patch_upgrade:
|
| 511 |
+
# FR-F3:codex 偏好下不升级,直接保留 usage_based
|
| 512 |
+
return status, data
|
| 513 |
+
|
| 514 |
+
invites = data.get("account_invites", []) ...
|
| 515 |
+
# 原 PATCH 升级链路保留
|
| 516 |
+
```
|
| 517 |
+
|
| 518 |
+
#### 3.4.4 `_invite_member_with_fallback` 调整(L1387)
|
| 519 |
+
|
| 520 |
+
```python
|
| 521 |
+
def _invite_member_with_fallback(self, email, seat_type, *, allow_fallback):
|
| 522 |
+
preferred = get_preferred_seat_type()
|
| 523 |
+
allow_patch_upgrade = (preferred == "chatgpt")
|
| 524 |
+
|
| 525 |
+
status, data = self._invite_member_once(email, seat_type,
|
| 526 |
+
allow_patch_upgrade=allow_patch_upgrade)
|
| 527 |
+
# codex 偏好下,不再做 default → usage_based 兜底(直接 usage_based 入口)
|
| 528 |
+
if preferred == "codex":
|
| 529 |
+
return status, data
|
| 530 |
+
|
| 531 |
+
# 现行 chatgpt 偏好兜底链路保留
|
| 532 |
+
```
|
| 533 |
+
|
| 534 |
+
### 3.5 personal 删除链短路 fetch_team_state
|
| 535 |
+
|
| 536 |
+
**文件**:`src/autoteam/account_ops.py:40-162` + `src/autoteam/api.py:1306-1404`
|
| 537 |
+
**FR**:G1~G4
|
| 538 |
+
|
| 539 |
+
#### 3.5.1 `account_ops.delete_managed_account` 短路
|
| 540 |
+
|
| 541 |
+
```python
|
| 542 |
+
# account_ops.py:72 起
|
| 543 |
+
from autoteam.accounts import STATUS_PERSONAL, STATUS_AUTH_INVALID
|
| 544 |
+
|
| 545 |
+
try:
|
| 546 |
+
account_id = get_chatgpt_account_id()
|
| 547 |
+
short_circuit = (
|
| 548 |
+
remove_remote
|
| 549 |
+
and acc
|
| 550 |
+
and acc.get("status") in (STATUS_PERSONAL, STATUS_AUTH_INVALID)
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
if remove_remote and not short_circuit:
|
| 554 |
+
if remote_state is not None:
|
| 555 |
+
members, invites = remote_state
|
| 556 |
+
else:
|
| 557 |
+
if chatgpt_api is None:
|
| 558 |
+
from autoteam.chatgpt_api import ChatGPTTeamAPI
|
| 559 |
+
own_chatgpt = ChatGPTTeamAPI()
|
| 560 |
+
own_chatgpt.start()
|
| 561 |
+
chatgpt_api = own_chatgpt
|
| 562 |
+
members, invites = fetch_team_state(chatgpt_api)
|
| 563 |
+
|
| 564 |
+
# 现有 member_matches / invite_matches 逻辑保留
|
| 565 |
+
...
|
| 566 |
+
elif short_circuit:
|
| 567 |
+
logger.info("[账号] %s 状态=%s,跳过 Team 远端同步,直接清本地",
|
| 568 |
+
email, acc.get("status"))
|
| 569 |
+
members, invites = [], []
|
| 570 |
+
|
| 571 |
+
# 后续 auth_file / cpa / local 删除链路与现行一致
|
| 572 |
+
```
|
| 573 |
+
|
| 574 |
+
#### 3.5.2 `api.delete_accounts_batch` 全 personal 短路
|
| 575 |
+
|
| 576 |
+
```python
|
| 577 |
+
# api.py:1306-1404 之间(伪代码,实施时见现行)
|
| 578 |
+
def delete_accounts_batch(emails: list[str]):
|
| 579 |
+
accounts = load_accounts()
|
| 580 |
+
targets = [a for a in accounts if a["email"].lower() in {e.lower() for e in emails}]
|
| 581 |
+
|
| 582 |
+
all_personal = all(a["status"] in (STATUS_PERSONAL, STATUS_AUTH_INVALID) for a in targets)
|
| 583 |
+
|
| 584 |
+
chatgpt_api = None
|
| 585 |
+
if not all_personal:
|
| 586 |
+
# 至少一个号需要 Team 远端同步
|
| 587 |
+
chatgpt_api = ChatGPTTeamAPI()
|
| 588 |
+
chatgpt_api.start()
|
| 589 |
+
|
| 590 |
+
try:
|
| 591 |
+
results = []
|
| 592 |
+
for acc in targets:
|
| 593 |
+
try:
|
| 594 |
+
cleanup = delete_managed_account(
|
| 595 |
+
acc["email"],
|
| 596 |
+
chatgpt_api=chatgpt_api,
|
| 597 |
+
sync_cpa_after=False,
|
| 598 |
+
)
|
| 599 |
+
results.append({"email": acc["email"], "success": True, "cleanup": cleanup})
|
| 600 |
+
except Exception as exc:
|
| 601 |
+
results.append({"email": acc["email"], "success": False, "error": str(exc)})
|
| 602 |
+
sync_to_cpa()
|
| 603 |
+
return results
|
| 604 |
+
finally:
|
| 605 |
+
if chatgpt_api:
|
| 606 |
+
chatgpt_api.stop()
|
| 607 |
+
```
|
| 608 |
+
|
| 609 |
+
### 3.6 UI removeAccount toast 改造
|
| 610 |
+
|
| 611 |
+
**文件**:`web/src/components/Dashboard.vue:566-585`
|
| 612 |
+
**FR**:G4
|
| 613 |
+
|
| 614 |
+
#### 3.6.1 改造伪代码(原文 vue + ts)
|
| 615 |
+
|
| 616 |
+
```vue
|
| 617 |
+
<script setup lang="ts">
|
| 618 |
+
import { ref } from 'vue'
|
| 619 |
+
const message = ref<{ text: string; type: 'success' | 'error' | 'warning' } | null>(null)
|
| 620 |
+
|
| 621 |
+
async function removeAccount(email: string) {
|
| 622 |
+
if (props.runningTask) {
|
| 623 |
+
message.value = { text: '有任务在运行,请先停止后再删除', type: 'warning' }
|
| 624 |
+
return
|
| 625 |
+
}
|
| 626 |
+
if (!adminReady.value && acc.status !== 'personal' && acc.status !== 'auth_invalid') {
|
| 627 |
+
message.value = { text: '主号未就绪,无法删除该 Team 子号(personal/auth_invalid 不受限)', type: 'warning' }
|
| 628 |
+
return
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
try {
|
| 632 |
+
const resp = await api.deleteAccount(email)
|
| 633 |
+
message.value = { text: `已删除 ${email}`, type: 'success' }
|
| 634 |
+
emit('refresh')
|
| 635 |
+
} catch (err: any) {
|
| 636 |
+
if (err.status === 409) {
|
| 637 |
+
message.value = { text: '操作冲突,请稍后重试', type: 'error' }
|
| 638 |
+
} else if (err.status === 500) {
|
| 639 |
+
message.value = { text: `删除失败:${err.body?.error || '服务器错误'}`, type: 'error' }
|
| 640 |
+
} else {
|
| 641 |
+
message.value = { text: `删除失败:${err.message}`, type: 'error' }
|
| 642 |
+
}
|
| 643 |
+
}
|
| 644 |
+
}
|
| 645 |
+
</script>
|
| 646 |
+
```
|
| 647 |
+
|
| 648 |
+
#### 3.6.2 quota 显示 no_quota 识别
|
| 649 |
+
|
| 650 |
+
```vue
|
| 651 |
+
<template>
|
| 652 |
+
<span v-if="acc.last_quota?.primary_total === 0" class="quota-empty">
|
| 653 |
+
无配额(联系管理员)
|
| 654 |
+
</span>
|
| 655 |
+
<span v-else-if="acc.last_quota">
|
| 656 |
+
剩余 {{ 100 - acc.last_quota.primary_pct }}%
|
| 657 |
+
</span>
|
| 658 |
+
</template>
|
| 659 |
+
```
|
| 660 |
+
|
| 661 |
+
---
|
| 662 |
+
|
| 663 |
+
## 4. 数据契约
|
| 664 |
+
|
| 665 |
+
### 4.1 `accounts.json` 新增字段
|
| 666 |
+
|
| 667 |
+
| 字段 | 类型 | 默认 | 来源 | 用途 |
|
| 668 |
+
|---|---|---|---|---|
|
| 669 |
+
| `plan_supported` | bool \| null | null | `_exchange_auth_code` 写入(经 `is_supported_plan`) | 标识当时 OAuth bundle 是否在白名单内;旧记录 null,不破坏兼容 |
|
| 670 |
+
| `plan_type_raw` | str \| null | null | `_exchange_auth_code` 写入(JWT 原始字面量) | 事后排查 |
|
| 671 |
+
| `last_kicked_at` | float \| null | null | `sync_account_states` 探测到 wham 401 时写入 | reconcile 历史回放 / UI 展示"X 分钟前被踢" |
|
| 672 |
+
| `last_quota.primary_total` | int \| null | null | `check_codex_quota` 解析 wham/usage 写入 | no_quota 识别;UI 显示"无配额" |
|
| 673 |
+
| `last_quota.primary_remaining` | int \| null | null | 同上 | UI 精确剩余值显示 |
|
| 674 |
+
|
| 675 |
+
**JSON Schema 片段**:
|
| 676 |
+
|
| 677 |
+
```json
|
| 678 |
+
{
|
| 679 |
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
| 680 |
+
"type": "object",
|
| 681 |
+
"additionalProperties": false,
|
| 682 |
+
"required": ["email", "status", "created_at"],
|
| 683 |
+
"properties": {
|
| 684 |
+
"email": {"type": "string"},
|
| 685 |
+
"password": {"type": "string"},
|
| 686 |
+
"cloudmail_account_id": {"type": ["string", "null"]},
|
| 687 |
+
"status": {"enum": ["active", "exhausted", "standby", "pending", "personal", "auth_invalid", "orphan"]},
|
| 688 |
+
"seat_type": {"enum": ["chatgpt", "codex", "unknown"]},
|
| 689 |
+
"workspace_account_id": {"type": ["string", "null"]},
|
| 690 |
+
"auth_file": {"type": ["string", "null"]},
|
| 691 |
+
"quota_exhausted_at": {"type": ["number", "null"]},
|
| 692 |
+
"quota_resets_at": {"type": ["number", "null"]},
|
| 693 |
+
"last_quota_check_at": {"type": ["number", "null"]},
|
| 694 |
+
"last_quota": {
|
| 695 |
+
"type": ["object", "null"],
|
| 696 |
+
"properties": {
|
| 697 |
+
"primary_pct": {"type": "integer", "minimum": 0, "maximum": 100},
|
| 698 |
+
"primary_resets_at": {"type": "integer"},
|
| 699 |
+
"primary_total": {"type": ["integer", "null"]},
|
| 700 |
+
"primary_remaining": {"type": ["integer", "null"]},
|
| 701 |
+
"weekly_pct": {"type": "integer", "minimum": 0, "maximum": 100},
|
| 702 |
+
"weekly_resets_at": {"type": "integer"}
|
| 703 |
+
}
|
| 704 |
+
},
|
| 705 |
+
"last_active_at": {"type": ["number", "null"]},
|
| 706 |
+
"created_at": {"type": "number"},
|
| 707 |
+
"plan_supported": {"type": ["boolean", "null"]},
|
| 708 |
+
"plan_type_raw": {"type": ["string", "null"]},
|
| 709 |
+
"last_kicked_at": {"type": ["number", "null"]}
|
| 710 |
+
}
|
| 711 |
+
}
|
| 712 |
+
```
|
| 713 |
+
|
| 714 |
+
### 4.2 `runtime_config.json` PREFERRED_SEAT_TYPE 等
|
| 715 |
+
|
| 716 |
+
```json
|
| 717 |
+
{
|
| 718 |
+
"register_domain": "...",
|
| 719 |
+
"preferred_seat_type": "chatgpt",
|
| 720 |
+
"sync_probe_concurrency": 5,
|
| 721 |
+
"sync_probe_cooldown_minutes": 30,
|
| 722 |
+
"quota_probe_threshold_pct": 10
|
| 723 |
+
}
|
| 724 |
+
```
|
| 725 |
+
|
| 726 |
+
| key | 类型 | 默认 | 取值 | UI 字段(Settings.vue) |
|
| 727 |
+
|---|---|---|---|---|
|
| 728 |
+
| `preferred_seat_type` | str | `"chatgpt"` | `chatgpt` / `codex` | "邀请席位偏好"(下拉) |
|
| 729 |
+
| `sync_probe_concurrency` | int | 5 | 1..20 | "同步探测并发数"(数字输入) |
|
| 730 |
+
| `sync_probe_cooldown_minutes` | int | 30 | 5..1440 | "探测去重分钟数"(数字输入) |
|
| 731 |
+
| `quota_probe_threshold_pct` | int | 10 | 0..100 | "Quota 阈值百分比"(已存在,新管理) |
|
| 732 |
+
|
| 733 |
+
### 4.3 完整 Pydantic 模型(实施期复制粘贴)
|
| 734 |
+
|
| 735 |
+
```python
|
| 736 |
+
# src/autoteam/_models.py(可新建,或挂在 accounts.py 末尾)
|
| 737 |
+
"""
|
| 738 |
+
内部 Pydantic 模型 — 给类型检查 / 测试断言 / OpenAPI schema 用。
|
| 739 |
+
|
| 740 |
+
落盘仍是 dict(load_accounts/save_accounts),Pydantic 模型不替换 dict。
|
| 741 |
+
仅在边界(api.py 入参 / out / 测试)用 model_validate / model_dump。
|
| 742 |
+
"""
|
| 743 |
+
from typing import Literal, Optional, Union
|
| 744 |
+
from pydantic import BaseModel, Field
|
| 745 |
+
|
| 746 |
+
AccountStatus = Literal["active", "exhausted", "standby", "pending", "personal", "auth_invalid", "orphan"]
|
| 747 |
+
SeatType = Literal["chatgpt", "codex", "unknown"]
|
| 748 |
+
QuotaStatus = Literal["ok", "exhausted", "no_quota", "auth_error", "network_error"]
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
class QuotaSnapshot(BaseModel):
|
| 752 |
+
primary_pct: int = 0
|
| 753 |
+
primary_resets_at: int = 0
|
| 754 |
+
primary_total: Optional[int] = None
|
| 755 |
+
primary_remaining: Optional[int] = None
|
| 756 |
+
weekly_pct: int = 0
|
| 757 |
+
weekly_resets_at: int = 0
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
class QuotaExhaustedInfo(BaseModel):
|
| 761 |
+
window: Literal["primary", "weekly", "combined", "limit", "no_quota"]
|
| 762 |
+
resets_at: int
|
| 763 |
+
quota_info: QuotaSnapshot
|
| 764 |
+
limit_reached: bool = False
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
class QuotaProbeResult(BaseModel):
|
| 768 |
+
status: QuotaStatus
|
| 769 |
+
info: Optional[Union[QuotaSnapshot, QuotaExhaustedInfo]] = None
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
class AccountRecord(BaseModel):
|
| 773 |
+
email: str
|
| 774 |
+
password: str = ""
|
| 775 |
+
cloudmail_account_id: Optional[str] = None
|
| 776 |
+
status: AccountStatus
|
| 777 |
+
seat_type: SeatType = "unknown"
|
| 778 |
+
workspace_account_id: Optional[str] = None
|
| 779 |
+
auth_file: Optional[str] = None
|
| 780 |
+
quota_exhausted_at: Optional[float] = None
|
| 781 |
+
quota_resets_at: Optional[float] = None
|
| 782 |
+
last_quota_check_at: Optional[float] = None
|
| 783 |
+
last_quota: Optional[QuotaSnapshot] = None
|
| 784 |
+
last_active_at: Optional[float] = None
|
| 785 |
+
created_at: float
|
| 786 |
+
plan_supported: Optional[bool] = None
|
| 787 |
+
plan_type_raw: Optional[str] = None
|
| 788 |
+
last_kicked_at: Optional[float] = None
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
class OAuthBundle(BaseModel):
|
| 792 |
+
"""codex_auth._exchange_auth_code 返回的 bundle 结构(实施期可作为类型注解)"""
|
| 793 |
+
access_token: str
|
| 794 |
+
refresh_token: str
|
| 795 |
+
id_token: str
|
| 796 |
+
account_id: str
|
| 797 |
+
email: str
|
| 798 |
+
plan_type: str # 已归一化 .lower()
|
| 799 |
+
plan_type_raw: str # 原始字面量
|
| 800 |
+
plan_supported: bool
|
| 801 |
+
expired: float
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
class RegisterFailureRecord(BaseModel):
|
| 805 |
+
"""register_failures.json 单条记录结构"""
|
| 806 |
+
timestamp: float
|
| 807 |
+
email: str
|
| 808 |
+
category: Literal[
|
| 809 |
+
"phone_blocked", "duplicate_exhausted", "register_failed",
|
| 810 |
+
"oauth_failed", "kick_failed", "team_oauth_failed", "exception",
|
| 811 |
+
"oauth_phone_blocked", # 新增 §5.2.5
|
| 812 |
+
"plan_unsupported", # 新增
|
| 813 |
+
"no_quota_assigned", # 新增
|
| 814 |
+
"plan_drift", # 新增
|
| 815 |
+
"auth_error_at_oauth", # 新增
|
| 816 |
+
"quota_probe_network_error",# 新增
|
| 817 |
+
]
|
| 818 |
+
reason: str
|
| 819 |
+
stage: Optional[str] = None
|
| 820 |
+
step: Optional[str] = None
|
| 821 |
+
plan_type: Optional[str] = None
|
| 822 |
+
plan_type_raw: Optional[str] = None
|
| 823 |
+
url: Optional[str] = None
|
| 824 |
+
```
|
| 825 |
+
|
| 826 |
+
---
|
| 827 |
+
|
| 828 |
+
## 5. 测试用例
|
| 829 |
+
|
| 830 |
+
### 5.1 FR-A:plan_type 白名单(每 FR 至少 2 case)
|
| 831 |
+
|
| 832 |
+
| # | 用例 | 预期 |
|
| 833 |
+
|---|---|---|
|
| 834 |
+
| 5.1.1 | bundle.plan_type=`team` → `is_supported_plan` | True,plan_supported=True |
|
| 835 |
+
| 5.1.2 | bundle.plan_type=`self_serve_business_usage_based` | False;manager 调用方标 STATUS_AUTH_INVALID + record_failure("plan_unsupported") |
|
| 836 |
+
|
| 837 |
+
测试入口:`tests/unit/test_plan_type_whitelist.py`(参见 [./shared/plan-type-whitelist.md §6.2](./shared/plan-type-whitelist.md))
|
| 838 |
+
|
| 839 |
+
### 5.2 FR-B:wham/usage 5 分类
|
| 840 |
+
|
| 841 |
+
| # | 用例 | 预期 |
|
| 842 |
+
|---|---|---|
|
| 843 |
+
| 5.2.1 | wham 200 + `primary.limit==0` | status=`no_quota`,info.window=`no_quota` |
|
| 844 |
+
| 5.2.2 | wham 200 + `used_percent==100` | status=`exhausted`,info.window=`primary` |
|
| 845 |
+
| 5.2.3 | wham 401 | status=`auth_error`,info=None |
|
| 846 |
+
| 5.2.4 | wham 429 | status=`network_error`(不能误判 auth_error) |
|
| 847 |
+
|
| 848 |
+
测试入口:`tests/unit/test_quota_classification.py`(参见 [./shared/quota-classification.md §6.2](./shared/quota-classification.md))
|
| 849 |
+
|
| 850 |
+
### 5.3 FR-C:add-phone 探针
|
| 851 |
+
|
| 852 |
+
| # | 用例 | 预期 |
|
| 853 |
+
|---|---|---|
|
| 854 |
+
| 5.3.1 | OAuth callback 前 page.url=`/add-phone` | `assert_not_blocked` raise RegisterBlocked(is_phone=True) |
|
| 855 |
+
| 5.3.2 | consent 页含"phone"帮助链接但无 tel input | `detect_phone_verification` 返回 False(避免误报) |
|
| 856 |
+
| 5.3.3 | reinvite_account 抛 RegisterBlocked → STATUS_AUTH_INVALID + `_cleanup_team_leftover` 被调 | 验证 record_failure("oauth_phone_blocked", stage="reinvite_account") |
|
| 857 |
+
|
| 858 |
+
### 5.4 FR-D:`_run_post_register_oauth` quota probe
|
| 859 |
+
|
| 860 |
+
| # | 用例 | 预期 |
|
| 861 |
+
|---|---|---|
|
| 862 |
+
| 5.4.1 | bundle ok + wham `ok` | STATUS_ACTIVE,last_quota 有写入 |
|
| 863 |
+
| 5.4.2 | bundle ok + wham `no_quota` | STATUS_AUTH_INVALID,record_failure("no_quota_assigned") |
|
| 864 |
+
| 5.4.3 | bundle ok + plan_supported=False | STATUS_AUTH_INVALID,record_failure("plan_unsupported"),不进 ACTIVE |
|
| 865 |
+
| 5.4.4 | bundle 抛 RegisterBlocked(is_phone=True) | STATUS_AUTH_INVALID,record_failure("oauth_phone_blocked") |
|
| 866 |
+
|
| 867 |
+
### 5.5 FR-E:sync_account_states 区分被踢 / 待机
|
| 868 |
+
|
| 869 |
+
| # | 用例 | 预期 |
|
| 870 |
+
|---|---|---|
|
| 871 |
+
| 5.5.1 | active 不在 Team + wham 401 | STATUS_AUTH_INVALID,last_kicked_at 写入 |
|
| 872 |
+
| 5.5.2 | active 不在 Team + wham ok | STATUS_STANDBY(自然待机) |
|
| 873 |
+
| 5.5.3 | active 不在 Team + workspace_account_id 漂移 | 保留 ACTIVE(母号切换守卫) |
|
| 874 |
+
| 5.5.4 | 同 email 30 分���内重复探测 | 第二次跳过(去重),保持上轮状态 |
|
| 875 |
+
|
| 876 |
+
### 5.6 FR-F:PREFERRED_SEAT_TYPE
|
| 877 |
+
|
| 878 |
+
| # | 用例 | 预期 |
|
| 879 |
+
|---|---|---|
|
| 880 |
+
| 5.6.1 | preferred=chatgpt → invite_member(seat_type="default") + PATCH 升级 | 行为不变(回归测试) |
|
| 881 |
+
| 5.6.2 | preferred=codex → invite_member(seat_type="usage_based") + 0 处 PATCH | 日志中无"修改邀请 seat_type" |
|
| 882 |
+
|
| 883 |
+
### 5.7 FR-G:personal 删除短路
|
| 884 |
+
|
| 885 |
+
| # | 用例 | 预期 |
|
| 886 |
+
|---|---|---|
|
| 887 |
+
| 5.7.1 | acc.status=personal + 主号 session 失效 → 单点删除 | 不抛 ChatGPTTeamAPI 启动错误,本地记录被清 |
|
| 888 |
+
| 5.7.2 | 批量删除 5 个 personal | 完全不起 ChatGPTTeamAPI |
|
| 889 |
+
|
| 890 |
+
### 5.8 FR-H:reinvite plan_drift / phone_blocked
|
| 891 |
+
|
| 892 |
+
| # | 用例 | 预期 |
|
| 893 |
+
|---|---|---|
|
| 894 |
+
| 5.8.1 | reinvite bundle.plan=`free` | STATUS_AUTH_INVALID + record_failure("plan_drift"),auth_file=None |
|
| 895 |
+
| 5.8.2 | reinvite RegisterBlocked(is_phone=True) | STATUS_AUTH_INVALID + record_failure("oauth_phone_blocked", stage="reinvite_account") |
|
| 896 |
+
| 5.8.3 | 标到 AUTH_INVALID 后下轮不再被 standby 池选中 | reinvite_account 不再被调用 |
|
| 897 |
+
|
| 898 |
+
### 5.9 集成测试
|
| 899 |
+
|
| 900 |
+
| # | 用例 | 预期 |
|
| 901 |
+
|---|---|---|
|
| 902 |
+
| 5.9.1 | mock 一个 self_serve_business_usage_based bundle 跑全链路 | 注册 → STATUS_AUTH_INVALID,UI 显示 "无配额(联系管理员)" |
|
| 903 |
+
| 5.9.2 | 模拟管理员手动从 ChatGPT 后台踢号 → 等下次 sync | 状态机正确转 STATUS_AUTH_INVALID,reconcile 自动 KICK + 留本地记录 |
|
| 904 |
+
| 5.9.3 | preferred_seat_type=codex 下注册新号 | seat_type=codex,_seat_type=usage_based,UI 显示 "Codex 席位" |
|
| 905 |
+
|
| 906 |
+
---
|
| 907 |
+
|
| 908 |
+
## 6. 实施顺序(Story Map → Sub-task 依赖图)
|
| 909 |
+
|
| 910 |
+
依赖图(箭头 → 表示"必须先于"):
|
| 911 |
+
|
| 912 |
+
```
|
| 913 |
+
Phase 0 (spec 落地)
|
| 914 |
+
├─ S-0.1 accounts.py 加白名单常量 + 工具函数
|
| 915 |
+
├─ S-0.2 runtime_config.py 加 PREFERRED_SEAT_TYPE 等
|
| 916 |
+
├─ S-0.3 register_failures.py docstring 扩 6 category
|
| 917 |
+
└─ S-0.4 _models.py 写 Pydantic 模型(可选,用于测试)
|
| 918 |
+
│
|
| 919 |
+
▼
|
| 920 |
+
Phase 1 (核心实现)
|
| 921 |
+
├─ S-1.1 codex_auth._exchange_auth_code 写 bundle 字段 ◄── 依赖 S-0.1
|
| 922 |
+
├─ S-1.2 codex_auth.check_codex_quota 加 no_quota ◄── 独立
|
| 923 |
+
├─ S-1.3 codex_auth.login_codex_via_browser 4 探针 ◄── 独立
|
| 924 |
+
├─ S-1.4 manager._run_post_register_oauth probe + catch ◄── 依赖 S-1.1/1.2/1.3
|
| 925 |
+
├─ S-1.5 manager.sync_account_states 探测 ◄── 依赖 S-1.2
|
| 926 |
+
├─ S-1.6 manager.reinvite_account 兜底 ◄── 依赖 S-1.1/1.2/1.3
|
| 927 |
+
├─ S-1.7 manual_account._finalize_account 扩展 ◄── 依赖 S-0.1/S-1.2
|
| 928 |
+
└─ S-1.8 account_ops.delete_managed_account 短路 ◄── 独立
|
| 929 |
+
│
|
| 930 |
+
▼
|
| 931 |
+
Phase 2 (调用方处置)
|
| 932 |
+
├─ S-2.1 manager._check_pending_invites L1057 catch ◄── 依赖 S-1.3
|
| 933 |
+
├─ S-2.2 api.post_account_login L1479 catch + 409 ◄── 依赖 S-1.3
|
| 934 |
+
├─ S-2.3 9 处 check_codex_quota 调用方加 no_quota 分支 ◄── 依赖 S-1.2
|
| 935 |
+
└─ S-2.4 api.delete_accounts_batch 全 personal 短路 ◄── 依赖 S-1.8
|
| 936 |
+
│
|
| 937 |
+
▼
|
| 938 |
+
Phase 3 (席位策略,可与 P1/P2 并行)
|
| 939 |
+
├─ S-3.1 invite.py L496 读 PREFERRED_SEAT_TYPE ◄── 依赖 S-0.2
|
| 940 |
+
├─ S-3.2 chatgpt_api._invite_member_once 加参数 ◄── 依赖 S-3.1
|
| 941 |
+
└─ S-3.3 _invite_member_with_fallback 调整 ◄── 依赖 S-3.2
|
| 942 |
+
│
|
| 943 |
+
▼
|
| 944 |
+
Phase 4 (前端)
|
| 945 |
+
├─ S-4.1 Settings.vue 加 preferred_seat_type / 探测配置 ◄── 依赖 S-0.2
|
| 946 |
+
├─ S-4.2 Dashboard.vue removeAccount toast / quota 显示 ◄── 依赖 S-1.8
|
| 947 |
+
└─ S-4.3 api.ts 解析 409 phone_required + 422 no_quota ◄── 依赖 S-2.2
|
| 948 |
+
│
|
| 949 |
+
▼
|
| 950 |
+
Phase 5 (测试 + 文档,贯穿)
|
| 951 |
+
├─ S-5.1 单测覆盖 §5.1~5.8 ◄── 跟随各 sub-task
|
| 952 |
+
├─ S-5.2 集成测试 §5.9 ◄── 在 P1+P2 完成后
|
| 953 |
+
├─ S-5.3 CHANGELOG.md 增补 ◄── 在所有 sub-task 完成后
|
| 954 |
+
└─ S-5.4 docs/account-state-machine.md 等新文档 ◄── 同上
|
| 955 |
+
│
|
| 956 |
+
▼
|
| 957 |
+
Phase 6 (灰度上线)
|
| 958 |
+
├─ S-6.1 默认 chatgpt 偏好上线,观察 register_failures
|
| 959 |
+
└─ S-6.2 1-2 周后根据 PATCH 失败率决议默认值切换
|
| 960 |
+
```
|
| 961 |
+
|
| 962 |
+
**关键串行链**:S-0.1 → S-1.1 → S-1.4 / S-1.6 / S-1.7;S-0.2 → S-3.x;S-1.3 → S-2.1 / S-2.2
|
| 963 |
+
|
| 964 |
+
**可并行链**:Phase 1 的 S-1.2 / S-1.3 / S-1.8 互不依赖;Phase 3 与 Phase 1/2 完全并行
|
| 965 |
+
|
| 966 |
+
---
|
| 967 |
+
|
| 968 |
+
## 7. 验收清单(完成检查表)
|
| 969 |
+
|
| 970 |
+
### 7.1 代码层
|
| 971 |
+
|
| 972 |
+
- [ ] `accounts.py` 含 `SUPPORTED_PLAN_TYPES` + `is_supported_plan` + `normalize_plan_type`
|
| 973 |
+
- [ ] `codex_auth._exchange_auth_code` bundle 含 `plan_type`(归一化)/ `plan_type_raw` / `plan_supported`
|
| 974 |
+
- [ ] `codex_auth.check_codex_quota` 5 分类,no_quota 触发条件 4 项已实现
|
| 975 |
+
- [ ] `codex_auth.login_codex_via_browser` 4 处 `assert_not_blocked` 接入(C-P1~C-P4)
|
| 976 |
+
- [ ] `manager._run_post_register_oauth` Team / personal 双分支 catch + probe
|
| 977 |
+
- [ ] `manager.sync_account_states` 并发探测被踢识别 + 30 分钟去重
|
| 978 |
+
- [ ] `manager.reinvite_account` plan_drift / phone_blocked / plan_unsupported 三路兜底
|
| 979 |
+
- [ ] `manual_account._finalize_account` plan_supported 检查
|
| 980 |
+
- [ ] `account_ops.delete_managed_account` short_circuit
|
| 981 |
+
- [ ] `api.post_account_login` 409 phone_required
|
| 982 |
+
- [ ] `api.delete_accounts_batch` 全 personal 不起 ChatGPTTeamAPI
|
| 983 |
+
- [ ] `invite.py` + `chatgpt_api.py` PREFERRED_SEAT_TYPE 链路
|
| 984 |
+
- [ ] `runtime_config.py` 4 个新 getter / setter
|
| 985 |
+
|
| 986 |
+
### 7.2 数据层
|
| 987 |
+
|
| 988 |
+
- [ ] 旧 `accounts.json` 记录(无新字段)读取无报错
|
| 989 |
+
- [ ] 新增 `last_kicked_at` / `plan_supported` / `plan_type_raw` 落盘
|
| 990 |
+
- [ ] `last_quota.primary_total` / `primary_remaining` 落盘
|
| 991 |
+
- [ ] `register_failures.json` 6 个新 category 计数
|
| 992 |
+
|
| 993 |
+
### 7.3 前端
|
| 994 |
+
|
| 995 |
+
- [ ] Settings.vue 邀请席位偏好下拉
|
| 996 |
+
- [ ] Dashboard.vue removeAccount 失败 toast 区分 actionDisabled / 409 / 500
|
| 997 |
+
- [ ] Dashboard.vue quota 显示 no_quota 时文案 "无配额(联系管理员)"
|
| 998 |
+
|
| 999 |
+
### 7.4 测试
|
| 1000 |
+
|
| 1001 |
+
- [ ] 全部单测通过(§5.1~5.8 各 ≥2 case)
|
| 1002 |
+
- [ ] 集成测试通过(§5.9)
|
| 1003 |
+
- [ ] 回归测试通过(invite / reinvite / sync / cmd_check)
|
| 1004 |
+
|
| 1005 |
+
### 7.5 文档
|
| 1006 |
+
|
| 1007 |
+
- [ ] CHANGELOG.md 列出 5 个新 category + 4 个 OAuth 探针接入点 + PREFERRED_SEAT_TYPE
|
| 1008 |
+
- [ ] 4 份 shared spec 文件路径在 CHANGELOG / README 引用
|
| 1009 |
+
- [ ] docs/oauth-add-phone-detection.md / quota-classification.md / account-state-machine.md 落地(或合入主 README)
|
| 1010 |
+
|
| 1011 |
+
### 7.6 灰度
|
| 1012 |
+
|
| 1013 |
+
- [ ] 默认 PREFERRED_SEAT_TYPE=chatgpt 行为不变
|
| 1014 |
+
- [ ] 1-2 周后根据 register_failures 数据决议是否切默认值
|
| 1015 |
+
|
| 1016 |
+
---
|
| 1017 |
+
|
| 1018 |
+
**文档结束。** 工程师参照本 spec + 4 份 shared spec,无需查询 PRD 即可完成代码实施。
|
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPEC-3: Docker 镜像守卫 实施规范
|
| 2 |
+
|
| 3 |
+
## 0. 元数据
|
| 4 |
+
|
| 5 |
+
| 字段 | 值 |
|
| 6 |
+
|---|---|
|
| 7 |
+
| SPEC 编号 | SPEC-3 |
|
| 8 |
+
| 主笔 | prd-docker(autoteam-prd-0426) |
|
| 9 |
+
| 起草日期 | 2026-04-26 |
|
| 10 |
+
| 上游 PRD | `prompts/0426/prd/prd-3-docker-guard.md` |
|
| 11 |
+
| 关联调研 | `prompts/0426/research/issue-3-list-accounts-import.md` |
|
| 12 |
+
| 关联代码修复 | commit `cf2f7d3 fix(round-3): ...` |
|
| 13 |
+
| 实施优先级 | P1 |
|
| 14 |
+
| 预计耗时 | 2-3 小时(单人) |
|
| 15 |
+
|
| 16 |
+
> 本 SPEC 提供"打开就能编码"的所有具体内容:实际 entrypoint 脚本、Dockerfile diff、ruff 配置文件、`/api/version` 端点 Pydantic 响应模型、`docs/docker.md` SOP 完整文案。直接复制粘贴即可落地,**无需再做技术决策**。
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## 1. 文件级修改清单
|
| 21 |
+
|
| 22 |
+
| 文件 | 操作 | 说明 |
|
| 23 |
+
|---|---|---|
|
| 24 |
+
| `Dockerfile` | 修改 | 在 `WORKDIR /app` 之后增加 ARG/LABEL/ENV |
|
| 25 |
+
| `docker-compose.yml` | 修改 | `build: .` 扩为对象 + `args: GIT_SHA / BUILD_TIME` |
|
| 26 |
+
| `docker-entrypoint.sh` | 修改 | 在 `exec uv run autoteam` 之前增加 self-check |
|
| 27 |
+
| `pyproject.toml` | 修改 | 新增 `[tool.ruff.lint]` 段(dev 依赖已声明 ruff>=0.15.10) |
|
| 28 |
+
| `.pre-commit-config.yaml` | 新建 | ruff hook(规则 F401/F811/F821) |
|
| 29 |
+
| `src/autoteam/api.py` | 修改 | 在 `app = FastAPI(...)` 之后新增 Pydantic VersionResponse + `/api/version` 路由 |
|
| 30 |
+
| `docs/docker.md` | 修改 | 末尾追加"代码更新后的 rebuild SOP"+"版本验证"+"故障排查"三章节 |
|
| 31 |
+
| `docs/api.md` | 修改 | 追加 `/api/version` 端点说明 |
|
| 32 |
+
| `CHANGELOG.md` | 修改 | 添加 SPEC-3 落地记录 |
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## 2. entrypoint self-check 脚本(完整文件)
|
| 37 |
+
|
| 38 |
+
文件:`docker-entrypoint.sh`(在现有内容末尾、`exec uv run autoteam "$@"` 之前**插入**下列段)。
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
#!/bin/bash
|
| 42 |
+
set -e
|
| 43 |
+
|
| 44 |
+
# 清理残留锁文件并启动虚拟显示器
|
| 45 |
+
rm -f /tmp/.X99-lock
|
| 46 |
+
Xvfb :99 -screen 0 1280x800x24 &
|
| 47 |
+
export DISPLAY=:99
|
| 48 |
+
|
| 49 |
+
# 确保数据目录存在且可写
|
| 50 |
+
mkdir -p /app/data /app/data/auths /app/data/screenshots
|
| 51 |
+
chmod -R 777 /app/data
|
| 52 |
+
|
| 53 |
+
# 数据文件:无条件软链到 data/(确保所有写入都持久化)
|
| 54 |
+
for f in .env accounts.json state.json; do
|
| 55 |
+
[ -f "/app/data/$f" ] || touch "/app/data/$f"
|
| 56 |
+
rm -f "/app/$f"
|
| 57 |
+
ln -s "/app/data/$f" "/app/$f"
|
| 58 |
+
done
|
| 59 |
+
|
| 60 |
+
# 目录软链
|
| 61 |
+
for d in auths screenshots; do
|
| 62 |
+
rm -rf "/app/$d"
|
| 63 |
+
ln -s "/app/data/$d" "/app/$d"
|
| 64 |
+
done
|
| 65 |
+
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
# Self-check: 关键 import 白名单 — 失败即 crash-loop(SPEC-3 §2)
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
echo "[self-check] verifying critical imports..."
|
| 70 |
+
uv run python - <<'PYEOF' || {
|
| 71 |
+
echo "[self-check] FATAL: critical import failed - image is likely stale or broken." >&2
|
| 72 |
+
echo "[self-check] If you just pulled new code, the image must be rebuilt:" >&2
|
| 73 |
+
echo "[self-check] docker compose down" >&2
|
| 74 |
+
echo "[self-check] docker compose build --no-cache --build-arg GIT_SHA=\$(git rev-parse --short HEAD)" >&2
|
| 75 |
+
echo "[self-check] docker compose up -d" >&2
|
| 76 |
+
exit 1
|
| 77 |
+
}
|
| 78 |
+
import sys
|
| 79 |
+
|
| 80 |
+
# 核心 web 应用入口
|
| 81 |
+
from autoteam.api import app # noqa: F401
|
| 82 |
+
|
| 83 |
+
# accounts.py 全部对外契约符号(典型 typo 高发区)
|
| 84 |
+
from autoteam.accounts import ( # noqa: F401
|
| 85 |
+
load_accounts,
|
| 86 |
+
save_accounts,
|
| 87 |
+
STATUS_ACTIVE,
|
| 88 |
+
STATUS_EXHAUSTED,
|
| 89 |
+
STATUS_STANDBY,
|
| 90 |
+
STATUS_PENDING,
|
| 91 |
+
STATUS_PERSONAL,
|
| 92 |
+
STATUS_AUTH_INVALID,
|
| 93 |
+
STATUS_ORPHAN,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# manager → accounts 链路(覆盖跨模块 typo)
|
| 97 |
+
from autoteam.manager import sync_account_states # noqa: F401
|
| 98 |
+
|
| 99 |
+
print("[self-check] OK: %d critical symbols imported." % 11)
|
| 100 |
+
sys.exit(0)
|
| 101 |
+
PYEOF
|
| 102 |
+
echo "[self-check] passed."
|
| 103 |
+
|
| 104 |
+
# 执行命令
|
| 105 |
+
exec uv run autoteam "$@"
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
**设计要点**:
|
| 109 |
+
|
| 110 |
+
1. `uv run python - <<'PYEOF' ... PYEOF` heredoc 写法保证脚本与 entrypoint 同源,不需另起 `scripts/self_check.py`
|
| 111 |
+
2. heredoc 用单引号 `'PYEOF'` — 禁止 shell 变量插值,避免 `$variable` 被宿主 bash 解析
|
| 112 |
+
3. `||{ ... ; exit 1; }` — Python 进程非零退出时立即输出 rebuild 提示并 `exit 1`,触发 docker `restart: unless-stopped` 的 crash-loop
|
| 113 |
+
4. 自检符号只列 **11 个稳定契约**(2 函数 + 7 状态常量 + 1 外部 app + 1 跨模块函数),不列易变函数;改这些符号需同步改自检
|
| 114 |
+
5. 整体延迟 **< 1 秒**(冷启动 Python 解释器 + 单文件 import)
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## 3. Dockerfile diff(完整新版本)
|
| 119 |
+
|
| 120 |
+
```dockerfile
|
| 121 |
+
FROM python:3.12-slim
|
| 122 |
+
|
| 123 |
+
# 系统依赖
|
| 124 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 125 |
+
xvfb \
|
| 126 |
+
curl \
|
| 127 |
+
fonts-noto-cjk \
|
| 128 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 129 |
+
|
| 130 |
+
# 安装 uv
|
| 131 |
+
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 132 |
+
ENV PATH="/root/.local/bin:$PATH"
|
| 133 |
+
|
| 134 |
+
WORKDIR /app
|
| 135 |
+
|
| 136 |
+
# === SPEC-3 §3: 镜像版本指纹注入 ===
|
| 137 |
+
ARG GIT_SHA=unknown
|
| 138 |
+
ARG BUILD_TIME=unknown
|
| 139 |
+
LABEL org.opencontainers.image.revision="${GIT_SHA}"
|
| 140 |
+
LABEL org.opencontainers.image.created="${BUILD_TIME}"
|
| 141 |
+
ENV AUTOTEAM_GIT_SHA="${GIT_SHA}"
|
| 142 |
+
ENV AUTOTEAM_BUILD_TIME="${BUILD_TIME}"
|
| 143 |
+
# === SPEC-3 §3 end ===
|
| 144 |
+
|
| 145 |
+
# 复制项目文件
|
| 146 |
+
COPY pyproject.toml uv.lock ./
|
| 147 |
+
RUN uv sync --no-dev
|
| 148 |
+
|
| 149 |
+
# 安装 Playwright 浏览器
|
| 150 |
+
RUN uv run playwright install chromium && uv run playwright install-deps chromium
|
| 151 |
+
|
| 152 |
+
# 复制源码
|
| 153 |
+
COPY src/ src/
|
| 154 |
+
COPY web/ web/
|
| 155 |
+
|
| 156 |
+
# 数据卷
|
| 157 |
+
VOLUME ["/app/data"]
|
| 158 |
+
|
| 159 |
+
# 启动时将数据目录软链到工作目录
|
| 160 |
+
RUN mkdir -p /app/data
|
| 161 |
+
ENV DISPLAY=:99
|
| 162 |
+
|
| 163 |
+
EXPOSE 8787
|
| 164 |
+
|
| 165 |
+
# 启动脚本
|
| 166 |
+
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
| 167 |
+
RUN chmod +x /docker-entrypoint.sh
|
| 168 |
+
|
| 169 |
+
ENTRYPOINT ["/docker-entrypoint.sh"]
|
| 170 |
+
CMD ["api"]
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
**关键约束**:
|
| 174 |
+
|
| 175 |
+
- ARG 块**必须在 WORKDIR 之后、`COPY pyproject.toml` 之前** — 这样 build args 变化时不会击穿后续昂贵的 `uv sync` / `playwright install` 缓存层
|
| 176 |
+
- 默认值 `unknown` 让"不传 build-arg 直接 build"也能跑成功(降级语义)
|
| 177 |
+
- `org.opencontainers.image.*` 是 OCI 标准 label,`docker image inspect` 自动展示
|
| 178 |
+
|
| 179 |
+
---
|
| 180 |
+
|
| 181 |
+
## 4. docker-compose.yml diff(完整新版本)
|
| 182 |
+
|
| 183 |
+
```yaml
|
| 184 |
+
services:
|
| 185 |
+
autoteam:
|
| 186 |
+
build:
|
| 187 |
+
context: .
|
| 188 |
+
args:
|
| 189 |
+
GIT_SHA: ${GIT_SHA:-unknown}
|
| 190 |
+
BUILD_TIME: ${BUILD_TIME:-unknown}
|
| 191 |
+
ports:
|
| 192 |
+
- "8787:8787"
|
| 193 |
+
volumes:
|
| 194 |
+
- ./data:/app/data
|
| 195 |
+
restart: unless-stopped
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
**关键约束**:
|
| 199 |
+
|
| 200 |
+
- `${GIT_SHA:-unknown}` — Compose 标准变量插值,未设环境变量时回落 `unknown`
|
| 201 |
+
- 用户日常 `docker compose build` 仍能成功(只是 sha 显示 unknown,**不报错**)
|
| 202 |
+
- 推荐一键命令(写入 `docs/docker.md`):
|
| 203 |
+
|
| 204 |
+
```bash
|
| 205 |
+
GIT_SHA=$(git rev-parse --short HEAD) BUILD_TIME=$(date -u +%FT%TZ) docker compose build --no-cache
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
---
|
| 209 |
+
|
| 210 |
+
## 5. /api/version 端点契约
|
| 211 |
+
|
| 212 |
+
文件:`src/autoteam/api.py`,在 **line 25** `app = FastAPI(...)` 定义之后、line 27 注释行之前**插入**:
|
| 213 |
+
|
| 214 |
+
```python
|
| 215 |
+
# ---------------------------------------------------------------------------
|
| 216 |
+
# 版本端点 (SPEC-3 §5) - 不鉴权,纯只读
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class VersionResponse(BaseModel):
|
| 221 |
+
"""镜像版本指纹响应 - 来自 Dockerfile build args。"""
|
| 222 |
+
|
| 223 |
+
git_sha: str
|
| 224 |
+
build_time: str
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@app.get(
|
| 228 |
+
"/api/version",
|
| 229 |
+
response_model=VersionResponse,
|
| 230 |
+
summary="返回镜像构建期注入的 git-sha 与时间戳",
|
| 231 |
+
tags=["meta"],
|
| 232 |
+
)
|
| 233 |
+
def api_version() -> VersionResponse:
|
| 234 |
+
return VersionResponse(
|
| 235 |
+
git_sha=os.getenv("AUTOTEAM_GIT_SHA", "unknown"),
|
| 236 |
+
build_time=os.getenv("AUTOTEAM_BUILD_TIME", "unknown"),
|
| 237 |
+
)
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
**鉴权豁免**:`/api/version` 必须加入 `_AUTH_SKIP_PATHS`(api.py:31)白名单。修改:
|
| 241 |
+
|
| 242 |
+
```python
|
| 243 |
+
_AUTH_SKIP_PATHS = {"/api/auth/check", "/api/setup/status", "/api/setup/save", "/api/version"}
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
**响应示例**:
|
| 247 |
+
|
| 248 |
+
```json
|
| 249 |
+
{
|
| 250 |
+
"git_sha": "cf2f7d3",
|
| 251 |
+
"build_time": "2026-04-26T03:00:00Z"
|
| 252 |
+
}
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
**安全保证**:端点只暴露 build-arg 注入的两个字符串,不读 secret / 路径 / 主机名,免鉴权安全。
|
| 256 |
+
|
| 257 |
+
---
|
| 258 |
+
|
| 259 |
+
## 6. ruff 配置(pyproject.toml 完整片段)
|
| 260 |
+
|
| 261 |
+
在 `pyproject.toml` 末尾(line 35 之后)**追加**:
|
| 262 |
+
|
| 263 |
+
```toml
|
| 264 |
+
# ---------------------------------------------------------------------------
|
| 265 |
+
# Ruff lint (SPEC-3 §6) - 仅启 F401/F811/F821 三条零误报规则
|
| 266 |
+
# ---------------------------------------------------------------------------
|
| 267 |
+
[tool.ruff]
|
| 268 |
+
target-version = "py310"
|
| 269 |
+
line-length = 120
|
| 270 |
+
src = ["src", "tests"]
|
| 271 |
+
|
| 272 |
+
[tool.ruff.lint]
|
| 273 |
+
# F401: imported but unused (死 import)
|
| 274 |
+
# F811: redefinition of unused (重复定义)
|
| 275 |
+
# F821: undefined name (核心 — 抓 typo 类 ImportError)
|
| 276 |
+
select = ["F401", "F811", "F821"]
|
| 277 |
+
|
| 278 |
+
# 测试 / 文档 / 脚本豁免(可能含 docstring 示例 / 故意未定义符号)
|
| 279 |
+
exclude = [
|
| 280 |
+
"tests/",
|
| 281 |
+
"docs/",
|
| 282 |
+
"scripts/",
|
| 283 |
+
".venv/",
|
| 284 |
+
"build/",
|
| 285 |
+
"dist/",
|
| 286 |
+
]
|
| 287 |
+
|
| 288 |
+
[tool.ruff.lint.per-file-ignores]
|
| 289 |
+
# __init__.py 允许 re-export
|
| 290 |
+
"__init__.py" = ["F401"]
|
| 291 |
+
```
|
| 292 |
+
|
| 293 |
+
**为什么只选 F401/F811/F821**:
|
| 294 |
+
|
| 295 |
+
- 这三条**零误报**(语义错误而非风格),不打扰现有代码
|
| 296 |
+
- F821 是抓 `from autoteam.accounts import list_accounts` 这种 typo 的核心规则
|
| 297 |
+
- 后续如要追加 E/W/B 等 style 规则可单独 PR,不在本 SPEC 范围
|
| 298 |
+
|
| 299 |
+
**手动执行**(开发期自检):
|
| 300 |
+
|
| 301 |
+
```bash
|
| 302 |
+
uv run ruff check src/
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
## 7. pre-commit hook 配置
|
| 308 |
+
|
| 309 |
+
新建文件:`.pre-commit-config.yaml`(项目根目录)。
|
| 310 |
+
|
| 311 |
+
```yaml
|
| 312 |
+
# SPEC-3 §7: pre-commit ruff hook
|
| 313 |
+
# 安装:uv run pre-commit install
|
| 314 |
+
# 手动跑全仓:uv run pre-commit run --all-files
|
| 315 |
+
|
| 316 |
+
repos:
|
| 317 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 318 |
+
rev: v0.7.0
|
| 319 |
+
hooks:
|
| 320 |
+
- id: ruff
|
| 321 |
+
name: ruff (F401/F811/F821)
|
| 322 |
+
args:
|
| 323 |
+
- --select
|
| 324 |
+
- F401,F811,F821
|
| 325 |
+
- --exit-non-zero-on-fix
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
**首次启用步骤**(写入 docs):
|
| 329 |
+
|
| 330 |
+
```bash
|
| 331 |
+
uv sync # 装好 dev 依赖(pre-commit 已声明)
|
| 332 |
+
uv run pre-commit install # 注入 .git/hooks/pre-commit
|
| 333 |
+
uv run pre-commit run --all-files # 一次性扫全仓,确认基线干净
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
---
|
| 337 |
+
|
| 338 |
+
## 8. docs/docker.md SOP 章节完整��案
|
| 339 |
+
|
| 340 |
+
在 `docs/docker.md` 末尾**追加**以下章节(原文档已有的内容保持不变):
|
| 341 |
+
|
| 342 |
+
```markdown
|
| 343 |
+
---
|
| 344 |
+
|
| 345 |
+
## 代码更新后的 rebuild SOP(SPEC-3 §8)
|
| 346 |
+
|
| 347 |
+
> **关键认知**:本项目 `Dockerfile` 用 `COPY src/`(非 volume mount),
|
| 348 |
+
> **`git pull` 后必须 rebuild 镜像**,代码改动才会进入容器。
|
| 349 |
+
|
| 350 |
+
### 标准更新流程(4 步)
|
| 351 |
+
|
| 352 |
+
```bash
|
| 353 |
+
# 1. 拉取新代码
|
| 354 |
+
cd /path/to/AutoTeam && git pull
|
| 355 |
+
|
| 356 |
+
# 2. 停掉旧容器
|
| 357 |
+
docker compose down
|
| 358 |
+
|
| 359 |
+
# 3. 重建镜像(--no-cache 防意外缓存命中,GIT_SHA 注入版本指纹)
|
| 360 |
+
GIT_SHA=$(git rev-parse --short HEAD) \
|
| 361 |
+
BUILD_TIME=$(date -u +%FT%TZ) \
|
| 362 |
+
docker compose build --no-cache
|
| 363 |
+
|
| 364 |
+
# 4. 启动
|
| 365 |
+
docker compose up -d
|
| 366 |
+
```
|
| 367 |
+
|
| 368 |
+
### 验证镜像版本(三选一,结果应一致)
|
| 369 |
+
|
| 370 |
+
```bash
|
| 371 |
+
# 方式 A:HTTP 端点(免鉴权)
|
| 372 |
+
curl http://localhost:8787/api/version
|
| 373 |
+
# 期望:{"git_sha":"cf2f7d3","build_time":"2026-04-26T..."}
|
| 374 |
+
|
| 375 |
+
# 方式 B:进容器查环境变量
|
| 376 |
+
docker compose exec autoteam env | grep AUTOTEAM_GIT_SHA
|
| 377 |
+
|
| 378 |
+
# 方式 C:看镜像 OCI label(无需启动容器)
|
| 379 |
+
docker image inspect autoteam-autoteam --format '{{json .Config.Labels}}'
|
| 380 |
+
```
|
| 381 |
+
|
| 382 |
+
### 启动期 self-check
|
| 383 |
+
|
| 384 |
+
容器每次启动都会执行 `[self-check]` 段,白名单 import 任一失败立即 `exit 1` → docker 进入 crash-loop。
|
| 385 |
+
|
| 386 |
+
```bash
|
| 387 |
+
docker compose logs autoteam | head -20
|
| 388 |
+
# 期望看到:
|
| 389 |
+
# [self-check] verifying critical imports...
|
| 390 |
+
# [self-check] OK: 11 critical symbols imported.
|
| 391 |
+
# [self-check] passed.
|
| 392 |
+
```
|
| 393 |
+
|
| 394 |
+
### 故障排查:为什么修了代码 bug 还在?
|
| 395 |
+
|
| 396 |
+
**99% 是镜像没 rebuild**。先跑这条快速诊断:
|
| 397 |
+
|
| 398 |
+
```bash
|
| 399 |
+
# 对比 image 内 sha 与 repo HEAD
|
| 400 |
+
echo "image:" && curl -s http://localhost:8787/api/version | python -m json.tool
|
| 401 |
+
echo "repo HEAD:" && git rev-parse --short HEAD
|
| 402 |
+
```
|
| 403 |
+
|
| 404 |
+
如果 `image.git_sha` 与 `repo HEAD` 不一致 → 重做上面 4 步 SOP。
|
| 405 |
+
|
| 406 |
+
如果 self-check 报 `FATAL: critical import failed`:
|
| 407 |
+
- 说明镜像里的源码与最新代码的契约符号对不上(典型 typo 引入未定义名)
|
| 408 |
+
- 解决:回退最近 commit 或修复 typo,再 rebuild
|
| 409 |
+
```
|
| 410 |
+
|
| 411 |
+
---
|
| 412 |
+
|
| 413 |
+
## 9. 测试用例
|
| 414 |
+
|
| 415 |
+
### 9.1 本地 docker 端到端(必跑)
|
| 416 |
+
|
| 417 |
+
```bash
|
| 418 |
+
cd D:/Desktop/AutoTeam
|
| 419 |
+
|
| 420 |
+
# 步骤 1: 全量 rebuild
|
| 421 |
+
docker compose down
|
| 422 |
+
GIT_SHA=$(git rev-parse --short HEAD) BUILD_TIME=$(date -u +%FT%TZ) docker compose build --no-cache
|
| 423 |
+
|
| 424 |
+
# 步骤 2: 启动 + 验证 self-check 通过
|
| 425 |
+
docker compose up -d
|
| 426 |
+
sleep 5
|
| 427 |
+
docker compose logs autoteam | grep -E "(self-check|FATAL)"
|
| 428 |
+
# 期望: "[self-check] OK" + "[self-check] passed"
|
| 429 |
+
|
| 430 |
+
# 步骤 3: 验证 /api/version
|
| 431 |
+
curl -s http://localhost:8787/api/version | python -m json.tool
|
| 432 |
+
# 期望: { "git_sha": "<7-char sha>", "build_time": "<ISO8601>" } 两字段都非 unknown
|
| 433 |
+
|
| 434 |
+
# 步骤 4: 验证 env 与 OCI label 一致
|
| 435 |
+
docker compose exec autoteam env | grep AUTOTEAM_GIT_SHA
|
| 436 |
+
docker image inspect autoteam-autoteam --format '{{.Config.Labels}}'
|
| 437 |
+
# 期望: 三处 sha 字符串完全相同
|
| 438 |
+
```
|
| 439 |
+
|
| 440 |
+
### 9.2 self-check 故障注入(回归保险)
|
| 441 |
+
|
| 442 |
+
```bash
|
| 443 |
+
# 步骤 1: 临时损坏 accounts.py
|
| 444 |
+
sed -i.bak 's/^def load_accounts/def loadaccounts/' src/autoteam/accounts.py
|
| 445 |
+
|
| 446 |
+
# 步骤 2: rebuild + 启动 → 应 crash-loop
|
| 447 |
+
GIT_SHA=test docker compose build --no-cache
|
| 448 |
+
docker compose up -d
|
| 449 |
+
sleep 5
|
| 450 |
+
docker compose ps # 期望 STATUS = Restarting (1)
|
| 451 |
+
docker compose logs autoteam | grep "FATAL"
|
| 452 |
+
# 期望:命中 "[self-check] FATAL"
|
| 453 |
+
|
| 454 |
+
# 步骤 3: 恢复 + rebuild
|
| 455 |
+
mv src/autoteam/accounts.py.bak src/autoteam/accounts.py
|
| 456 |
+
docker compose build --no-cache && docker compose up -d
|
| 457 |
+
```
|
| 458 |
+
|
| 459 |
+
### 9.3 lint 守卫验证
|
| 460 |
+
|
| 461 |
+
```bash
|
| 462 |
+
# 步骤 1: 制造 typo
|
| 463 |
+
echo "from autoteam.accounts import list_accounts" > src/autoteam/_lint_canary.py
|
| 464 |
+
|
| 465 |
+
# 步骤 2: ruff 应拦截
|
| 466 |
+
uv run ruff check src/
|
| 467 |
+
# 期望: F821 Undefined name `list_accounts`,exit code = 1
|
| 468 |
+
|
| 469 |
+
# 步骤 3: pre-commit 应拦截
|
| 470 |
+
git add src/autoteam/_lint_canary.py
|
| 471 |
+
git commit -m "test"
|
| 472 |
+
# 期望:被 ruff hook 拒绝,commit 未生成
|
| 473 |
+
|
| 474 |
+
# 步骤 4: 清理
|
| 475 |
+
rm src/autoteam/_lint_canary.py
|
| 476 |
+
git reset HEAD
|
| 477 |
+
```
|
| 478 |
+
|
| 479 |
+
### 9.4 降级路径(不传 build-arg)
|
| 480 |
+
|
| 481 |
+
```bash
|
| 482 |
+
docker compose down
|
| 483 |
+
docker compose build --no-cache # 不传 GIT_SHA / BUILD_TIME
|
| 484 |
+
docker compose up -d
|
| 485 |
+
sleep 5
|
| 486 |
+
curl -s http://localhost:8787/api/version
|
| 487 |
+
# 期望: { "git_sha": "unknown", "build_time": "unknown" } — 不报错,正常返回
|
| 488 |
+
```
|
| 489 |
+
|
| 490 |
+
---
|
| 491 |
+
|
| 492 |
+
## 10. 实施顺序
|
| 493 |
+
|
| 494 |
+
按依赖顺序串行(每步独立可回滚):
|
| 495 |
+
|
| 496 |
+
1. **`pyproject.toml` 增 `[tool.ruff]`** → 跑 `uv run ruff check src/` 确认基线干净
|
| 497 |
+
2. **`.pre-commit-config.yaml` 新建** → `uv run pre-commit install` + `pre-commit run --all-files`
|
| 498 |
+
3. **`src/autoteam/api.py` 增 `/api/version`** → 本地 `uv run uvicorn autoteam.api:app` 测 `curl /api/version`(返 unknown)
|
| 499 |
+
4. **`Dockerfile` 增 ARG/LABEL/ENV** → `docker build --build-arg GIT_SHA=test .` 测构建成功
|
| 500 |
+
5. **`docker-compose.yml` 改 build 段** → `docker compose config` 验语法
|
| 501 |
+
6. **`docker-entrypoint.sh` 增 self-check** → `docker compose build && docker compose up -d` 看日志
|
| 502 |
+
7. **`docs/docker.md` + `docs/api.md` 增章节** → 评审文档可读性
|
| 503 |
+
8. **`CHANGELOG.md` 加条目**
|
| 504 |
+
9. 跑完整 §9.1 ~ §9.4 端到端测试
|
| 505 |
+
|
| 506 |
+
---
|
| 507 |
+
|
| 508 |
+
## 11. 验收清单
|
| 509 |
+
|
| 510 |
+
| ID | 验收点 | 检验方式 | 通过条件 |
|
| 511 |
+
|---|---|---|---|
|
| 512 |
+
| AC1 | self-check 正常路径通过 | `docker compose logs` 含 "self-check passed" | ✅ 命中 |
|
| 513 |
+
| AC2 | self-check 拦截 typo | §9.2 故障注入后容器 crash-loop | ✅ STATUS=Restarting |
|
| 514 |
+
| AC3 | `/api/version` 返回正确字段 | `curl /api/version` 返 `git_sha + build_time` 双字段 | ✅ JSON 合法 |
|
| 515 |
+
| AC4 | 三处 sha 一致 | env / curl / label 三种姿势 sha 字符串相同 | ✅ 字符串相等 |
|
| 516 |
+
| AC5 | ruff F821 拦截 typo | §9.3 第 2 步 exit code = 1 | ✅ 非零退出 |
|
| 517 |
+
| AC6 | pre-commit 拦截 commit | §9.3 第 3 步 commit 失败 | ✅ commit 未生成 |
|
| 518 |
+
| AC7 | 不传 build-arg 不报错 | §9.4 返 unknown 正常 | ✅ 返 200 |
|
| 519 |
+
| AC8 | docs SOP 可粘贴执行 | 新人按 docs/docker.md 4 步操作 | ✅ 一次跑通 |
|
| 520 |
+
| AC9 | `/api/version` 免鉴权 | 不带 Bearer 直接 curl | ✅ 返 200 |
|
| 521 |
+
| AC10 | 现有功能无回归 | 既有测试套 `uv run pytest` 全绿 | ✅ 0 失败 |
|
| 522 |
+
|
| 523 |
+
---
|
| 524 |
+
|
| 525 |
+
**SPEC-3 完。** 实施时严格按 §10 顺序;遇任何违反 §11 验收点的情况,**必须回滚该步**而非强推 — 每步都是独立可逆单元。
|
|
@@ -0,0 +1,458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPEC-4: Playwright 一致性硬化 实施规范
|
| 2 |
+
|
| 3 |
+
## 0. 元数据
|
| 4 |
+
|
| 5 |
+
| 字段 | 值 |
|
| 6 |
+
|---|---|
|
| 7 |
+
| SPEC 编号 | SPEC-4 |
|
| 8 |
+
| 关联 PRD | `prompts/0426/prd/prd-4-playwright-hardening.md` |
|
| 9 |
+
| 关联 Issue | #5(待用户补样本) |
|
| 10 |
+
| 类型 | hardening only(纯防御性,不动业务逻辑) |
|
| 11 |
+
| 状态 | DRAFT-IMPLEMENTABLE |
|
| 12 |
+
| 主笔 | prd-playwright |
|
| 13 |
+
| 创建日期 | 2026-04-26 |
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 1. 文件级修改清单
|
| 18 |
+
|
| 19 |
+
| 序号 | 路径 | 动作 | 说明 |
|
| 20 |
+
|---|---|---|---|
|
| 21 |
+
| 1 | `src/autoteam/_playwright_guard.py` | **新增** | 白名单常量 + `assert_sync_context()` + 豁免 marker 常量 |
|
| 22 |
+
| 2 | `src/autoteam/api.py` | **修改** | `_PlaywrightExecutor.run_with_timeout` + `_worker` 双入口插入 guard(锚点参考研究 §B.2.5,定位行 173-227) |
|
| 23 |
+
| 3 | `src/autoteam/manager.py` | **修改** | 函数内 import 上提至模块顶层(第 1491 行 + 第 1969 行) |
|
| 24 |
+
| 4 | `tests/static/__init__.py` | **新增** | 空文件,确保 pytest 能 collect |
|
| 25 |
+
| 5 | `tests/static/test_playwright_hygiene.py` | **新增** | 3 个 AST 测试 + 1 个豁免列表测试 |
|
| 26 |
+
| 6 | `tests/static/test_playwright_hygiene_negative.py` | **新增** | 反例验证(参见 §7) |
|
| 27 |
+
| 7 | `tests/unit/test_playwright_guard.py` | **新增** | runtime guard 行为测试(asyncio loop 内/外) |
|
| 28 |
+
| 8 | `pyproject.toml` 或 `pytest.ini` | **修改** | `testpaths` 加入 `tests/static`、`tests/unit` |
|
| 29 |
+
| 9 | `CONTRIBUTING.md` | **可选** | 若已存在,追加"Playwright 使用守则"5 行;不存在则跳过 |
|
| 30 |
+
|
| 31 |
+
**严禁动**:`chatgpt_api.py`、`codex_auth.py`、`invite.py`、`api.py:_PlaywrightExecutor` 之外的代码、所有 `with sync_playwright() as p:` 块本体。
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## 2. import 白名单常量
|
| 36 |
+
|
| 37 |
+
落地于 `src/autoteam/_playwright_guard.py`,作为**单一信源**(SSOT),所有静态测试与运行时 guard 共用:
|
| 38 |
+
|
| 39 |
+
```python
|
| 40 |
+
# src/autoteam/_playwright_guard.py
|
| 41 |
+
"""Playwright 一致性硬化(SPEC-4)。
|
| 42 |
+
本模块提供白名单常量与运行时守卫,禁止 sync_playwright 进入 asyncio loop。
|
| 43 |
+
"""
|
| 44 |
+
from __future__ import annotations
|
| 45 |
+
|
| 46 |
+
# 9 个允许从 playwright.sync_api 导入的符号
|
| 47 |
+
ALLOWED_SYNC_NAMES: frozenset[str] = frozenset({
|
| 48 |
+
"sync_playwright", # 上下文管理器
|
| 49 |
+
"Playwright", # 顶层句柄类型
|
| 50 |
+
"Browser", # 浏览器实例类型
|
| 51 |
+
"BrowserContext", # 浏览器上下文
|
| 52 |
+
"BrowserType", # chromium/firefox/webkit 类型
|
| 53 |
+
"Page", # 页面句柄
|
| 54 |
+
"Locator", # 定位器
|
| 55 |
+
"Error", # Playwright 通用异常
|
| 56 |
+
"TimeoutError", # Playwright 超时异常
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
# 1 个 typeshed 类型(仅在 if TYPE_CHECKING 块内允许)
|
| 60 |
+
TYPE_CHECKING_ONLY: frozenset[str] = frozenset({
|
| 61 |
+
"ElementHandle", # 仅类型注解使用,运行时禁止
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
# 任何 from <X> import * 形式的禁止模块
|
| 65 |
+
FORBIDDEN_MODULES: frozenset[str] = frozenset({
|
| 66 |
+
"playwright.async_api",
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
# per-file 豁免 marker,文件首 5 行内出现即整文件跳过守卫
|
| 70 |
+
EXEMPTION_MARKER: str = "autoteam: allow-async-playwright"
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## 3. `_PlaywrightExecutor` guard 完整代码
|
| 76 |
+
|
| 77 |
+
`assert_sync_context()` 实现(同样在 `_playwright_guard.py` 内)+ `api.py` 调用点 patch。
|
| 78 |
+
|
| 79 |
+
### 3.1 guard 函数
|
| 80 |
+
|
| 81 |
+
```python
|
| 82 |
+
# src/autoteam/_playwright_guard.py(续)
|
| 83 |
+
import asyncio
|
| 84 |
+
import logging
|
| 85 |
+
import threading
|
| 86 |
+
|
| 87 |
+
logger = logging.getLogger(__name__)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def assert_sync_context() -> None:
|
| 91 |
+
"""断言当前线程不在 asyncio loop 中。
|
| 92 |
+
仅用于 sync_playwright 调用前的前置检查。
|
| 93 |
+
若检测到 loop,立即抛 RuntimeError(中文堆栈 + thread/loop 标识)。
|
| 94 |
+
"""
|
| 95 |
+
try:
|
| 96 |
+
loop = asyncio.get_running_loop()
|
| 97 |
+
except RuntimeError:
|
| 98 |
+
return # 期望路径:无 loop
|
| 99 |
+
raise RuntimeError(
|
| 100 |
+
f"[PlaywrightGuard] thread={threading.current_thread().name!r} "
|
| 101 |
+
f"loop_id=0x{id(loop):x} — sync_playwright 不允许在 asyncio loop 中调用。"
|
| 102 |
+
f"详见 PRD-4 §12 R1 opt-out 流程。"
|
| 103 |
+
)
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
### 3.2 `api.py` 双入口 patch(diff 形式)
|
| 107 |
+
|
| 108 |
+
```python
|
| 109 |
+
# api.py:155 区域(_PlaywrightExecutor 类内,run_with_timeout 入口)
|
| 110 |
+
class _PlaywrightExecutor:
|
| 111 |
+
def run_with_timeout(self, timeout, func, *args, **kwargs):
|
| 112 |
+
+ from autoteam._playwright_guard import assert_sync_context
|
| 113 |
+
+ assert_sync_context() # SPEC-4 §3:主线程前置检查
|
| 114 |
+
result_event = threading.Event()
|
| 115 |
+
result_holder: dict = {}
|
| 116 |
+
self._queue.put((func, args, kwargs, result_event, result_holder))
|
| 117 |
+
...
|
| 118 |
+
|
| 119 |
+
def _worker(self):
|
| 120 |
+
+ from autoteam._playwright_guard import assert_sync_context
|
| 121 |
+
+ assert_sync_context() # SPEC-4 §3:worker 启动检查
|
| 122 |
+
while True:
|
| 123 |
+
item = self._queue.get()
|
| 124 |
+
if item is None:
|
| 125 |
+
break
|
| 126 |
+
...
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
**双入口必要性**:`run_with_timeout` 在调用方线程检查(可能是 anyio threadpool worker),`_worker` 在专用线程启动时再检查一遍 — 防止某次重构里把 worker 的 `target=` 改成在 loop 内启的协程。
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## 4. AST 守卫单测完整代码
|
| 134 |
+
|
| 135 |
+
### 4.1 主测试文件(可粘贴运行)
|
| 136 |
+
|
| 137 |
+
```python
|
| 138 |
+
# tests/static/test_playwright_hygiene.py
|
| 139 |
+
"""SPEC-4 §4:Playwright import + async/sync 分离静态守卫。
|
| 140 |
+
通过 AST 扫描 src/autoteam/**/*.py,断言:
|
| 141 |
+
- 不出现 from playwright.async_api ...
|
| 142 |
+
- async def 函数体内不引用任何 playwright 符号
|
| 143 |
+
- playwright.sync_api 导入仅限白名单
|
| 144 |
+
"""
|
| 145 |
+
from __future__ import annotations
|
| 146 |
+
|
| 147 |
+
import ast
|
| 148 |
+
from pathlib import Path
|
| 149 |
+
|
| 150 |
+
import pytest
|
| 151 |
+
|
| 152 |
+
from autoteam._playwright_guard import (
|
| 153 |
+
ALLOWED_SYNC_NAMES,
|
| 154 |
+
EXEMPTION_MARKER,
|
| 155 |
+
FORBIDDEN_MODULES,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
SRC_ROOT = Path(__file__).resolve().parents[2] / "src" / "autoteam"
|
| 159 |
+
PLAYWRIGHT_NAMESPACE = ALLOWED_SYNC_NAMES | {"async_playwright"}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _is_exempt(path: Path) -> bool:
|
| 163 |
+
"""文件首 5 行内出现 EXEMPTION_MARKER 即视为豁免。"""
|
| 164 |
+
try:
|
| 165 |
+
head = "\n".join(path.read_text(encoding="utf-8").splitlines()[:5])
|
| 166 |
+
except OSError:
|
| 167 |
+
return False
|
| 168 |
+
return EXEMPTION_MARKER in head
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _iter_py_files():
|
| 172 |
+
for path in SRC_ROOT.rglob("*.py"):
|
| 173 |
+
if not _is_exempt(path):
|
| 174 |
+
yield path
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _parse(path: Path) -> ast.AST:
|
| 178 |
+
return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_no_forbidden_module_import():
|
| 182 |
+
"""A1+A2: 禁止 from playwright.async_api / import playwright.async_api"""
|
| 183 |
+
bad: list[str] = []
|
| 184 |
+
for path in _iter_py_files():
|
| 185 |
+
tree = _parse(path)
|
| 186 |
+
for node in ast.walk(tree):
|
| 187 |
+
if isinstance(node, ast.ImportFrom) and node.module in FORBIDDEN_MODULES:
|
| 188 |
+
bad.append(f"{path}:{node.lineno} from {node.module}")
|
| 189 |
+
elif isinstance(node, ast.Import):
|
| 190 |
+
for alias in node.names:
|
| 191 |
+
if alias.name in FORBIDDEN_MODULES:
|
| 192 |
+
bad.append(f"{path}:{node.lineno} import {alias.name}")
|
| 193 |
+
assert not bad, "禁止使用 playwright.async_api:\n" + "\n".join(bad)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def test_no_playwright_in_async_def():
|
| 197 |
+
"""A3: async def 函数体内不允许 playwright 符号"""
|
| 198 |
+
bad: list[str] = []
|
| 199 |
+
for path in _iter_py_files():
|
| 200 |
+
tree = _parse(path)
|
| 201 |
+
for fn in ast.walk(tree):
|
| 202 |
+
if not isinstance(fn, ast.AsyncFunctionDef):
|
| 203 |
+
continue
|
| 204 |
+
for child in ast.walk(fn):
|
| 205 |
+
name = getattr(child, "id", None) or getattr(child, "attr", None)
|
| 206 |
+
if name in PLAYWRIGHT_NAMESPACE:
|
| 207 |
+
bad.append(
|
| 208 |
+
f"{path}:{getattr(child, 'lineno', fn.lineno)} "
|
| 209 |
+
f"async def {fn.name} 内引用了 {name}"
|
| 210 |
+
)
|
| 211 |
+
assert not bad, "async def 函数体内禁止 playwright:\n" + "\n".join(bad)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def test_sync_import_whitelist():
|
| 215 |
+
"""A4: playwright.sync_api 导入仅限 ALLOWED_SYNC_NAMES"""
|
| 216 |
+
bad: list[str] = []
|
| 217 |
+
for path in _iter_py_files():
|
| 218 |
+
tree = _parse(path)
|
| 219 |
+
for node in ast.walk(tree):
|
| 220 |
+
if isinstance(node, ast.ImportFrom) and node.module == "playwright.sync_api":
|
| 221 |
+
for alias in node.names:
|
| 222 |
+
if alias.name not in ALLOWED_SYNC_NAMES:
|
| 223 |
+
bad.append(f"{path}:{node.lineno} 非白名单符号 {alias.name}")
|
| 224 |
+
assert not bad, "sync_api 导入不在白名单:\n" + "\n".join(bad)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_exemption_list_intentional():
|
| 228 |
+
"""SPEC-4 §5: 豁免文件需登记理由,避免静默扩散"""
|
| 229 |
+
exempt = [p for p in SRC_ROOT.rglob("*.py") if _is_exempt(p)]
|
| 230 |
+
# 当前期望 0 个文件豁免;若未来需要 opt-out,在此处 explicit allow
|
| 231 |
+
expected_exempt: set[str] = set()
|
| 232 |
+
actual = {p.relative_to(SRC_ROOT).as_posix() for p in exempt}
|
| 233 |
+
unexpected = actual - expected_exempt
|
| 234 |
+
assert not unexpected, (
|
| 235 |
+
f"未登记的豁免文件:{unexpected}。"
|
| 236 |
+
f"如需新增,先更新 SPEC-4 §5 + 本测试 expected_exempt 集合"
|
| 237 |
+
)
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
### 4.2 runtime guard 单测
|
| 241 |
+
|
| 242 |
+
```python
|
| 243 |
+
# tests/unit/test_playwright_guard.py
|
| 244 |
+
"""SPEC-4 §3:assert_sync_context 行为验证"""
|
| 245 |
+
import asyncio
|
| 246 |
+
import threading
|
| 247 |
+
|
| 248 |
+
import pytest
|
| 249 |
+
|
| 250 |
+
from autoteam._playwright_guard import assert_sync_context
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def test_passes_in_plain_thread():
|
| 254 |
+
"""普通线程无 loop,guard 必须放行(不抛异常)"""
|
| 255 |
+
holder: dict = {}
|
| 256 |
+
|
| 257 |
+
def worker():
|
| 258 |
+
try:
|
| 259 |
+
assert_sync_context()
|
| 260 |
+
holder["ok"] = True
|
| 261 |
+
except Exception as e:
|
| 262 |
+
holder["err"] = e
|
| 263 |
+
|
| 264 |
+
t = threading.Thread(target=worker, name="test-plain")
|
| 265 |
+
t.start()
|
| 266 |
+
t.join(timeout=2.0)
|
| 267 |
+
assert holder.get("ok") is True
|
| 268 |
+
assert "err" not in holder
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@pytest.mark.asyncio
|
| 272 |
+
async def test_blocks_in_asyncio_loop():
|
| 273 |
+
"""asyncio loop 内调用必须抛 RuntimeError"""
|
| 274 |
+
with pytest.raises(RuntimeError, match=r"\[PlaywrightGuard\].*asyncio loop"):
|
| 275 |
+
assert_sync_context()
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def test_message_contains_thread_and_loop_id():
|
| 279 |
+
"""异常消息应携带 thread 名 + loop id 便于诊断"""
|
| 280 |
+
async def runner():
|
| 281 |
+
try:
|
| 282 |
+
assert_sync_context()
|
| 283 |
+
except RuntimeError as e:
|
| 284 |
+
return str(e)
|
| 285 |
+
return None
|
| 286 |
+
|
| 287 |
+
msg = asyncio.run(runner())
|
| 288 |
+
assert msg is not None
|
| 289 |
+
assert "thread=" in msg
|
| 290 |
+
assert "loop_id=0x" in msg
|
| 291 |
+
```
|
| 292 |
+
|
| 293 |
+
---
|
| 294 |
+
|
| 295 |
+
## 5. per-file 豁免机制
|
| 296 |
+
|
| 297 |
+
### 5.1 豁免格式(注释式 marker)
|
| 298 |
+
|
| 299 |
+
需要使用 `playwright.async_api` 的文件,**必须**在文件首 5 行内出现:
|
| 300 |
+
|
| 301 |
+
```python
|
| 302 |
+
# autoteam: allow-async-playwright reason="<具体原因,如 PR#NN 引入并发抓取>"
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
`reason="..."` 字段是**强制的**,无 reason 视为未豁免(`_is_exempt` 仅检测 marker 字面量,但 review 时人工把关 reason 完整性)。
|
| 306 |
+
|
| 307 |
+
### 5.2 豁免登记表
|
| 308 |
+
|
| 309 |
+
`tests/static/test_playwright_hygiene.py::test_exemption_list_intentional` 维护 `expected_exempt: set[str]`,新增豁免**必须同步更新**该集合,否则测试 fail。
|
| 310 |
+
|
| 311 |
+
当前期望值:`expected_exempt = set()` — **0** 个文件豁免。
|
| 312 |
+
|
| 313 |
+
### 5.3 豁免审批流程
|
| 314 |
+
|
| 315 |
+
1. 提案 PR 在该文件首加 marker
|
| 316 |
+
2. 在 `expected_exempt` 集合追加文件相对路径(`as_posix()` 形式)
|
| 317 |
+
3. PR 描述里链接到本 SPEC §5,说明 reason 与 review 决议
|
| 318 |
+
4. 至少 2 名维护者批准,理由不可"开发方便"或"暂时绕过"
|
| 319 |
+
|
| 320 |
+
---
|
| 321 |
+
|
| 322 |
+
## 6. CI 集成(pytest collect 配置)
|
| 323 |
+
|
| 324 |
+
### 6.1 `pyproject.toml`(若使用)
|
| 325 |
+
|
| 326 |
+
```toml
|
| 327 |
+
[tool.pytest.ini_options]
|
| 328 |
+
testpaths = [
|
| 329 |
+
"tests/static", # SPEC-4 静态守卫
|
| 330 |
+
"tests/unit", # SPEC-4 runtime guard + 项目既有单测
|
| 331 |
+
"tests/integration",
|
| 332 |
+
]
|
| 333 |
+
addopts = "-ra -q --strict-markers"
|
| 334 |
+
asyncio_mode = "auto" # 启用 pytest-asyncio,服务 §4.2
|
| 335 |
+
markers = [
|
| 336 |
+
"static: SPEC-4 AST 守卫,不可 deselect",
|
| 337 |
+
]
|
| 338 |
+
```
|
| 339 |
+
|
| 340 |
+
### 6.2 `pytest.ini`(若使用,二选一)
|
| 341 |
+
|
| 342 |
+
```ini
|
| 343 |
+
[pytest]
|
| 344 |
+
testpaths = tests/static tests/unit tests/integration
|
| 345 |
+
addopts = -ra -q --strict-markers
|
| 346 |
+
asyncio_mode = auto
|
| 347 |
+
```
|
| 348 |
+
|
| 349 |
+
### 6.3 GitHub Actions / 本地预提交
|
| 350 |
+
|
| 351 |
+
```yaml
|
| 352 |
+
# .github/workflows/test.yml(片段)
|
| 353 |
+
- name: Static Playwright hygiene
|
| 354 |
+
run: pytest tests/static -q
|
| 355 |
+
- name: Unit (incl. playwright guard)
|
| 356 |
+
run: pytest tests/unit -q
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
CI 必须**单独**跑 `tests/static`,即便 unit 失败也要拿到静态结果(便于诊断)。
|
| 360 |
+
|
| 361 |
+
---
|
| 362 |
+
|
| 363 |
+
## 7. 反例验证(测试守卫真起作用)
|
| 364 |
+
|
| 365 |
+
`tests/static/test_playwright_hygiene_negative.py` — 以**临时文件**方式构造违例,验证 3 个测试函数确实 fail。该文件**不进 CI**,作为 SPEC-4 实施期的一次性手测。
|
| 366 |
+
|
| 367 |
+
```python
|
| 368 |
+
# tests/static/test_playwright_hygiene_negative.py
|
| 369 |
+
"""手测脚本:验证 SPEC-4 §4 的守卫能正确报错。
|
| 370 |
+
运行方式: pytest tests/static/test_playwright_hygiene_negative.py -q
|
| 371 |
+
默认 skip,需 SPEC4_NEGATIVE=1 环境变量启用。
|
| 372 |
+
"""
|
| 373 |
+
from __future__ import annotations
|
| 374 |
+
|
| 375 |
+
import os
|
| 376 |
+
import textwrap
|
| 377 |
+
from pathlib import Path
|
| 378 |
+
|
| 379 |
+
import pytest
|
| 380 |
+
|
| 381 |
+
SKIP_REASON = "set SPEC4_NEGATIVE=1 to run negative samples"
|
| 382 |
+
ENABLED = os.getenv("SPEC4_NEGATIVE") == "1"
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
@pytest.mark.skipif(not ENABLED, reason=SKIP_REASON)
|
| 386 |
+
def test_async_def_with_sync_playwright_should_fail(tmp_path: Path, monkeypatch):
|
| 387 |
+
"""注入一段 async def + sync_playwright 的违规代码,期望主测试 fail"""
|
| 388 |
+
bad_module = tmp_path / "bad.py"
|
| 389 |
+
bad_module.write_text(textwrap.dedent("""\
|
| 390 |
+
from playwright.sync_api import sync_playwright
|
| 391 |
+
|
| 392 |
+
async def evil_handler():
|
| 393 |
+
# 这里把 sync_playwright 拖进了协程,正是我们要拦的反模式
|
| 394 |
+
with sync_playwright() as p:
|
| 395 |
+
p.chromium.launch()
|
| 396 |
+
"""), encoding="utf-8")
|
| 397 |
+
monkeypatch.setenv("SPEC4_SRC_OVERRIDE", str(tmp_path))
|
| 398 |
+
# 用例期望:在主测试套(切换 SRC_ROOT 至 tmp_path 后)运行
|
| 399 |
+
# test_no_playwright_in_async_def 应抛 AssertionError
|
| 400 |
+
# 实施时:由 implementor 通过将 SRC_ROOT 改为 env-driven 完成此切换
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
@pytest.mark.skipif(not ENABLED, reason=SKIP_REASON)
|
| 404 |
+
def test_async_api_import_should_fail(tmp_path: Path):
|
| 405 |
+
bad = tmp_path / "evil.py"
|
| 406 |
+
bad.write_text(
|
| 407 |
+
"from playwright.async_api import async_playwright\n",
|
| 408 |
+
encoding="utf-8",
|
| 409 |
+
)
|
| 410 |
+
# 期望:test_no_forbidden_module_import 在 SRC_ROOT=tmp_path 时 fail
|
| 411 |
+
```
|
| 412 |
+
|
| 413 |
+
> **注**:为保持主测文件无副作用,反例验证通过 `SPEC4_NEGATIVE=1` 与 `SPEC4_SRC_OVERRIDE` 双 env 触发;implementor 落地时需同步在 `_iter_py_files` / `SRC_ROOT` 加上 env 读取逻辑(`os.getenv("SPEC4_SRC_OVERRIDE", str(default))`),**不影响默认 CI 路径**。
|
| 414 |
+
|
| 415 |
+
---
|
| 416 |
+
|
| 417 |
+
## 8. 实施顺序(implementor 工序)
|
| 418 |
+
|
| 419 |
+
| Step | 操作 | 验证 |
|
| 420 |
+
|---|---|---|
|
| 421 |
+
| 1 | 新建 `src/autoteam/_playwright_guard.py`(§2 + §3.1) | `python -c "from autoteam._playwright_guard import assert_sync_context"` 无报错 |
|
| 422 |
+
| 2 | 在 `api.py:_PlaywrightExecutor` 双入口插入 `assert_sync_context()`(§3.2) | 既有 invite/register 集成测试全绿 |
|
| 423 |
+
| 3 | 上提 `manager.py:1491/1969` 函数内 import 至模块顶层 | `python -c "import autoteam.manager"` 无循环 import |
|
| 424 |
+
| 4 | 新建 `tests/static/__init__.py` + `test_playwright_hygiene.py`(§4.1) | `pytest tests/static -q` 全绿(因为现状 0 违规) |
|
| 425 |
+
| 5 | 新建 `tests/unit/test_playwright_guard.py`(§4.2) | `pytest tests/unit/test_playwright_guard.py -q` 全绿 |
|
| 426 |
+
| 6 | 更新 `pyproject.toml` / `pytest.ini`(§6) | `pytest --collect-only` 包含上述测试 |
|
| 427 |
+
| 7 | 跑全套 `pytest -q` | 0 fail,0 error |
|
| 428 |
+
| 8 | 反例手测(§7,可选) | 注入违规后 §4.1 三测必 fail |
|
| 429 |
+
|
| 430 |
+
**任一 step 失败必须停止,不允许跳过到下一步。**
|
| 431 |
+
|
| 432 |
+
---
|
| 433 |
+
|
| 434 |
+
## 9. 验收清单
|
| 435 |
+
|
| 436 |
+
- [ ] `_playwright_guard.py` 新增,`ALLOWED_SYNC_NAMES` 包含恰好 9 个符号
|
| 437 |
+
- [ ] `api.py` 在 `run_with_timeout` 入口调用 `assert_sync_context()`
|
| 438 |
+
- [ ] `api.py` 在 `_worker` 入口调用 `assert_sync_context()`
|
| 439 |
+
- [ ] `manager.py` grep `from playwright` 仅命中模块顶层 1 处(或保留豁免并登记理由)
|
| 440 |
+
- [ ] `tests/static/test_playwright_hygiene.py` 4 个测试全绿
|
| 441 |
+
- [ ] `tests/unit/test_playwright_guard.py` 3 个测试全绿
|
| 442 |
+
- [ ] `pyproject.toml` / `pytest.ini` `testpaths` 包含 `tests/static`
|
| 443 |
+
- [ ] 既有 invite / register / sync 集成测试 0 回归
|
| 444 |
+
- [ ] PR 描述链接 PRD-4 + SPEC-4,勾选"未引入 async playwright"
|
| 445 |
+
|
| 446 |
+
---
|
| 447 |
+
|
| 448 |
+
**附:与 PRD-4 §条款 → SPEC-4 §条款映射**
|
| 449 |
+
|
| 450 |
+
| PRD § | SPEC § | 实施粒度 |
|
| 451 |
+
|---|---|---|
|
| 452 |
+
| §5.1 import 白名单 | §2 + §4.1 | 9 个符号常量 + AST 测试 |
|
| 453 |
+
| §5.2 runtime guard | §3 | 双入口 patch + 异常消息规范 |
|
| 454 |
+
| §5.3 AST 静态守卫 | §4.1 | 3 个测试 + 1 个豁免登记 |
|
| 455 |
+
| §5.4 CI 集成 | §6 | testpaths + asyncio_mode |
|
| 456 |
+
| §7.3 AST 示例 | §4.1 | 已上升为 production-ready |
|
| 457 |
+
| §12 R1 opt-out | §5 | marker + expected_exempt |
|
| 458 |
+
| §13 Q1(单点 vs 双入口) | §3.2 | 决议:双入口(主线程 + worker) |
|
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 调研综合分析(Synthesis)
|
| 2 |
+
|
| 3 |
+
> 时间:2026-04-26
|
| 4 |
+
> 输入:`prompts/0426/research/issue-{1..6}-*.md` 共 6 份调研报告
|
| 5 |
+
> 输出:跨 issue 共因识别、PRD 切分方案、依赖图、优先级
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. 跨 Issue 共因(实施时必须统一)
|
| 10 |
+
|
| 11 |
+
| 共因点 | 涉及 Issue | 文件:行号 | 一句话 |
|
| 12 |
+
|---|---|---|---|
|
| 13 |
+
| **A. plan_type 白名单** | #2 + #6 | `codex_auth.py:111`,`manager.py:1463/2489`,`manual_account.py:233` | `self_serve_business_usage_based` 等新值无识别,通通走 codex 但实际无配额 |
|
| 14 |
+
| **B. wham/usage 识别 quota=0** | #2 + #6 | `codex_auth.py:1582-1700` | `used_percent=0 + total=0` 当 ok 处理,UI 显示"100% 剩余"实际 429 |
|
| 15 |
+
| **C. login_codex_via_browser 接 add-phone 探针** | #4 + #6 | `codex_auth.py:250-932` | invite.py 的 `detect_phone_verification` 没复用,撞 add-phone 静默 30s 超时 |
|
| 16 |
+
| **D. _run_post_register_oauth 加 quota probe** | #6 | `manager.py:1463-1486` | 注册收尾不验配额,假入池 |
|
| 17 |
+
| **E. sync_account_states 区分"被踢" vs "待机"** | #6 | `manager.py:526-541` | 都被无脑标 STANDBY,被踢号反复死循环 |
|
| 18 |
+
|
| 19 |
+
**实施纪律**:A/B/C/D/E 必须由同一 PR 处理,否则会出现"修了 D 没修 B,quota probe 仍然不识别 quota=0"这类半截修复。
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## 2. PRD 切分方案(4 + 1)
|
| 24 |
+
|
| 25 |
+
### PRD-1 — Mail Provider 全量化(Issue#1 独立)
|
| 26 |
+
- **范围**:SetupConfig 扩字段、cf_temp_email 嗅探收紧、`/api/mail-provider/probe` 新端点、前端归属验证 4 步流、6 个文档改写
|
| 27 |
+
- **不依赖其他 PRD**,可单独实施
|
| 28 |
+
- **风险**:UI 变动大、跨域代理设计需小心
|
| 29 |
+
|
| 30 |
+
### PRD-2 — 账号生命周期与配额(Issue#2 + #4 + #6 合并)
|
| 31 |
+
- **范围**:plan_type 白名单 + wham/usage no_quota 分类 + add-phone 探针 + _run_post_register_oauth probe + sync_account_states 区分被踢 + reinvite 兜底
|
| 32 |
+
- **PREFERRED_SEAT_TYPE 配置**(独属 #2)+ **personal 删除解耦**(独属 #2)
|
| 33 |
+
- **关键**:5 个共因点必须同 PR,否则半截
|
| 34 |
+
- **风险**:扩散到 manager/codex_auth/account_ops/api,需充分回归测试
|
| 35 |
+
|
| 36 |
+
### PRD-3 — Docker 镜像守卫(Issue#3 收尾)
|
| 37 |
+
- **范围**:entrypoint self-check + lint 守卫 + 镜像 git-sha 标签 + docs/docker.md 增加 rebuild SOP
|
| 38 |
+
- **代码层 cf2f7d3 已修**,这是防回归 + 用户操作指引
|
| 39 |
+
- **风险**:低
|
| 40 |
+
|
| 41 |
+
### PRD-4 — Playwright 一致性(Issue#5 待定)
|
| 42 |
+
- **状态**:研究反转 — 全项目 0 处实际误用
|
| 43 |
+
- **行动**:需向用户索取具体报错堆栈或样本;在拿到样本前,**仅落地"可选优化"**(顶层 import 统一 + asyncio 运行时 guard + AST 静态守卫单测)
|
| 44 |
+
- **风险**:用户可能描述的是其他问题,不要凭空重构
|
| 45 |
+
|
| 46 |
+
### 增量 — UI 删除 toast / OAuth 模式选择(可选,后置)
|
| 47 |
+
- 来自 #2 + #6 的 D.2 可选项,P1
|
| 48 |
+
- 单独 PR,不阻塞主线
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## 3. 实施依赖图
|
| 53 |
+
|
| 54 |
+
```
|
| 55 |
+
PRD-1 (Mail Provider) ────────────┐
|
| 56 |
+
├─→ 集成测试(各自独立可并行)
|
| 57 |
+
PRD-2 (Lifecycle) ────────────────┤
|
| 58 |
+
│
|
| 59 |
+
PRD-3 (Docker Guard) ─────────────┤
|
| 60 |
+
│
|
| 61 |
+
PRD-4 (Playwright) [aw user] ─────┘
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
PRD-1 / PRD-2 / PRD-3 三个互不依赖,可并行实施。
|
| 65 |
+
PRD-4 阻塞在用户答复。
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## 4. 优先级
|
| 70 |
+
|
| 71 |
+
| Rank | PRD | 价值 | 风险 |
|
| 72 |
+
|---|---|---|---|
|
| 73 |
+
| **P0** | PRD-2 (生命周期) | 高 — 解决 3 个 issue 的核心 bug,直接改善可用率 | 高 — 跨多文件 |
|
| 74 |
+
| **P0** | PRD-1 (Mail Provider) | 高 — 401 阻塞用户 setup,且补齐缺失功能 | 中 — UI 改动 |
|
| 75 |
+
| **P1** | PRD-3 (Docker) | 中 — 防回归 + 用户操作友好 | 低 |
|
| 76 |
+
| **P2** | PRD-4 (Playwright) | 不确定 — 待用户澄清 | 低 |
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## 5. 共享 spec(横切关注点)
|
| 81 |
+
|
| 82 |
+
以下抽象应在 spec 阶段抽出独立文档,供多个 PRD 引用:
|
| 83 |
+
|
| 84 |
+
- `spec/shared/plan-type-whitelist.md`:`SUPPORTED_PLAN_TYPES` 常量、判定函数、所有调用点
|
| 85 |
+
- `spec/shared/quota-classification.md`:wham/usage 4+1 分类(ok/exhausted/no_quota/auth_error/network_error)、no_quota 触发条件、上游处理
|
| 86 |
+
- `spec/shared/add-phone-detection.md`:`detect_phone_verification` 复用契约、OAuth 模式定制探针、错误归类
|
| 87 |
+
- `spec/shared/account-state-machine.md`:7 状态(active/exhausted/standby/pending/personal/auth_invalid/orphan)的转换规则、被踢识别规则
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
## 6. 测试矩阵
|
| 92 |
+
|
| 93 |
+
| 测试类型 | 范围 | 工具 |
|
| 94 |
+
|---|---|---|
|
| 95 |
+
| 单元测试 | 共因函数 (plan_type/quota/add-phone) | pytest |
|
| 96 |
+
| 集成测试 | _run_post_register_oauth 全链路 | pytest + httpx mock |
|
| 97 |
+
| 回归测试 | invite/reinvite/sync_account_states 现有路径 | 既有测试套件 |
|
| 98 |
+
| E2E | mail provider 切换 + 归属验证 | playwright |
|
| 99 |
+
| 手测 | docker rebuild 流程、UI 删除 toast、OAuth 模式选择 | 用户参与 |
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## 7. 已知未决问题(送 PRD 阶段决议)
|
| 104 |
+
|
| 105 |
+
1. **wham/usage schema** — `limit/total` 字段是否真存在?需要从用户 `auths/codex-*.json` 实测样本
|
| 106 |
+
2. **`self_serve_business_usage_based` 字面量** — 用户报告的字面值需 grep 用户实际 bundle 确认
|
| 107 |
+
3. **PATCH 升级失败率** — 改默认席位策略前应有数据
|
| 108 |
+
4. **add-phone 探针误报** — OAuth 域(auth.openai.com)的 detect 规则可能需要定制
|
| 109 |
+
5. **PRD-4 用户澄清** — 需要给出具体 async/sync 报错样本
|
|
@@ -31,4 +31,33 @@ dev = [
|
|
| 31 |
|
| 32 |
[tool.pytest.ini_options]
|
| 33 |
addopts = "-ra"
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
[tool.pytest.ini_options]
|
| 33 |
addopts = "-ra"
|
| 34 |
+
# tests/static 是 SPEC-4 AST 守卫所在地,显式列入避免被未来 collect-only 误排除
|
| 35 |
+
testpaths = ["tests", "tests/static", "tests/unit", "tests/integration"]
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Ruff lint (SPEC-3 §6) - 仅启 F401/F811/F821 三条零误报规则
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
[tool.ruff]
|
| 41 |
+
target-version = "py310"
|
| 42 |
+
line-length = 120
|
| 43 |
+
src = ["src", "tests"]
|
| 44 |
+
|
| 45 |
+
[tool.ruff.lint]
|
| 46 |
+
# F401: imported but unused (死 import)
|
| 47 |
+
# F811: redefinition of unused (重复定义)
|
| 48 |
+
# F821: undefined name (核心 — 抓 typo 类 ImportError)
|
| 49 |
+
select = ["F401", "F811", "F821"]
|
| 50 |
+
|
| 51 |
+
# 测试 / 文档 / 脚本豁免(可能含 docstring 示例 / 故意未定义符号)
|
| 52 |
+
exclude = [
|
| 53 |
+
"tests/",
|
| 54 |
+
"docs/",
|
| 55 |
+
"scripts/",
|
| 56 |
+
".venv/",
|
| 57 |
+
"build/",
|
| 58 |
+
"dist/",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
[tool.ruff.lint.per-file-ignores]
|
| 62 |
+
# __init__.py 允许 re-export
|
| 63 |
+
"__init__.py" = ["F401"]
|
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Playwright 一致性硬化(SPEC-4)。
|
| 2 |
+
|
| 3 |
+
本模块提供白名单常量与运行时守卫,禁止 sync_playwright 进入 asyncio loop。
|
| 4 |
+
所有静态测试(tests/static/test_playwright_hygiene.py)与运行时 guard
|
| 5 |
+
(api.py:_PlaywrightExecutor)共享本文件作为单一信源(SSOT)。
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
import threading
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# 9 个允许从 playwright.sync_api 导入的符号
|
| 18 |
+
ALLOWED_SYNC_NAMES: frozenset[str] = frozenset({
|
| 19 |
+
"sync_playwright", # 上下文管理器
|
| 20 |
+
"Playwright", # 顶层句柄类型
|
| 21 |
+
"Browser", # 浏览器实例类型
|
| 22 |
+
"BrowserContext", # 浏览器上下文
|
| 23 |
+
"BrowserType", # chromium/firefox/webkit 类型
|
| 24 |
+
"Page", # 页面句柄
|
| 25 |
+
"Locator", # 定位器
|
| 26 |
+
"Error", # Playwright 通用异常
|
| 27 |
+
"TimeoutError", # Playwright 超时异常
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
# 1 个 typeshed 类型(仅在 if TYPE_CHECKING 块内允许)
|
| 31 |
+
TYPE_CHECKING_ONLY: frozenset[str] = frozenset({
|
| 32 |
+
"ElementHandle",
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
# 任何形式的 from <X> import / import <X> 禁止模块
|
| 36 |
+
FORBIDDEN_MODULES: frozenset[str] = frozenset({
|
| 37 |
+
"playwright.async_api",
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
# per-file 豁免 marker,文件首 5 行内出现即整文件跳过守卫
|
| 41 |
+
EXEMPTION_MARKER: str = "autoteam: allow-async-playwright"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def assert_sync_context() -> None:
|
| 45 |
+
"""断言当前线程不在 asyncio loop 中。
|
| 46 |
+
|
| 47 |
+
仅用于 sync_playwright 调用前的前置检查。若检测到 loop,立即抛
|
| 48 |
+
RuntimeError(消息含 thread/loop_id 标识,便于诊断)。
|
| 49 |
+
|
| 50 |
+
期望路径: 普通线程 / 专用 worker 线程 → asyncio.get_running_loop()
|
| 51 |
+
抛 RuntimeError → 无 loop → 放行。
|
| 52 |
+
"""
|
| 53 |
+
try:
|
| 54 |
+
loop = asyncio.get_running_loop()
|
| 55 |
+
except RuntimeError:
|
| 56 |
+
return
|
| 57 |
+
raise RuntimeError(
|
| 58 |
+
f"[PlaywrightGuard] thread={threading.current_thread().name!r} "
|
| 59 |
+
f"loop_id=0x{id(loop):x} — sync_playwright 不允许在 asyncio loop 中调用。"
|
| 60 |
+
f"详见 PRD-4 §12 R1 opt-out 流程。"
|
| 61 |
+
)
|
|
@@ -24,11 +24,37 @@ app = FastAPI(
|
|
| 24 |
version="0.1.0",
|
| 25 |
)
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
# ---------------------------------------------------------------------------
|
| 28 |
# API Key 鉴权中间件
|
| 29 |
# ---------------------------------------------------------------------------
|
| 30 |
|
| 31 |
-
_AUTH_SKIP_PATHS = {"/api/auth/check", "/api/setup/status", "/api/setup/save"}
|
| 32 |
|
| 33 |
|
| 34 |
@app.middleware("http")
|
|
@@ -169,6 +195,8 @@ MAX_TASK_HISTORY = 50
|
|
| 169 |
|
| 170 |
import queue as _queue
|
| 171 |
|
|
|
|
|
|
|
| 172 |
|
| 173 |
class _PlaywrightExecutor:
|
| 174 |
"""将 Playwright 操作派发到专用线程执行,避免跨线程错误"""
|
|
@@ -178,6 +206,8 @@ class _PlaywrightExecutor:
|
|
| 178 |
self._thread: threading.Thread | None = None
|
| 179 |
|
| 180 |
def _worker(self):
|
|
|
|
|
|
|
| 181 |
while True:
|
| 182 |
item = self._queue.get()
|
| 183 |
if item is None:
|
|
@@ -207,6 +237,8 @@ class _PlaywrightExecutor:
|
|
| 207 |
后续通过 _pw_executor 提交的调用会在队列里等它自然完成。调用方需要自己
|
| 208 |
确保不会越过 _playwright_lock 边界并发触发这种情况。
|
| 209 |
"""
|
|
|
|
|
|
|
| 210 |
self.ensure_started()
|
| 211 |
result_event = threading.Event()
|
| 212 |
result_holder: dict = {}
|
|
|
|
| 24 |
version="0.1.0",
|
| 25 |
)
|
| 26 |
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# 版本端点 (SPEC-3 §5) - 不鉴权,纯只读
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class VersionResponse(BaseModel):
|
| 34 |
+
"""镜像版本指纹响应 - 来自 Dockerfile build args。"""
|
| 35 |
+
|
| 36 |
+
git_sha: str
|
| 37 |
+
build_time: str
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@app.get(
|
| 41 |
+
"/api/version",
|
| 42 |
+
response_model=VersionResponse,
|
| 43 |
+
summary="返回镜像构建期注入的 git-sha 与时间戳",
|
| 44 |
+
tags=["meta"],
|
| 45 |
+
)
|
| 46 |
+
def api_version() -> VersionResponse:
|
| 47 |
+
return VersionResponse(
|
| 48 |
+
git_sha=os.getenv("AUTOTEAM_GIT_SHA", "unknown"),
|
| 49 |
+
build_time=os.getenv("AUTOTEAM_BUILD_TIME", "unknown"),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
# ---------------------------------------------------------------------------
|
| 54 |
# API Key 鉴权中间件
|
| 55 |
# ---------------------------------------------------------------------------
|
| 56 |
|
| 57 |
+
_AUTH_SKIP_PATHS = {"/api/auth/check", "/api/setup/status", "/api/setup/save", "/api/version"}
|
| 58 |
|
| 59 |
|
| 60 |
@app.middleware("http")
|
|
|
|
| 195 |
|
| 196 |
import queue as _queue
|
| 197 |
|
| 198 |
+
from autoteam._playwright_guard import assert_sync_context
|
| 199 |
+
|
| 200 |
|
| 201 |
class _PlaywrightExecutor:
|
| 202 |
"""将 Playwright 操作派发到专用线程执行,避免跨线程错误"""
|
|
|
|
| 206 |
self._thread: threading.Thread | None = None
|
| 207 |
|
| 208 |
def _worker(self):
|
| 209 |
+
# SPEC-4 §3.2: worker 线程入口必须在普通线程,不允许 asyncio loop
|
| 210 |
+
assert_sync_context()
|
| 211 |
while True:
|
| 212 |
item = self._queue.get()
|
| 213 |
if item is None:
|
|
|
|
| 237 |
后续通过 _pw_executor 提交的调用会在队列里等它自然完成。调用方需要自己
|
| 238 |
确保不会越过 _playwright_lock 边界并发触发这种情况。
|
| 239 |
"""
|
| 240 |
+
# SPEC-4 §3.2: 主线程前置检查,拦下 asyncio loop 误入
|
| 241 |
+
assert_sync_context()
|
| 242 |
self.ensure_started()
|
| 243 |
result_event = threading.Event()
|
| 244 |
result_holder: dict = {}
|
|
@@ -26,6 +26,8 @@ import sys
|
|
| 26 |
import time
|
| 27 |
from pathlib import Path
|
| 28 |
|
|
|
|
|
|
|
| 29 |
from autoteam.account_ops import delete_managed_account, fetch_team_state
|
| 30 |
from autoteam.accounts import (
|
| 31 |
STATUS_ACTIVE,
|
|
@@ -1488,8 +1490,6 @@ def _run_post_register_oauth(email, password, mail_client, leave_workspace=False
|
|
| 1488 |
|
| 1489 |
def _complete_registration(email, password, invite_link, mail_client, *, leave_workspace=False, out_outcome=None):
|
| 1490 |
"""完成注册 + Codex 登录(从已有邀请链接继续)。out_outcome 透传给 _run_post_register_oauth。"""
|
| 1491 |
-
from playwright.sync_api import sync_playwright
|
| 1492 |
-
|
| 1493 |
from autoteam.invite import register_with_invite
|
| 1494 |
|
| 1495 |
logger.info("[注册] 开始注册 %s...", email)
|
|
@@ -1966,8 +1966,6 @@ def _register_direct_once(mail_client, email, password, cloudmail_account_id=Non
|
|
| 1966 |
在邮箱/密码/验证码/about-you 四个提交节点调用 assert_not_blocked,
|
| 1967 |
一旦命中 add-phone / duplicate 就抛 RegisterBlocked,由 create_account_direct 分流处理。
|
| 1968 |
"""
|
| 1969 |
-
from playwright.sync_api import sync_playwright
|
| 1970 |
-
|
| 1971 |
from autoteam.invite import RegisterBlocked, assert_not_blocked
|
| 1972 |
|
| 1973 |
logger.info("[直接注册] %s", email)
|
|
|
|
| 26 |
import time
|
| 27 |
from pathlib import Path
|
| 28 |
|
| 29 |
+
from playwright.sync_api import sync_playwright
|
| 30 |
+
|
| 31 |
from autoteam.account_ops import delete_managed_account, fetch_team_state
|
| 32 |
from autoteam.accounts import (
|
| 33 |
STATUS_ACTIVE,
|
|
|
|
| 1490 |
|
| 1491 |
def _complete_registration(email, password, invite_link, mail_client, *, leave_workspace=False, out_outcome=None):
|
| 1492 |
"""完成注册 + Codex 登录(从已有邀请链接继续)。out_outcome 透传给 _run_post_register_oauth。"""
|
|
|
|
|
|
|
| 1493 |
from autoteam.invite import register_with_invite
|
| 1494 |
|
| 1495 |
logger.info("[注册] 开始注册 %s...", email)
|
|
|
|
| 1966 |
在邮箱/密码/验证码/about-you 四个提交节点调用 assert_not_blocked,
|
| 1967 |
一旦命中 add-phone / duplicate 就抛 RegisterBlocked,由 create_account_direct 分流处理。
|
| 1968 |
"""
|
|
|
|
|
|
|
| 1969 |
from autoteam.invite import RegisterBlocked, assert_not_blocked
|
| 1970 |
|
| 1971 |
logger.info("[直接注册] %s", email)
|
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SPEC-3 Docker 镜像守卫的非容器化集成测试。
|
| 2 |
+
|
| 3 |
+
覆盖以下 SPEC §9 场景中可在宿主机执行的部分:
|
| 4 |
+
- 9.1 步骤 3:`/api/version` 端点契约(JSON 字段、降级语义、免鉴权)
|
| 5 |
+
- 9.3 步骤 1-2:ruff F821 拦截 typo
|
| 6 |
+
- self-check 白名单 import 在宿主机环境也能跑通(等同于容器启动期检查)
|
| 7 |
+
|
| 8 |
+
宿主机不可达的部分(docker compose up / crash-loop / OCI label)走 shell SOP,
|
| 9 |
+
见 `docs/docker.md` 的"代码更新后的 rebuild SOP"章节。
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import shutil
|
| 16 |
+
import subprocess
|
| 17 |
+
import sys
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import pytest
|
| 21 |
+
from fastapi.testclient import TestClient
|
| 22 |
+
|
| 23 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# §9.1 /api/version 端点契约
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.fixture
|
| 32 |
+
def client() -> TestClient:
|
| 33 |
+
from autoteam.api import app
|
| 34 |
+
|
| 35 |
+
return TestClient(app)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_api_version_returns_two_fields(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
| 39 |
+
"""SPEC AC3: /api/version 必须返回 git_sha + build_time 双字段。"""
|
| 40 |
+
|
| 41 |
+
monkeypatch.setenv("AUTOTEAM_GIT_SHA", "cf2f7d3")
|
| 42 |
+
monkeypatch.setenv("AUTOTEAM_BUILD_TIME", "2026-04-26T03:00:00Z")
|
| 43 |
+
resp = client.get("/api/version")
|
| 44 |
+
assert resp.status_code == 200
|
| 45 |
+
body = resp.json()
|
| 46 |
+
assert set(body.keys()) == {"git_sha", "build_time"}
|
| 47 |
+
assert body["git_sha"] == "cf2f7d3"
|
| 48 |
+
assert body["build_time"] == "2026-04-26T03:00:00Z"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_api_version_falls_back_to_unknown(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
| 52 |
+
"""SPEC AC7: 不传 build-arg(env 未设)应降级返回 unknown,不报 500。"""
|
| 53 |
+
|
| 54 |
+
monkeypatch.delenv("AUTOTEAM_GIT_SHA", raising=False)
|
| 55 |
+
monkeypatch.delenv("AUTOTEAM_BUILD_TIME", raising=False)
|
| 56 |
+
resp = client.get("/api/version")
|
| 57 |
+
assert resp.status_code == 200
|
| 58 |
+
body = resp.json()
|
| 59 |
+
assert body == {"git_sha": "unknown", "build_time": "unknown"}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_api_version_is_unauthenticated(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
| 63 |
+
"""SPEC AC9: 即便配了 API_KEY,/api/version 也免鉴权直接返回 200。"""
|
| 64 |
+
|
| 65 |
+
# 模拟"已配 API_KEY 但请求不带 Bearer"
|
| 66 |
+
import autoteam.api as api_module
|
| 67 |
+
|
| 68 |
+
monkeypatch.setattr(api_module, "API_KEY", "fake-secret-do-not-leak")
|
| 69 |
+
resp = client.get("/api/version") # 故意不带 Authorization header
|
| 70 |
+
assert resp.status_code == 200, "version 端点应在 _AUTH_SKIP_PATHS 白名单中"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_api_version_in_auth_skip_paths() -> None:
|
| 74 |
+
"""额外契约:_AUTH_SKIP_PATHS 必须显式包含 /api/version。"""
|
| 75 |
+
|
| 76 |
+
from autoteam.api import _AUTH_SKIP_PATHS
|
| 77 |
+
|
| 78 |
+
assert "/api/version" in _AUTH_SKIP_PATHS
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# §9.3 lint 守卫:ruff F821 拦截 typo(同 entrypoint self-check 等价语义)
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@pytest.mark.skipif(
|
| 87 |
+
shutil.which("ruff") is None,
|
| 88 |
+
reason="本机 PATH 中未找到 ruff 二进制,跳过 lint 集成验证(CI 应直接装 ruff)",
|
| 89 |
+
)
|
| 90 |
+
def test_ruff_catches_undefined_import(tmp_path: Path) -> None:
|
| 91 |
+
"""SPEC AC5: 制造 typo 的 import,ruff F821 必须返回非零 exit code。
|
| 92 |
+
|
| 93 |
+
用 PATH 上已存在的 ruff 直接跑,避开 `uv run` 在 Windows 上可能的虚拟环境锁。
|
| 94 |
+
CI 与 pre-commit hook 走 ruff-pre-commit,语义等价。
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
canary = tmp_path / "canary.py"
|
| 98 |
+
canary.write_text(
|
| 99 |
+
"from autoteam.accounts import list_accounts # typo: 实际应为 load_accounts\n",
|
| 100 |
+
encoding="utf-8",
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
cmd = ["ruff", "check", "--select", "F401,F811,F821", str(canary)]
|
| 104 |
+
proc = subprocess.run(cmd, capture_output=True, text=True, cwd=REPO_ROOT)
|
| 105 |
+
assert proc.returncode != 0, (
|
| 106 |
+
f"ruff 应拦截 typo,但返回 exit=0。stdout={proc.stdout!r}, stderr={proc.stderr!r}"
|
| 107 |
+
)
|
| 108 |
+
# F401(unused) 或 F821(undefined) 任一命中即可 — typo 引入的 import 通常两者都中
|
| 109 |
+
combined = proc.stdout + proc.stderr
|
| 110 |
+
assert ("F401" in combined) or ("F821" in combined), (
|
| 111 |
+
f"输出中缺少 F401/F821 标记: {combined!r}"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# self-check 白名单契约:entrypoint 列出的 11 个符号必须全部 importable
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_self_check_whitelist_imports() -> None:
|
| 121 |
+
"""SPEC AC1: docker-entrypoint.sh self-check 列出的所有符号必须可导入。
|
| 122 |
+
|
| 123 |
+
若 PR 改名/删除这些符号,本测试先红 → entrypoint 才不会在容器启动期红。
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
from autoteam.api import app # noqa: F401
|
| 127 |
+
from autoteam.accounts import ( # noqa: F401
|
| 128 |
+
STATUS_ACTIVE,
|
| 129 |
+
STATUS_AUTH_INVALID,
|
| 130 |
+
STATUS_EXHAUSTED,
|
| 131 |
+
STATUS_ORPHAN,
|
| 132 |
+
STATUS_PENDING,
|
| 133 |
+
STATUS_PERSONAL,
|
| 134 |
+
STATUS_STANDBY,
|
| 135 |
+
load_accounts,
|
| 136 |
+
save_accounts,
|
| 137 |
+
)
|
| 138 |
+
from autoteam.manager import sync_account_states # noqa: F401
|
| 139 |
+
|
| 140 |
+
# 简单的契约校验 — 函数应可调用、状态常量应是字符串
|
| 141 |
+
assert callable(load_accounts)
|
| 142 |
+
assert callable(save_accounts)
|
| 143 |
+
assert callable(sync_account_states)
|
| 144 |
+
for const in (
|
| 145 |
+
STATUS_ACTIVE,
|
| 146 |
+
STATUS_EXHAUSTED,
|
| 147 |
+
STATUS_STANDBY,
|
| 148 |
+
STATUS_PENDING,
|
| 149 |
+
STATUS_PERSONAL,
|
| 150 |
+
STATUS_AUTH_INVALID,
|
| 151 |
+
STATUS_ORPHAN,
|
| 152 |
+
):
|
| 153 |
+
assert isinstance(const, str) and const, f"状态常量必须是非空字符串: {const!r}"
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
# Dockerfile / compose / entrypoint 静态契约(避免 SPEC §1-§4 被悄悄回退)
|
| 158 |
+
# ---------------------------------------------------------------------------
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def test_dockerfile_declares_git_sha_arg() -> None:
|
| 162 |
+
"""SPEC AC4: Dockerfile 必须声明 ARG GIT_SHA + ENV AUTOTEAM_GIT_SHA。"""
|
| 163 |
+
|
| 164 |
+
dockerfile = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8")
|
| 165 |
+
assert "ARG GIT_SHA" in dockerfile
|
| 166 |
+
assert "ARG BUILD_TIME" in dockerfile
|
| 167 |
+
assert "ENV AUTOTEAM_GIT_SHA" in dockerfile
|
| 168 |
+
assert "ENV AUTOTEAM_BUILD_TIME" in dockerfile
|
| 169 |
+
assert "org.opencontainers.image.revision" in dockerfile
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_compose_passes_build_args() -> None:
|
| 173 |
+
"""compose 必须把 GIT_SHA / BUILD_TIME 作为 build-arg 透传(降级 unknown)。"""
|
| 174 |
+
|
| 175 |
+
compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
| 176 |
+
assert "GIT_SHA: ${GIT_SHA:-unknown}" in compose
|
| 177 |
+
assert "BUILD_TIME: ${BUILD_TIME:-unknown}" in compose
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_entrypoint_contains_self_check() -> None:
|
| 181 |
+
"""entrypoint 必须包含 self-check + crash-loop 兜底。"""
|
| 182 |
+
|
| 183 |
+
entrypoint = (REPO_ROOT / "docker-entrypoint.sh").read_text(encoding="utf-8")
|
| 184 |
+
assert "[self-check]" in entrypoint
|
| 185 |
+
assert "from autoteam.api import app" in entrypoint
|
| 186 |
+
assert "from autoteam.accounts import" in entrypoint
|
| 187 |
+
assert "exit 1" in entrypoint, "self-check 失败必须 exit 1 触发 crash-loop"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_pyproject_has_ruff_config() -> None:
|
| 191 |
+
"""pyproject.toml 必须配 [tool.ruff.lint] 三条规则。"""
|
| 192 |
+
|
| 193 |
+
pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
| 194 |
+
assert "[tool.ruff.lint]" in pyproject
|
| 195 |
+
assert "F401" in pyproject
|
| 196 |
+
assert "F811" in pyproject
|
| 197 |
+
assert "F821" in pyproject
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
if __name__ == "__main__":
|
| 201 |
+
sys.exit(pytest.main([__file__, "-v"]))
|
|
File without changes
|
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SPEC-4 §4: Playwright import + async/sync 分离静态守卫。
|
| 2 |
+
|
| 3 |
+
通过 AST 扫描 src/autoteam/**/*.py,断言:
|
| 4 |
+
- 不出现 from playwright.async_api ...
|
| 5 |
+
- async def 函数体内不引用任何 playwright 符号
|
| 6 |
+
- playwright.sync_api 导入仅限白名单
|
| 7 |
+
- 已豁免的文件必须显式登记在 expected_exempt 集合
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import ast
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from autoteam._playwright_guard import (
|
| 16 |
+
ALLOWED_SYNC_NAMES,
|
| 17 |
+
EXEMPTION_MARKER,
|
| 18 |
+
FORBIDDEN_MODULES,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
SRC_ROOT = Path(__file__).resolve().parents[2] / "src" / "autoteam"
|
| 22 |
+
PLAYWRIGHT_NAMESPACE = ALLOWED_SYNC_NAMES | {"async_playwright"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _is_exempt(path: Path) -> bool:
|
| 26 |
+
"""文件首 5 行内出现 EXEMPTION_MARKER 即视为豁免。"""
|
| 27 |
+
try:
|
| 28 |
+
head = "\n".join(path.read_text(encoding="utf-8").splitlines()[:5])
|
| 29 |
+
except OSError:
|
| 30 |
+
return False
|
| 31 |
+
return EXEMPTION_MARKER in head
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _iter_py_files():
|
| 35 |
+
for path in SRC_ROOT.rglob("*.py"):
|
| 36 |
+
if not _is_exempt(path):
|
| 37 |
+
yield path
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _parse(path: Path) -> ast.AST:
|
| 41 |
+
return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_no_forbidden_module_import():
|
| 45 |
+
"""A1+A2: 禁止 from playwright.async_api / import playwright.async_api"""
|
| 46 |
+
bad: list[str] = []
|
| 47 |
+
for path in _iter_py_files():
|
| 48 |
+
tree = _parse(path)
|
| 49 |
+
for node in ast.walk(tree):
|
| 50 |
+
if isinstance(node, ast.ImportFrom) and node.module in FORBIDDEN_MODULES:
|
| 51 |
+
bad.append(f"{path}:{node.lineno} from {node.module}")
|
| 52 |
+
elif isinstance(node, ast.Import):
|
| 53 |
+
for alias in node.names:
|
| 54 |
+
if alias.name in FORBIDDEN_MODULES:
|
| 55 |
+
bad.append(f"{path}:{node.lineno} import {alias.name}")
|
| 56 |
+
assert not bad, "禁止使用 playwright.async_api:\n" + "\n".join(bad)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_no_playwright_in_async_def():
|
| 60 |
+
"""A3: async def 函数体内不允许 playwright 符号"""
|
| 61 |
+
bad: list[str] = []
|
| 62 |
+
for path in _iter_py_files():
|
| 63 |
+
tree = _parse(path)
|
| 64 |
+
for fn in ast.walk(tree):
|
| 65 |
+
if not isinstance(fn, ast.AsyncFunctionDef):
|
| 66 |
+
continue
|
| 67 |
+
for child in ast.walk(fn):
|
| 68 |
+
name = getattr(child, "id", None) or getattr(child, "attr", None)
|
| 69 |
+
if name in PLAYWRIGHT_NAMESPACE:
|
| 70 |
+
bad.append(
|
| 71 |
+
f"{path}:{getattr(child, 'lineno', fn.lineno)} "
|
| 72 |
+
f"async def {fn.name} 内引用了 {name}"
|
| 73 |
+
)
|
| 74 |
+
assert not bad, "async def 函数体内禁止 playwright:\n" + "\n".join(bad)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_sync_import_whitelist():
|
| 78 |
+
"""A4: playwright.sync_api 导入仅限 ALLOWED_SYNC_NAMES"""
|
| 79 |
+
bad: list[str] = []
|
| 80 |
+
for path in _iter_py_files():
|
| 81 |
+
tree = _parse(path)
|
| 82 |
+
for node in ast.walk(tree):
|
| 83 |
+
if isinstance(node, ast.ImportFrom) and node.module == "playwright.sync_api":
|
| 84 |
+
for alias in node.names:
|
| 85 |
+
if alias.name not in ALLOWED_SYNC_NAMES:
|
| 86 |
+
bad.append(f"{path}:{node.lineno} 非白名单符号 {alias.name}")
|
| 87 |
+
assert not bad, "sync_api 导入不在白名单:\n" + "\n".join(bad)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_exemption_list_intentional():
|
| 91 |
+
"""SPEC-4 §5: 豁免文件需登记理由,避免静默扩散"""
|
| 92 |
+
exempt = [p for p in SRC_ROOT.rglob("*.py") if _is_exempt(p)]
|
| 93 |
+
expected_exempt: set[str] = set()
|
| 94 |
+
actual = {p.relative_to(SRC_ROOT).as_posix() for p in exempt}
|
| 95 |
+
unexpected = actual - expected_exempt
|
| 96 |
+
assert not unexpected, (
|
| 97 |
+
f"未登记的豁免文件:{unexpected}。"
|
| 98 |
+
f"如需新增,先更新 SPEC-4 §5 + 本测试 expected_exempt 集合"
|
| 99 |
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SPEC-4 §7: 反例验证 — 证明 hygiene 守卫真能拦下违规代码。
|
| 2 |
+
|
| 3 |
+
通过把违规代码字符串直接 ast.parse 后跑相同的 AST 规则,断言它们被识别为
|
| 4 |
+
违规。该测试不依赖 SRC_ROOT,默认进 CI 跑;它独立于主 hygiene 测试运行,
|
| 5 |
+
因此不会污染主测试的 SRC_ROOT 路径。
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import ast
|
| 11 |
+
import textwrap
|
| 12 |
+
|
| 13 |
+
from autoteam._playwright_guard import (
|
| 14 |
+
ALLOWED_SYNC_NAMES,
|
| 15 |
+
FORBIDDEN_MODULES,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
PLAYWRIGHT_NAMESPACE = ALLOWED_SYNC_NAMES | {"async_playwright"}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _has_forbidden_import(tree: ast.AST) -> bool:
|
| 22 |
+
for node in ast.walk(tree):
|
| 23 |
+
if isinstance(node, ast.ImportFrom) and node.module in FORBIDDEN_MODULES:
|
| 24 |
+
return True
|
| 25 |
+
if isinstance(node, ast.Import):
|
| 26 |
+
for alias in node.names:
|
| 27 |
+
if alias.name in FORBIDDEN_MODULES:
|
| 28 |
+
return True
|
| 29 |
+
return False
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _has_playwright_in_async_def(tree: ast.AST) -> bool:
|
| 33 |
+
for fn in ast.walk(tree):
|
| 34 |
+
if not isinstance(fn, ast.AsyncFunctionDef):
|
| 35 |
+
continue
|
| 36 |
+
for child in ast.walk(fn):
|
| 37 |
+
name = getattr(child, "id", None) or getattr(child, "attr", None)
|
| 38 |
+
if name in PLAYWRIGHT_NAMESPACE:
|
| 39 |
+
return True
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _has_non_whitelist_sync_import(tree: ast.AST) -> bool:
|
| 44 |
+
for node in ast.walk(tree):
|
| 45 |
+
if isinstance(node, ast.ImportFrom) and node.module == "playwright.sync_api":
|
| 46 |
+
for alias in node.names:
|
| 47 |
+
if alias.name not in ALLOWED_SYNC_NAMES:
|
| 48 |
+
return True
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_forbidden_async_api_import_is_detected():
|
| 53 |
+
src = "from playwright.async_api import async_playwright\n"
|
| 54 |
+
tree = ast.parse(src)
|
| 55 |
+
assert _has_forbidden_import(tree), "async_api import 应当被守卫识别"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_async_def_with_sync_playwright_is_detected():
|
| 59 |
+
src = textwrap.dedent("""\
|
| 60 |
+
from playwright.sync_api import sync_playwright
|
| 61 |
+
|
| 62 |
+
async def evil_handler():
|
| 63 |
+
with sync_playwright() as p:
|
| 64 |
+
p.chromium.launch()
|
| 65 |
+
""")
|
| 66 |
+
tree = ast.parse(src)
|
| 67 |
+
assert _has_playwright_in_async_def(tree), "async def 内 sync_playwright 应被识别"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_async_def_with_page_attribute_is_detected():
|
| 71 |
+
src = textwrap.dedent("""\
|
| 72 |
+
async def evil_handler(browser):
|
| 73 |
+
page = await browser.new_page()
|
| 74 |
+
return Page # noqa
|
| 75 |
+
""")
|
| 76 |
+
tree = ast.parse(src)
|
| 77 |
+
assert _has_playwright_in_async_def(tree), "async def 内引用 Page 应被识别"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_non_whitelist_sync_import_is_detected():
|
| 81 |
+
src = "from playwright.sync_api import Request\n"
|
| 82 |
+
tree = ast.parse(src)
|
| 83 |
+
assert _has_non_whitelist_sync_import(tree), "白名单外符号应被识别"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_clean_file_passes_all_checks():
|
| 87 |
+
"""正向反例: 合法的 sync 用法不应被任一规则误伤"""
|
| 88 |
+
src = textwrap.dedent("""\
|
| 89 |
+
from playwright.sync_api import sync_playwright, Page, Browser
|
| 90 |
+
|
| 91 |
+
def login_flow():
|
| 92 |
+
with sync_playwright() as p:
|
| 93 |
+
browser: Browser = p.chromium.launch()
|
| 94 |
+
page: Page = browser.new_page()
|
| 95 |
+
page.goto("https://example.com")
|
| 96 |
+
browser.close()
|
| 97 |
+
""")
|
| 98 |
+
tree = ast.parse(src)
|
| 99 |
+
assert not _has_forbidden_import(tree)
|
| 100 |
+
assert not _has_playwright_in_async_def(tree)
|
| 101 |
+
assert not _has_non_whitelist_sync_import(tree)
|
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SPEC-4 §3: assert_sync_context 行为验证。
|
| 2 |
+
|
| 3 |
+
注: SPEC §4.2 原稿用 @pytest.mark.asyncio,但仓库未引入 pytest-asyncio。
|
| 4 |
+
为避免新增依赖,asyncio loop 内的测试改用 asyncio.run() 直接驱动协程,
|
| 5 |
+
行为等价,在 loop 内调用 assert_sync_context() 仍会触发 RuntimeError。
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import re
|
| 12 |
+
import threading
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from autoteam._playwright_guard import assert_sync_context
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_passes_in_plain_thread():
|
| 20 |
+
"""普通线程无 loop, guard 必须放行"""
|
| 21 |
+
holder: dict = {}
|
| 22 |
+
|
| 23 |
+
def worker():
|
| 24 |
+
try:
|
| 25 |
+
assert_sync_context()
|
| 26 |
+
holder["ok"] = True
|
| 27 |
+
except Exception as e: # noqa: BLE001
|
| 28 |
+
holder["err"] = e
|
| 29 |
+
|
| 30 |
+
t = threading.Thread(target=worker, name="test-plain")
|
| 31 |
+
t.start()
|
| 32 |
+
t.join(timeout=2.0)
|
| 33 |
+
assert holder.get("ok") is True
|
| 34 |
+
assert "err" not in holder
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_blocks_in_asyncio_loop():
|
| 38 |
+
"""asyncio loop 内调用必须抛 RuntimeError"""
|
| 39 |
+
async def runner():
|
| 40 |
+
assert_sync_context()
|
| 41 |
+
|
| 42 |
+
with pytest.raises(RuntimeError, match=r"\[PlaywrightGuard\].*asyncio loop"):
|
| 43 |
+
asyncio.run(runner())
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_message_contains_thread_and_loop_id():
|
| 47 |
+
"""异常消息应携带 thread 名 + loop id 便于诊断"""
|
| 48 |
+
captured: dict = {}
|
| 49 |
+
|
| 50 |
+
async def runner():
|
| 51 |
+
try:
|
| 52 |
+
assert_sync_context()
|
| 53 |
+
except RuntimeError as e:
|
| 54 |
+
captured["msg"] = str(e)
|
| 55 |
+
|
| 56 |
+
asyncio.run(runner())
|
| 57 |
+
msg = captured.get("msg", "")
|
| 58 |
+
assert "thread=" in msg
|
| 59 |
+
assert re.search(r"loop_id=0x[0-9a-f]+", msg)
|