diff --git a/app/ai.py b/app/ai.py index 8abb453..f53fa21 100644 --- a/app/ai.py +++ b/app/ai.py @@ -48,7 +48,7 @@ class CMHubError(AIError): CMHUB_IMAGE_MAX_BYTES = 20 * 1024 * 1024 CMHUB_TITLE_READ_TIMEOUT_SECONDS = 600 CMHUB_IMAGE_READ_TIMEOUT_SECONDS = 900 -CMHUB_IMAGE_SUBMIT_READ_TIMEOUT_SECONDS = 30 +CMHUB_IMAGE_SUBMIT_READ_TIMEOUT_SECONDS = 36 CMHUB_IMAGE_POLL_READ_TIMEOUT_SECONDS = 15 CMHUB_IMAGE_POLL_DELAYS_SECONDS = (3.0, 5.0, 8.0, 10.0) CMHUB_IMAGE_CONCURRENCY_LIMIT = 5 diff --git a/docs/api.md b/docs/api.md index d3450b9..86f3f1c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -317,7 +317,7 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li - 标题提示词组装:`gen_title()` 的 direct 与 cmhub 路径共用标题 prompt 规则。若标题提示词包含 `{旧标题}`,生成前替换为该任务旧标题,不再自动追加旧标题块;若不包含 `{旧标题}`,保持旧行为自动追加“旧标题:...”块。两种情况都会追加“请只返回新标题,不要解释。”输出约束;其它 `{...}` 原样保留。 - `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。 - `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。 -- direct 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。cmhub 使用 `requests timeout=(connect, read)`,connect 来自 `ai.cmhub.connect_timeout`;cmhub 生文读取等待固定 600s。②批量生图异步 submit 读取等待 30s、poll 单次读取等待 15s、本地总预算 900s,图片下载读取等待 900s;`gen_cover()` 旧同步兼容路径仍用 900s 读取等待。 +- direct 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。cmhub 使用 `requests timeout=(connect, read)`,connect 来自 `ai.cmhub.connect_timeout`;cmhub 生文读取等待固定 600s。②批量生图异步 submit 读取等待 36s、poll 单次读取等待 15s、本地总预算 900s,图片下载读取等待 900s;`gen_cover()` 旧同步兼容路径仍用 900s 读取等待。 - 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;标题/图片并发会被夹到 1..5,失败重试次数会被夹到 0..10,兼容旧配置中的超限值;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。 - 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。cmhub 批量生图使用内部实际并发 `min(image_concurrency, 5)` 约束 submit+poll 在途数;已有 `tasks.image_task_id` 时直接 GET 续查,不二次 submit;submit 前先持久化 `image_task_key`,submit 成功立即持久化 `image_task_id`;poll `failed/expired` 会清空二者,poll 超时/用户停止/程序退出则保留二者供下次续查。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数与实际生图并发一致且最大 5;T-548 后图片下载后端由 `ai.cmhub.download_with_curl` 控制,默认 `auto`:Windows 且检测到系统 curl 时优先用 curl 下载,否则回退 requests;curl 失败也会自动回退 requests。下载前仍执行公网 URL 校验,curl 使用 `-K` 临时配置文件传入 URL,不把带 token 的 `image_url` 放进命令行参数;`use_system_proxy=false` 时 curl 加 `--noproxy "*"`。下载失败最多安全重试 3 次,只复用同一个 `image_url`,不会重新调用 cmhub 生图接口;下载总耗时超过 20 秒时写“图片下载较慢”警告;direct 兼容路径暂保持原 `image_concurrency` 语义。 - cmhub 图片地址兼容:优先递归读取 `image_url` / `image_url.url` / `images[]` / `image_urls[]` 等字段;若返回 `/generated/...` 或 `generated/...` 相对路径,先按 cmhub Base URL 补全为完整 `http(s)` URL,再进入原有公网 URL 安全校验和下载。 diff --git a/docs/cmhub-integration-design.md b/docs/cmhub-integration-design.md index a606d04..aa0629c 100644 --- a/docs/cmhub-integration-design.md +++ b/docs/cmhub-integration-design.md @@ -32,7 +32,7 @@ | 生图请求 | `images_edits` multipart 或 vision chat | `{prompt, model:别名, image_base64?/image_url?, resolution?, aspect_ratio?, parameters?}` | | 生图响应 | 直接返回 image bytes | ②批量:submit 返回 `{task_id, ...}`,poll 成功返回 `{result:{image_url}}`;兼容同步:`{image_url, ...}` → 需再下载 | | 错误 | HTTP error 文本 | `{error:{code,message}}`:`insufficient_points`(402)/`upstream_error`(502)/`rate_limited`(429)/`unauthorized`(401)/`account_disabled`(403)/`bad_request`(400)/`model_not_allowed`/`no_pricing_rule` | -| 超时 | 按 `resolution_timeouts` | ②批量生图 submit 短读超时、poll 总预算 900s、下载 900s;旧同步兼容路径仍 900s | +| 超时 | 按 `resolution_timeouts` | ②批量生图 submit 读取等待 36s、poll 总预算 900s、下载 900s;旧同步兼容路径仍 900s | | 幂等 | 直连一次成功一次 | **非幂等、无幂等键**:客户端超时 ≠ 未扣点,读超时后不可无脑重发 | 关键差异(决定改造点): @@ -100,7 +100,7 @@ - **单独 `gen_cover()` 兼容**:公开函数没有本地 `task.id/db_path` 上下文,第一版继续调用旧同步 `POST /api/v1/generate/image`,保持返回值和旧调用方兼容;②批量生成才使用完整持久化、幂等键和重启续查。 - `resolution` 归一为**大写** `512/1K/2K/4K`(cmshopee 内部用小写 `1k`,发请求前转 `1K`);`aspect_ratio` 默认 `1:1`(Shopee 封面)。 - **下载**:拿 `image_url` 后下载该图字节(cmhub 自家对象存储公网 URL)→ 交给现有 `_save_jpeg(image_bytes, out_path, resolution, quality)` 落盘。cmshopee 兼容 `image_url` / `image_url.url` / `images[]` / `image_urls[]` 等返回结构;若 cmhub 返回相对路径(如 `/generated/...`),先按 cmhub Base URL 补全为完整 `http(s)` URL。下载 helper 必须校验 URL scheme 只允许 `http/https`,拒绝内网/回环/本机地址,并校验域名解析后的 IP 仍不属于内网/回环/本机地址,设置超时和大小上限;生成后**立即下载**(对象存储 URL 可能有有效期)。`points_cost`/`points_balance`/`call_id` 通过事件回调传播,不改变 `gen_cover` 返回值。 -- **超时(关键)**:②批量生图 submit 读取等待约 30 秒,poll 单次读取等待约 15 秒,本地总预算 900 秒;撞预算、用户停止或程序退出都保留 `image_task_id/image_task_key`,下次直接续查,不重新 submit。下载读取等待 900 秒;旧同步兼容路径也使用 900 秒。绝不用 30s/60s 同步死等旧生图,否则客户端超时但服务端仍在算并扣点。 +- **超时(关键)**:②批量生图 submit 读取等待 36 秒,poll 单次读取等待约 15 秒,本地总预算 900 秒;撞预算、用户停止或程序退出都保留 `image_task_id/image_task_key`,下次直接续查,不重新 submit。下载读取等待 900 秒;旧同步兼容路径也使用 900 秒。绝不用 30s/60s 同步死等旧生图,否则客户端超时但服务端仍在算并扣点。 - **并发(T-545/T-564 已实现)**:最近实测 `/media/generated/images/*.png` 下载链路在 10 并发下明显慢且有连接失败。cmhub 模式下采用内置保护:实际 submit+poll 在途并发 = `min(ai.image_concurrency, 5)`;下载/保存使用独立线程池,线程数与实际生图并发一致,同样最大 5;不新增用户可见配置项。运行日志必须同时显示用户设置和实际并发,避免用户误解设置 10 就会对 cmhub 打 10 并发。下载失败记为该任务失败,不得重新调用生图接口导致重复扣点。 - **下载后端(T-548 已实现)**:cmhub 生成/models/balance 仍走共享 requests Session;仅 `image_url` 图片下载可按 `ai.cmhub.download_with_curl` 选择系统 curl。默认 `auto` 在 Windows 且检测到系统 curl 时优先 curl,非 Windows、无 curl 或 curl 失败自动回退 requests。curl 调用前仍做公网 URL 校验,用 `-K` 临时配置文件传 URL,避免 token 出现在进程命令行;`use_system_proxy=false` 时加 `--noproxy "*"`。 diff --git a/docs/tasks/T-570.md b/docs/tasks/T-570.md new file mode 100644 index 0000000..dd89314 --- /dev/null +++ b/docs/tasks/T-570.md @@ -0,0 +1,53 @@ +--- +id: T-570 +title: cmhub 异步生图 submit 读取等待调整为 36 秒 +phase: 7 +deps: [T-564] +status: DONE +created: 2026-07-09 +--- + +## 问题 / 背景 + +T-564 后,②批量生图已改为异步任务接口:先 `POST /api/v1/generate/image/tasks` 提交任务,再轮询 `GET /api/v1/generate/image/tasks/{task_id}` 获取图片地址。当前 submit 阶段单次 read timeout 是 30 秒。 + +真实使用中,cmhub submit 可能因为上游排队、网关抖动或服务端首次调度稍慢,30 秒边界略紧,容易在服务端可能已受理任务但客户端未拿到 `task_id` 的窗口报超时。为降低边界抖动,本任务把 submit 阶段单次 read timeout 从 30 秒调到 36 秒。 + +## 方案 + +- `app/ai.py` + - 将 `CMHUB_IMAGE_SUBMIT_READ_TIMEOUT_SECONDS` 从 `30` 改为 `36`。 + - 只影响 `POST /api/v1/generate/image/tasks` 的读取等待。 + - 不改 poll 单次 15 秒、本地总预算 900 秒、图片下载 900 秒。 +- `tests/test_ai.py` + - 补充/更新 cmhub 异步生图测试,断言 submit 请求实际使用 `timeout=(connect_timeout, 36)`。 +- 文档 + - 同步 `docs/api.md` 和 `docs/cmhub-integration-design.md` 中关于 submit 读取等待的精确说明。 + +## 验收要点 + +- `POST /api/v1/generate/image/tasks` 的 read timeout 为 36 秒。 +- poll 单次读取等待仍为 15 秒,本地总预算仍为 900 秒。 +- 下载读取等待仍为 900 秒。 +- 不改 cmhub submit/poll 状态机、DB schema、幂等键和重启续查逻辑。 +- 验证命令: + - `py -3.10 -m unittest tests.test_ai` + - `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` + +## 边界(不改什么) + +- 不改 `ai.cmhub.connect_timeout` 默认值 66 秒。 +- 不改生图 poll 总等待 900 秒、poll 单次 15 秒和轮询间隔。 +- 不改图片下载逻辑、curl/requests 选择、图片 URL 安全校验。 +- 不改 CDP/Shopee、Excel、GUI 布局或 DB schema。 + +## 执行记录 + +- 2026-07-09:完成 T-570。 + - `app/ai.py`:将 `CMHUB_IMAGE_SUBMIT_READ_TIMEOUT_SECONDS` 从 30 调整为 36,只影响 `POST /api/v1/generate/image/tasks` 的 submit 读取等待;poll 单次 15 秒、本地总预算 900 秒和下载 900 秒不变。 + - `tests/test_ai.py`:在 cmhub 异步 submit 落库测试中断言实际请求 timeout 为 `(connect_timeout, 36)`,防止后续回退。 + - 文档:同步 `docs/api.md` 与 `docs/cmhub-integration-design.md` 的 submit 读取等待口径。 + - 验证通过:`py -3.10 -m unittest tests.test_ai`、`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`。 diff --git a/tests/test_ai.py b/tests/test_ai.py index 6a91c36..7a78309 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -1155,11 +1155,13 @@ class AITests(TempDirMixin, unittest.TestCase): ] poll_db_values = [] headers_seen = [] + submit_timeouts = [] downloads = [] def fake_request(method, url, **kwargs): if str(method).upper() == "POST": headers_seen.append(dict(kwargs["headers"])) + submit_timeouts.append(kwargs.get("timeout")) return _RequestsResponse( { "task_id": "cmhub-task-1", @@ -1200,6 +1202,7 @@ class AITests(TempDirMixin, unittest.TestCase): self.assertEqual(["cmhub-task-1"], poll_db_values) self.assertTrue(headers_seen[0]["Idempotency-Key"].startswith("cmshopee-task-")) self.assertTrue(headers_seen[0]["X-Client-Version"]) + self.assertEqual([(3, 36)], submit_timeouts) self.assertEqual( "https://cmhub.example.com/generated/images/generated.png", downloads[0][0],