diff --git a/app/ai.py b/app/ai.py index 5d9767f..8abb453 100644 --- a/app/ai.py +++ b/app/ai.py @@ -987,7 +987,7 @@ def _request_cmhub_cover_image_sync( request_elapsed = time.perf_counter() - request_started _emit_cmhub_metadata(on_event, data, "cover_request") _notify_step(on_step, "cover_parse_response") - image_url = str(data.get("image_url") or "").strip() + image_url = _extract_cmhub_image_url(data, runtime["base_url"]) if not image_url: raise AIError("AI 返回中没有图片数据") _notify_step_event( @@ -1202,8 +1202,7 @@ def _poll_cmhub_cover_image_task( poll_index += 1 continue if status == "succeeded": - result = data.get("result") if isinstance(data.get("result"), dict) else {} - image_url = str(result.get("image_url") or data.get("image_url") or "").strip() + image_url = _extract_cmhub_image_url(data, runtime["base_url"]) if not image_url: raise CMHubError("bad_response", "cmhub 生图任务成功但没有图片地址", retryable=False) _notify_step(on_step, "cover_parse_response") @@ -1362,6 +1361,40 @@ def _debug_cmhub_image_url_enabled(): return value.strip().lower() in {"1", "true", "yes", "on", "debug"} +def _extract_cmhub_image_url(data, base_url): + candidate = _find_image_ref(data) + if not candidate: + return "" + return _normalize_cmhub_image_url(candidate, base_url) + + +def _normalize_cmhub_image_url(value, base_url): + text = str(value or "").strip() + if not text: + return "" + parts = urllib.parse.urlsplit(text) + if parts.scheme in {"http", "https"}: + return text + if parts.scheme: + raise CMHubError( + "bad_response", + "cmhub 生图任务返回的图片地址格式错误", + retryable=False, + ) + base = str(base_url or "").strip() + if not base: + raise CMHubError( + "bad_response", + "cmhub 生图任务返回了相对图片地址,但缺少 cmhub Base URL", + retryable=False, + ) + base_parts = urllib.parse.urlsplit(base) + if text.startswith("//"): + scheme = base_parts.scheme or "https" + return f"{scheme}:{text}" + return urllib.parse.urljoin(base.rstrip("/") + "/", text) + + def _download_cmhub_image_with_retry( url, connect_timeout, @@ -2273,7 +2306,19 @@ def _find_image_ref(value): candidate = image_url.get("url") if isinstance(candidate, str): return candidate - for key in ("data", "choices", "output", "content", "images"): + if isinstance(image_url, list): + candidate = _find_image_ref_from_list(image_url) + if candidate: + return candidate + for key in ("image_urls", "urls"): + candidate = value.get(key) + if isinstance(candidate, str) and candidate.strip(): + return candidate.strip() + if isinstance(candidate, list): + found = _find_image_ref_from_list(candidate) + if found: + return found + for key in ("result", "data", "choices", "output", "content", "images", "files"): candidate = _find_image_ref(value.get(key)) if candidate: return candidate @@ -2283,10 +2328,17 @@ def _find_image_ref(value): if candidate: return candidate elif isinstance(value, list): - for item in value: - candidate = _find_image_ref(item) - if candidate: - return candidate + return _find_image_ref_from_list(value) + return None + + +def _find_image_ref_from_list(values): + for item in values: + if isinstance(item, str) and item.strip(): + return item.strip() + candidate = _find_image_ref(item) + if candidate: + return candidate return None diff --git a/docs/api.md b/docs/api.md index cb4c1ca..d3450b9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -320,6 +320,7 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li - 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 读取等待。 - 并发数/重试/分辨率/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 安全校验和下载。 - 调试 cmhub 图片 URL:默认不在日志或 DB 中记录完整 `image_url`。若本机调试需要查看,启动程序前设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1`,②本轮可见运行日志会显示脱敏后的“cmhub 图片 URL”,但该调试行不写入 SQLite `run_log_events`。 - 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。 - 生成结果**直接进入 ③ 更新候选**;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。 diff --git a/docs/cmhub-integration-design.md b/docs/cmhub-integration-design.md index 555af38..a606d04 100644 --- a/docs/cmhub-integration-design.md +++ b/docs/cmhub-integration-design.md @@ -99,7 +99,7 @@ - **②批量响应(T-564)**:submit 返回 `202 {task_id,status:"queued",call_id,points_cost,points_balance,expires_at}`;cmshopee 立即写 `tasks.image_task_id`,随后 `GET /api/v1/generate/image/tasks/{task_id}` 轮询。`queued/running` 继续等待;`succeeded` 取 `result.image_url`;`failed/expired` 视为终态失败且 cmhub 已退点。 - **单独 `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)` 落盘。下载 helper 必须校验 URL scheme 只允许 `http/https`,拒绝内网/回环/本机地址,并校验域名解析后的 IP 仍不属于内网/回环/本机地址,设置超时和大小上限;生成后**立即下载**(对象存储 URL 可能有有效期)。`points_cost`/`points_balance`/`call_id` 通过事件回调传播,不改变 `gen_cover` 返回值。 +- **下载**:拿 `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 同步死等旧生图,否则客户端超时但服务端仍在算并扣点。 - **并发(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-565.md b/docs/tasks/T-565.md new file mode 100644 index 0000000..fbdeba5 --- /dev/null +++ b/docs/tasks/T-565.md @@ -0,0 +1,55 @@ +--- +id: T-565 +title: 兼容 cmhub 异步生图返回相对图片地址 +phase: 7 +deps: [T-564] +status: DONE +created: 2026-07-09 +--- + +## 问题 / 背景 + +T-564 切到 cmhub 异步生图任务接口后,②AI生成在 poll 返回 `succeeded` 后进入下载阶段,但真实运行出现: + +```text +图片生成失败:下载新封面失败:cmhub 图片地址只允许 http/https +``` + +从日志看,submit 和 poll 都成功,失败发生在本地下载前的 URL 安全校验。根因是当前 `app/ai.py` 的异步 poll 成功分支只读取 `result.image_url` / 顶层 `image_url`,并直接传给下载器;如果 cmhub 返回的是相对路径(如 `/generated/images/...png`)、协议相对 URL(`//host/path`)、`image_url` 对象或图片列表字段,本地会把非完整 URL 字符串交给 `_assert_public_http_url()`,从而报“只允许 http/https”。 + +## 方案 + +- `app/ai.py` + - 新增 cmhub 异步任务图片地址提取/规整 helper。 + - poll `succeeded` 后复用现有 `_find_image_ref()` 递归提取图片地址,兼容 `result.image_url`、`result.image_url.url`、`result.images[]`、`result.image_urls[]`、顶层同类字段。 + - 对相对路径按当前 cmhub Base URL 规整为完整 `http(s)://host/...` 后再进入原有公网 URL 安全校验和下载流程。 + - 如果仍无法提取地址,报清晰中文错误“cmhub 生图任务成功但没有图片地址”;如果字段存在但格式不可用,报“cmhub 生图任务返回的图片地址格式错误”。 +- `tests/test_ai.py` + - 覆盖异步 poll 返回相对路径时能拼成完整 URL 下载。 + - 覆盖异步 poll 返回 `image_urls` 列表时能提取第一张图片。 + +## 验收要点 + +- cmhub poll 返回 `result.image_url="/generated/xxx.png"` 时,下载 URL 应为 `/generated/xxx.png`。 +- cmhub poll 返回 `result.image_urls=["https://..."]` 时可正常下载。 +- 原有绝对 `https://...` 图片地址路径不回归。 +- 验证命令: + - `py -3.10 -m unittest tests.test_ai` + - `python -m ruff check app tests main.py` + - `py -3.10 -m compileall app main.py` + - `git diff --check` + +## 边界(不改什么) + +- 不改 DB schema、`tasks.image_task_id/image_task_key` 生命周期。 +- 不改 cmhub submit/poll 状态机和重试策略。 +- 不改 curl/requests 下载实现和公网 URL 安全校验。 +- 不改 CDP/Shopee、Excel、GUI 布局。 + +## 执行记录 + +- 2026-07-09:完成 T-565。 + - `app/ai.py`:新增 `_extract_cmhub_image_url()` / `_normalize_cmhub_image_url()`,异步 poll 和旧同步兼容路径统一通过 helper 提取图片地址;兼容 `image_url`、`image_url.url`、`images[]`、`image_urls[]`、协议相对 URL 与相对路径,并在下载前按 cmhub Base URL 补全。 + - `tests/test_ai.py`:新增/调整 cmhub 异步生图测试,覆盖 poll 返回相对路径时拼接完整 URL 下载,以及 `image_urls` 列表提取。 + - 文档:同步 `docs/api.md`、`docs/cmhub-integration-design.md` 的图片地址兼容口径。 + - 验证通过:`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 f0893a3..6a91c36 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -1155,6 +1155,7 @@ class AITests(TempDirMixin, unittest.TestCase): ] poll_db_values = [] headers_seen = [] + downloads = [] def fake_request(method, url, **kwargs): if str(method).upper() == "POST": @@ -1174,11 +1175,12 @@ class AITests(TempDirMixin, unittest.TestCase): { "task_id": "cmhub-task-1", "status": "succeeded", - "result": {"image_url": "https://cdn.example.com/generated.png"}, + "result": {"image_url": "/generated/images/generated.png"}, } ) def fake_get(url, **kwargs): + downloads.append((url, kwargs)) return _RequestsResponse(content=generated_png) with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \ @@ -1198,6 +1200,10 @@ 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( + "https://cmhub.example.com/generated/images/generated.png", + downloads[0][0], + ) updated = db.get_task(task.id, path=cfg["db_path"]) self.assertEqual("cmhub-task-1", updated.image_task_id) self.assertTrue(updated.image_task_key) @@ -1205,6 +1211,23 @@ class AITests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cmhub_async_image_url_extractor_accepts_image_urls_list(self): + data = { + "task_id": "cmhub-task-1", + "status": "succeeded", + "result": { + "image_urls": [ + "https://cdn.example.com/generated-a.png", + "https://cdn.example.com/generated-b.png", + ] + }, + } + + self.assertEqual( + "https://cdn.example.com/generated-a.png", + ai._extract_cmhub_image_url(data, "https://cmhub.example.com"), + ) + def test_generate_batch_cmhub_resumes_existing_image_task_without_submit(self): try: from PIL import Image # noqa: F401