Files
2026-07-09 00:07:09 +08:00

11 KiB
Raw Permalink Blame History

id, title, phase, deps, status, created
id title phase deps status created
T-564 生图对接 cmhub 异步任务接口(submit + 轮询 + task_id 续查),替代 900s 同步长等待 7
T-526
T-529
T-546
DONE 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)。

执行记录

  • 2026-07-08:完成 T-564。
    • app/db.py:新增 tasks.image_task_id / tasks.image_task_key schema 与 ad-hoc 迁移,补 Task dataclass 字段;新增 ensure_image_task_key()、set_image_task_submitted()、clear_image_task();reset_generated(..., reset_cover=True) 会清空异步生图状态,只重置标题时保留。
    • app/ai.py:②批量 cmhub 生图切到 POST /api/v1/generate/image/tasks + GET /api/v1/generate/image/tasks/{task_id};submit 带 Idempotency-Key / X-Client-Version,submit 成功后立即落库 image_task_id;已有 image_task_id 时直接续查;poll succeeded 后复用原图片下载/保存;poll failed/expired 清空 key/id;poll 超时或用户停止保留 key/id;单独 gen_cover() 保留旧同步接口兼容。
    • tests/test_db.py:覆盖新列、幂等键稳定、submit 落库、清空、重置标题/封面生命周期。
    • tests/test_ai.py:覆盖 submit 后 poll 前已落库、已有 task_id 续查不 POST、failed 任务清 key、停止轮询保留 task_id,以及既有下载/并发回归。
    • 文档:同步 docs/04-architecture.md、docs/api.md、docs/cmhub-integration-design.md、docs/routes.md、docs/ui/tab5-settings.svg;按仓库规则未手动覆盖 docs/current-state.md,未修改冻结 docs/06-tasks.md。
    • 验证通过: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。