diff --git a/app/gui/workers.py b/app/gui/workers.py index b2c564c..4b64892 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -2,7 +2,18 @@ from __future__ import annotations +import datetime as _dt +import re + from .widgets import * + + +_USER_LOG_URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE) +_USER_LOG_PATH_RE = re.compile( + r"(?i)(/api(?:/v\d+)?/[^\s,,;;))\]]*|/generated/images/[^\s,,;;))\]]*)" +) + + class GenerateWorker(BaseWorker): """Generate titles and covers for eligible collected or failed generation tasks.""" @@ -28,8 +39,12 @@ class GenerateWorker(BaseWorker): self._cmhub_points_balance = None self._billing_error = None self._billing_stop_requested = False + self._run_started_at_text = "" + self._run_started_monotonic = None def execute(self): + self._run_started_at_text = self._format_local_time() + self._run_started_monotonic = time.monotonic() account_rows = accounts.list_accounts(path=self.db_path, config=self.config) account_by_alias = { str(account.alias).strip(): account @@ -57,7 +72,7 @@ class GenerateWorker(BaseWorker): if generate_cover: 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( + start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency},cmhub实际生图并发{request_concurrency},下载并发{download_concurrency};开始时间 {started_at}".format( total=len(eligible), title_total=component_totals["title_total"], cover_total=component_totals["cover_total"], @@ -65,20 +80,23 @@ class GenerateWorker(BaseWorker): image_concurrency=concurrency["configured_image_concurrency"], request_concurrency=concurrency["request_concurrency"], download_concurrency=concurrency["download_concurrency"], + started_at=self._run_started_at_text, ) else: - start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency}".format( + start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency};开始时间 {started_at}".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), + started_at=self._run_started_at_text, ) else: - start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题{title_total};标题并发{title_concurrency}".format( + start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题{title_total};标题并发{title_concurrency};开始时间 {started_at}".format( total=len(eligible), title_total=component_totals["title_total"], title_concurrency=ai_cfg.get("title_concurrency", 1), + started_at=self._run_started_at_text, ) self._log_run_event(start_message) try: @@ -113,8 +131,9 @@ class GenerateWorker(BaseWorker): "cancelled": self.should_cancel(), "generate_cover": generate_cover, } + user_error = self._user_log_detail(error) or "未知错误" self._log_run_event( - f"[失败] AI 生成运行失败:{error}", + f"[失败] AI 生成运行失败:{user_error}", level="error", ) self._write_diagnostic_log( @@ -179,7 +198,13 @@ class GenerateWorker(BaseWorker): phase = payload.get("phase") or "generate" step = payload.get("step") or "unknown" result = payload.get("result") or "start" - detail = self._short_detail(payload.get("detail")) + detail = self._user_log_detail( + payload.get("detail"), + phase=phase, + step=step, + code=payload.get("code"), + status=payload.get("status"), + ) if isinstance(payload.get("metadata"), dict): return self._format_cmhub_billing_event(task, phase, payload.get("metadata")) if phase == "title": @@ -206,7 +231,7 @@ class GenerateWorker(BaseWorker): 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 已返回,耗时未知'}" + return f"[图片] {self._task_progress_label(task)} {detail or 'cmhub 已返回图片,耗时未知'}" if result == "warning" and step == "cover_download": return f"[图片] {self._task_progress_label(task)} {detail or '图片下载较慢'}" if result == "success" and step == "cover_download": @@ -214,7 +239,7 @@ class GenerateWorker(BaseWorker): 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 "" + suffix = ",已保存到本地" if detail else "" return f"[图片] {self._task_progress_label(task)} 成功{suffix}" if result == "retry": return self._retry_message("图片", task, payload, detail) @@ -285,16 +310,88 @@ class GenerateWorker(BaseWorker): return text[:1997] + "..." return text + def _user_log_detail(self, detail, phase=None, step=None, code=None, status=None): + if detail is None: + return "" + raw = diagnostics.redact_log_text(str(detail)).replace("\r", " ").replace("\n", " ").strip() + lowered = raw.lower() + code_text = str(code or "").strip().lower() + if code_text == "insufficient_points" or "点数不足" in raw: + return "点数不足,请先充值" + if code_text == "unauthorized" or "api key 无效" in raw.lower(): + return "cmhub API Key 无效,请去⑤设置重填" + if code_text in {"model_not_allowed", "no_pricing_rule"} or "模型别名" in raw or "模型配置不可用" in raw: + return "cmhub 模型别名不可用,请去⑤设置刷新别名并保存" + if code_text == "content_blocked" or "内容安全" in raw: + return "cmhub 内容安全策略拒绝本次生成" + if code_text == "rate_limited" or "rate_limited" in lowered or "请求过于频繁" in raw: + return "cmhub 请求过于频繁,请稍后重试" + if code_text == "connect_timeout" or "连接 cmhub 超时" in raw: + return "连接 cmhub 超时,请检查网络或稍后重试" + if code_text == "read_timeout" or "等待 cmhub 返回超时" in raw: + return "等待 cmhub 返回超时,本条已失败;可稍后重试" + if ( + code_text == "not_found" + or "not_found" in lowered + or "接口不存在" in raw + or str(status or "") == "404" + ): + return "cmhub 网关接口不可用,请检查⑤设置中的 Base URL,或联系服务方确认网关版本" + if code_text == "upstream_error" or "upstream_error" in lowered or "上游" in raw: + return "cmhub 上游生成失败,请稍后重试" + if "下载 cmhub 图片失败" in raw: + return "下载 cmhub 图片失败,请检查网络后稍后重试" + + text = raw.replace("image_url", "图片") + text = text.replace("返回 图片", "返回图片") + text = _USER_LOG_URL_RE.sub("[链接已隐藏]", text) + text = _USER_LOG_PATH_RE.sub("[接口路径已隐藏]", text) + text = text.replace("GET [链接已隐藏]", "请求 cmhub") + text = text.replace("POST [链接已隐藏]", "请求 cmhub") + if len(text) > 180: + return text[:177] + "..." + return text + def _format_generate_completion(self, summary): progress = self._summary_text(summary) billing_error = summary.get("billing_error") or {} + finished_at = self._format_local_time() + elapsed = self._format_run_elapsed() + suffix = f";{self._completion_time_label(summary)} {finished_at},总用时 {elapsed}" if billing_error: - return f"[失败] AI 生成已中止:{billing_error.get('message') or '点数不足,请先充值'},{progress}" + message = self._user_log_detail(billing_error.get("message") or "点数不足,请先充值") or "点数不足,请先充值" + return f"[失败] AI 生成已中止:{message},{progress}{suffix}" if summary.get("cancelled"): - return f"[停止] AI 生成已停止:{progress}" + return f"[停止] AI 生成已停止:{progress}{suffix}" if summary.get("error"): - return f"[失败] AI 生成失败:{summary.get('error')},{progress}" - return f"[完成] AI 生成完成:{progress}" + error = self._user_log_detail(summary.get("error")) or "未知错误" + return f"[失败] AI 生成失败:{error},{progress}{suffix}" + return f"[完成] AI 生成完成:{progress}{suffix}" + + def _completion_time_label(self, summary): + if summary.get("billing_error") or summary.get("error"): + return "失败时间" + if summary.get("cancelled"): + return "停止时间" + return "完成时间" + + def _format_local_time(self): + return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + def _format_run_elapsed(self): + if self._run_started_monotonic is None: + return "0秒" + return self._format_duration(time.monotonic() - self._run_started_monotonic) + + def _format_duration(self, seconds): + total = max(0, int(seconds or 0)) + hours, remainder = divmod(total, 3600) + minutes, seconds = divmod(remainder, 60) + if hours: + return f"{hours}小时{minutes:02d}分{seconds:02d}秒" + if minutes: + return f"{minutes}分{seconds:02d}秒" + return f"{seconds}秒" def _summary_text(self, summary): title_total = summary.get("title_total", summary.get("total", 0)) diff --git a/docs/tasks/T-556.md b/docs/tasks/T-556.md index cb9c002..f2dbd75 100644 --- a/docs/tasks/T-556.md +++ b/docs/tasks/T-556.md @@ -3,7 +3,7 @@ id: T-556 title: ②AI生成运行日志运营化与错误脱敏 phase: 7 deps: [T-519, T-553, T-555] -status: TODO +status: DONE created: 2026-07-08 --- @@ -96,3 +96,11 @@ created: 2026-07-08 ## 边界(不改什么) 本任务只优化②AI生成“用户可见滚动日志/业务运行日志”的文案、脱敏与总结信息;不新增日志窗口、不改进度条布局、不改 T-555 用时标签、不改 cmhub API、下载逻辑、计费 metadata、图片保存格式、SQLite schema、Excel 回写或蝦皮更新流程。 + +## 执行记录 + +- 2026-07-08:完成 `GenerateWorker` 用户日志优化:本轮开始日志增加开始时间,完成/停止/失败总结增加对应时间和总用时。 +- 2026-07-08:新增用户可见日志详情清洗逻辑,重试/失败/总结中的 cmhub 错误不再暴露完整 URL、接口路径、媒体路径、`image_url`、query token 或 API Key;`not_found`、超时、上游失败、别名不可用、点数不足等错误映射为可行动中文文案。 +- 2026-07-08:保留本地诊断日志分层;`CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1` 产生的 debug-only 图片 URL 仍只作为本机调试输出,不持久化到 SQLite 运行日志。 +- 2026-07-08:更新 GUI worker 单测,覆盖开始/完成时间、总用时、cmhub URL/接口路径脱敏,以及可行动中文错误文案。 +- 验证通过:`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_gui.py b/tests/test_gui.py index 6442cd8..d7fc47a 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2375,9 +2375,10 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(summary["run_id"], run_log.id) joined_logs = "\n".join(logs) self.assertIn("[开始] 本轮生成 1 条", joined_logs) + self.assertRegex(joined_logs, r"开始时间 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}") self.assertIn("[图片] 1/1 商品 51100639510", joined_logs) self.assertIn("准备重试 1/2", joined_logs) - self.assertIn("cmhub 已返回 image_url,耗时 91.2秒", joined_logs) + self.assertIn("cmhub 已返回图片,耗时 91.2秒", joined_logs) self.assertIn("下载完成,1.3MB,耗时 12.4秒", joined_logs) self.assertIn("图片下载较慢,已用 24.0秒,大小 1.3MB", joined_logs) self.assertIn("cmhub 图片 URL:https://cdn.example.com/generated.png?token=***", joined_logs) @@ -2385,6 +2386,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIn("token=***", joined_logs) self.assertNotIn("SECRET-TOKEN", joined_logs) self.assertIn("[完成] AI 生成完成:标题1/1,图片1/1,失败0", joined_logs) + self.assertRegex(joined_logs, r"完成时间 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},总用时 \d+秒") events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) event_messages = "\n".join(event.message for event in events) self.assertIn("[图片] 1/1 商品 51100639510", event_messages) @@ -2395,6 +2397,110 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generate_worker_user_log_hides_cmhub_request_urls(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + cfg["ai"] = appconfig.ai_config(cfg) + cfg["ai"]["backend"] = "cmhub" + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + } + ], + path=cfg["db_path"], + ) + task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + logs = [] + + def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None): + ai_cfg["on_event"]( + { + "task": tasks_arg[0], + "phase": "title", + "step": "title_request", + "result": "retry", + "attempt": 1, + "attempts": 3, + "detail": ( + "cmhub upstream_error: POST " + "https://cmhub.example.com/api/v1/generate/title " + "failed image_url=http://43.128.3.240:8080/generated/images/a.png?token=SECRET-TOKEN" + ), + "level": "warning", + } + ) + ai_cfg["on_event"]( + { + "task": tasks_arg[0], + "phase": "title", + "step": "title_request", + "result": "failed", + "detail": ( + "cmhub not_found: cmhub 接口不存在,请检查 Base URL " + "或该实例是否已部署 /api/v1/models " + "https://cmhub.example.com/api/v1/models" + ), + "level": "error", + } + ) + return { + "ok": False, + "total": 1, + "title_total": 1, + "title_done": 0, + "cover_done": 0, + "cover_total": 0, + "generated_done": 0, + "failed": 1, + "generate_cover": False, + "error": ( + "cmhub not_found: GET " + "https://cmhub.example.com/api/v1/models?token=SECRET-TOKEN" + ), + } + + worker = GenerateWorker( + tasks, + {"title": "标题提示", "cover": "封面提示"}, + db_path=cfg["db_path"], + config=cfg, + ) + worker.log.connect(logs.append) + + with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch): + summary = worker.execute() + + self.assertFalse(summary["ok"]) + joined_logs = "\n".join(logs) + self.assertIn("cmhub 上游生成失败,请稍后重试", joined_logs) + self.assertIn("cmhub 网关接口不可用,请检查⑤设置中的 Base URL", joined_logs) + self.assertRegex(joined_logs, r"失败时间 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},总用时 \d+秒") + events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) + event_messages = "\n".join(event.message for event in events) + combined = joined_logs + "\n" + event_messages + for forbidden in [ + "https://", + "http://", + "/api/v1/", + "/generated/images/", + "image_url", + "SECRET-TOKEN", + ]: + self.assertNotIn(forbidden, combined) + + self.assert_removed(temp_dir) + def test_generate_worker_records_cmhub_billing_metadata(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)