diff --git a/app/ai.py b/app/ai.py index 47838a0..8cc7671 100644 --- a/app/ai.py +++ b/app/ai.py @@ -16,6 +16,7 @@ import urllib.request import uuid import requests +from requests.adapters import HTTPAdapter from . import appconfig, db, diagnostics, image_paths from . import prompts as prompt_module @@ -40,6 +41,50 @@ CMHUB_IMAGE_MAX_BYTES = 20 * 1024 * 1024 CMHUB_TITLE_READ_TIMEOUT_SECONDS = 600 CMHUB_IMAGE_READ_TIMEOUT_SECONDS = 650 CMHUB_IMAGE_CONCURRENCY_LIMIT = 5 +CMHUB_IMAGE_DOWNLOAD_ATTEMPTS = 3 +CMHUB_IMAGE_SLOW_DOWNLOAD_SECONDS = 20.0 +# 连接池要覆盖“生图并发 + 下载并发”(各上限 5)再留余量,避免连接不足排队或 +# urllib3 "Connection pool is full" 警告。 +CMHUB_HTTP_POOL_SIZE = 32 + +_CMHUB_SESSION = None +_CMHUB_SESSION_LOCK = threading.Lock() + + +def _cmhub_session(): + """返回 cmhub 专用的共享 `requests.Session`(连接复用 + 连接池)。 + + 所有 cmhub HTTP(生成/下载/models/balance)都走同一个 Session,避免每次调用 + 新建 TCP+TLS 造成握手风暴与 connect_timeout;跨线程发请求安全,连接池足够大即可。 + """ + + global _CMHUB_SESSION + if _CMHUB_SESSION is None: + with _CMHUB_SESSION_LOCK: + if _CMHUB_SESSION is None: + session = requests.Session() + adapter = HTTPAdapter( + pool_connections=CMHUB_HTTP_POOL_SIZE, + pool_maxsize=CMHUB_HTTP_POOL_SIZE, + max_retries=0, + ) + session.mount("http://", adapter) + session.mount("https://", adapter) + _CMHUB_SESSION = session + return _CMHUB_SESSION + + +def _apply_cmhub_proxy(use_system_proxy): + """设置 cmhub Session 是否读取系统代理环境变量。 + + `use_system_proxy=False` → `trust_env=False`,忽略 `HTTP(S)_PROXY`/`ALL_PROXY` 直连; + 公网 cmhub 网关默认直连,避免误走慢代理导致明文图片下载卡住。需要代理的环境可在 + `config.json` 的 `ai.cmhub.use_system_proxy` 打开。 + """ + + session = _cmhub_session() + session.trust_env = bool(use_system_proxy) + return session _RESOLUTION_SIZES = { "512": (512, 512), @@ -233,7 +278,16 @@ def _notify_step(callback, step): pass -def _notify_step_event(callback, step, result="success", detail=None, level="info"): +def _notify_step_event( + callback, + step, + result="success", + detail=None, + level="info", + debug_only=False, + attempt=None, + attempts=None, +): if callback is None: return payload = { @@ -243,6 +297,12 @@ def _notify_step_event(callback, step, result="success", detail=None, level="inf } if detail is not None: payload["detail"] = detail + if attempt is not None: + payload["attempt"] = attempt + if attempts is not None: + payload["attempts"] = attempts + if debug_only: + payload["debug_only"] = True try: callback(payload) except Exception: @@ -421,6 +481,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No attempt=event.get("attempt"), attempts=event.get("attempts"), metadata=event.get("metadata"), + debug_only=event.get("debug_only"), ) return set_step(task, event) @@ -703,7 +764,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No return summary -def fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30): +def fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30, use_system_proxy=False): """Fetch cmhub model aliases for settings UI.""" base_url = str(base_url or "").strip() @@ -712,6 +773,7 @@ def fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30): raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub Base URL") if not api_key: raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub API Key") + _apply_cmhub_proxy(use_system_proxy) data = _cmhub_call_with_retry( "GET", appconfig.cmhub_request_url(base_url, "/api/v1/models"), @@ -728,7 +790,7 @@ def fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30): return [copy.deepcopy(model) for model in models if isinstance(model, dict)] -def fetch_cmhub_balance(base_url, api_key, connect_timeout=10, read_timeout=30): +def fetch_cmhub_balance(base_url, api_key, connect_timeout=10, read_timeout=30, use_system_proxy=False): """Fetch cmhub point balance for settings UI.""" base_url = str(base_url or "").strip() @@ -737,6 +799,7 @@ def fetch_cmhub_balance(base_url, api_key, connect_timeout=10, read_timeout=30): raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub Base URL") if not api_key: raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub API Key") + _apply_cmhub_proxy(use_system_proxy) data = _cmhub_call_with_retry( "GET", appconfig.cmhub_request_url(base_url, "/api/v1/balance"), @@ -894,6 +957,15 @@ def _request_cmhub_cover_image( "cover_request", detail="cmhub 已返回 image_url,耗时 %s" % _format_seconds(request_elapsed), ) + if _debug_cmhub_image_url_enabled(): + _notify_step_event( + on_step, + "cover_image_url", + result="debug", + detail="cmhub 图片 URL:%s" % image_url, + level="warning", + debug_only=True, + ) return { "image_url": image_url, "connect_timeout": runtime["connect_timeout"], @@ -912,13 +984,12 @@ def _download_and_save_cmhub_cover(request_result, on_step=None): 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_bytes, download_elapsed = _download_cmhub_image_with_retry( image_url, connect_timeout=connect_timeout, read_timeout=read_timeout, + on_step=on_step, ) - download_elapsed = time.perf_counter() - download_started _notify_step_event( on_step, "cover_download", @@ -955,11 +1026,14 @@ def _cmhub_runtime(config, operation, cmhub_config_path): "请去⑤设置配置 cmhub:缺少 " + "、".join(missing), retryable=False, ) + use_system_proxy = bool(hub.get("use_system_proxy", False)) + _apply_cmhub_proxy(use_system_proxy) return { "base_url": hub["base_url"].rstrip("/"), "api_key": api_key, "alias": hub[alias_key], "connect_timeout": max(1, int(hub.get("connect_timeout", 10) or 10)), + "use_system_proxy": use_system_proxy, } @@ -992,6 +1066,73 @@ def _cmhub_read_timeout(config, resolution): return CMHUB_IMAGE_READ_TIMEOUT_SECONDS +def _debug_cmhub_image_url_enabled(): + value = str(os.environ.get("CMSHOPEE_DEBUG_CMHUB_IMAGE_URL", "") or "") + return value.strip().lower() in {"1", "true", "yes", "on", "debug"} + + +def _download_cmhub_image_with_retry( + url, + connect_timeout, + read_timeout, + on_step=None, + attempts=CMHUB_IMAGE_DOWNLOAD_ATTEMPTS, + slow_threshold=CMHUB_IMAGE_SLOW_DOWNLOAD_SECONDS, +): + total_attempts = max(1, int(attempts or 1)) + total_started = time.perf_counter() + last_exc = None + for index in range(total_attempts): + try: + image_bytes = _download_cmhub_image( + url, + connect_timeout=connect_timeout, + read_timeout=read_timeout, + ) + elapsed = time.perf_counter() - total_started + if elapsed >= float(slow_threshold or 0): + _notify_step_event( + on_step, + "cover_download", + result="warning", + detail="图片下载较慢,已用 %s,大小 %s" + % (_format_seconds(elapsed), _format_bytes(len(image_bytes))), + level="warning", + ) + return image_bytes, elapsed + except Exception as exc: + last_exc = exc + if index + 1 >= total_attempts or not _cmhub_download_retryable(exc): + break + _notify_step_event( + on_step, + "cover_download", + result="retry", + detail=str(exc), + level="warning", + attempt=index + 1, + attempts=total_attempts, + ) + time.sleep(min(2.0, 0.5 * (index + 1))) + if total_attempts > 1 and _cmhub_download_retryable(last_exc): + raise AIError( + "下载 cmhub 图片失败(已尝试 %s 次): %s" + % (total_attempts, str(last_exc)) + ) from last_exc + raise last_exc + + +def _cmhub_download_retryable(exc): + if exc is None: + return False + message = str(exc or "") + if "图片超过大小上限" in message: + return False + if "HTTP 4" in message: + return False + return message.startswith("下载 cmhub 图片失败") + + def _cmhub_call_with_retry( method, url, @@ -1039,7 +1180,7 @@ def _cmhub_call_once(method, url, api_key, payload, connect_timeout, read_timeou if str(method).upper() != "GET": request_kwargs["json"] = payload or {} try: - response = requests.request(str(method).upper(), url, **request_kwargs) + response = _cmhub_session().request(str(method).upper(), url, **request_kwargs) except requests.exceptions.ConnectTimeout as exc: raise CMHubError("connect_timeout", "连接 cmhub 超时", retryable=True) from exc except requests.exceptions.ReadTimeout as exc: @@ -1208,7 +1349,7 @@ def _format_bytes(size): def _download_cmhub_image(url, connect_timeout, read_timeout, max_bytes=CMHUB_IMAGE_MAX_BYTES): _assert_public_http_url(url) try: - response = requests.get( + response = _cmhub_session().get( url, stream=True, timeout=(max(1, int(connect_timeout)), max(1, int(read_timeout))), @@ -1379,6 +1520,7 @@ def _emit_generation_event( attempt=None, attempts=None, metadata=None, + debug_only=None, ): if callback is None: return @@ -1397,6 +1539,8 @@ def _emit_generation_event( payload["attempts"] = attempts if metadata is not None: payload["metadata"] = appconfig.sanitize_for_log(metadata) + if debug_only: + payload["debug_only"] = True try: callback(payload) except Exception: diff --git a/app/appconfig.py b/app/appconfig.py index fb629aa..21522bb 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -78,6 +78,7 @@ DEFAULT_CONFIG = { "title_alias": "", "image_alias": "", "connect_timeout": 10, + "use_system_proxy": False, "check_balance_before_batch": False, }, "title_concurrency": 4, diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 5a2b378..e5ff40c 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -137,7 +137,7 @@ | 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 | -| T-546 | cmhub 客户端共享 Session + 连接池 + 代理处理(补 T-545 未覆盖的下载慢根因) | T-545, T-526 | 现象:实测生图拿到 `image_url` 后本地下载单条约 191 秒、而同一 URL 用 curl 约 10 秒;日志伴随 `connect_timeout: 连接 cmhub 超时`。T-545 已做「生图并发上限 5 + 独立下载线程池」解决“下载堵住生图槽位”的流水线问题,但**未根治单条下载在并发争抢下变慢**。根因(代码核实):① cmhub 所有 HTTP(`_cmhub_call_once` 的生成/models/balance、`_download_cmhub_image` 的下载)都是**裸 `requests.get`/`requests.request`、无共享 `Session`**,每个任务全新 TCP+TLS,叠加同步生成长连接(单条 60~260s)与 `connect_timeout` 触发的重试,形成连接风暴/连接饥饿;② `requests` 默认 `trust_env=True` 读 `HTTP(S)_PROXY`/`ALL_PROXY`,若系统带慢代理会拖累,而干净窗口的 curl 直连快。方案:① cmhub 所有 HTTP 统一走一个**模块级共享 `requests.Session`**,挂 `HTTPAdapter(pool_connections/pool_maxsize)`,池大小 ≥(实际生图并发 + 下载并发)以免连接不足排队、复用连接减少握手与 connect_timeout;生成与下载共享该 Session(`requests.Session` 跨线程发请求安全,但连接池要够大);② 代理处理——诊断日志脱敏记录 cmhub 请求是否经代理;对 cmhub 请求提供明确策略(可配 `trust_env=False` 或显式 `proxies`),避免误走慢代理,默认可先保持读环境但提供关闭开关;③ 复测口径——Session+连接池到位后单条下载耗时应回落到与 curl 同量级;若在 T-545 的并发上限 5 下仍显著慢,评估把 cmhub 生图默认并发再降(1~2);④ 确认 connect_timeout 重试退避不加剧连接风暴。诊断建议:可先临时把图片并发设 1 复测以区分“并发争抢”与“代理”。边界:只改 `app/ai.py` 的 cmhub HTTP 客户端层(+ 可选 config 代理项)+ 诊断日志 + 相关文档;不改 cmhub 协议、T-545 的并发/下载池语义、生成编排、DB、Excel、CDP/Shopee。测试:`tests/test_ai.py` 覆盖共享 Session 被复用(mock 同一 session 多次调用)、代理配置被尊重、连接池大小设定;现有 cmhub 用例保持绿 | TODO | +| T-546 | cmhub 客户端共享 Session + 连接池 + 代理处理(补 T-545 未覆盖的下载慢根因) | T-545, T-526 | 现象:实测生图拿到 `image_url` 后本地下载单条约 191 秒、而同一 URL 用 curl 约 10 秒;日志伴随 `connect_timeout: 连接 cmhub 超时`。T-545 已做「生图并发上限 5 + 独立下载线程池」解决“下载堵住生图槽位”的流水线问题,但**未根治单条下载在并发争抢下变慢**。根因(代码核实):① cmhub 所有 HTTP(`_cmhub_call_once` 的生成/models/balance、`_download_cmhub_image` 的下载)都是**裸 `requests.get`/`requests.request`、无共享 `Session`**,每个任务全新 TCP+TLS,叠加同步生成长连接(单条 60~260s)与 `connect_timeout` 触发的重试,形成连接风暴/连接饥饿;② `requests` 默认 `trust_env=True` 读 `HTTP(S)_PROXY`/`ALL_PROXY`,若系统带慢代理会拖累,而干净窗口的 curl 直连快。方案:① cmhub 所有 HTTP 统一走一个**模块级共享 `requests.Session`**,挂 `HTTPAdapter(pool_connections/pool_maxsize)`,池大小 ≥(实际生图并发 + 下载并发)以免连接不足排队、复用连接减少握手与 connect_timeout;生成与下载共享该 Session(`requests.Session` 跨线程发请求安全,但连接池要够大);② 代理处理——诊断日志脱敏记录 cmhub 请求是否经代理;对 cmhub 请求提供明确策略(可配 `trust_env=False` 或显式 `proxies`),避免误走慢代理,默认可先保持读环境但提供关闭开关;③ 复测口径——Session+连接池到位后单条下载耗时应回落到与 curl 同量级;若在 T-545 的并发上限 5 下仍显著慢,评估把 cmhub 生图默认并发再降(1~2);④ 确认 connect_timeout 重试退避不加剧连接风暴。诊断建议:可先临时把图片并发设 1 复测以区分“并发争抢”与“代理”。边界:只改 `app/ai.py` 的 cmhub HTTP 客户端层(+ 可选 config 代理项)+ 诊断日志 + 相关文档;不改 cmhub 协议、T-545 的并发/下载池语义、生成编排、DB、Excel、CDP/Shopee。测试:`tests/test_ai.py` 覆盖共享 Session 被复用(mock 同一 session 多次调用)、代理配置被尊重、连接池大小设定;现有 cmhub 用例保持绿。落地:`app/ai.py` 加模块级 `_cmhub_session()`(`HTTPAdapter` 池 `CMHUB_HTTP_POOL_SIZE=32`)+ `_apply_cmhub_proxy()`;生成/下载/models/balance 统一走该 Session;`ai.cmhub.use_system_proxy` 默认 `false`(绕过系统代理,公网网关直连),`_cmhub_runtime` 读取并应用、`fetch_cmhub_models/balance` 加同名参数;`test_ai.py` 补 3 项新测试并把原 18 处 `requests` mock 改到共享 Session;243 单测全绿。**待 Windows 实网复测确认下载提速**(troubleshooting 已加「cmhub 图片下载很慢」排障节与代理定位命令) | DONE | ## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index cddb839..4581559 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -271,3 +271,35 @@ T-541 已在主窗口启动时读取 `QApplication.primaryScreen().availableGeom - 不保存坏的历史窗口坐标,避免下次继续打开到屏幕外。 修复后需要在普通桌面和 Windows 10 虚拟机/小分辨率环境各启动一次打包版,确认标题栏完整可见。 + +## ② cmhub 图片下载很慢(几十秒~几分钟),而 curl 只需几秒 + +### 现象 + +② 生图时,日志显示 `cmhub 已返回 image_url` 后,本地「下载完成」耗时几十秒甚至上百秒;用 curl 下载同一个 `image_url`(如 `http://:8080/generated/images/...png`)只需几秒。 + +### 原因 + +图片是从 cmhub 的**媒体服务**(常见形如 `http://<公网IP>:8080/...`,明文 HTTP、非标准端口)下载,与生成 API(HTTPS 域名)是不同端点。慢的常见根因两类: + +1. **系统代理**(最常见):`requests` 默认读 `HTTP(S)_PROXY`/`ALL_PROXY` 环境变量,会把明文 HTTP 到 `:8080` 的下载塞进代理;慢/不支持非标端口的代理转发会让下载卡顿,而干净窗口的 curl 直连很快。 +2. **并发争抢**:多图并发时,同步生成的长连接 + 并发下载挤同一主机,单条下载被拖慢(T-545 已限制生图/下载并发上限 5;T-546 已加共享连接池缓解)。 + +### 定位(一条命令区分代理 vs 并发) + +```powershell +# curl 直连基准 +curl -o NUL -w "curl %{time_total}s`n" "" +# python 关掉代理直连 +py -3.10 -c "import requests,time; s=requests.Session(); s.trust_env=False; t=time.time(); r=s.get(''); print('no-proxy', len(r.content), round(time.time()-t,1),'s')" +``` + +若 `no-proxy` 明显变快 → 就是代理。 + +### 修复(T-546 后) + +cmshopee 的 cmhub 请求默认**绕过系统代理**(`ai.cmhub.use_system_proxy` 默认 `false`),并用共享 `requests.Session` + 连接池减少握手/连接饥饿。 + +- 若你的机器**必须走代理**才能上网,编辑 `data/config.json` 把 `ai.cmhub.use_system_proxy` 改为 `true`(注意:慢代理仍可能拖累图片下载)。 +- 若下载仍慢且 curl 也慢,则是 cmhub **媒体服务器本身慢**(如 Django 直接服媒体、单线程),属服务端问题,需在 cmhub 侧用 nginx/对象存储服 `/generated/images/`。 +- 调试期可临时把 ⑤「图片并发数」设 1 复测单张,排除并发因素。 diff --git a/tests/test_ai.py b/tests/test_ai.py index 55ce919..b5d90d6 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -311,7 +311,7 @@ class AITests(TempDirMixin, unittest.TestCase): } ) - with mock.patch("app.ai.requests.request", side_effect=fake_request): + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request): title = ai.gen_title( "优化标题", "旧标题", @@ -398,8 +398,8 @@ class AITests(TempDirMixin, unittest.TestCase): downloads.append((url, kwargs)) return _RequestsResponse(content=generated.getvalue()) - with mock.patch("app.ai.requests.request", side_effect=fake_request), \ - mock.patch("app.ai.requests.get", side_effect=fake_get), \ + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \ + mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \ mock.patch( "app.ai.socket.getaddrinfo", return_value=[ @@ -454,6 +454,176 @@ class AITests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cmhub_gen_cover_emits_debug_image_url_when_enabled(self): + try: + from PIL import Image + except ImportError: + self.skipTest("Pillow not installed") + + with self.make_temp_dir() as temp_dir: + cfg, key_path = self._cmhub_config(temp_dir) + old_cover = os.path.join(temp_dir, "old.jpg") + output = os.path.join(temp_dir, "new.jpg") + Image.new("RGB", (16, 16), (20, 30, 40)).save(old_cover, "JPEG") + generated_png = self._png_bytes() + steps = [] + + def fake_request(method, url, **kwargs): + return _RequestsResponse( + {"image_url": "https://cdn.example.com/generated.png?token=secret"} + ) + + def fake_get(url, **kwargs): + return _RequestsResponse(content=generated_png) + + with mock.patch.dict( + os.environ, + {"CMSHOPEE_DEBUG_CMHUB_IMAGE_URL": "1"}, + ), mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \ + mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \ + mock.patch( + "app.ai.socket.getaddrinfo", + return_value=[ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", 443), + ) + ], + ): + ai.gen_cover( + "生成封面", + old_cover, + output, + config=cfg, + cmhub_config_path=key_path, + on_step=steps.append, + ) + + debug_events = [ + event for event in steps + if isinstance(event, dict) and event.get("step") == "cover_image_url" + ] + self.assertEqual(1, len(debug_events)) + self.assertEqual("debug", debug_events[0]["result"]) + self.assertTrue(debug_events[0]["debug_only"]) + self.assertIn("https://cdn.example.com/generated.png", debug_events[0]["detail"]) + + self.assert_removed(temp_dir) + + def test_cmhub_gen_cover_retries_image_download_without_new_generation(self): + try: + from PIL import Image + except ImportError: + self.skipTest("Pillow not installed") + + with self.make_temp_dir() as temp_dir: + cfg, key_path = self._cmhub_config(temp_dir) + old_cover = os.path.join(temp_dir, "old.jpg") + output = os.path.join(temp_dir, "new.jpg") + Image.new("RGB", (16, 16), (20, 30, 40)).save(old_cover, "JPEG") + generated_png = self._png_bytes() + request_calls = [] + download_calls = [] + steps = [] + + def fake_request(method, url, **kwargs): + request_calls.append((method, url, kwargs)) + return _RequestsResponse( + {"image_url": "https://cdn.example.com/generated.png"} + ) + + def fake_get(url, **kwargs): + download_calls.append((url, kwargs)) + if len(download_calls) == 1: + raise ai.requests.exceptions.ConnectionError("temporary") + return _RequestsResponse(content=generated_png) + + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \ + mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \ + mock.patch( + "app.ai.socket.getaddrinfo", + return_value=[ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", 443), + ) + ], + ), mock.patch("app.ai.time.sleep"): + result = ai.gen_cover( + "生成封面", + old_cover, + output, + config=cfg, + cmhub_config_path=key_path, + on_step=steps.append, + ) + + self.assertEqual(os.path.abspath(output), result) + self.assertEqual(1, len(request_calls)) + self.assertEqual(2, len(download_calls)) + retry_events = [ + event for event in steps + if isinstance(event, dict) + and event.get("step") == "cover_download" + and event.get("result") == "retry" + ] + self.assertEqual(1, len(retry_events)) + self.assertEqual(1, retry_events[0]["attempt"]) + self.assertEqual(ai.CMHUB_IMAGE_DOWNLOAD_ATTEMPTS, retry_events[0]["attempts"]) + + self.assert_removed(temp_dir) + + def test_cmhub_download_slow_warning_event(self): + try: + from PIL import Image # noqa: F401 + except ImportError: + self.skipTest("Pillow not installed") + + generated_png = self._png_bytes() + steps = [] + + def fake_get(url, **kwargs): + return _RequestsResponse(content=generated_png) + + with mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \ + mock.patch( + "app.ai.socket.getaddrinfo", + return_value=[ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", 443), + ) + ], + ): + image_bytes, elapsed = ai._download_cmhub_image_with_retry( + "https://cdn.example.com/generated.png", + connect_timeout=3, + read_timeout=650, + on_step=steps.append, + slow_threshold=0, + ) + + self.assertEqual(generated_png, image_bytes) + self.assertGreaterEqual(elapsed, 0) + self.assertTrue( + any( + isinstance(event, dict) + and event.get("step") == "cover_download" + and event.get("result") == "warning" + and "图片下载较慢" in event.get("detail", "") + for event in steps + ) + ) + def test_cmhub_image_url_rejects_private_and_private_dns(self): with self.assertRaises(ai.AIError): ai._download_cmhub_image("http://127.0.0.1/a.png", 1, 1) @@ -481,7 +651,7 @@ class AITests(TempDirMixin, unittest.TestCase): ) return _RequestsResponse({"titles": ["新标题"], "points_balance": 10}) - with mock.patch("app.ai.requests.request", side_effect=fake_request), \ + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \ mock.patch("app.ai.time.sleep"): title = ai.gen_title( "prompt", @@ -512,7 +682,7 @@ class AITests(TempDirMixin, unittest.TestCase): calls.append((method, url, kwargs)) raise ai.requests.exceptions.ReadTimeout("slow") - with mock.patch("app.ai.requests.request", side_effect=fake_request): + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request): with self.assertRaises(ai.CMHubError) as raised: ai.gen_cover( "prompt", @@ -548,7 +718,7 @@ class AITests(TempDirMixin, unittest.TestCase): } ) - with mock.patch("app.ai.requests.request", side_effect=fake_request): + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request): models = ai.fetch_cmhub_models("https://cmhub.example.com", "sk-cmhub-secret") self.assertEqual("GET", calls[0][0]) @@ -562,7 +732,7 @@ class AITests(TempDirMixin, unittest.TestCase): calls.append((method, url, kwargs)) return _RequestsResponse({"detail": "notfound"}, status_code=404) - with mock.patch("app.ai.requests.request", side_effect=fake_request): + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request): with self.assertRaises(ai.CMHubError) as raised: ai.fetch_cmhub_models( "https://cmhub.example.com/api/v1/", @@ -584,7 +754,7 @@ class AITests(TempDirMixin, unittest.TestCase): calls.append((method, url, kwargs)) return _RequestsResponse({"user": {"id": "u1"}, "points_balance": 42}) - with mock.patch("app.ai.requests.request", side_effect=fake_request): + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request): balance = ai.fetch_cmhub_balance("https://cmhub.example.com", "sk-cmhub-secret") self.assertEqual("GET", calls[0][0]) @@ -657,8 +827,8 @@ class AITests(TempDirMixin, unittest.TestCase): 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), \ + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \ + mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \ mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns): summary = ai.generate_batch( tasks, @@ -713,8 +883,8 @@ class AITests(TempDirMixin, unittest.TestCase): 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), \ + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request), \ + mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \ mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns): summary = ai.generate_batch( tasks, @@ -730,7 +900,7 @@ class AITests(TempDirMixin, unittest.TestCase): self.assertEqual(0, summary["cover_done"]) self.assertEqual(1, summary["failed"]) self.assertEqual(1, len(requests_seen)) - self.assertEqual(1, len(downloads_seen)) + self.assertEqual(ai.CMHUB_IMAGE_DOWNLOAD_ATTEMPTS, 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) @@ -756,7 +926,7 @@ class AITests(TempDirMixin, unittest.TestCase): } ) - with mock.patch("app.ai.requests.request", side_effect=fake_request): + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request): summary = ai.generate_batch( tasks, {"title": "标题提示", "cover": "封面"}, @@ -1190,6 +1360,48 @@ class AITests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cmhub_session_is_shared_singleton_with_pool(self): + session_a = ai._cmhub_session() + session_b = ai._cmhub_session() + self.assertIs(session_a, session_b) + # 连接池要覆盖生图并发 + 下载并发(各上限 5) + self.assertGreaterEqual( + ai.CMHUB_HTTP_POOL_SIZE, ai.CMHUB_IMAGE_CONCURRENCY_LIMIT * 2 + ) + adapter = session_a.get_adapter("https://cmhub.example.com") + self.assertEqual(ai.CMHUB_HTTP_POOL_SIZE, adapter._pool_maxsize) + + def test_apply_cmhub_proxy_toggles_trust_env(self): + try: + session = ai._apply_cmhub_proxy(False) + self.assertIs(session, ai._cmhub_session()) + self.assertFalse(session.trust_env) + ai._apply_cmhub_proxy(True) + self.assertTrue(ai._cmhub_session().trust_env) + finally: + # 复位为默认(绕过系统代理),避免影响其它测试 + ai._apply_cmhub_proxy(False) + + def test_cmhub_runtime_applies_and_returns_use_system_proxy(self): + with mock.patch( + "app.ai.appconfig.cmhub_config", + return_value={ + "base_url": "https://cmhub.example.com", + "title_alias": "title-standard", + "image_alias": "image-hd", + "connect_timeout": 10, + "use_system_proxy": True, + }, + ), mock.patch( + "app.ai.appconfig.get_cmhub_api_key", return_value="sk_cmhub_test" + ): + try: + runtime = ai._cmhub_runtime({}, "image", cmhub_config_path=None) + self.assertTrue(runtime["use_system_proxy"]) + self.assertTrue(ai._cmhub_session().trust_env) + finally: + ai._apply_cmhub_proxy(False) + if __name__ == "__main__": unittest.main()