From 3d902d49076f920d32655a00f2845a4eab633330 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 7 Jul 2026 21:00:43 +0800 Subject: [PATCH] feat: cap cmhub image concurrency --- app/ai.py | 394 +++++++++++++++++++++++++------ app/gui/workers.py | 32 ++- docs/02-requirements.md | 2 +- docs/04-architecture.md | 3 +- docs/06-tasks.md | 1 + docs/api.md | 4 +- docs/cmhub-integration-design.md | 1 + docs/current-state.md | 10 +- docs/routes.md | 4 +- progress.md | 24 ++ tests/test_ai.py | 183 ++++++++++++++ tests/test_gui.py | 38 +++ 12 files changed, 601 insertions(+), 95 deletions(-) diff --git a/app/ai.py b/app/ai.py index d038f76..47838a0 100644 --- a/app/ai.py +++ b/app/ai.py @@ -2,7 +2,7 @@ import base64 import ipaddress -from concurrent.futures import CancelledError, ThreadPoolExecutor, as_completed +from concurrent.futures import CancelledError, FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait import copy import json import mimetypes @@ -39,6 +39,7 @@ class CMHubError(AIError): CMHUB_IMAGE_MAX_BYTES = 20 * 1024 * 1024 CMHUB_TITLE_READ_TIMEOUT_SECONDS = 600 CMHUB_IMAGE_READ_TIMEOUT_SECONDS = 650 +CMHUB_IMAGE_CONCURRENCY_LIMIT = 5 _RESOLUTION_SIZES = { "512": (512, 512), @@ -124,12 +125,7 @@ def gen_cover( ): """Generate a new cover image and save it as a JPEG file.""" - _notify_step(on_step, "cover_validate_input") - old_cover_path = os.path.abspath(str(old_cover_path)) - if not os.path.exists(old_cover_path): - raise FileNotFoundError("旧封面图片不存在: %s" % old_cover_path) - if not out_path: - raise AIError("缺少新封面输出路径") + old_cover_path, out_path = _prepare_cover_input(old_cover_path, out_path, on_step) cfg = appconfig.load_config() if config is None else config ai_cfg = appconfig.ai_config(cfg) @@ -198,6 +194,36 @@ def gen_cover( return _save_jpeg(image_bytes, out_path, resolution, quality) +def cmhub_image_concurrency_plan(ai_cfg): + """Return protected cmhub image request/download concurrency.""" + + configured = _positive_int((ai_cfg or {}).get("image_concurrency", 1), 1) + actual = min(configured, CMHUB_IMAGE_CONCURRENCY_LIMIT) + return { + "configured_image_concurrency": configured, + "request_concurrency": actual, + "download_concurrency": actual, + "limit": CMHUB_IMAGE_CONCURRENCY_LIMIT, + } + + +def _positive_int(value, default=1): + try: + return max(1, int(value)) + except (TypeError, ValueError): + return max(1, int(default or 1)) + + +def _prepare_cover_input(old_cover_path, out_path, on_step=None): + _notify_step(on_step, "cover_validate_input") + old_cover_path = os.path.abspath(str(old_cover_path)) + if not os.path.exists(old_cover_path): + raise FileNotFoundError("旧封面图片不存在: %s" % old_cover_path) + if not out_path: + raise AIError("缺少新封面输出路径") + return old_cover_path, out_path + + def _notify_step(callback, step): if callback is None: return @@ -207,6 +233,22 @@ def _notify_step(callback, step): pass +def _notify_step_event(callback, step, result="success", detail=None, level="info"): + if callback is None: + return + payload = { + "step": step, + "result": result, + "level": level, + } + if detail is not None: + payload["detail"] = detail + try: + callback(payload) + except Exception: + pass + + def _notify_retry(callback, step, attempt, attempts, exc, model): if callback is None: @@ -471,83 +513,190 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No task for task in cover_candidates if getattr(task, "id", None) in title_results ] - with ThreadPoolExecutor( - max_workers=max(1, int(generation_cfg.get("image_concurrency", 1))) - ) as executor: - futures = {} - for task in cover_tasks: - if should_stop(): - summary["cancelled"] = True - break - new_title = title_results[task.id] - try: - set_step(task, "cover_prompt_render") - _emit_generation_event(on_event, task, "cover", "cover_prompt_render", "start") - rendered_cover_prompt = prompt_module.render_prompt( - cover_prompt, - _prompt_context(task, new_title, account_by_alias), - ) - _emit_generation_event(on_event, task, "cover", "cover_prompt_render", "success") - set_step(task, "cover_submit") - _emit_generation_event(on_event, task, "cover", "cover_submit", "start") - futures[ - executor.submit( - gen_cover, + + def prepare_cover_task(task, new_title): + set_step(task, "cover_prompt_render") + _emit_generation_event(on_event, task, "cover", "cover_prompt_render", "start") + rendered_cover_prompt = prompt_module.render_prompt( + cover_prompt, + _prompt_context(task, new_title, account_by_alias), + ) + _emit_generation_event(on_event, task, "cover", "cover_prompt_render", "success") + set_step(task, "cover_submit") + _emit_generation_event(on_event, task, "cover", "cover_submit", "start") + return rendered_cover_prompt, _new_cover_path(task, account_by_alias, image_root) + + def record_cover_failure(task, exc, fallback_step="cover_request"): + summary["failed"] += 1 + summary["ok"] = False + step = get_step(task, fallback_step) + error = _mark_generate_failed(task, exc, db_path, on_task_update) + _emit_generation_event(on_event, task, "cover", step, "failed", detail=error, level="error") + _emit_generation_error(on_error, task, "cover", step, exc, error) + + def record_cover_cancelled(task, fallback_step="cover_request"): + summary["cancelled"] = True + _emit_generation_event( + on_event, + task, + "cover", + get_step(task, fallback_step), + "cancelled", + level="warning", + ) + + def persist_cover_success(task, new_title, new_cover_path): + set_step(task, "db_write") + _emit_generation_event(on_event, task, "cover", "db_write", "start") + db.set_generated(task.id, new_title, new_cover_path, path=db_path) + summary["cover_done"] += 1 + summary["generated_done"] += 1 + if on_task_update is not None: + on_task_update( + task.id, + { + "stage": "generated", + "status": "success", + "new_title": new_title, + "new_cover_path": new_cover_path, + }, + ) + _emit_generation_event(on_event, task, "cover", "db_write", "success", detail=new_cover_path) + + def run_direct_cover_tasks(): + with ThreadPoolExecutor( + max_workers=_positive_int(generation_cfg.get("image_concurrency", 1), 1) + ) as executor: + futures = {} + for task in cover_tasks: + if should_stop(): + summary["cancelled"] = True + break + new_title = title_results[task.id] + try: + rendered_cover_prompt, new_cover_path = prepare_cover_task(task, new_title) + futures[ + executor.submit( + gen_cover, + rendered_cover_prompt, + getattr(task, "old_cover_path", "") or "", + new_cover_path, + resolution=generation_cfg.get("resolution"), + jpg_quality=generation_cfg.get("jpg_quality"), + retry=generation_cfg.get("retry"), + config=config, + models_path=models_path, + on_step=step_callback(task, "cover"), + on_event=step_callback(task, "cover"), + cmhub_config_path=cmhub_config_path, + ) + ] = (task, new_title) + except Exception as exc: + record_cover_failure(task, exc, fallback_step="cover_prompt_render") + _emit_generation_progress(on_progress, summary) + for future in as_completed(futures): + task, new_title = futures[future] + if should_stop(): + summary["cancelled"] = True + _cancel_pending(futures) + try: + persist_cover_success(task, new_title, future.result()) + except CancelledError: + record_cover_cancelled(task) + except Exception as exc: + record_cover_failure(task, exc) + _emit_generation_progress(on_progress, summary) + + def run_cmhub_cover_tasks(): + plan = cmhub_image_concurrency_plan(generation_cfg) + request_futures = {} + download_futures = {} + next_index = 0 + + def submit_next_request(request_executor): + nonlocal next_index + while next_index < len(cover_tasks): + if should_stop(): + summary["cancelled"] = True + return False + task = cover_tasks[next_index] + next_index += 1 + new_title = title_results[task.id] + try: + rendered_cover_prompt, new_cover_path = prepare_cover_task(task, new_title) + future = request_executor.submit( + _request_cmhub_cover_image, rendered_cover_prompt, getattr(task, "old_cover_path", "") or "", - _new_cover_path(task, account_by_alias, image_root), + new_cover_path, resolution=generation_cfg.get("resolution"), jpg_quality=generation_cfg.get("jpg_quality"), retry=generation_cfg.get("retry"), config=config, - models_path=models_path, + cmhub_config_path=cmhub_config_path, on_step=step_callback(task, "cover"), on_event=step_callback(task, "cover"), - cmhub_config_path=cmhub_config_path, ) - ] = (task, new_title) - except Exception as exc: - summary["failed"] += 1 - summary["ok"] = False - step = get_step(task, "cover_prompt_render") - error = _mark_generate_failed(task, exc, db_path, on_task_update) - _emit_generation_event(on_event, task, "cover", step, "failed", detail=error, level="error") - _emit_generation_error(on_error, task, "cover", step, exc, error) - _emit_generation_progress(on_progress, summary) - for future in as_completed(futures): - task, new_title = futures[future] - if should_stop(): - summary["cancelled"] = True - _cancel_pending(futures) - try: - new_cover_path = future.result() - set_step(task, "db_write") - _emit_generation_event(on_event, task, "cover", "db_write", "start") - db.set_generated(task.id, new_title, new_cover_path, path=db_path) - summary["cover_done"] += 1 - summary["generated_done"] += 1 - if on_task_update is not None: - on_task_update( - task.id, - { - "stage": "generated", - "status": "success", - "new_title": new_title, - "new_cover_path": new_cover_path, - }, - ) - _emit_generation_event(on_event, task, "cover", "db_write", "success", detail=new_cover_path) - except CancelledError: - summary["cancelled"] = True - _emit_generation_event(on_event, task, "cover", get_step(task, "cover_request"), "cancelled", level="warning") - except Exception as exc: - summary["failed"] += 1 - summary["ok"] = False - step = get_step(task, "cover_request") - error = _mark_generate_failed(task, exc, db_path, on_task_update) - _emit_generation_event(on_event, task, "cover", step, "failed", detail=error, level="error") - _emit_generation_error(on_error, task, "cover", step, exc, error) - _emit_generation_progress(on_progress, summary) + request_futures[future] = (task, new_title) + return True + except Exception as exc: + record_cover_failure(task, exc, fallback_step="cover_prompt_render") + _emit_generation_progress(on_progress, summary) + return False + + with ThreadPoolExecutor(max_workers=plan["request_concurrency"]) as request_executor, \ + ThreadPoolExecutor(max_workers=plan["download_concurrency"]) as download_executor: + for _ in range(plan["request_concurrency"]): + if not submit_next_request(request_executor): + break + + while request_futures or download_futures: + if should_stop(): + summary["cancelled"] = True + _cancel_pending(request_futures) + done, _ = wait( + set(request_futures.keys()) | set(download_futures.keys()), + return_when=FIRST_COMPLETED, + ) + for future in done: + if future in request_futures: + task, new_title = request_futures.pop(future) + try: + request_result = future.result() + download_future = download_executor.submit( + _download_and_save_cmhub_cover, + request_result, + on_step=step_callback(task, "cover"), + ) + download_futures[download_future] = (task, new_title) + except CancelledError: + record_cover_cancelled(task) + _emit_generation_progress(on_progress, summary) + except Exception as exc: + record_cover_failure(task, exc) + _emit_generation_progress(on_progress, summary) + else: + task, new_title = download_futures.pop(future) + try: + persist_cover_success(task, new_title, future.result()) + except CancelledError: + record_cover_cancelled(task, fallback_step="cover_download") + except Exception as exc: + record_cover_failure(task, exc, fallback_step="cover_download") + _emit_generation_progress(on_progress, summary) + while ( + not summary["cancelled"] + and not should_stop() + and len(request_futures) < plan["request_concurrency"] + and next_index < len(cover_tasks) + ): + if not submit_next_request(request_executor): + break + + if _ai_backend(generation_cfg) == "cmhub": + run_cmhub_cover_tasks() + else: + run_direct_cover_tasks() if summary["cancelled"]: summary["ok"] = False @@ -670,6 +819,37 @@ def _gen_cover_cmhub( on_step=None, on_event=None, ): + request_result = _request_cmhub_cover_image( + cover_prompt, + old_cover_path, + out_path, + resolution=resolution, + jpg_quality=jpg_quality, + retry=retry, + config=config, + cmhub_config_path=cmhub_config_path, + on_step=on_step, + on_event=on_event, + validate_input=False, + ) + return _download_and_save_cmhub_cover(request_result, on_step=on_step) + + +def _request_cmhub_cover_image( + cover_prompt, + old_cover_path, + out_path, + resolution, + jpg_quality, + retry, + config, + cmhub_config_path, + on_step=None, + on_event=None, + validate_input=True, +): + if validate_input: + old_cover_path, out_path = _prepare_cover_input(old_cover_path, out_path, on_step) ai_cfg = appconfig.ai_config(config) _notify_step(on_step, "load_image_model") runtime = _cmhub_runtime(config, "image", cmhub_config_path) @@ -686,6 +866,7 @@ def _gen_cover_cmhub( attempts = _attempt_count(ai_cfg, retry) read_timeout = _cmhub_read_timeout(config, resolution) _notify_step(on_step, "cover_request") + request_started = time.perf_counter() data = _cmhub_call_with_retry( "POST", appconfig.cmhub_request_url(runtime["base_url"], "/api/v1/generate/image"), @@ -702,18 +883,59 @@ def _gen_cover_cmhub( exc, ), ) + 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() if not image_url: raise AIError("AI 返回中没有图片数据") + _notify_step_event( + on_step, + "cover_request", + detail="cmhub 已返回 image_url,耗时 %s" % _format_seconds(request_elapsed), + ) + return { + "image_url": image_url, + "connect_timeout": runtime["connect_timeout"], + "read_timeout": read_timeout, + "out_path": out_path, + "resolution": resolution, + "quality": quality, + } + + +def _download_and_save_cmhub_cover(request_result, on_step=None): + image_url = request_result["image_url"] + connect_timeout = request_result["connect_timeout"] + read_timeout = request_result["read_timeout"] + out_path = request_result["out_path"] + resolution = request_result["resolution"] + quality = request_result["quality"] + _notify_step(on_step, "cover_download") + download_started = time.perf_counter() image_bytes = _download_cmhub_image( image_url, - connect_timeout=runtime["connect_timeout"], + connect_timeout=connect_timeout, read_timeout=read_timeout, ) + download_elapsed = time.perf_counter() - download_started + _notify_step_event( + on_step, + "cover_download", + detail="下载完成,%s,耗时 %s" + % (_format_bytes(len(image_bytes)), _format_seconds(download_elapsed)), + ) _notify_step(on_step, "cover_save") - return _save_jpeg(image_bytes, out_path, resolution, quality) + save_started = time.perf_counter() + saved_path = _save_jpeg(image_bytes, out_path, resolution, quality) + save_elapsed = time.perf_counter() - save_started + detail = "JPEG 已保存,耗时 %s" % _format_seconds(save_elapsed) + try: + detail += ",文件 %s" % _format_bytes(os.path.getsize(saved_path)) + except OSError: + pass + _notify_step_event(on_step, "cover_save", detail=detail) + return saved_path def _cmhub_runtime(config, operation, cmhub_config_path): @@ -965,6 +1187,24 @@ def _emit_cmhub_metadata(callback, data, step): pass +def _format_seconds(seconds): + value = max(0.0, float(seconds or 0.0)) + return "%.1f秒" % value + + +def _format_bytes(size): + value = float(max(0, int(size or 0))) + units = ["B", "KB", "MB", "GB"] + unit = units[0] + for unit in units: + if value < 1024 or unit == units[-1]: + break + value /= 1024 + if unit == "B": + return "%d%s" % (int(value), unit) + return "%.1f%s" % (value, unit) + + def _download_cmhub_image(url, connect_timeout, read_timeout, max_bytes=CMHUB_IMAGE_MAX_BYTES): _assert_public_http_url(url) try: diff --git a/app/gui/workers.py b/app/gui/workers.py index 5887724..674385d 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -55,13 +55,25 @@ class GenerateWorker(BaseWorker): batch_ids = self._batch_ids(eligible) self._run_id = self._create_run_log(eligible, batch_ids) if generate_cover: - start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency}".format( - total=len(eligible), - title_total=component_totals["title_total"], - cover_total=component_totals["cover_total"], - title_concurrency=ai_cfg.get("title_concurrency", 1), - image_concurrency=ai_cfg.get("image_concurrency", 1), - ) + if appconfig.ai_backend(self.config) == "cmhub": + concurrency = ai.cmhub_image_concurrency_plan(ai_cfg) + start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency},cmhub实际生图并发{request_concurrency},下载并发{download_concurrency}".format( + total=len(eligible), + title_total=component_totals["title_total"], + cover_total=component_totals["cover_total"], + title_concurrency=ai_cfg.get("title_concurrency", 1), + image_concurrency=concurrency["configured_image_concurrency"], + request_concurrency=concurrency["request_concurrency"], + download_concurrency=concurrency["download_concurrency"], + ) + else: + start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency}".format( + total=len(eligible), + title_total=component_totals["title_total"], + cover_total=component_totals["cover_total"], + title_concurrency=ai_cfg.get("title_concurrency", 1), + image_concurrency=ai_cfg.get("image_concurrency", 1), + ) else: start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题{title_total};标题并发{title_concurrency}".format( total=len(eligible), @@ -185,6 +197,12 @@ class GenerateWorker(BaseWorker): if phase == "cover": if result == "start" and step == "cover_submit": return f"[图片] {self._task_progress_label(task)} 开始生成" + if result == "success" and step == "cover_request": + return f"[图片] {self._task_progress_label(task)} {detail or 'cmhub 已返回,耗时未知'}" + if result == "success" and step == "cover_download": + return f"[图片] {self._task_progress_label(task)} {detail or '图片下载完成,耗时未知'}" + if result == "success" and step == "cover_save": + return f"[图片] {self._task_progress_label(task)} 本地保存完成,{detail or '耗时未知'}" if result == "success" and step == "db_write": suffix = f",已保存 {detail}" if detail else "" return f"[图片] {self._task_progress_label(task)} 成功{suffix}" diff --git a/docs/02-requirements.md b/docs/02-requirements.md index d33507b..a59f1b3 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -48,7 +48,7 @@ | 更新蝦皮(③) | 按批次/店铺/状态筛选;可先「检查本轮更新」确认范围;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务按每批最大条数分批更新蝦皮商品,逐条换标题+封面并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 | | 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 | | 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 | -| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,普通默认 cmhub 模式下只控制生图尺寸,返回超时只读展示实际口径:标题 600 秒 / 图片 650 秒;direct 兼容路径仍按分辨率映射);并发/重试/jpg质量;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 | +| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,普通默认 cmhub 模式下只控制生图尺寸,返回超时只读展示实际口径:标题 600 秒 / 图片 650 秒;direct 兼容路径仍按分辨率映射);并发/重试/jpg质量,其中 cmhub 生图请求与图片下载实际并发最大 5,超过后内部限流且运行日志展示实际并发;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 | | 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④;③ 不自动打开缺失账号 Chrome,必须中止本轮更新 | P0 | ### 后续迭代 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 566fc7f..e76d2d0 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -401,6 +401,7 @@ data/images///__new. # AI 生成的新 1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。 2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `data/images///__new.jpg`。默认 `backend=cmhub`,调用 `POST /api/v1/generate/image`,拿 `image_url` 后立即安全下载并转本地 JPEG;`backend=direct` 仅内部兼容时使用 `default_image_model`。 + - T-545 已实现:cmhub 模式下不再直接按用户填写的 `image_concurrency` 全量打到网关;实际生图请求并发 = `min(image_concurrency, 5)`。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数量与实际生图请求并发一致,同样最大 5。这样批量时“下一批生图请求”和“上一批图片下载/保存”可以流水线并行,但不会对 cmhub 生图接口或 `/media/generated/images/*.png` 静态下载打出超过 5 的并发。direct 兼容路径暂不改变。 - cmhub 生图连接超时取 `ai.cmhub.connect_timeout`;生图请求和随后 `image_url` 下载的读取等待统一固定 650 秒,读超时不自动重发,避免重复扣点。`backend=direct` 兼容路径仍按模型 `timeout_seconds` 或 `resolution_timeouts[resolution]`(512→180/1k→240/2k→360/4k→600)取返回超时。 3. **若未勾选生成封面**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。 @@ -409,7 +410,7 @@ data/images///__new. # AI 生成的新 - **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。 - 进度:标题和图片两条进度分开显示;未勾选生成封面时图片进度显示本轮未生成/0 张,并在运行日志写明“本轮仅生成标题”。 - 生成后 stage=generated;**不设逐条人工审核阶段**。若未生成封面,任务仍可进入③并只更新标题;若后续需要封面,用户可重置生成结果或重新生成该任务。双击任务弹窗查看新旧封面(纯查看,无新封面时显示为空);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。 -- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。 +- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。⑤仍只展示一个「图片并发」设置;当 backend 为 cmhub 且用户填写值大于 5 时,运行时自动按 5 执行,并在②运行日志中显示“图片并发 X,cmhub实际生图并发 5,下载并发 5”。 提示词管理: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index a3f5b1d..e79004d 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -135,6 +135,7 @@ | T-533 | ② 增量生成:按缺失组件补生成(先标题后封面分离) | T-303, T-520, T-509 | 现象:先关闭「生成封面图片」只生成了标题(`stage=generated/status=success`、`new_cover_path` 为空),之后勾选封面再点「开始生成」,提示「当前筛选结果没有待生成任务」,无法补生成封面;若改用「重置生成结果」会连标题一起清掉、重生成时白扣一次标题点并覆盖 T-509 手动改的标题。根因:`ai.is_generatable_task` 把 `generated+success` 一律判为不可生成,且 `generate_batch` 对每条 eligible 任务都先重生成标题再生成封面。方案:把「开始生成」改为**按缺失组件增量补齐**——① 判定放宽:`generated+success` 且(封面开关 ON 且 `new_cover_path` 为空)判为可生成(只补封面);② `generate_batch` 组件级跳过:`new_title` 已存在则**跳过标题生成**(复用现有标题,不再调 cmhub 生文、不扣标题点、**不覆盖用户手动标题**),封面开关 ON 且 `new_cover_path` 为空才生成封面;③ 进度计数分组件——`title_total`=缺标题任务数、`cover_total`=(封面开关 ON 时)缺封面任务数,不再用 `len(eligible)` 一刀切;④「没有待生成任务」提示只在**按当前设置真的无缺口**(标题全有 且(封面关 或 封面全有))时出现;⑤ 幂等/点数:全组件齐备的任务不纳入、不重复调用扣点。保持 `set_generated` 写库、run_logs、诊断日志、停止取消未开始项语义不变;不改 cmhub HTTP 协议、DB schema、Excel、Shopee/CDP 流程。单测覆盖:只补封面(跳过标题、标题点不增、手动标题不被覆盖)、混合批(部分缺标题+部分缺封面)、无缺口提示无任务、封面关时标题态任务不纳入 | DONE | | T-534 | ② 重置增强:多选/筛选范围 + 按组件(标题/封面/全部)重置 | T-533, T-404a, T-509 | 现状:②「重置生成结果」只能对单条、且整条全清(`db.reset_generated` 同时清 `new_title` 和 `new_cover_path` 退回 collected)。真实诉求是精准重置——一批标题不满意但封面 OK(只重标题、保封面、不重扣封面点)、或封面差但标题 OK(只重封面、**保留 `new_title` 保护 T-509 手动标题**、不重扣标题点);且需要作用于多条。方案:与 T-533 的组件模型咬合——① 后端 `db.reset_generated` 加组件开关 `reset_title`/`reset_cover`(`delete_file` 仅在重置封面时可选),只清对应字段与 `last_error`,stage 保持 `generated`,由 T-533 泛化后的 `is_generatable_task`(缺任一必需组件即可生成)识别缺口后由「开始生成」只补被清组件;② UI 重置入口给三选项「重置标题 / 重置封面 / 重置全部」;③ 作用域从单条扩到**多选(表格改 `ExtendedSelection`)或当前筛选结果**,与③「作用于当前筛选结果」心智一致;④ 二次确认框显示将重置的标题/封面条数、其中 `committed=1` 条数,并提示「线上已提交、本地重置不回滚蝦皮、重生成后再更新会再次提交线上」+ 重生成会消耗 cmhub 点数;⑤ 运行中禁用;批量写 `run_type=reset` 运行日志,不触碰蝦皮、不回写 Excel。**只重置封面时绝不改 `new_title`**。不改 cmhub HTTP 协议、Excel、Shopee/CDP 流程。单测覆盖:只重标题保留封面、只重封面保留标题(含手动标题不被清)、重置全部、多选/筛选批量、committed 提示、运行中禁用 | DONE | | T-535 | ② cmhub 生成标题读取等待固定 600 秒 | T-526, T-533 | 问题:当前 cmhub 生文请求的读取等待时间复用 `ai.resolution_timeouts`,会跟随当前分辨率变化;默认 `1k=240s`,若上游模型排队或响应较慢,标题生成容易先超时。方案:cmhub `gen_title()` 的 `title_request` 读取等待固定使用 600 秒(等同 4k 上限),不再跟随当前分辨率;连接超时仍使用 `ai.cmhub.connect_timeout`,重试次数仍使用 `ai.retry`。2026-07-07 补充:cmhub 生图请求与图片下载读取等待统一固定 650 秒,读超时仍不自动重发;⑤设置页「返回超时」标签在普通默认 cmhub 模式下展示「标题 600 秒 / 图片 650 秒」,避免用户误以为分辨率下拉仍会改变 cmhub 等待时间;direct 兼容路径保持现状。补 `tests/test_ai.py` 断言标题请求 timeout 为 `(connect_timeout, 600)`、生图请求和下载 timeout 为 `(connect_timeout, 650)`,补 `tests/test_gui.py` 断言 cmhub 标签固定展示实际口径、direct 仍展示分辨率映射;不改配置 schema、cmhub HTTP 协议、DB、Excel 或 Shopee/CDP | DONE | +| T-545 | ② cmhub 生图和下载并发上限 5 + 独立下载线程池 | T-535, T-533, T-519 | 背景:实测 10 图片并发时,cmhub 后台单条生成小于 200 秒,但本地下载 `/media/generated/images/*.png` 常见 24~185 秒且有连接失败;如果每个生图线程同时负责“等待 cmhub 返回 + 下载 + 转 JPEG + 保存”,下载慢会占住生图线程,后续任务排队。方案:cmhub 模式下实际生图请求并发 = `min(ai.image_concurrency, 5)`;拿到 `image_url` 后把下载/转 JPEG/保存交给独立下载线程池,下载线程数与实际生图请求并发一致、同样最大 5;不新增用户可见配置项,⑤仍只保留「图片并发」。②开始日志必须显示用户设置图片并发、cmhub 实际生图并发和下载并发;分段日志继续记录“cmhub 已返回 image_url / 下载完成 / 本地保存完成”的耗时。下载失败按当前任务失败记录,但不得重新调用 cmhub 生图接口导致重复扣点;`cover_done/generated_done` 必须等下载保存成功并写 DB 后才计数;停止逻辑继续取消未开始项,运行中请求/下载允许自然完成或失败。direct 兼容路径暂不改变。补 `tests/test_ai.py` 覆盖并发上限和下载线程池不阻塞后续生图提交、下载失败不重复扣点;补 `tests/test_gui.py` 覆盖开始日志显示实际并发 | DONE | | T-536 | GUI 按钮圆角全局统一 | T-512, T-513, T-523 | 现象:只有③「开始更新」及少数上色按钮(删除批次/删除账号/未匹配(n))有 `border-radius: 4px`,其余按钮走原生渲染显直角——不一致。根因:圆角是 T-512/T-513 给按钮上色时顺带写进 QSS 的副产品,不是全局形状决策;一旦给 `QPushButton` 设 stylesheet 就放弃原生渲染,才补了 radius/border。方案(**方式 A:全局统一圆角**):① 在 `app/gui/widgets.py` 抽一个**共享按钮基础样式常量/helper**(统一 `border-radius`,如 4px,与卡片 6px 圆角语言协调),并**接管按钮的全部视觉状态**——normal/hover/pressed/disabled/focus 的背景、边框都定义好,避免全局设 QSS 后按钮变扁平方块、丢 hover 反馈;② 在主窗口/app 级用全局 `QPushButton` QSS 应用该基础样式,让**所有按钮共享同一圆角**;③ warning(`startUpdateButton`)与 danger(`_danger_outline_button_style`:删除批次/删除账号/未匹配)按钮改为**只叠加颜色**,复用共享基础样式的圆角/内边距/状态,不再各自重写 radius/border——杜绝“上色=顺带圆角”的隐性耦合;④ 以 Windows 为主目标做一次视觉自测(hover/按下/禁用不劣于原生)。只改 GUI 样式层(`widgets.py` + 主窗口全局 QSS + 各上色按钮引用),不改任何按钮的启用/禁用逻辑、行为、业务流程、DB、Excel、Shopee/CDP。GUI 单测至少断言上色按钮仍带各自语义色且不再各自硬写 radius(改为引用共享样式);圆角外观本身以人工视觉验收为准 | DONE | ## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`) diff --git a/docs/api.md b/docs/api.md index 7962d6b..23b63f5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -313,7 +313,7 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li - `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,cmhub 生图请求和图片下载读取等待固定 650s,且生图读超时不自动重发。 - 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。 -- 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。 +- 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。cmhub 生图使用内部实际并发 `min(image_concurrency, 5)`,拿到 `image_url` 后交给独立下载/保存线程池,下载线程数与实际生图并发一致且最大 5;direct 兼容路径暂保持原 `image_concurrency` 语义。 - 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。 - 生成结果**直接进入 ③ 更新候选**;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。 @@ -424,7 +424,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。 - 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。 - 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。 -- 底部「开始生成」只处理当前筛选结果里 `stage=collected` 的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;通过 `GenerateWorker` 调 `ai.generate_batch()`,先并发标题;只有②「生成封面图片(成本较高)」勾选时才继续并发封面。 +- 底部「开始生成」只处理当前筛选结果里 `stage=collected` 的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;通过 `GenerateWorker` 调 `ai.generate_batch()`,先并发标题;只有②「生成封面图片(成本较高)」勾选时才继续并发封面。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。 - 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。 - 进度标签显示标题/图片/失败;未勾选生成封面时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, None)` 并进入 generated;勾选时每条封面生成成功后立即 `db.set_generated(task_id, new_title, new_cover_path)`。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。 - T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。 diff --git a/docs/cmhub-integration-design.md b/docs/cmhub-integration-design.md index 01fce1d..c210ac0 100644 --- a/docs/cmhub-integration-design.md +++ b/docs/cmhub-integration-design.md @@ -97,6 +97,7 @@ - `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` 返回值。 - **超时(关键)**:生图同步且慢。当前 cmshopee 的 cmhub 生图请求和随后 `image_url` 下载读取等待统一固定 650 秒,不再按分辨率变化,绝不用 30s/60s 调生图——否则客户端超时但服务端仍在算并扣点(见 §4.4 幂等)。 +- **并发(T-545 已实现)**:最近实测 `/media/generated/images/*.png` 下载链路在 10 并发下明显慢且有连接失败。cmhub 模式下采用内置保护:实际生图请求并发 = `min(ai.image_concurrency, 5)`;下载/保存使用独立线程池,线程数与实际生图请求并发一致,同样最大 5;不新增用户可见配置项。运行日志必须同时显示用户设置和实际并发,避免用户误解设置 10 就会对 cmhub 打 10 并发。下载失败记为该任务失败,不得重新调用生图接口导致重复扣点;读超时仍按 §4.4 的非幂等规则处理。 ### 4.4 错误映射与重试策略 diff --git a/docs/current-state.md b/docs/current-state.md index 2b4ff24..a0cfbe6 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -49,13 +49,13 @@ | `.github/workflows/tests.yml` | 已有 | T-522/T-525 产出:push / pull_request 自动在 `windows-latest` + Python 3.11 安装 `requirements.txt` 与 `requirements-dev.txt`,设置 `QT_QPA_PLATFORM=offscreen`,运行 ruff lint、`python -m compileall app main.py` 与 `python -m unittest discover -s tests` | | `app/version.py` | 已有 | T-540 产出:统一维护 `APP_NAME`、`APP_CODE_NAME`、`APP_VERSION` 和窗口显示名;GUI 标题、打包脚本、release 目录、portable zip 和 `version.txt` 共用该版本源 | | `cmshopee.spec` / `scripts/build_exe.ps1` | 已有 | T-524/T-540 产出:PyInstaller onedir 构建配置与发布目录校验脚本;`cmshopee.spec` 不声明本地数据 `datas`,脚本固定使用 `py -3.10`,先检查 `dist\\cmshopee` 不含配置、DB、图片、日志、Chrome 登录态或提示词,再组装 `release\\cmshopee-\\` 与 `release\\cmshopee--portable.zip` | -| `app/gui/` | 已有 | T-523 产出:由旧 `app/gui.py` 拆分的 PySide6 GUI 包,包入口 `__init__.py` 继续兼容 `from app import gui` / `from app.gui import MainWindow`;`main_window.py` 放 MainWindow,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI workers,`tabs/` 放 ①~⑤ Tab; T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-404a/T-501/T-501b/T-501c/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520/T-527/T-528/T-529/T-530/T-531/T-532/T-536/T-537/T-539/T-540/T-541/T-542 产出:PySide6 `QMainWindow` + 五 Tab;窗口标题显示「蝦皮圈優化助手 v」,启动时按屏幕可用区域限制尺寸、居中并保证标题栏可见;顶部 Tab 栏防误点样式;全局 `QPushButton` 基础样式统一 4px 圆角、hover/pressed/disabled/focus 状态;统一语义色板、①②③状态列前景色、③开始更新 warning 语义色、①导入校验数字标红、④登录状态点、③Tab warning 小圆点、删除/未匹配按钮 danger 语义色样式、①②③首次空状态引导卡片和批次阶段进度总览;① 导入采集导入按钮、导入汇总栏、批次/店铺/商品ID/状态筛选、删除批次软删除按钮、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、新标题列本地编辑、开始生成/停止/标题与图片双进度条、双击新旧封面预览、用户可读自动滚动AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新蝦皮批次/店铺/商品ID/状态筛选栏、任务列表、开始更新主按钮、重置更新状态右键菜单、蝦皮更新安全拦截与「前往设置」跳转、「检查本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 检查/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置普通默认展示 cmhub 网关 Base URL、API Key、生文/生图动态别名、Base URL 网关根提示、刷新别名、测试连接/查余额并保存 `config/cmhub.json` Key,不再显示 AI 后端选择、direct 模型选择/详情/角色下拉;direct 配置仅内部兼容/手工回滚;通用生成参数里分辨率只控制 cmhub 生图尺寸,返回超时标签展示实际等待口径「标题 600 秒 / 图片 650 秒」,direct 兼容路径仍显示分辨率映射;Chrome 路径和端口配置、三列 蝦皮更新安全与执行模式设置仍保留,数据路径输入框已隐藏但保留配置兼容,高频安全/执行模式分区前置、基础设施分区后置,`test_item_id`/`dry_run` 兼容字段无用户入口且普通更新不再阻断正式更新;②封面模板「另存为/重命名/删除」低频操作已收敛进「模板操作」菜单;②AI生成底部已增加标题/图片双进度条,运行日志已改为用户可读、自动滚动、脱敏的长任务日志,且②本轮「生成封面图片(成本较高)」开关默认关闭并持久化到 `ai.generate_cover`,关闭时只生成标题并进入可更新状态;②cmhub模式会显示生成后的剩余点数,记录 points_cost/call_id 计费日志,并在点数不足时弹提示且中止未开始任务;⑤设置页已显示“● 未保存更改”,切 Tab/关闭窗口时拦截保存/放弃/取消,放弃会从 `config.json` 与 `config/cmhub.json` 重新回填,刷新别名/测试连接不自动保存并提醒点保存;cmhub 测试连接/查余额成功时会优先显示 cmhub 账号名或脱敏邮箱,缺账号信息时保留普通成功提示 | +| `app/gui/` | 已有 | T-523 产出:由旧 `app/gui.py` 拆分的 PySide6 GUI 包,包入口 `__init__.py` 继续兼容 `from app import gui` / `from app.gui import MainWindow`;`main_window.py` 放 MainWindow,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI workers,`tabs/` 放 ①~⑤ Tab; T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-404a/T-501/T-501b/T-501c/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520/T-527/T-528/T-529/T-530/T-531/T-532/T-536/T-537/T-539/T-540/T-541/T-542/T-545 产出:PySide6 `QMainWindow` + 五 Tab;窗口标题显示「蝦皮圈優化助手 v」,启动时按屏幕可用区域限制尺寸、居中并保证标题栏可见;顶部 Tab 栏防误点样式;全局 `QPushButton` 基础样式统一 4px 圆角、hover/pressed/disabled/focus 状态;统一语义色板、①②③状态列前景色、③开始更新 warning 语义色、①导入校验数字标红、④登录状态点、③Tab warning 小圆点、删除/未匹配按钮 danger 语义色样式、①②③首次空状态引导卡片和批次阶段进度总览;① 导入采集导入按钮、导入汇总栏、批次/店铺/商品ID/状态筛选、删除批次软删除按钮、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、新标题列本地编辑、开始生成/停止/标题与图片双进度条、双击新旧封面预览、用户可读自动滚动AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新蝦皮批次/店铺/商品ID/状态筛选栏、任务列表、开始更新主按钮、重置更新状态右键菜单、蝦皮更新安全拦截与「前往设置」跳转、「检查本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 检查/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置普通默认展示 cmhub 网关 Base URL、API Key、生文/生图动态别名、Base URL 网关根提示、刷新别名、测试连接/查余额并保存 `config/cmhub.json` Key,不再显示 AI 后端选择、direct 模型选择/详情/角色下拉;direct 配置仅内部兼容/手工回滚;通用生成参数里分辨率只控制 cmhub 生图尺寸,返回超时标签展示实际等待口径「标题 600 秒 / 图片 650 秒」,direct 兼容路径仍显示分辨率映射;Chrome 路径和端口配置、三列 蝦皮更新安全与执行模式设置仍保留,数据路径输入框已隐藏但保留配置兼容,高频安全/执行模式分区前置、基础设施分区后置,`test_item_id`/`dry_run` 兼容字段无用户入口且普通更新不再阻断正式更新;②封面模板「另存为/重命名/删除」低频操作已收敛进「模板操作」菜单;②AI生成底部已增加标题/图片双进度条,运行日志已改为用户可读、自动滚动、脱敏的长任务日志,且②本轮「生成封面图片(成本较高)」开关默认关闭并持久化到 `ai.generate_cover`,关闭时只生成标题并进入可更新状态;②cmhub模式会显示生成后的剩余点数,记录 points_cost/call_id 计费日志,并在点数不足时弹提示且中止未开始任务;②cmhub 生图会额外记录“cmhub 已返回 image_url / 图片下载完成 / 本地保存完成”的分段耗时和下载/保存大小,开始日志会显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,便于区分服务端生成慢、下载慢或本地保存慢;⑤设置页已显示“● 未保存更改”,切 Tab/关闭窗口时拦截保存/放弃/取消,放弃会从 `config.json` 与 `config/cmhub.json` 重新回填,刷新别名/测试连接不自动保存并提醒点保存;cmhub 测试连接/查余额成功时会优先显示 cmhub 账号名或脱敏邮箱,缺账号信息时保留普通成功提示 | | `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 | | `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | | `app/editor.py` | 已有 | T-001/T-103/T-205b/T-207/T-501c/T-502/T-404b + T-404 补丁产出:登录状态检测、打开商品页、详情页加载失败 toast 捕获与商品失效归因、读/写标题、读/下载封面、采集步骤回调、更新步骤回调、采集后关闭自动新建商品页 tab、上传前等待图片管理器稳定、点击上传块后注入文件并检测 CDN 后拖封面、更新封面统一先校验旧封面备份再删线上第一张、重复图片 toast 明确失败、页面主更新按钮、Shopee 站点侧确认框主按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | | `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504/T-520/T-526/T-527/T-538 产出:`data/config.json` 默认值、读写、更新、路径/端口/AI 参数读取、`data_dir` 路径解析、旧顶层用户数据迁移、`data/` 可写性检测、AI `backend=direct/cmhub` 和 `ai.cmhub` 默认值、`data/config/cmhub.json` Key helper、蝦皮更新安全与 dry-run/多账号并行默认值,②生成封面默认关闭的 `ai.generate_cover`;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | | `app/diagnostics.py` | 已有 | T-207 + AI生成诊断补丁 + T-505 产出:本地 `data/logs/cmshopee.log` 诊断日志、大小滚动、异常类型/traceback/step/耗时记录,结构化 payload 和自由文本脱敏后写入 | -| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁 + T-519/T-520/T-526/T-528/T-533/T-535 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;支持 direct 默认模型直连和 cmhub 网关 backend;cmhub 使用 `CMHubError` 结构化错误、tuple timeout、非幂等生图读超时不重发、`image_url` 安全下载、`fetch_cmhub_models()` 别名发现、`fetch_cmhub_balance()` 余额查询和 metadata 事件;cmhub 标题请求连接超时取 `ai.cmhub.connect_timeout`、读取等待固定 600 秒;cmhub 生图请求和图片下载读取等待固定 650 秒;direct 保留通用 HTTP 调用、失败重试、错误脱敏;封面按 resolution/jpg_quality 保存并按批次/店铺/任务路径落盘;批量生成可按 `ai.generate_cover` 跳过封面阶段,开启封面时按缺失组件增量补齐,已有标题不重生、不覆盖手动标题,已有封面不重生;进度回调按标题/封面组件统计,逐条落库、失败标记、事件/错误回调、停止取消未开始项 | +| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁 + T-519/T-520/T-526/T-528/T-533/T-535/T-545 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;支持 direct 默认模型直连和 cmhub 网关 backend;cmhub 使用 `CMHubError` 结构化错误、tuple timeout、非幂等生图读超时不重发、`image_url` 安全下载、`fetch_cmhub_models()` 别名发现、`fetch_cmhub_balance()` 余额查询和 metadata 事件;cmhub 标题请求连接超时取 `ai.cmhub.connect_timeout`、读取等待固定 600 秒;cmhub 生图请求和图片下载读取等待固定 650 秒;cmhub 生图实际请求并发 = `min(ai.image_concurrency, 5)`,拿到 `image_url` 后交给独立下载/保存线程池,下载并发同样最大 5,下载失败只记该任务失败、不重发 cmhub 生图请求;direct 保留通用 HTTP 调用、失败重试、错误脱敏;封面按 resolution/jpg_quality 保存并按批次/店铺/任务路径落盘;批量生成可按 `ai.generate_cover` 跳过封面阶段,开启封面时按缺失组件增量补齐,已有标题不重生、不覆盖手动标题,已有封面不重生;进度回调按标题/封面组件统计,逐条落库、失败标记、事件/错误回调、停止取消未开始项 | | `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 | | `app/db.py` | 已有 | T-003/T-206/T-404a/T-504/T-509/T-534 产出:batches/accounts/tasks schema;batches 软删除字段与默认业务查询过滤;run_logs/run_log_events;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库;生成结果/更新状态本地重置,②生成结果支持按标题/封面组件清空并保持 generated;`update_generated_title()` 本地新标题微调;运行日志写入与查询 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | @@ -81,8 +81,8 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 - 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-103b(登录检测补充识别 Shopee accounts 登录页)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-105b(④启动登录复用已打开 Chrome)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-206(Tab① 删除指定批次软删除)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ 蝦皮更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新蝦皮生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)、T-511(语义色板 + ①②③任务状态列上色)、T-512(③高风险按钮上色 + ①导入校验数字标红)、T-513(登录点 / ③Tab危险标识 / 破坏性按钮上色)、T-514(①②③ 首次空状态引导卡片)、T-515(批次阶段进度总览)、T-516(①筛选对齐②③)、T-517(⑤设置分区 + 清理兼容字段)、T-518(②左栏提示词区组件密度优化)、T-519(②AI生成长任务进度条 + 用户可读滚动日志)、T-520(②AI生成封面可选生成开关)、T-521(依赖清单:锁版本 requirements)、T-522(CI:自动跑语法 + 单元 / GUI 测试)、T-523(拆分 `app/gui.py` 为 `app/gui/` 包)、T-523a(②/③ 新一轮运行前清空界面日志显示)、T-404b(商品详情页加载失败 toast 自动捕获)、T-524(PyInstaller 打包为免安装 exe)、T-526(`app/ai.py` + `appconfig` 接入 cmhub backend)、T-527(⑤设置 cmhub 网关面板)、T-528(②计费错误提示 + 余额展示)、T-529(默认 cmhub 网关并隐藏 AI 后端选择)、T-530(cmhub Base URL 规整 + 404 明确提示)、T-531(⑤设置未保存状态追踪 + 离开确认)、T-532(⑤cmhub连接成功提示显示账号名)。 -- 最近完成补充:T-533(②增量生成:按缺失组件补生成)、T-534(②重置增强:多选/筛选范围 + 按组件重置)、T-535(②cmhub生成标题读取等待固定600秒)、T-536(GUI按钮圆角全局统一)、T-537(品牌名显示为蝦皮圈優化助手)、T-538(打包产物用户数据收进 `data/`)、T-539(⑤隐藏数据路径设置)、T-540(打包版本号与 release 产物命名统一)、T-541(打包版主窗口初始位置与小屏适配)、T-542(GUI 用户可见 shopee/Shopee 文案统一改为蝦皮)、T-543(状态栏语义色与统一提示入口)、T-544(启动时检查强制升级,第一版只提示下载,不自动覆盖)、T-525(引入 ruff lint/format 工程检查)。 -- 下一个可领取任务:暂无正式 TODO;后续可从 Backlog 选择新任务或重新打包验证生产版启动检查。 +- 最近完成补充:T-533(②增量生成:按缺失组件补生成)、T-534(②重置增强:多选/筛选范围 + 按组件重置)、T-535(②cmhub生成标题读取等待固定600秒)、T-536(GUI按钮圆角全局统一)、T-537(品牌名显示为蝦皮圈優化助手)、T-538(打包产物用户数据收进 `data/`)、T-539(⑤隐藏数据路径设置)、T-540(打包版本号与 release 产物命名统一)、T-541(打包版主窗口初始位置与小屏适配)、T-542(GUI 用户可见 shopee/Shopee 文案统一改为蝦皮)、T-543(状态栏语义色与统一提示入口)、T-544(启动时检查强制升级,第一版只提示下载,不自动覆盖)、T-525(引入 ruff lint/format 工程检查)、T-545(②cmhub 生图和下载并发上限 5 + 独立下载线程池)。 +- 下一个可领取任务:暂无正式 TODO;后续可从 Backlog 选择新任务或重新打包验证生产版。 ## 当前已知限制 @@ -125,7 +125,7 @@ - T-523a 已完成:②点击「开始生成」、③点击「检查本轮更新」或「开始更新」时,先清空对应界面的旧日志显示并写入本轮开始摘要;运行中只追加本轮日志。历史 `run_logs/run_log_events` 和本地 `logs/` 不删除、不自动混入当前运行界面。 - T-404b 已完成:`open_product()` 在导航商品详情页前后安装 toast 捕获,等待详情页关键元素超时时会读取最近 `.eds-toasts`/toast/message 节点及页面缓存,把明确商品失效/商品不存在/无权限类 toast 上浮为 `商品失效:<原始toast>`;如果失败发生在 `open_product()` 内部,本轮自动新建的失败 tab 会关闭,复用用户已有 tab 不关闭;①列表“阶段”列只在这类明确失效错误时显示“商品失效”,底层仍保持 `stage=imported/status=failed`,其他打开失败仍显示“失败”。 - T-524 已完成:新增 PyInstaller onedir 打包配置、构建脚本和发布目录校验;T-538 后打包版不再切换全进程工作目录,首次运行在 exe 同级 `data/` 生成/使用本地配置、DB、图片、日志、登录态和提示词;发布包不内置 `data/`,用户后续更新采用关闭程序后覆盖程序文件、保留 `data/` 的方式。T-544 第一版的强制升级弹窗也沿用该手动覆盖方式。 -- Phase 7 cmhub 网关对接已完成 T-526~T-535;②生成阶段已支持按标题/封面缺失组件增量生成,②重置生成结果已支持多选/筛选范围和按标题/封面组件重置,cmhub 标题请求读取等待固定 600 秒。T-539、T-541、T-542、T-543、T-544 与 T-525 已完成;正式任务看板当前无 TODO。 +- Phase 7 cmhub 网关对接已完成 T-526~T-535 与 T-545;②生成阶段已支持按标题/封面缺失组件增量生成,②重置生成结果已支持多选/筛选范围和按标题/封面组件重置,cmhub 标题请求读取等待固定 600 秒,cmhub 生图请求和图片下载读取等待固定 650 秒,并把生图请求/下载保存拆成最大 5 并发的独立线程池。T-539、T-541、T-542、T-543、T-544 与 T-525 已完成;正式任务看板当前无 TODO。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `data/config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 - 本地 `data/config/ai_models.json` 若由旧版本或手工维护,可能缺少 `category`;启动报 “AI 模型 category 必须是 text 或 image” 时,按 [`troubleshooting.md`](troubleshooting.md) 只补 `category` / `enabled` 等非密钥字段,保留 API Key,且不要提交该文件。 diff --git a/docs/routes.md b/docs/routes.md index b59dfc2..1346033 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -98,8 +98,8 @@ - 变量:`{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。 - 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 - 右下:任务列表(店铺名、商品id、旧标题、新标题、状态)+ AI生成运行日志;已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面 | 新封面(纯查看)。 -- 底部**单个「开始生成」+「停止」**,并增加「生成封面图片(成本较高)」复选框:默认不勾选,只生成标题;勾选后才在标题完成后按 `image_concurrency` 并发生成封面。该开关状态持久化到 `config.json` 的 `ai.generate_cover`,但入口放在②,便于用户在每轮生成前做成本判断。 -- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **⑤ 设置**;②只暴露“本轮是否生成封面”的成本开关。 +- 底部**单个「开始生成」+「停止」**,并增加「生成封面图片(成本较高)」复选框:默认不勾选,只生成标题;勾选后才在标题完成后按 `image_concurrency` 生成封面。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。该开关状态持久化到 `config.json` 的 `ai.generate_cover`,但入口放在②,便于用户在每轮生成前做成本判断。 +- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **⑤ 设置**;②只暴露“本轮是否生成封面”的成本开关。⑤ 不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。 - 标题-only 模式标题成功即写库并进入 `generated`,`new_cover_path` 留空;标题+封面模式图片成功后写入本地新封面路径。两种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;未勾选生成封面时日志明确显示“本轮仅生成标题”。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。 - 「重置生成结果」仅作用当前选中单条,运行中禁用;确认后只改本地 DB,清空 `new_title/new_cover_path/last_error` 并退回 `collected/success` 供重新生成,默认不删除本地新封面文件。若该记录已提交过线上,必须在确认框提示本地重置不回滚蝦皮。 - **无逐条人工审核环节**;新标题默认直接用 AI 输出,但允许对已生成且未提交线上的单行做本地微调;可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。 diff --git a/progress.md b/progress.md index cbc71db..daacc1e 100644 --- a/progress.md +++ b/progress.md @@ -1445,3 +1445,27 @@ - 代码:`app/gui/tabs/settings.py` 的返回超时标签读取 `app.ai` 中的 cmhub 超时常量,避免 UI 文案和真实请求等待值分叉。 - 文档:同步 `docs/02-requirements.md`、`docs/04-architecture.md`、`docs/06-tasks.md`、`docs/routes.md`、`docs/api.md`、`docs/current-state.md` 和 `docs/ui/tab5-settings.svg`。 - 验证:`py -3.10 -m unittest discover -s tests -p "test_gui.py"` 通过(102 tests);`py -3.10 -m unittest discover -s tests -p "test_ai.py"` 通过(24 tests);`python -m ruff check app tests main.py` 通过;`py -3.10 -m compileall app main.py` 通过;`py -3.10 -m unittest discover -s tests` 通过(234 tests)。 + +## 【2026-07-07】cmhub 生图链路增加分段耗时日志 + +- 背景:用户观察到 cmhub 后台单条生成时间小于 200 秒,但本地整轮用时接近 8 分钟;需要区分慢在 cmhub 生成、image_url 下载、本地 JPEG 保存还是 DB 写入。 +- 代码:`app/ai.py` 在 cmhub 生图成功链路新增三类事件:`cover_request` 成功时记录“cmhub 已返回 image_url”和请求耗时;`cover_download` 成功时记录下载字节数和下载耗时;`cover_save` 成功时记录 JPEG 保存耗时和保存后文件大小。失败时当前 step 会切到 `cover_download` 或 `cover_save`,便于错误归因。 +- GUI:`app/gui/workers.py` 将上述事件写入②AI生成运行日志和 SQLite `run_log_events`,不记录完整 image_url、prompt、base64、API Key、Cookie 或 token。 +- 文档:同步 `docs/current-state.md`。 +- 验证:`py -3.10 -m unittest discover -s tests -p "test_ai.py"` 通过(24 tests);`py -3.10 -m unittest discover -s tests -p "test_gui.py"` 通过(102 tests);`python -m ruff check app tests main.py` 通过;`py -3.10 -m compileall app main.py` 通过;`py -3.10 -m unittest discover -s tests` 通过(234 tests)。 + +## 【2026-07-07】T-545 文档 · cmhub 生图和下载并发上限 5 + +- 背景:分段日志确认慢点主要在 cmhub `image_url` PNG 下载链路;10 图片并发时 1.6~2.1MB 下载耗时 24~185 秒,并出现下载连接失败。若每个生图线程同时负责等待 cmhub、下载、转 JPEG 和保存,慢下载会占住生图线程。 +- 方案:新增 T-545。cmhub 模式下实际生图请求并发 = `min(ai.image_concurrency, 5)`;拿到 `image_url` 后交给独立下载/保存线程池;下载线程数与实际生图请求并发一致,同样最大 5;不新增用户可见下载并发配置。②运行日志必须显示用户设置图片并发、cmhub 实际生图并发和下载并发。 +- 边界:下载失败可在下载层安全重试或记失败,但不得重新调用 cmhub 生图接口造成重复扣点;`cover_done/generated_done` 必须等下载保存成功并写库后才计数;direct 兼容路径暂不改变。 +- 文档:同步 `docs/02-requirements.md`、`docs/04-architecture.md`、`docs/cmhub-integration-design.md`、`docs/api.md`、`docs/routes.md`、`docs/06-tasks.md` 和 `docs/current-state.md`;本轮只更新文档,未改代码。 + +## 【2026-07-07】T-545 · cmhub 生图和下载并发上限 5 + 独立下载线程池 + +- 代码:`app/ai.py` 新增 `cmhub_image_concurrency_plan()`,cmhub 生图实际请求并发限制为 `min(ai.image_concurrency, 5)`;把 cmhub 封面生成拆成 `_request_cmhub_cover_image()` 和 `_download_and_save_cmhub_cover()` 两段,`generate_batch()` 在 cmhub 模式下使用独立请求线程池和下载/保存线程池,direct 兼容路径保持原 `image_concurrency` 语义。 +- 边界:拿到 `image_url` 后即交给下载池,下载失败只标记该任务失败,不重新调用 cmhub 生图接口;`cover_done/generated_done` 仍在图片下载保存成功并写 DB 后才计数;停止时取消未开始的请求,已拿到 `image_url` 的下载保存允许自然完成或失败,避免丢掉已生成结果。 +- GUI:`app/gui/workers.py` 的②开始日志在 cmhub 模式显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`;继续保留“cmhub 已返回 image_url / 下载完成 / 本地保存完成”的分段耗时日志。 +- 测试:`tests/test_ai.py` 覆盖并发计划、下载慢不阻塞后续 cmhub 生图请求提交、下载失败不重复请求 cmhub 生图;`tests/test_gui.py` 覆盖开始日志中的实际并发展示,并把 direct/mock 路径测试明确指定为 direct backend。 +- 文档:`docs/06-tasks.md` 将 T-545 标为 DONE;同步 `docs/04-architecture.md`、`docs/cmhub-integration-design.md`、`docs/api.md`、`docs/routes.md` 和 `docs/current-state.md`。 +- 验证:`py -3.10 -m unittest discover -s tests -p "test_ai.py"` 通过(27 tests);`py -3.10 -m unittest discover -s tests -p "test_gui.py"` 通过(102 tests);`python -m ruff check app tests main.py` 通过;`py -3.10 -m compileall app main.py` 通过;`git diff --check` 通过;`py -3.10 -m unittest discover -s tests` 通过(237 tests)。 diff --git a/tests/test_ai.py b/tests/test_ai.py index 89d01fd..55ce919 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -4,6 +4,7 @@ import json import os import socket import sys +import threading import unittest from types import SimpleNamespace from unittest import mock @@ -127,6 +128,24 @@ class AITests(TempDirMixin, unittest.TestCase): ) return batch_id, db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + def _write_old_cover_files(self, tasks): + from PIL import Image + + for index, task in enumerate(tasks): + os.makedirs(os.path.dirname(task.old_cover_path), exist_ok=True) + Image.new( + "RGB", + (16, 16), + ((index * 35) % 255, 80, 120), + ).save(task.old_cover_path, "JPEG") + + def _png_bytes(self, color=(200, 120, 80)): + from PIL import Image + + generated = io.BytesIO() + Image.new("RGB", (8, 8), color).save(generated, "PNG") + return generated.getvalue() + def test_gen_title_uses_configured_model_and_retries(self): with self.make_temp_dir() as temp_dir: models_path = os.path.join(temp_dir, "ai_models.json") @@ -360,6 +379,7 @@ class AITests(TempDirMixin, unittest.TestCase): calls = [] downloads = [] events = [] + steps = [] def fake_request(method, url, **kwargs): calls.append((method, url, kwargs)) @@ -399,6 +419,7 @@ class AITests(TempDirMixin, unittest.TestCase): resolution="1k", config=cfg, cmhub_config_path=key_path, + on_step=steps.append, on_event=events.append, ) @@ -412,6 +433,22 @@ class AITests(TempDirMixin, unittest.TestCase): self.assertEqual("https://cdn.example.com/generated.png", downloads[0][0]) self.assertEqual((3, 650), downloads[0][1]["timeout"]) self.assertEqual(91, events[0]["metadata"]["points_balance"]) + timed_steps = [ + event for event in steps + if isinstance(event, dict) and event.get("result") == "success" + ] + timed_step_names = [event.get("step") for event in timed_steps] + self.assertIn("cover_request", timed_step_names) + self.assertIn("cover_download", timed_step_names) + self.assertIn("cover_save", timed_step_names) + self.assertTrue( + any( + event.get("step") == "cover_download" + and "下载完成" in event.get("detail", "") + and "耗时" in event.get("detail", "") + for event in timed_steps + ) + ) with Image.open(output) as saved: self.assertEqual((1024, 1024), saved.size) @@ -554,6 +591,152 @@ class AITests(TempDirMixin, unittest.TestCase): self.assertEqual("https://cmhub.example.com/api/v1/balance", calls[0][1]) self.assertEqual(42, balance["points_balance"]) + def test_cmhub_image_concurrency_plan_caps_at_five(self): + plan = ai.cmhub_image_concurrency_plan({"image_concurrency": 10}) + + self.assertEqual(10, plan["configured_image_concurrency"]) + self.assertEqual(5, plan["request_concurrency"]) + self.assertEqual(5, plan["download_concurrency"]) + self.assertEqual(5, plan["limit"]) + + def test_generate_batch_cmhub_image_downloads_do_not_block_later_requests(self): + try: + from PIL import Image # noqa: F401 + except ImportError: + self.skipTest("Pillow not installed") + + with self.make_temp_dir() as temp_dir: + cfg, key_path = self._cmhub_config(temp_dir) + cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db") + cfg["image_dir"] = os.path.join(temp_dir, "images") + cfg["ai"]["generate_cover"] = True + cfg["ai"]["image_concurrency"] = 10 + titles = ["旧标题%s" % index for index in range(7)] + batch_id, tasks = self._collected_tasks(temp_dir, cfg, titles) + self._write_old_cover_files(tasks) + for index, task in enumerate(tasks): + db.set_generated(task.id, "已有标题%s" % index, None, path=cfg["db_path"]) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + generated_png = self._png_bytes() + public_dns = [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)) + ] + lock = threading.Lock() + all_requests_seen = threading.Event() + counters = {"request_count": 0, "active": 0, "max_active": 0} + downloads = [] + + def fake_request(method, url, **kwargs): + with lock: + counters["request_count"] += 1 + request_index = counters["request_count"] + counters["active"] += 1 + counters["max_active"] = max( + counters["max_active"], + counters["active"], + ) + if counters["request_count"] >= len(tasks): + all_requests_seen.set() + try: + return _RequestsResponse( + { + "image_url": "https://cdn.example.com/generated-%s.png" + % request_index + } + ) + finally: + with lock: + counters["active"] -= 1 + + def fake_get(url, **kwargs): + self.assertTrue( + all_requests_seen.wait(2), + "慢下载不能阻塞后续 cmhub 生图请求提交", + ) + with lock: + downloads.append((url, kwargs)) + return _RequestsResponse(content=generated_png) + + with mock.patch("app.ai.requests.request", side_effect=fake_request), \ + mock.patch("app.ai.requests.get", side_effect=fake_get), \ + mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns): + summary = ai.generate_batch( + tasks, + {"title": "标题提示", "cover": "封面 {新标题}"}, + ai_cfg={ + "config": cfg, + "db_path": cfg["db_path"], + "cmhub_config_path": key_path, + }, + ) + + self.assertTrue(summary["ok"]) + self.assertEqual(len(tasks), summary["cover_done"]) + self.assertEqual(len(tasks), summary["generated_done"]) + self.assertEqual(len(tasks), counters["request_count"]) + self.assertLessEqual(counters["max_active"], 5) + self.assertEqual(len(tasks), len(downloads)) + updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + self.assertTrue(all(os.path.exists(task.new_cover_path) for task in updated)) + + self.assert_removed(temp_dir) + + def test_generate_batch_cmhub_download_failure_does_not_request_image_again(self): + try: + from PIL import Image # noqa: F401 + except ImportError: + self.skipTest("Pillow not installed") + + with self.make_temp_dir() as temp_dir: + cfg, key_path = self._cmhub_config(temp_dir) + cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db") + cfg["image_dir"] = os.path.join(temp_dir, "images") + cfg["ai"]["generate_cover"] = True + cfg["ai"]["image_concurrency"] = 10 + batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题"]) + self._write_old_cover_files(tasks) + db.set_generated(tasks[0].id, "已有标题", None, path=cfg["db_path"]) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + public_dns = [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)) + ] + requests_seen = [] + downloads_seen = [] + + def fake_request(method, url, **kwargs): + requests_seen.append((method, url, kwargs)) + return _RequestsResponse( + {"image_url": "https://cdn.example.com/generated.png"} + ) + + def fake_get(url, **kwargs): + downloads_seen.append((url, kwargs)) + raise ai.requests.exceptions.ConnectionError("download failed") + + with mock.patch("app.ai.requests.request", side_effect=fake_request), \ + mock.patch("app.ai.requests.get", side_effect=fake_get), \ + mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns): + summary = ai.generate_batch( + tasks, + {"title": "标题提示", "cover": "封面 {新标题}"}, + ai_cfg={ + "config": cfg, + "db_path": cfg["db_path"], + "cmhub_config_path": key_path, + }, + ) + + self.assertFalse(summary["ok"]) + self.assertEqual(0, summary["cover_done"]) + self.assertEqual(1, summary["failed"]) + self.assertEqual(1, len(requests_seen)) + self.assertEqual(1, len(downloads_seen)) + updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + self.assertEqual("failed", updated.status) + self.assertIn("下载 cmhub 图片失败", updated.last_error) + + self.assert_removed(temp_dir) + def test_generate_batch_forwards_cmhub_metadata_event(self): with self.make_temp_dir() as temp_dir: cfg, key_path = self._cmhub_config(temp_dir) diff --git a/tests/test_gui.py b/tests/test_gui.py index 8027b05..60d0a83 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1936,6 +1936,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.ai_config(cfg) + cfg["ai"]["backend"] = "direct" cfg["ai"]["generate_cover"] = True account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) @@ -1989,6 +1990,33 @@ class GuiTests(TempDirMixin, unittest.TestCase): "level": "warning", } ) + ai_cfg["on_event"]( + { + "task": tasks[0], + "phase": "cover", + "step": "cover_request", + "result": "success", + "detail": "cmhub 已返回 image_url,耗时 91.2秒", + } + ) + ai_cfg["on_event"]( + { + "task": tasks[0], + "phase": "cover", + "step": "cover_download", + "result": "success", + "detail": "下载完成,1.3MB,耗时 12.4秒", + } + ) + ai_cfg["on_event"]( + { + "task": tasks[0], + "phase": "cover", + "step": "cover_save", + "result": "success", + "detail": "JPEG 已保存,耗时 1.1秒,文件 220.0KB", + } + ) ai_cfg["on_task_update"](tasks[0].id, {"stage": "generated"}) return {"ok": True, "total": 1, "title_done": 1, "cover_done": 1, "failed": 0} @@ -2016,6 +2044,9 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIn("[开始] 本轮生成 1 条", joined_logs) self.assertIn("[图片] 1/1 商品 51100639510", joined_logs) self.assertIn("准备重试 1/2", joined_logs) + self.assertIn("cmhub 已返回 image_url,耗时 91.2秒", joined_logs) + self.assertIn("下载完成,1.3MB,耗时 12.4秒", joined_logs) + self.assertIn("本地保存完成,JPEG 已保存,耗时 1.1秒,文件 220.0KB", joined_logs) self.assertIn("token=***", joined_logs) self.assertNotIn("SECRET-TOKEN", joined_logs) self.assertIn("[完成] AI 生成完成:标题1/1,图片1/1,失败0", joined_logs) @@ -2023,6 +2054,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): event_messages = "\n".join(event.message for event in events) self.assertIn("[图片] 1/1 商品 51100639510", event_messages) self.assertIn("准备重试 1/2", event_messages) + self.assertIn("下载完成,1.3MB,耗时 12.4秒", event_messages) self.assert_removed(temp_dir) @@ -2032,6 +2064,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): cfg["ai"] = appconfig.ai_config(cfg) cfg["ai"]["backend"] = "cmhub" cfg["ai"]["generate_cover"] = True + cfg["ai"]["image_concurrency"] = 10 accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) db.insert_tasks( @@ -2096,6 +2129,9 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(88, summary["points_balance"]) self.assertEqual(88, progress[-1]["points_balance"]) joined_logs = "\n".join(logs) + self.assertIn("图片并发10", joined_logs) + self.assertIn("cmhub实际生图并发5", joined_logs) + self.assertIn("下载并发5", joined_logs) self.assertIn("[计费] 商品 51100639510", joined_logs) self.assertIn("别名 title-standard", joined_logs) self.assertIn("扣点 1", joined_logs) @@ -2193,6 +2229,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.ai_config(cfg) + cfg["ai"]["backend"] = "direct" cfg["ai"]["generate_cover"] = True accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) @@ -2337,6 +2374,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.ai_config(cfg) + cfg["ai"]["backend"] = "direct" cfg["ai"]["generate_cover"] = True accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])