ZRainbow Claude Opus 4.7 (1M context) commited on
Commit
d9d9e44
·
1 Parent(s): cf2f7d3

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>

Files changed (35) hide show
  1. .pre-commit-config.yaml +9 -2
  2. CHANGELOG.md +25 -0
  3. Dockerfile +9 -0
  4. docker-compose.yml +5 -1
  5. docker-entrypoint.sh +38 -0
  6. docs/api.md +2 -0
  7. docs/docker.md +85 -0
  8. prompts/0426/prd/prd-1-mail-provider.md +455 -0
  9. prompts/0426/prd/prd-2-account-lifecycle.md +564 -0
  10. prompts/0426/prd/prd-3-docker-guard.md +508 -0
  11. prompts/0426/prd/prd-4-playwright-hardening.md +329 -0
  12. prompts/0426/research/issue-1-cloudmail.md +613 -0
  13. prompts/0426/research/issue-2-seat-strategy.md +182 -0
  14. prompts/0426/research/issue-3-list-accounts-import.md +292 -0
  15. prompts/0426/research/issue-4-oauth-addphone.md +416 -0
  16. prompts/0426/research/issue-5-playwright-async-sync.md +438 -0
  17. prompts/0426/research/issue-6-invite-postkick.md +229 -0
  18. prompts/0426/spec/shared/account-state-machine.md +518 -0
  19. prompts/0426/spec/shared/add-phone-detection.md +441 -0
  20. prompts/0426/spec/shared/plan-type-whitelist.md +327 -0
  21. prompts/0426/spec/shared/quota-classification.md +410 -0
  22. prompts/0426/spec/spec-1-mail-provider.md +1045 -0
  23. prompts/0426/spec/spec-2-account-lifecycle.md +1018 -0
  24. prompts/0426/spec/spec-3-docker-guard.md +525 -0
  25. prompts/0426/spec/spec-4-playwright-hardening.md +458 -0
  26. prompts/0426/synthesis.md +109 -0
  27. pyproject.toml +30 -1
  28. src/autoteam/_playwright_guard.py +61 -0
  29. src/autoteam/api.py +33 -1
  30. src/autoteam/manager.py +2 -4
  31. tests/integration/test_docker_guard.py +201 -0
  32. tests/static/__init__.py +0 -0
  33. tests/static/test_playwright_hygiene.py +99 -0
  34. tests/static/test_playwright_hygiene_negative.py +101 -0
  35. tests/unit/test_playwright_guard.py +59 -0
.pre-commit-config.yaml CHANGED
@@ -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
- args: [--fix, --exit-non-zero-on-fix]
7
- - id: ruff-format
 
 
 
 
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
CHANGELOG.md CHANGED
@@ -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 删除守卫
Dockerfile CHANGED
@@ -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
docker-compose.yml CHANGED
@@ -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:
docker-entrypoint.sh CHANGED
@@ -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 "$@"
docs/api.md CHANGED
@@ -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` | 活跃账号 |
docs/docker.md CHANGED
@@ -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
+ ```
prompts/0426/prd/prd-1-mail-provider.md ADDED
@@ -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
+
prompts/0426/prd/prd-2-account-lifecycle.md ADDED
@@ -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
+
prompts/0426/prd/prd-3-docker-guard.md ADDED
@@ -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
+ ---
prompts/0426/prd/prd-4-playwright-hardening.md ADDED
@@ -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 |
prompts/0426/research/issue-1-cloudmail.md ADDED
@@ -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 阶段产出)
prompts/0426/research/issue-2-seat-strategy.md ADDED
@@ -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)。
prompts/0426/research/issue-3-list-accounts-import.md ADDED
@@ -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 标签 |
prompts/0426/research/issue-4-oauth-addphone.md ADDED
@@ -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 条历史失败记录)
prompts/0426/research/issue-5-playwright-async-sync.md ADDED
@@ -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 字符串;请提供具体堆栈定位 |
prompts/0426/research/issue-6-invite-postkick.md ADDED
@@ -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 共因,实施时应统一规划而不是分两次改。
prompts/0426/spec/shared/account-state-machine.md ADDED
@@ -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 模型、单测,不需额外决策。
prompts/0426/spec/shared/add-phone-detection.md ADDED
@@ -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 + 单元/集成测试,无需额外决策。
prompts/0426/spec/shared/plan-type-whitelist.md ADDED
@@ -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
+ **文档结束。** 工程师据此可直接编写常量集 + 工具函数 + 单测,无需额外决策。
prompts/0426/spec/shared/quota-classification.md ADDED
@@ -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 调用点的处置代码,无需额外决策。
prompts/0426/spec/spec-1-mail-provider.md ADDED
@@ -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
+
prompts/0426/spec/spec-2-account-lifecycle.md ADDED
@@ -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 即可完成代码实施。
prompts/0426/spec/spec-3-docker-guard.md ADDED
@@ -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 验收点的情况,**必须回滚该步**而非强推 — 每步都是独立可逆单元。
prompts/0426/spec/spec-4-playwright-hardening.md ADDED
@@ -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) |
prompts/0426/synthesis.md ADDED
@@ -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 报错样本
pyproject.toml CHANGED
@@ -31,4 +31,33 @@ dev = [
31
 
32
  [tool.pytest.ini_options]
33
  addopts = "-ra"
34
- testpaths = ["tests"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"]
src/autoteam/_playwright_guard.py ADDED
@@ -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
+ )
src/autoteam/api.py CHANGED
@@ -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 = {}
src/autoteam/manager.py CHANGED
@@ -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)
tests/integration/test_docker_guard.py ADDED
@@ -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"]))
tests/static/__init__.py ADDED
File without changes
tests/static/test_playwright_hygiene.py ADDED
@@ -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
+ )
tests/static/test_playwright_hygiene_negative.py ADDED
@@ -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)
tests/unit/test_playwright_guard.py ADDED
@@ -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)