Files
cmshoppe/docs/tasks/T-564.md
T
chengmaandClaude Opus 4.8 487408ba0f docs(tasks): refine T-564 with two-commit hint and three-state resume check
补实现提示:分两步 commit(先 DB 层加列+同步 Task dataclass+helper 跑绿,
再 ai.py submit/poll 对接),并写清续查三态判断(image_task_id/image_task_key
的存在组合区分已 submit / submit 前崩溃 / 全新 submit)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:38:51 +08:00

96 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: T-564
title: 生图对接 cmhub 异步任务接口(submit + 轮询 + task_id 续查),替代 900s 同步长等待
phase: 7
deps: [T-526, T-529, T-546]
status: TODO
created: 2026-07-08
---
## 问题 / 背景
当前 cmshopee 生封面(`app/ai.py`)是两段串行同步阻塞:`POST /api/v1/generate/image` 读超时 900s 死等,拿到 `image_url` 后再下载最长 900s。上游卡死时 cmshopee QThread 与 cmhub Gunicorn worker 双双被占满 15 分钟,且第 899 秒抖动会「扣了点却拿不到图、无法找回」(这步 `retryable=False` 防重复扣点)。
cmhub 侧已实现并测试通过异步任务化接口(新增 `/generate/image/tasks` 子资源,T-612~T-615),旧同步接口保持不变仍可用。本任务把 cmshopee 生图链路切到新异步路:submit 拿 `task_id` → 轮询到 `succeeded` → 下载保存;`task_id` 持久化支撑重启续查、幂等键防重复扣点。
设计与逐点落地评估见 Obsidian「生图接口异步任务化-提交轮询方案」§「对接契约 v3」+「cmshopee 侧落地评估」。**对接字段/错误码以该文 v3 节与 cmhub `docs/api.md` 为准。**
> 编号说明:当前仓库任务文件为 `T-564`。如果外部沟通里写成 `T-654`,按本文件 `T-564` 执行,避免任务编号分叉。
## 方案(改哪个文件、改成什么)
> 实现提示:本任务横跨 DB 层与 ai.py 对接,建议**分两步 commit**——先落 `app/db.py`(加列 + 同步 `Task` dataclass + 迁移 + 3 个 helper + `test_db` 跑绿),确认 `list_tasks`/`get_task` 因 `cls(**dict(row))`(`db.py:323`)不因新列崩溃后,再落 `app/ai.py` 的 submit/poll/续查对接。降低单次改动风险,也便于回滚。
> 续查前置的三态判断(实现时写清、单测覆盖):`image_task_id` 有值 = 已 submit,直接 GET 续查;`image_task_key` 有值但 `image_task_id` 空 = submit 前崩溃,带同 key 重发;两者皆空 = 全新 submit。
### 接口(cmhub 已实现,本任务对接)
- submit:`POST /api/v1/generate/image/tasks` → `202 {task_id, status:queued, call_id, points_cost, points_balance, expires_at}`(**提交即预扣**)。请求体与旧生图一致;头带 `Idempotency-Key`、`X-Client-Version`。
- poll:`GET /api/v1/generate/image/tasks/{task_id}` → 除 404 外恒 `200`,按 `status` 分支:`queued/running` / `succeeded`(带 `result.image_url`) / `failed`|`expired`(带 `error.code`,**均已退点**)。
- 无 cancel 接口。
### `app/ai.py`
1. `_request_cmhub_cover_image`(`ai.py:900`)改为两段:
- **submit**:POST `/generate/image/tasks`,带 `Idempotency-Key`、`X-Client-Version`;连接超时保留 66s、读超时降 ~30s。拿 `task_id` 后**立即写库 `image_task_id`**(见结构改动 ①)。
- **poll**:循环 GET,递增退避 `3s→5s→8s→10s` 封顶带抖动,撞本地总预算(默认 900s,可配)才放弃;`succeeded` → 返回 `result.image_url` 交给原 `_download_and_save_cmhub_cover`;`failed`/`expired` → 抛结构化 `CMHubError`(已退点,按可重试);撞预算 → 超时错误但**保留 `task_id`**。
2. **续查前置**:进 submit 前先看 task 有无 `image_task_id`,有则直接 GET 续查,命中 `succeeded` 取图,**不重新 submit**。
3. `_cmhub_call_once`(`ai.py:1184`)加请求头 `Idempotency-Key`、`X-Client-Version`。
4. 错误码表 / `_cmhub_retryable`(`ai.py:1252-1265`)补 `upstream_timeout`、`task_timeout`(可重试、已退点)。
5. 超时分段:不再用 `_cmhub_read_timeout` 恒 900(`ai.py:1074`),submit/poll 各自超时。
6. 余额/计费事件回调(`_emit_cmhub_metadata`)在 submit 202 触发一次(`points_cost/points_balance/call_id`)。
7. 重试语义分层:失败重试是「换新 Idempotency-Key 重新 submit」的上层重跑,不走 `_cmhub_call_with_retry`(`ai.py:1149`)复用同 payload 的内层重试;内层对 submit 关闭或强制带同一幂等键。
8. **`gen_cover(...)` 单独调用兼容**:`generate_batch()` 内有 `task.id/db_path`,可完整使用异步 submit/poll/续查/落库;单独直接调用 `gen_cover()` 时没有任务上下文,不得强行写库。第一版可走“无持久化异步”(submit+poll 但不支持重启续查),或保留旧同步兼容路径;必须在代码和文档里明确,不能让公开函数签名变成必须传 DB。
### 结构改动
- **① request 段写库落 `task_id`**:`_request_cmhub_cover_image` 现无状态、不碰 DB。需把 `task.id` + 持久化回调(或 `db_path`)传进去,在 submit 成功点写 `image_task_id`。调用点在 `run_cmhub_cover_tasks`(`ai.py:687`)。
- **② per-attempt 幂等键不要直接依赖 `generate_attempts`**:当前 `generate_attempts` 只在 `set_generated()` / `mark_failed()` 时递增,用户重置封面后重新生成时可能仍复用旧值。新增本地 `image_task_key`(例如 `task.id + uuid/nonce`)作为 `Idempotency-Key` 更稳:submit 前若没有 key 先落库生成;同一次网络抖动重发复用同 key;用户重置封面、poll 返回 `failed/expired` 后重试、或需要重新 submit 时清空并生成新 key。
- **③ 轮询占 request-pool 线程**:submit+poll 同步塞进 `request_executor` future 即可(本地线程、廉价,非稀缺资源);`cmhub_image_concurrency_plan`(`ai.py:241`)现约束「在途轮询数」,第一版保持池形、可放宽上限,不重构编排。
### `app/db.py`
- 加列 `tasks.image_task_id TEXT`、`tasks.image_task_key TEXT`(沿用现有 `ALTER TABLE ADD COLUMN` ad-hoc 迁移模式,参考 `db.py:363`)。
- 同步 `Task` dataclass、历史 DB 迁移、`SELECT t.*` 映射与相关测试,避免 SQLite 多列后 dataclass 构造失败。
- 提供读写 helper(命名可按实现调整):
- `ensure_image_task_key(task_id)`:无 key 时生成并写入,返回稳定 key。
- `set_image_task_submitted(task_id, image_task_id, image_task_key)`:submit 202 后立即落库。
- `clear_image_task(task_id)`:清空 `image_task_id/image_task_key`,用于失败终态或用户重置封面后重新生成。
- 生命周期规则:
- submit 前先落 `image_task_key`;submit 成功后立即落 `image_task_id`。
- 本地总预算超时、用户点击停止、程序退出:保留 `image_task_id/image_task_key`,下次可续查。
- poll 返回 `succeeded`:下载保存并 `set_generated()`;可保留 `image_task_id/image_task_key` 作审计,不影响已生成任务。
- poll 返回 `failed` / `expired`(已退点):清空 `image_task_id/image_task_key`,再按重试次数决定是否新 submit,避免后续一直续查旧失败任务。
- `reset_generated(..., reset_cover=True)` 或重置全部生成结果时必须清空 `image_task_id/image_task_key`;只重置标题且保留封面时不必清空。
- submit 阶段 `content_blocked`、`insufficient_points`、`bad_request` 没有有效任务时不保留 key;`idempotency_conflict` 说明本地 key/payload 复用错误,应清空 key 后提示或失败。
### 文档
- `docs/cmhub-integration-design.md` 出 v3.6:不推翻 v3.5 全量,标注「旧同步接口沿用 v3.5 口径 + 新增任务化接口口径」,记弃用计划。
- 同步 `docs/api.md`、`docs/current-state.md` 里生图链路的描述。
## 验收要点
- submit 返回 202 → `image_task_id` **已落库**、进入轮询、不阻塞主线程;单测断言写库发生在轮询前。
- 轮询 `running`→`succeeded` → 走下载保存、`gen_cover` 仍返回已存 JPEG 路径;`failed`/`expired`(已退点) → 抛结构化错误、按可重试、**不重复扣点**。
- **重启续查**:已有 `image_task_id` 的 task 直接 GET,命中 `succeeded` 取图,**无二次 submit、无二次扣点**(断言未调 POST /tasks)。
- **幂等键 per-attempt 隔离**:同 task 同 `image_task_key` 重发 → 同 `task_id`;用户重置封面、旧任务 `failed/expired` 或需要重新 submit 时清空旧 key 并生成新 `image_task_key` → 新 `task_id`。
- 撞本地总预算未出结果 → 超时错误但 `image_task_id` 已持久化、可续查。
- 轮询期 `should_cancel()` → 停止轮询、不再下载(服务端继续跑完,按结果结算),GUI 日志明确提示“已停止等待生图结果;服务端任务可能仍在完成,下次可继续查询”。
- 错误码 `upstream_timeout`/`task_timeout`/`content_blocked`/`insufficient_points`/`idempotency_conflict` 各有对应处理与中文提示。
- `gen_cover()` 单独直接调用不破坏旧调用方;②批量生成才要求完整持久化和重启续查。
- 未配置 cmhub / Base URL 双拼等既有错误口径不回归。
- 验证命令:新增异步路用例用 `unittest`,不要引入 pytest。至少运行:
- `py -3.10 -m unittest tests.test_ai tests.test_db`
- `python -m ruff check app tests main.py`
- `py -3.10 -m compileall app main.py`
- `py -3.10 -m unittest discover -s tests`
- `git diff --check`
## 边界(不改什么)
- 不改旧同步 `POST /api/v1/generate/image` 的对接(保留为兼容/回滚路径)。
- 不改 `gen_cover(...)` 对外返回值(仍返回已存 JPEG 路径);单独调用没有 DB 上下文时不强制支持重启续查。
- 不改 `generate_batch` 编排骨架、`_download_and_save_cmhub_cover` 下载/保存逻辑、`data/images/<batch_id>/<slug>/` 路径、封面开关。
- 不改 CDP 选择器 / Shopee 交互 / Excel / 生文链路。
- 不引入 cancel 接口(cmhub 本期未实现)。
- 不做 DB 版本化迁移重构(沿用 ad-hoc ALTER;版本化仍在 backlog)。
## 执行记录
(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。)