对接 cmhub 已实现的异步生图接口(POST /generate/image/tasks + 轮询), 替代当前 900s 同步长等待:submit 拿 task_id、轮询到 succeeded 再下载、 task_id 持久化支撑重启续查、per-attempt 幂等键防重复扣点。含三处结构 改动、五类小改、单测点与 v3.6 文档修订项。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.3 KiB
6.3 KiB
id, title, phase, deps, status, created
| id | title | phase | deps | status | created | |||
|---|---|---|---|---|---|---|---|---|
| T-564 | 生图对接 cmhub 异步任务接口(submit + 轮询 + task_id 续查),替代 900s 同步长等待 | 7 |
|
TODO | 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 为准。
方案(改哪个文件、改成什么)
接口(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
_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。
- submit:POST
- 续查前置:进 submit 前先看 task 有无
image_task_id,有则直接 GET 续查,命中succeeded取图,不重新 submit。 _cmhub_call_once(ai.py:1184)加请求头Idempotency-Key、X-Client-Version。- 错误码表 /
_cmhub_retryable(ai.py:1252-1265)补upstream_timeout、task_timeout(可重试、已退点)。 - 超时分段:不再用
_cmhub_read_timeout恒 900(ai.py:1074),submit/poll 各自超时。 - 余额/计费事件回调(
_emit_cmhub_metadata)在 submit 202 触发一次(points_cost/points_balance/call_id)。 - 重试语义分层:失败重试是「换新 Idempotency-Key 重新 submit」的上层重跑,不走
_cmhub_call_with_retry(ai.py:1149)复用同 payload 的内层重试;内层对 submit 关闭或强制带同一幂等键。
结构改动
- ① 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 幂等键:键用
f"{task.id}:{generate_attempts}"(复用app/db.py:239的generate_attempts),保证「手动重新生成封面」不撞回旧任务,而「同一次 submit 网络抖动重发」仍幂等返回同一task_id。 - ③ 轮询占 request-pool 线程:submit+poll 同步塞进
request_executorfuture 即可(本地线程、廉价,非稀缺资源);cmhub_image_concurrency_plan(ai.py:241)现约束「在途轮询数」,第一版保持池形、可放宽上限,不重构编排。
app/db.py
- 加列
tasks.image_task_id TEXT(沿用现有ALTER TABLE ADD COLUMNad-hoc 迁移模式,参考db.py:363)。 - 提供读写
image_task_id的辅助(或并入现有 task 更新路径)。
文档
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 同
generate_attempts重发 → 同task_id;generate_attempts递增后 → 新task_id。 - 撞本地总预算未出结果 → 超时错误但
image_task_id已持久化、可续查。 - 轮询期
should_cancel()→ 停止轮询、不再下载(服务端继续跑完,按结果结算)。 - 错误码
upstream_timeout/task_timeout/content_blocked/insufficient_points/idempotency_conflict各有对应处理与中文提示。 - 未配置 cmhub / Base URL 双拼等既有错误口径不回归。
- 验证命令:
py -3.10 -m pytest tests/test_ai.py -q(新增异步路用例,mock_cmhub_session()的 request/get 按 status 分支返回)。
边界(不改什么)
- 不改旧同步
POST /api/v1/generate/image的对接(保留为兼容/回滚路径)。 - 不改
gen_cover(...)对外返回值(仍返回已存 JPEG 路径)、generate_batch编排骨架、_download_and_save_cmhub_cover下载/保存逻辑、data/images/<batch_id>/<slug>/路径、封面开关。 - 不改 CDP 选择器 / Shopee 交互 / Excel / 生文链路。
- 不引入 cancel 接口(cmhub 本期未实现)。
- 不做 DB 版本化迁移重构(沿用 ad-hoc ALTER;版本化仍在 backlog)。
执行记录
(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。)