diff --git a/app/editor.py b/app/editor.py index c600e21..dd2c01f 100644 --- a/app/editor.py +++ b/app/editor.py @@ -694,6 +694,19 @@ def _notify_collect_step(callback, step): except Exception: pass + +def _notify_apply_step(callback, step, result="start", detail=None): + if callback is None: + return + payload = {"step": step, "result": result} + if detail: + payload["detail"] = str(detail) + try: + callback(payload) + except Exception: + pass + + def _close_collected_product(cdp): target_id = getattr(cdp, "target_id", None) created_by_app = bool(getattr(cdp, "created_by_app", False)) @@ -1056,14 +1069,17 @@ def _confirm_update_modal(cdp, timeout=3): return last_present or {"present": False, "clicked": False, "reason": "NO_UPDATE_CONFIRM_MODAL"} -def apply_task(account, task, close_success_tab=False) -> dict: +def apply_task(account, task, close_success_tab=False, on_step=None) -> dict: """Apply generated title/cover to Shopee. The caller must perform the batch confirmation before calling this function. """ item_id = _item_id(task) + current_step = "open_product" + _notify_apply_step(on_step, current_step, "start") cdp = open_product(account, item_id) + _notify_apply_step(on_step, current_step, "success") committed = False try: title_result = None @@ -1071,19 +1087,36 @@ def apply_task(account, task, close_success_tab=False) -> dict: new_title = _get(task, "new_title") new_cover_path = _get(task, "new_cover_path") if new_title: + current_step = "change_title" + _notify_apply_step(on_step, current_step, "start") title_result = change_title(cdp, new_title) if not title_result.get("ok"): + _notify_apply_step(on_step, current_step, "failed", "标题写入后 value/modelvalue 未同步") return {"committed": False, "error": "标题写入后 value/modelvalue 未同步", "title": title_result} + _notify_apply_step(on_step, current_step, "success") if new_cover_path: + current_step = "replace_cover" + _notify_apply_step(on_step, current_step, "start") cover_result = replace_cover( cdp, new_cover_path, old_cover_path=_get(task, "old_cover_path"), ) if not cover_result.get("ok"): - return {"committed": False, "error": _cover_upload_error_message(cover_result), "cover": cover_result} + error = _cover_upload_error_message(cover_result) + _notify_apply_step(on_step, current_step, "failed", error) + return {"committed": False, "error": error, "cover": cover_result} + _notify_apply_step(on_step, current_step, "success") + current_step = "click_update" + _notify_apply_step(on_step, current_step, "start") update_result = click_update(cdp) committed = bool(update_result.get("clicked", False)) + _notify_apply_step( + on_step, + current_step, + "success" if committed else "failed", + update_result.get("reason"), + ) return { "committed": committed, "error": update_result.get("reason"), @@ -1092,6 +1125,7 @@ def apply_task(account, task, close_success_tab=False) -> dict: "update": update_result, } except Exception as exc: + _notify_apply_step(on_step, current_step, "failed", str(exc)) return {"committed": False, "error": str(exc)} finally: _close_applied_product(cdp, close_success_tab=close_success_tab and committed) diff --git a/app/gui.py b/app/gui.py index db5548c..09895ff 100644 --- a/app/gui.py +++ b/app/gui.py @@ -142,6 +142,95 @@ if QT_IMPORT_ERROR is None: return run_id + def _safe_create_run_log(run_type, db_path=None, dry_run=False, total=0, options=None): + try: + return db.create_run_log( + run_type, + dry_run=dry_run, + total=total, + options=options or {}, + path=db_path, + ) + except Exception: + return None + + + def _safe_finish_run_log(run_id, db_path=None, **fields): + if run_id is None: + return + try: + db.finish_run_log(run_id, path=db_path, **fields) + except Exception: + return + + + def _safe_add_run_log_event( + run_id, + message, + db_path=None, + task=None, + account=None, + level="info", + task_id=None, + alias=None, + item_id=None, + ): + safe_message = diagnostics.redact_log_text(message) + if task is not None: + task_id = getattr(task, "id", task_id) + alias = getattr(task, "alias", alias) + item_id = getattr(task, "item_id", item_id) + if account is not None: + alias = getattr(account, "alias", alias) + if run_id is None: + return safe_message + try: + db.add_run_log_event( + run_id, + safe_message, + task_id=task_id, + alias=alias, + item_id=item_id, + level=level, + path=db_path, + ) + except Exception: + pass + return safe_message + + + def _safe_write_diagnostic_log( + message, + level="INFO", + step=None, + task=None, + account=None, + elapsed_ms=None, + payload=None, + exc=None, + log_dir=None, + ): + try: + diagnostics.write_diagnostic_log( + message, + level=level, + step=step, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None) or getattr(account, "alias", None), + item_id=getattr(task, "item_id", None), + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=log_dir, + ) + except Exception: + return + + + def _elapsed_ms(started): + return int((time.monotonic() - started) * 1000) + + class TaskTableModel(QAbstractTableModel): """Table model for task rows shared by workflow tabs.""" @@ -1354,6 +1443,7 @@ if QT_IMPORT_ERROR is None: int(update_cfg.get("max_parallel_accounts", 1) or 1), ), batch_size=batch_size, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, ) worker.progress.connect(self._on_apply_progress) worker.row_updated.connect(self._on_apply_row_updated) @@ -1803,7 +1893,12 @@ if QT_IMPORT_ERROR is None: if self.result_write_back_thread is not None: self._set_status("Excel 结果回写正在进行...") return False - worker = WriteBackWorker(batch_ids, db_path=self.db_path, mode="results") + worker = WriteBackWorker( + batch_ids, + db_path=self.db_path, + mode="results", + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) worker.failed.connect( lambda task_id, error, auto=auto, apply_summary=apply_summary: self._on_result_write_back_failed( @@ -2060,14 +2155,113 @@ if QT_IMPORT_ERROR is None: file_paths = self._choose_excel_files() if not file_paths: return + run_id = _safe_create_run_log( + "import", + db_path=self.db_path, + total=len(file_paths), + options={"files": file_paths}, + ) + started = time.monotonic() + _safe_add_run_log_event( + run_id, + f"step=select_files result=success detail=选择 Excel 文件 {len(file_paths)} 个", + db_path=self.db_path, + ) try: + _safe_add_run_log_event( + run_id, + "step=parse_file result=start detail=开始解析 Excel 并写入 SQLite", + db_path=self.db_path, + ) result = excel.import_tasks(file_paths, path=self.db_path) except Exception as exc: - self._show_error(exc) + elapsed_ms = _elapsed_ms(started) + safe_error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + _safe_add_run_log_event( + run_id, + f"step=import result=failed detail={safe_error} elapsed_ms={elapsed_ms}", + db_path=self.db_path, + level="error", + ) + _safe_write_diagnostic_log( + "Excel导入失败", + level="ERROR", + step="import", + elapsed_ms=elapsed_ms, + payload={"files": file_paths, "error": safe_error}, + exc=exc, + log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + _safe_finish_run_log( + run_id, + db_path=self.db_path, + status="failed", + done=0, + failed_count=1, + summary_json={"ok": False, "error": safe_error, "elapsed_ms": elapsed_ms}, + ) + self._show_error(safe_error) return + elapsed_ms = _elapsed_ms(started) self.has_import_result = True self.last_import_stats = result.get("stats") or {} self.current_batch_id = result.get("batch_id") + file_errors = self.last_import_stats.get("file_errors") or [] + row_errors = self.last_import_stats.get("row_errors") or [] + for item in file_errors: + missing = ",".join(item.get("missing_columns") or []) + detail = "file={file} sheet={sheet} error={error}{missing}".format( + file=os.path.basename(str(item.get("file") or "")), + sheet=item.get("sheet") or "", + error=item.get("error") or "", + missing=f" missing={missing}" if missing else "", + ) + _safe_add_run_log_event( + run_id, + f"step=parse_file result=failed detail={detail}", + db_path=self.db_path, + level="error", + ) + for item in row_errors: + detail = "file={file} sheet={sheet} row={row} error={error}".format( + file=os.path.basename(str(item.get("file") or "")), + sheet=item.get("sheet") or "", + row=item.get("row") or "", + error=item.get("error") or "", + ) + _safe_add_run_log_event( + run_id, + f"step=row_validate result=failed detail={detail}", + db_path=self.db_path, + level="warning", + ) + _safe_add_run_log_event( + run_id, + "step=db_insert result=success detail=batch_id={batch_id} files={files} total={total} valid={valid} invalid={invalid} inserted={inserted} elapsed_ms={elapsed_ms}".format( + batch_id=self.current_batch_id or "", + files=self.last_import_stats.get("files", 0), + total=self.last_import_stats.get("total", 0), + valid=self.last_import_stats.get("valid", 0), + invalid=self.last_import_stats.get("invalid", 0), + inserted=self.last_import_stats.get("inserted", 0), + elapsed_ms=elapsed_ms, + ), + db_path=self.db_path, + ) + _safe_finish_run_log( + run_id, + db_path=self.db_path, + status="done", + done=self.last_import_stats.get("files", 0), + success_count=self.last_import_stats.get("inserted", 0), + failed_count=len(file_errors) + len(row_errors), + summary_json={ + "ok": True, + "batch_id": self.current_batch_id, + "stats": self.last_import_stats, + "elapsed_ms": elapsed_ms, + }, + ) self.refresh_tasks() self._set_status( "导入完成:有效{valid},无效{invalid},入库{inserted},未匹配{unmatched}".format( @@ -2214,7 +2408,11 @@ if QT_IMPORT_ERROR is None: if self.write_back_thread is not None: self._set_status("Excel 回写正在进行...") return False - worker = WriteBackWorker(batch_id, db_path=self.db_path) + worker = WriteBackWorker( + batch_id, + db_path=self.db_path, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) worker.failed.connect( lambda task_id, error, auto=auto: self._on_write_back_failed( task_id, @@ -2767,6 +2965,7 @@ if QT_IMPORT_ERROR is None: parallel_accounts=False, max_parallel_accounts=1, batch_size=None, + diagnostic_log_dir=None, ): super().__init__() self.tasks = list(tasks) @@ -2781,6 +2980,7 @@ if QT_IMPORT_ERROR is None: self._current_batch_size = None self._batch_count = 0 self._progress_lock = threading.Lock() + self.diagnostic_log_dir = diagnostic_log_dir self._run_id = None def execute(self): @@ -2805,7 +3005,7 @@ if QT_IMPORT_ERROR is None: } self._run_id = self._create_run_log(eligible, batch_ids) self._log_run_event( - "运行开始:{mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format( + "step=start result=start detail=运行开始:{mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format( mode="检查本轮更新" if self.dry_run else "真实更新", total=total, batch_size=batch_size, @@ -2819,8 +3019,10 @@ if QT_IMPORT_ERROR is None: ) if self.preflight and not self.dry_run: + self._log_run_event("step=preflight result=start detail=账号就绪检查") blocked = self._preflight_block(eligible, account_rows, account_by_alias) if blocked: + self._log_preflight_blocked(blocked) summary = self._summary( ok=False, total=total, @@ -2831,6 +3033,12 @@ if QT_IMPORT_ERROR is None: ) self._finish_run_log("blocked", summary) return summary + self._log_run_event("step=preflight result=success detail=账号检查通过") + elif not self.preflight: + self._log_run_event( + "step=preflight result=skipped detail=测试模式跳过更新前检查", + level="warning", + ) for batch_index, batch_tasks in enumerate(batches, start=1): if self.should_cancel(): @@ -2890,13 +3098,39 @@ if QT_IMPORT_ERROR is None: not_running = [] logged_out = [] for account in required_accounts: + self._log_run_event( + f"step=check_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", + level="info", + ) if not chrome.is_running(account.debug_port): + self._log_run_event( + f"step=check_chrome result=blocked detail=账号 {account.alias} CDP 端口未响应 debug_port={account.debug_port}", + level="warning", + ) not_running.append(self._account_payload(account, "CDP 端口未响应")) continue + self._log_run_event( + f"step=check_chrome result=success detail=账号 {account.alias} debug_port={account.debug_port}", + level="info", + ) + self._log_run_event( + f"step=login_check result=start detail=账号 {account.alias}", + level="info", + ) status = self._login_status(account) if not status.get("logged_in"): + reason = self._login_skip_reason(status) + self._log_run_event( + f"step=login_check result=blocked detail=账号 {account.alias} {reason}", + level="warning", + ) logged_out.append( - self._account_payload(account, self._login_skip_reason(status)) + self._account_payload(account, reason) + ) + else: + self._log_run_event( + f"step=login_check result=success detail=账号 {account.alias}", + level="info", ) if not_running or logged_out: return { @@ -2946,7 +3180,7 @@ if QT_IMPORT_ERROR is None: last = min(first + len(batch_tasks) - 1, total) label = "检查批次" if self.dry_run else "更新批次" self._log_run_event( - f"{label} {batch_index}/{batch_count} 开始:任务 {first}-{last}/{total}" + f"step=batch result=start detail={label} {batch_index}/{batch_count} 开始:任务 {first}-{last}/{total}" ) def _run_parallel_by_account(self, eligible, account_by_alias, counters, total): @@ -2993,7 +3227,7 @@ if QT_IMPORT_ERROR is None: if account is None: reason = "别名未匹配账号" self._log_run_event( - f"检查:任务 {task.id} 商品 {task.item_id} 将略过:{reason}", + f"step=preview result=skipped detail=检查:任务 {task.id} 商品 {task.item_id} 将略过:{reason}", task=task, level="warning", ) @@ -3005,7 +3239,7 @@ if QT_IMPORT_ERROR is None: action_parts.append("封面") action_text = "+".join(action_parts) or "无变更" self._log_run_event( - "检查:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format( + "step=preview result=success detail=检查:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format( task_id=task.id, item_id=task.item_id, alias=account.alias, @@ -3022,15 +3256,47 @@ if QT_IMPORT_ERROR is None: db.mark_skipped(task.id, reason, path=self.db_path) self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) self._log_run_event( - f"任务 {task.id} 商品 {task.item_id} 已略过:{reason}", + f"step=preflight result=skipped detail=任务 {task.id} 商品 {task.item_id} 已略过:{reason}", task=task, level="warning", ) return "skipped" + started = time.monotonic() + current_step = "db_write" + + def on_step(event): + nonlocal current_step + if isinstance(event, dict): + step = str(event.get("step") or "apply_task") + result = str(event.get("result") or "start") + detail = event.get("detail") + else: + step = str(event) + result = "start" + detail = None + current_step = step + level = "error" if result == "failed" else "info" + detail_text = "任务 {task_id} 商品 {item_id}".format( + task_id=task.id, + item_id=task.item_id, + ) + if detail: + detail_text = f"{detail_text} {detail}" + self._log_run_event( + f"step={step} result={result} detail={detail_text}", + task=task, + level=level, + ) + try: self._log_run_event( - f"任务 {task.id} 商品 {task.item_id} 开始更新,账号 {account.alias}", + f"step=apply_task result=start detail=任务 {task.id} 商品 {task.item_id} 开始更新,账号 {account.alias}", + task=task, + ) + current_step = "db_write" + self._log_run_event( + f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 标记更新运行", task=task, ) db.mark_running(task.id, "apply", path=self.db_path) @@ -3039,11 +3305,18 @@ if QT_IMPORT_ERROR is None: account, task, close_success_tab=self.close_success_tab, + on_step=on_step, ) committed = bool(result.get("committed")) and not result.get("error") error = result.get("error") + current_step = "db_write" + self._log_run_event( + f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 保存更新结果", + task=task, + ) if committed: db.set_applied(task.id, True, path=self.db_path) + elapsed_ms = self._elapsed_ms(started) self.row_updated.emit( task.id, { @@ -3054,39 +3327,62 @@ if QT_IMPORT_ERROR is None: }, ) self._log_run_event( - f"任务 {task.id} 商品 {task.item_id} 更新成功", + f"step=db_write result=success detail=任务 {task.id} 商品 {task.item_id} 更新成功 elapsed_ms={elapsed_ms}", task=task, ) return "applied" - error = error or "更新未提交" + error = diagnostics.redact_log_text(error or "更新未提交") + failed_step = self._failed_apply_step(result, current_step) db.set_applied(task.id, False, error, path=self.db_path) + elapsed_ms = self._elapsed_ms(started) self.failed.emit(task.id, str(error)) self.row_updated.emit( task.id, {"status": "failed", "last_error": str(error), "committed": 0}, ) self._log_run_event( - f"任务 {task.id} 商品 {task.item_id} 更新失败:{error}", + f"step={failed_step} result=failed detail={error} elapsed_ms={elapsed_ms}", task=task, level="error", ) + self._log_run_event( + f"step=db_write result=success detail=任务 {task.id} 商品 {task.item_id} 保存失败状态 elapsed_ms={elapsed_ms}", + task=task, + ) + self._write_diagnostic_log( + "Shopee更新任务失败", + level="ERROR", + step=failed_step, + task=task, + elapsed_ms=elapsed_ms, + payload={"error": error, "result": result}, + ) return "failed" except Exception as exc: - error = str(exc) or exc.__class__.__name__ + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) db.set_applied(task.id, False, error, path=self.db_path) + elapsed_ms = self._elapsed_ms(started) self.failed.emit(task.id, error) self.row_updated.emit( task.id, {"status": "failed", "last_error": error, "committed": 0}, ) self._log_run_event( - f"任务 {task.id} 商品 {task.item_id} 更新异常:{error}", + f"step={current_step} result=failed detail={error} elapsed_ms={elapsed_ms}", task=task, level="error", ) + self._write_diagnostic_log( + "Shopee更新任务异常", + level="ERROR", + step=current_step, + task=task, + elapsed_ms=elapsed_ms, + payload={"error": error}, + exc=exc, + ) return "failed" - def _record_outcome(self, counters, total, outcome): with self._progress_lock: counters["done"] += 1 @@ -3226,6 +3522,75 @@ if QT_IMPORT_ERROR is None: return + def _log_preflight_blocked(self, blocked): + if blocked.get("no_accounts"): + self._log_run_event( + "step=preflight result=blocked detail=当前没有配置账号", + level="warning", + ) + for item in blocked.get("duplicate_ports") or []: + self._log_run_event( + "step=preflight result=blocked detail=调试端口重复 debug_port={port} aliases={aliases}".format( + port=item.get("debug_port") or "", + aliases=",".join(item.get("aliases") or []), + ), + level="warning", + ) + for item in blocked.get("not_running") or []: + self._log_run_event( + "step=check_chrome result=blocked detail=账号 {alias} Chrome 未启动或调试端口不可访问: {reason}".format( + alias=item.get("alias") or "", + reason=item.get("reason") or "", + ), + level="warning", + ) + for item in blocked.get("logged_out") or []: + self._log_run_event( + "step=login_check result=blocked detail=账号 {alias} 未登录 Shopee: {reason}".format( + alias=item.get("alias") or "", + reason=item.get("reason") or "", + ), + level="warning", + ) + + def _failed_apply_step(self, result, fallback): + if not isinstance(result, dict): + return fallback or "apply_task" + title = result.get("title") + if isinstance(title, dict) and not title.get("ok", True): + return "change_title" + cover = result.get("cover") + if isinstance(cover, dict) and not cover.get("ok", True): + return "replace_cover" + update = result.get("update") + if isinstance(update, dict): + return "click_update" + return fallback or "apply_task" + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + task=None, + elapsed_ms=None, + payload=None, + exc=None, + ): + _safe_write_diagnostic_log( + message, + level=level, + step=step, + task=task, + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + + def _elapsed_ms(self, started): + return _elapsed_ms(started) + class CollectWorker(BaseWorker): """Collect old title and cover for imported tasks.""" @@ -3458,13 +3823,39 @@ if QT_IMPORT_ERROR is None: not_running = [] logged_out = [] for account in required_accounts: + self._log_run_event( + f"step=check_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", + level="info", + ) if not chrome.is_running(account.debug_port): + self._log_run_event( + f"step=check_chrome result=blocked detail=账号 {account.alias} CDP 端口未响应 debug_port={account.debug_port}", + level="warning", + ) not_running.append(self._account_payload(account, "CDP 端口未响应")) continue + self._log_run_event( + f"step=check_chrome result=success detail=账号 {account.alias} debug_port={account.debug_port}", + level="info", + ) + self._log_run_event( + f"step=login_check result=start detail=账号 {account.alias}", + level="info", + ) status = self._login_status(account) if not status.get("logged_in"): + reason = self._login_skip_reason(status) + self._log_run_event( + f"step=login_check result=blocked detail=账号 {account.alias} {reason}", + level="warning", + ) logged_out.append( - self._account_payload(account, self._login_skip_reason(status)) + self._account_payload(account, reason) + ) + else: + self._log_run_event( + f"step=login_check result=success detail=账号 {account.alias}", + level="info", ) if not_running or logged_out: return { @@ -3653,18 +4044,70 @@ if QT_IMPORT_ERROR is None: class WriteBackWorker(BaseWorker): """Write Excel fields back in a background thread.""" - def __init__(self, batch_id, db_path=None, excel_path=None, mode="old"): + def __init__(self, batch_id, db_path=None, excel_path=None, mode="old", diagnostic_log_dir=None): super().__init__() self.batch_id = batch_id self.db_path = db_path self.excel_path = excel_path self.mode = mode + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None def execute(self): - results = [ - self._write_one(batch_id) - for batch_id in self._batch_ids() - ] + batch_ids = self._batch_ids() + self._run_id = _safe_create_run_log( + "write_back", + db_path=self.db_path, + total=len(batch_ids), + options={ + "batch_ids": batch_ids, + "mode": self.mode, + "excel_path": self.excel_path, + }, + ) + self._log_run_event( + f"step=start result=start detail=Excel 回写开始 mode={self.mode} batch_count={len(batch_ids)}" + ) + results = [] + try: + for batch_id in batch_ids: + started = time.monotonic() + self._log_run_event( + f"step=write_excel result=start detail=batch_id={batch_id} mode={self.mode}" + ) + result = self._write_one(batch_id) + results.append(result) + self._log_run_event( + "step=write_excel result=success detail=batch_id={batch_id} files={files} rows={rows} elapsed_ms={elapsed_ms}".format( + batch_id=batch_id, + files=result.get("files", 0), + rows=result.get("rows", 0), + elapsed_ms=self._elapsed_ms(started), + ) + ) + except Exception as exc: + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + self._log_run_event( + f"step=write_excel result=failed detail={error}", + level="error", + ) + self._write_diagnostic_log( + "Excel回写失败", + level="ERROR", + step="write_excel", + payload={"batch_ids": batch_ids, "mode": self.mode, "error": error}, + exc=exc, + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="failed", + done=len(results), + success_count=sum(result.get("rows", 0) for result in results), + failed_count=1, + summary_json={"ok": False, "error": error, "mode": self.mode}, + ) + raise result = results[0] if len(results) == 1 else self._combined_result(results) self.progress.emit( { @@ -3673,6 +4116,15 @@ if QT_IMPORT_ERROR is None: "files": result.get("files", 0), } ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="done", + done=len(batch_ids), + success_count=result.get("rows", 0), + failed_count=0, + summary_json={"ok": result.get("ok", False), "mode": self.mode, "result": result}, + ) return result def _batch_ids(self): @@ -3707,40 +4159,251 @@ if QT_IMPORT_ERROR is None: "written_files": written_files, } + def _log_run_event(self, message, level="info"): + safe_message = _safe_add_run_log_event( + self._run_id, + message, + db_path=self.db_path, + level=level, + ) + self.log.emit(str(safe_message)) + + def _write_diagnostic_log(self, message, level="INFO", step=None, payload=None, exc=None): + _safe_write_diagnostic_log( + message, + level=level, + step=step, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + + def _elapsed_ms(self, started): + return _elapsed_ms(started) class AccountLoginCheckWorker(BaseWorker): - def __init__(self, account, db_path=None, config=None, timeout=8): + def __init__(self, account, db_path=None, config=None, timeout=8, diagnostic_log_dir=None): super().__init__() self.account = account self.db_path = db_path self.config = config self.timeout = timeout + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None def execute(self): - status = accounts.detect_login( - self.account, - timeout=self.timeout, - path=self.db_path, - config=self.config, + self._run_id = _safe_create_run_log( + "login_check", + db_path=self.db_path, + total=1, + options={ + "alias": self.account.alias, + "debug_port": self.account.debug_port, + "timeout": self.timeout, + }, + ) + started = time.monotonic() + self._log_run_event( + f"step=detect_login result=start detail=账号 {self.account.alias} debug_port={self.account.debug_port}" + ) + try: + status = accounts.detect_login( + self.account, + timeout=self.timeout, + path=self.db_path, + config=self.config, + ) + except Exception as exc: + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + elapsed_ms = self._elapsed_ms(started) + self._log_run_event( + f"step=detect_login result=failed detail={error} elapsed_ms={elapsed_ms}", + level="error", + ) + self._write_diagnostic_log( + "登录检测失败", + level="ERROR", + step="detect_login", + elapsed_ms=elapsed_ms, + payload={"alias": self.account.alias, "error": error}, + exc=exc, + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="failed", + done=0, + failed_count=1, + summary_json={"ok": False, "alias": self.account.alias, "error": error}, + ) + raise + elapsed_ms = self._elapsed_ms(started) + logged_in = bool(status.get("logged_in")) + result_text = "success" if logged_in else "failed" + level = "info" if logged_in else "warning" + self._log_run_event( + "step=detect_login result={result} detail=账号 {alias} logged_in={logged_in} reason={reason} elapsed_ms={elapsed_ms}".format( + result=result_text, + alias=self.account.alias, + logged_in=logged_in, + reason=status.get("reason") or "", + elapsed_ms=elapsed_ms, + ), + level=level, + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="done", + done=1, + success_count=1 if logged_in else 0, + failed_count=0 if logged_in else 1, + summary_json={"ok": logged_in, "alias": self.account.alias, "status": status}, ) self.row_updated.emit(self.account.id, status) return {"alias": self.account.alias, "status": status} + def _log_run_event(self, message, level="info"): + safe_message = _safe_add_run_log_event( + self._run_id, + message, + db_path=self.db_path, + account=self.account, + level=level, + ) + self.log.emit(str(safe_message)) + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + elapsed_ms=None, + payload=None, + exc=None, + ): + _safe_write_diagnostic_log( + message, + level=level, + step=step, + account=self.account, + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + + def _elapsed_ms(self, started): + return _elapsed_ms(started) class AIModelTestWorker(BaseWorker): """Test one AI model connection without blocking the GUI thread.""" - def __init__(self, model_name, ai_models_path=None): + def __init__(self, model_name, ai_models_path=None, db_path=None, diagnostic_log_dir=None): super().__init__() self.model_name = model_name self.ai_models_path = ai_models_path or appconfig.AI_MODELS_PATH + self.db_path = db_path + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None def execute(self): - result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path) + self._run_id = self._create_run_log() + started = time.monotonic() + self._log_run_event( + f"step=test_connection result=start detail=AI模型 {self.model_name}" + ) + try: + result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path) + except Exception as exc: + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + elapsed_ms = self._elapsed_ms(started) + self._log_run_event( + f"step=test_connection result=failed detail={error} elapsed_ms={elapsed_ms}", + level="error", + ) + self._write_diagnostic_log( + "AI模型测试连接异常", + level="ERROR", + step="test_connection", + elapsed_ms=elapsed_ms, + payload={"model_name": self.model_name, "error": error}, + exc=exc, + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="failed", + done=0, + failed_count=1, + summary_json={"ok": False, "name": self.model_name, "error": error}, + ) + raise + elapsed_ms = self._elapsed_ms(started) payload = dict(appconfig.sanitize_for_log(result or {})) payload["name"] = self.model_name + ok = bool(payload.get("ok")) + self._log_run_event( + "step=test_connection result={result} detail=AI模型 {name} status={status} error={error} elapsed_ms={elapsed_ms}".format( + result="success" if ok else "failed", + name=self.model_name, + status=payload.get("status") or "", + error=payload.get("error") or "", + elapsed_ms=elapsed_ms, + ), + level="info" if ok else "warning", + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="done", + done=1, + success_count=1 if ok else 0, + failed_count=0 if ok else 1, + summary_json=payload, + ) return payload + def _create_run_log(self): + if not self.db_path: + return None + return _safe_create_run_log( + "ai_model_test", + db_path=self.db_path, + total=1, + options={"model_name": self.model_name}, + ) + + def _log_run_event(self, message, level="info"): + safe_message = _safe_add_run_log_event( + self._run_id, + message, + db_path=self.db_path, + level=level, + ) + self.log.emit(str(safe_message)) + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + elapsed_ms=None, + payload=None, + exc=None, + ): + _safe_write_diagnostic_log( + message, + level=level, + step=step, + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + + def _elapsed_ms(self, started): + return _elapsed_ms(started) class SettingsTab(QWidget): """Tab 5: AI model definitions stored in config/ai_models.json.""" @@ -4149,7 +4812,12 @@ if QT_IMPORT_ERROR is None: if self.name_edit.text().strip() != model["name"]: self._set_status("请先保存模型名称变更后再测试连接") return - worker = AIModelTestWorker(model["name"], ai_models_path=self.ai_models_path) + worker = AIModelTestWorker( + model["name"], + ai_models_path=self.ai_models_path, + db_path=_database_path(config=self.config), + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) worker.finished.connect(self._on_test_finished) worker.failed.connect(self._on_test_failed) thread = run_worker(worker, thread_name="AIModelTestWorker", start=False) @@ -4510,8 +5178,8 @@ if QT_IMPORT_ERROR is None: def __init__(self, parent=None, db_path=None, config=None, status_callback=None): super().__init__(parent) - self.db_path = db_path self.config = appconfig.load_config() if config is None else config + self.db_path = _database_path(db_path, self.config) self.status_callback = status_callback self.account_rows = [] self.login_statuses = {} @@ -4700,11 +5368,68 @@ if QT_IMPORT_ERROR is None: account = self._selected_account() if account is None: return + run_id = _safe_create_run_log( + "chrome_launch", + db_path=self.db_path, + total=1, + options={"alias": account.alias, "debug_port": account.debug_port}, + ) + started = time.monotonic() + _safe_add_run_log_event( + run_id, + f"step=launch_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", + db_path=self.db_path, + account=account, + ) try: - accounts.launch_for_login(account, config=self.config) + process = accounts.launch_for_login(account, config=self.config) except Exception as exc: - self._show_error(exc) + elapsed_ms = _elapsed_ms(started) + safe_error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + _safe_add_run_log_event( + run_id, + f"step=launch_chrome result=failed detail={safe_error} elapsed_ms={elapsed_ms}", + db_path=self.db_path, + account=account, + level="error", + ) + _safe_write_diagnostic_log( + "Chrome启动失败", + level="ERROR", + step="launch_chrome", + account=account, + elapsed_ms=elapsed_ms, + payload={"alias": account.alias, "debug_port": account.debug_port, "error": safe_error}, + exc=exc, + log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + _safe_finish_run_log( + run_id, + db_path=self.db_path, + status="failed", + done=0, + failed_count=1, + summary_json={"ok": False, "alias": account.alias, "error": safe_error}, + ) + self._show_error(safe_error) return + elapsed_ms = _elapsed_ms(started) + pid = getattr(process, "pid", None) + _safe_add_run_log_event( + run_id, + f"step=launch_chrome result=success detail=账号 {account.alias} pid={pid or ''} elapsed_ms={elapsed_ms}", + db_path=self.db_path, + account=account, + ) + _safe_finish_run_log( + run_id, + db_path=self.db_path, + status="done", + done=1, + success_count=1, + failed_count=0, + summary_json={"ok": True, "alias": account.alias, "debug_port": account.debug_port, "pid": pid}, + ) self.login_statuses[account.alias] = "已启动" self.refresh_accounts() self._set_status("Chrome 已启动,请人工登录") @@ -4719,6 +5444,7 @@ if QT_IMPORT_ERROR is None: account, db_path=self.db_path, config=self.config, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, ) worker.finished.connect(self._on_login_check_finished) worker.failed.connect( diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 15de877..dea9734 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -278,20 +278,20 @@ CREATE TABLE run_log_events ( ); ``` -### 5.2c 诊断日志分层(T-207 已接入 ①,② AI生成已先补诊断,T-505 待扩展) +### 5.2c 诊断日志分层(T-207 + T-505 已接入全流程) 当前问题:① 采集旧标题/旧封面出现单条失败时,现有代码只把该任务写成 `status=failed`、`last_error=<错误>`,并在状态栏显示失败计数;但没有记录“卡在哪一步”,也没有完整 traceback,后续 debug 难以判断是打开商品页、页面就绪、读标题、读封面 URL、下载图片还是写库/回写失败。 采用两层日志: -- **SQLite 运行日志(业务可读)**:复用 `run_logs/run_log_events`,用于 GUI 查看和运营排查。`run_type` 扩展到 `collect/generate/import/write_back/apply/login_check/ai_model_test`。事件必须带 `task_id/alias/item_id`(能拿到时),`message` 统一包含 `step=<步骤>`、结果和简短错误;不新增敏感字段,不写 Cookie、密码、API Key、token。 +- **SQLite 运行日志(业务可读)**:复用 `run_logs/run_log_events`,用于 GUI 查看和运营排查。`run_type` 扩展到 `collect/generate/import/write_back/apply/chrome_launch/login_check/ai_model_test`。事件必须带 `task_id/alias/item_id`(能拿到时),`message` 统一包含 `step=<步骤>`、结果和简短错误;不新增敏感字段,不写 Cookie、密码、API Key、token。 - **本地诊断 log 文件(开发调试)**:写入 `logs/cmshopee.log` 或按日期滚动文件,保存脱敏后的 traceback、异常类型、步骤、耗时和必要上下文。`logs/` 必须 gitignore;自由文本异常进入日志前要用 `appconfig.redact_secrets()`,结构化 payload 先过 `sanitize_for_log()`。 优先级: 1. **T-207 已接入 ① 采集**:记录批次开始/结束、账号预检、每个商品开始/成功/失败/略过;关键步骤覆盖 `preflight`、`open_product`、`wait_ready`、`read_title`、`read_cover`、`download_cover`、`db_write`、`excel_write_back`。失败时 DB 事件保存最后步骤和简短错误,本地 `logs/cmshopee.log` 保存完整脱敏 traceback。 2. **② AI生成已先补诊断**:`GenerateWorker` 创建 `run_type=generate`,按任务记录标题/封面阶段事件;关键步骤覆盖 `title_submit`、`load_text_model`、`title_build_request`、`title_request`、`title_parse_response`、`cover_prompt_render`、`cover_submit`、`cover_validate_input`、`load_image_model`、`cover_build_request`、`cover_request`、`cover_parse_response`、`cover_save`、`db_write`。失败时任务 `last_error`、DB 运行日志和本地 `logs/cmshopee.log` 都写脱敏错误。 -3. **T-505 剩余扩展全流程**:Excel 导入/回写(缺列、脏行、文件锁、行定位)、③ 更新shopee(安全开关、账号预检、打开页面、改标题、换封面、点更新、回写)、④ 登录检测/Chrome 启动、⑤ AI 模型测试连接。 +3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新shopee 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;④「启动登录」创建 `run_type=chrome_launch`,④「检测登录」创建 `run_type=login_check`;⑤ AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。 原则:数据库日志给运营和 GUI 看“哪个商品、哪个步骤、为什么失败”;本地 log 文件给开发看完整错误栈。两者都必须脱敏。 关键事实: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index bdeaf8e..98d9353 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -88,7 +88,7 @@ | T-502 | 换封面:删第一张再上传 | T-001 | 已实现备份校验、删第一张、确认弹窗、再上传和拖首位;更新封面统一先删当前第一张,不再只限满 9 张;删除前必须确认该任务已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失则拒绝删除并报错;已在 9 图测试商品 `29671243750` 上实测不提交流程 | DONE | | T-503 | 敏感信息本地明文存储提示与日志脱敏 | T-105, T-501 | 首次保存密码/API Key 时提示“本地明文保存”;UI 打码;日志/导出不含密码/API Key;文档说明 `config.json`/`config/ai_models.json`/DB/user-data-dir/images 必须 gitignore | DONE | | T-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | DONE | -| T-505 | 全流程诊断日志扩展 | T-207, T-504 | 在 T-207 采集日志机制稳定后,扩展到② AI生成、Excel 导入/回写、③ 更新shopee步骤级日志、④ Chrome 启动/登录检测、⑤ AI模型测试连接;数据库日志保存业务可读步骤和错误,本地 log 保存脱敏 traceback;不得记录 Cookie、密码、API Key、token | TODO | +| T-505 | 全流程诊断日志扩展 | T-207, T-504 | 在 T-207 采集日志机制稳定后,已扩展到② AI生成、Excel 导入/回写、③ 更新shopee步骤级日志、④ Chrome 启动/登录检测、⑤ AI模型测试连接;数据库日志保存业务可读步骤和错误,本地 log 保存脱敏 traceback;不得记录 Cookie、密码、API Key、token | DONE | | T-506 | 正式使用批量更新体验:③ 检查本轮更新 + 分批更新全部 + ⑤ 设置页三列布局 | T-501c, T-504 | ③ 增加「检查本轮更新」按钮替代用户可见 dry-run;「开始更新」对当前筛选结果按每批最大更新条数自动分批,确认弹窗显示总数/每批/预计批次,停止为当前商品安全结束后不再开始新任务;⑤ 设置页使用居中内容区,左右留白已缩短为 T-506 初始实现约 40%,模型详情/角色与生成参数/路径与端口/Shopee 更新安全均使用响应式三列布局,长字段跨列;「多账号并行更新」与「最大并行账号数」合并为同一个横向组件,最大并行账号数紧跟其后且不换行 | DONE | | T-507 | 正式批量更新:移除普通流程测试商品 ID 限制 | T-506 | 普通正式更新不再以 `test_item_id` 阻断当前筛选结果;③ 确认弹窗不再显示测试商品 ID;⑤ 普通设置页隐藏测试商品 ID,仅保留历史/调试兼容字段;仍保留允许真实提交、允许更新封面、分批、二次确认、账号就绪预检和运行日志 | DONE | | T-508 | ③ 更新shopee生产化操作区 | T-401, T-404a, T-506 | 「开始更新」作为主操作视觉强化;「重置更新状态」从底部按钮移到任务表右键菜单/高级入口,与批量更新物理分离,运行中禁用;更新安全拦截弹窗写明具体开关并提供「前往设置」深链到⑤,不改 Shopee/CDP 更新流程 | DONE | diff --git a/docs/current-state.md b/docs/current-state.md index 70032c0..b94b8d1 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,10 +6,10 @@ ## 当前快照 - 日期:2026-07-01 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、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② AI 生成布局与任务列表、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⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、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② AI 生成布局与任务列表、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⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a 本地生成结果/更新状态重置函数、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/开始生成/停止/进度/双击新旧封面预览/AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、居中内容区、三列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全与多账号并行设置并持久化 `config.json`,保存成功后弹轻量提示框,`dry_run` 仅保留内部兼容字段,普通设置页已隐藏测试商品 ID 入口;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a 本地生成结果/更新状态重置函数、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/开始生成/停止/进度/双击新旧封面预览/AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、居中内容区、三列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全与多账号并行设置并持久化 `config.json`,保存成功后弹轻量提示框,`dry_run` 仅保留内部兼容字段,普通设置页已隐藏测试商品 ID 入口;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`cmshopee.db-*`、`chrome_user_data_dir/`、`images/`、`logs/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) @@ -33,19 +33,19 @@ | `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 | | `docs/troubleshooting.md` | 已有 | 常见问题排查;已记录 `config/ai_models.json` 缺失 `category` 导致 GUI 启动报 “AI 模型 category 必须是 text 或 image” 的原因、修复和验证步骤 | | `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 | -| `app/gui.py` | 已有 | 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 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、批次筛选、删除批次软删除按钮、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、新标题列本地编辑、开始生成/停止/进度、双击新旧封面预览、AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与「前往设置」跳转、「检查本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 检查/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、三列 Shopee 更新安全与执行模式设置,dry_run 用户入口改为③检查按钮,测试商品 ID 普通入口已隐藏且不再阻断正式更新 | +| `app/gui.py` | 已有 | 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 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、批次筛选、删除批次软删除按钮、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、新标题列本地编辑、开始生成/停止/进度、双击新旧封面预览、AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与「前往设置」跳转、「检查本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 检查/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、三列 Shopee 更新安全与执行模式设置,dry_run 用户入口改为③检查按钮,测试商品 ID 普通入口已隐藏且不再阻断正式更新 | | `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-404 补丁产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集步骤回调、采集后关闭自动新建商品页 tab、上传前等待图片管理器稳定、点击上传块后注入文件并检测 CDN 后拖封面、更新封面统一先校验旧封面备份再删线上第一张、重复图片 toast 明确失败、页面主更新按钮、Shopee 站点侧确认框主按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | +| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-207/T-501c/T-502 + T-404 补丁产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集步骤回调、更新步骤回调、采集后关闭自动新建商品页 tab、上传前等待图片管理器稳定、点击上传块后注入文件并检测 CDN 后拖封面、更新封面统一先校验旧封面备份再删线上第一张、重复图片 toast 明确失败、页面主更新按钮、Shopee 站点侧确认框主按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | | `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全与 dry-run/多账号并行默认值;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | -| `app/diagnostics.py` | 已有 | T-207 + AI生成诊断补丁产出:本地 `logs/cmshopee.log` 诊断日志、大小滚动、异常类型/traceback/step/耗时记录,结构化 payload 和自由文本脱敏后写入 | +| `app/diagnostics.py` | 已有 | T-207 + AI生成诊断补丁 + T-505 产出:本地 `logs/cmshopee.log` 诊断日志、大小滚动、异常类型/traceback/step/耗时记录,结构化 payload 和自由文本脱敏后写入 | | `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;标题/封面步骤级回调;封面按 resolution/jpg_quality 保存并按批次/店铺/任务路径落盘;批量生成先标题后封面、进度回调、逐条落库、失败标记、事件/错误回调、停止取消未开始项 | | `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 | | `app/db.py` | 已有 | T-003/T-206/T-404a/T-504/T-509 产出:batches/accounts/tasks schema;batches 软删除字段与默认业务查询过滤;run_logs/run_log_events;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库;生成结果/更新状态本地重置;`update_generated_title()` 本地新标题微调;运行日志写入与查询 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `app/image_paths.py` | 已有 | 本地图片路径 helper:新采集旧封面和新生成封面统一写入 `images///__old/new.jpg`;历史 DB 已存路径继续按原路径读取 | | `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 | -| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-301/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502/T-503/T-504/T-506/T-507/T-508/T-509/T-510 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/image_paths/prompts/gui/workers | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-301/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/image_paths/prompts/gui/workers | | `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `cmshopee.db-*` / `chrome_user_data_dir/` / `images/` / `logs/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片、本地诊断日志,不提交版本库;密码/API Key 保存或变更时提示,展示/日志/导出脱敏 | @@ -63,12 +63,12 @@ 任务状态以 [`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-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、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⑤ Shopee 更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新shopee生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)。 -- 下一个可领取任务:**T-505(全流程诊断日志扩展)**。T-206 已完成软删除;T-505 依赖的 T-207/T-504 均已完成。 +- 已完成: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-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、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⑤ Shopee 更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新shopee生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)。 +- 下一个可领取任务:当前看板无 TODO;后续按产品优先级从 `docs/ux-review.md` 或新任务中领取。 ## 当前已知限制 -- ① 采集已接入 `run_type=collect`,② AI生成已接入 `run_type=generate` 与本地 `logs/cmshopee.log`;T-505 仍待把同类步骤级诊断扩展到 Excel 导入/回写、③更新、Chrome/登录检测与 AI 模型测试连接。 +- T-505 已完成:①采集、②AI生成、Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接均已接入业务可读 `run_logs/run_log_events`;异常路径写本地 `logs/cmshopee.log` 脱敏 traceback,业务日志不记录 Cookie、密码、API Key、token。 - ① 采集和③真实更新都依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205/T-402 已在执行前拦截未配置账号、Chrome 未启动、CDP 端口不可访问、未登录,并引导去④账号管理。③ 点击「开始更新」后若本轮需要账号未就绪,必须中止本轮更新,不自动打开账号 Chrome、不提交任何商品。 - T-501/T-501b/T-501c 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 Shopee 更新安全开关。 diff --git a/progress.md b/progress.md index 6098e0f..c46fd69 100644 --- a/progress.md +++ b/progress.md @@ -917,9 +917,18 @@ - 新标题微调:②「新标题」列允许已生成、未提交线上、非运行中任务双击本地编辑;写回 `tasks.new_title`,保留 `stage=generated`,清空 `last_error` 并回到可更新状态,不触碰 Shopee/CDP/Excel/封面。 - 测试:新增 DB 与 GUI 回归测试,覆盖本地标题编辑、③ 右键重置入口、开始更新主按钮标识、安全拦截前往设置。 - 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(145 tests);`git diff --check` 无空白错误,仅 LF/CRLF 提示。 + ## 【2026-07-01】T-510 完成 · 「预览本轮更新」改名为「检查本轮更新」 - 文档:先同步 `docs/00-ai-start-here.md`、`docs/02-requirements.md`、`docs/04-architecture.md`、`docs/06-tasks.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`,新增 T-510,明确用户可见按钮/弹窗/日志统一使用「检查本轮更新」。 - 代码:`app/gui.py` 将③按钮、风险提示、确认框标题、取消/开始/完成状态、完成弹窗和 dry-run 运行日志文案从「预览本轮更新」改为「检查本轮更新」;内部 `preview_update_button`、`preview_update()` 与 `dry_run` 字段保留,不改变执行语义。 - 测试:更新 `tests/test_gui.py` 的按钮文案、状态栏和 dry-run 日志断言。 -- 验证:`python -m py_compile app\gui.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_gui.py"` 通过(57 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(145 tests);`git diff --check` 无空白错误,仅 LF/CRLF 提示。 \ No newline at end of file +- 验证:`python -m py_compile app\gui.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_gui.py"` 通过(57 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(145 tests);`git diff --check` 无空白错误,仅 LF/CRLF 提示。 + +## 【2026-07-01】T-505 完成 · 全流程诊断日志扩展 + +- 范围:在 T-207 采集诊断和②AI生成诊断基础上,补齐 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接的业务运行日志与本地脱敏诊断日志。 +- 代码:`app/gui.py` 新增安全 run log helper;`CollectTab.import_excel()` 写 `run_type=import`,记录选中文件、缺列、脏行和入库统计;`WriteBackWorker` 写 `run_type=write_back`,记录写入文件、行数和文件锁/保存异常;`ApplyWorker` 的 `run_type=apply` 改为步骤级事件,覆盖预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;④「启动登录」写 `run_type=chrome_launch`,登录检测 worker 写 `run_type=login_check`;⑤AI模型测试连接写 `run_type=ai_model_test`。 +- CDP:`app/editor.py` 只给 `apply_task()` 增加可选 `on_step` 回调,用于上报 `open_product/change_title/replace_cover/click_update`,不改选择器、上传方式、确认框点击或提交逻辑。 +- 安全:DB 业务日志和本地 `logs/cmshopee.log` 均通过现有脱敏工具处理,不记录 Cookie、密码、API Key、token;异常路径本地日志保留脱敏 traceback。 +- 测试:新增/更新 `tests/test_gui.py` 覆盖 import/write_back/apply/chrome_launch/login_check/ai_model_test 运行日志和本地日志脱敏;`python -m py_compile app\gui.py app\editor.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(63 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(151 tests);`git diff --check` 无空白错误,仅 LF/CRLF 提示。 diff --git a/tests/test_gui.py b/tests/test_gui.py index b7085b4..43f6c68 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -21,6 +21,7 @@ from PySide6.QtWidgets import QApplication, QLineEdit, QPlainTextEdit, QTableVie from app.gui import ( AccountDialog, + AccountLoginCheckWorker, AccountsTab, AIModelTestWorker, ApplyTab, @@ -1434,7 +1435,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): progress = [] rows = [] - def fake_apply(account, task, close_success_tab=False): + def fake_apply(account, task, close_success_tab=False, on_step=None): applied_aliases.append(account.alias) close_flags.append(close_success_tab) if account.alias == "alias-a": @@ -1605,7 +1606,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): started = {"alias-a": threading.Event(), "alias-b": threading.Event()} thread_names = set() - def fake_apply(account, task, close_success_tab=False): + def fake_apply(account, task, close_success_tab=False, on_step=None): thread_names.add(threading.current_thread().name) started[account.alias].set() other = "alias-b" if account.alias == "alias-a" else "alias-a" @@ -2769,5 +2770,267 @@ class GuiTests(TempDirMixin, unittest.TestCase): ) + def test_collect_tab_import_excel_writes_diagnostic_run_log(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + excel_path = os.path.join(temp_dir, "input.xlsx") + statuses = [] + tab = CollectTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + + def fake_import(file_paths, path=None): + self.assertEqual([excel_path], file_paths) + self.assertEqual(cfg["db_path"], path) + return { + "batch_id": None, + "rows": [], + "stats": { + "files": 1, + "total": 2, + "valid": 0, + "invalid": 1, + "inserted": 0, + "file_errors": [ + { + "file": excel_path, + "sheet": "商品", + "error": "缺少必需列", + "missing_columns": ["别名"], + } + ], + "row_errors": [ + { + "file": excel_path, + "sheet": "商品", + "row": 3, + "error": "商品id必须是数字 token=SECRET", + } + ], + }, + } + + with mock.patch.object(tab, "_choose_excel_files", return_value=[excel_path]), \ + mock.patch("app.gui.excel.import_tasks", side_effect=fake_import): + tab.import_excel() + + run_log = db.list_run_logs(limit=1, run_type="import", path=cfg["db_path"])[0] + self.assertEqual("done", run_log.status) + self.assertEqual(1, run_log.done) + self.assertEqual(2, run_log.failed_count) + events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) + messages = "\n".join(event.message for event in events) + self.assertIn("step=parse_file result=failed", messages) + self.assertIn("missing=别名", messages) + self.assertIn("step=row_validate result=failed", messages) + self.assertIn("step=db_insert result=success", messages) + self.assertNotIn("SECRET", messages) + self.assertIn("token=***", messages) + self.assertIn("入库0", statuses[-1]) + + self.assert_removed(temp_dir) + + def test_write_back_worker_writes_run_log_and_diagnostic_on_failure(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + diagnostic_log_dir = os.path.join(temp_dir, "logs") + + with mock.patch( + "app.gui.excel.write_back", + side_effect=RuntimeError("Excel 文件被占用 token=SECRET"), + ): + worker = WriteBackWorker( + "batch-1", + db_path=cfg["db_path"], + diagnostic_log_dir=diagnostic_log_dir, + ) + with self.assertRaises(RuntimeError): + worker.execute() + + run_log = db.list_run_logs(limit=1, run_type="write_back", path=cfg["db_path"])[0] + self.assertEqual("failed", run_log.status) + self.assertEqual(1, run_log.failed_count) + events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) + messages = "\n".join(event.message for event in events) + self.assertIn("step=write_excel result=failed", messages) + self.assertNotIn("SECRET", messages) + self.assertIn("token=***", messages) + + log_path = os.path.join(diagnostic_log_dir, "cmshopee.log") + self.assertTrue(os.path.exists(log_path)) + with open(log_path, "r", encoding="utf-8") as fh: + raw_log = fh.read() + self.assertNotIn("SECRET", raw_log) + entry = json.loads(raw_log.strip().splitlines()[-1]) + self.assertEqual("write_excel", entry["step"]) + self.assertEqual("RuntimeError", entry["exception"]) + + self.assert_removed(temp_dir) + + def test_apply_worker_writes_step_run_log_and_diagnostic_on_failure(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + 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"]) + db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) + task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + diagnostic_log_dir = os.path.join(temp_dir, "logs") + + def fake_apply(account, task, close_success_tab=False, on_step=None): + on_step({"step": "open_product", "result": "start"}) + on_step({"step": "replace_cover", "result": "failed", "detail": "token=SECRET"}) + return { + "committed": False, + "error": "新封面上传失败 token=SECRET", + "cover": {"ok": False, "error": "token=SECRET"}, + } + + with mock.patch("app.gui.editor.apply_task", side_effect=fake_apply): + summary = ApplyWorker( + [task], + db_path=cfg["db_path"], + config=cfg, + preflight=False, + diagnostic_log_dir=diagnostic_log_dir, + ).execute() + + self.assertFalse(summary["ok"]) + self.assertEqual(1, summary["failed"]) + run_log = db.list_run_logs(limit=1, run_type="apply", path=cfg["db_path"])[0] + events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) + messages = "\n".join(event.message for event in events) + self.assertIn("step=open_product result=start", messages) + self.assertIn("step=replace_cover result=failed", messages) + self.assertIn("step=db_write result=success", messages) + self.assertNotIn("SECRET", messages) + self.assertIn("token=***", messages) + + log_path = os.path.join(diagnostic_log_dir, "cmshopee.log") + self.assertTrue(os.path.exists(log_path)) + with open(log_path, "r", encoding="utf-8") as fh: + raw_log = fh.read() + self.assertNotIn("SECRET", raw_log) + entry = json.loads(raw_log.strip().splitlines()[-1]) + self.assertEqual("replace_cover", entry["step"]) + self.assertEqual("alias-a", entry["alias"]) + self.assertEqual("51100639510", entry["item_id"]) + + self.assert_removed(temp_dir) + def test_account_login_check_worker_writes_run_log(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + diagnostic_log_dir = os.path.join(temp_dir, "logs") + + with mock.patch( + "app.gui.accounts.detect_login", + return_value={"logged_in": False, "reason": "LOGIN_PAGE token=SECRET"}, + ) as detect_login: + result = AccountLoginCheckWorker( + account, + db_path=cfg["db_path"], + config=cfg, + diagnostic_log_dir=diagnostic_log_dir, + ).execute() + + detect_login.assert_called_once_with( + account, + timeout=8, + path=cfg["db_path"], + config=cfg, + ) + self.assertEqual("alias-a", result["alias"]) + self.assertFalse(result["status"]["logged_in"]) + run_log = db.list_run_logs(limit=1, run_type="login_check", path=cfg["db_path"])[0] + self.assertEqual("done", run_log.status) + self.assertEqual(1, run_log.failed_count) + events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) + messages = "\n".join(event.message for event in events) + self.assertIn("step=detect_login result=start", messages) + self.assertIn("step=detect_login result=failed", messages) + self.assertNotIn("SECRET", messages) + self.assertIn("token=***", messages) + + self.assert_removed(temp_dir) + + def test_ai_model_test_worker_writes_run_log(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + models_path = os.path.join(temp_dir, "ai_models.json") + worker = AIModelTestWorker( + "Text A", + ai_models_path=models_path, + db_path=cfg["db_path"], + diagnostic_log_dir=os.path.join(temp_dir, "logs"), + ) + + with mock.patch( + "app.gui.appconfig.test_ai_model", + return_value={"ok": False, "status": 401, "error": "api_key=SECRET"}, + ): + result = worker.execute() + + self.assertFalse(result["ok"]) + self.assertEqual("Text A", result["name"]) + run_log = db.list_run_logs(limit=1, run_type="ai_model_test", path=cfg["db_path"])[0] + self.assertEqual("done", run_log.status) + self.assertEqual(1, run_log.failed_count) + events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) + messages = "\n".join(event.message for event in events) + self.assertIn("step=test_connection result=start", messages) + self.assertIn("step=test_connection result=failed", messages) + self.assertNotIn("SECRET", messages) + self.assertIn("api_key=***", messages) + + self.assert_removed(temp_dir) + + def test_accounts_tab_launch_login_writes_chrome_launch_run_log(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + statuses = [] + tab = AccountsTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + tab.table.selectRow(0) + + class FakeProcess: + pid = 1234 + + with mock.patch( + "app.gui.accounts.launch_for_login", + return_value=FakeProcess(), + ) as launch_for_login: + tab.launch_login() + + launch_for_login.assert_called_once_with(account, config=cfg) + self.assertEqual("已启动", tab.login_statuses["alias-a"]) + self.assertIn("Chrome 已启动", statuses[-1]) + run_log = db.list_run_logs(limit=1, run_type="chrome_launch", path=cfg["db_path"])[0] + self.assertEqual("done", run_log.status) + self.assertEqual(1, run_log.success_count) + events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) + messages = "\n".join(event.message for event in events) + self.assertIn("step=launch_chrome result=start", messages) + self.assertIn("step=launch_chrome result=success", messages) + self.assertIn("pid=1234", messages) + + self.assert_removed(temp_dir) if __name__ == "__main__": unittest.main()