diff --git a/.gitignore b/.gitignore index 75d6d0f..3d4030d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,13 @@ config/ai_models.json config.json # SQLite(账号/任务/结果,含密码) cmshopee.db +cmshopee.db-* # 各账号 Chrome 配置(含登录态) chrome_user_data_dir/ # 采集的旧封面 / AI 生成的新封面 images/ +# 本地诊断日志 +logs/ # 运营填写后的 Excel 业务文件;标准空模板可提交 *.xlsx *.xlsm diff --git a/app/ai.py b/app/ai.py index f1e6217..c14cb89 100644 --- a/app/ai.py +++ b/app/ai.py @@ -6,12 +6,13 @@ import copy import json import mimetypes import os +import threading import time import urllib.error import urllib.request import uuid -from . import appconfig, db +from . import appconfig, db, diagnostics from . import prompts as prompt_module from .config import make_slug @@ -34,12 +35,15 @@ def gen_title( retry=None, config=None, models_path=appconfig.AI_MODELS_PATH, + on_step=None, ): """Generate a new product title from a prompt and the old title.""" cfg = appconfig.load_config() if config is None else config ai_cfg = appconfig.ai_config(cfg) + _notify_step(on_step, "load_text_model") model = _role_model("text", ai_cfg.get("default_text_model"), models_path) + _notify_step(on_step, "title_build_request") payload = _chat_payload( model, [ @@ -51,6 +55,7 @@ def gen_title( ], ) attempts = _attempt_count(ai_cfg, retry) + _notify_step(on_step, "title_request") data = _call_with_retry( model, payload, @@ -58,6 +63,7 @@ def gen_title( attempts, request_kind="json", ) + _notify_step(on_step, "title_parse_response") text = _extract_text(data).strip() if not text: raise AIError("AI 返回为空标题") @@ -73,9 +79,11 @@ def gen_cover( retry=None, config=None, models_path=appconfig.AI_MODELS_PATH, + on_step=None, ): """Generate a new cover image and save it as a JPEG file.""" + _notify_step(on_step, "cover_validate_input") old_cover_path = os.path.abspath(str(old_cover_path)) if not os.path.exists(old_cover_path): raise FileNotFoundError("旧封面图片不存在: %s" % old_cover_path) @@ -84,14 +92,17 @@ def gen_cover( cfg = appconfig.load_config() if config is None else config ai_cfg = appconfig.ai_config(cfg) + _notify_step(on_step, "load_image_model") model = _role_model("image", ai_cfg.get("default_image_model"), models_path) resolution = str(resolution or ai_cfg.get("resolution", "1k")) quality = _jpg_quality(jpg_quality if jpg_quality is not None else ai_cfg.get("jpg_quality", 90)) attempts = _attempt_count(ai_cfg, retry) api_type = model.get("api_type", "auto") + _notify_step(on_step, "cover_build_request") if api_type == "images_edits": body, content_type = _image_edit_body(model, cover_prompt, old_cover_path, resolution) + _notify_step(on_step, "cover_request") data = _call_with_retry( model, body, @@ -102,12 +113,24 @@ def gen_cover( ) else: payload = _image_chat_payload(model, cover_prompt, old_cover_path, resolution) + _notify_step(on_step, "cover_request") data = _call_with_retry(model, payload, cfg, attempts, request_kind="json") + _notify_step(on_step, "cover_parse_response") image_bytes = _extract_image_bytes(data, model, cfg) + _notify_step(on_step, "cover_save") return _save_jpeg(image_bytes, out_path, resolution, quality) +def _notify_step(callback, step): + if callback is None: + return + try: + callback(step) + except Exception: + pass + + def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None): """Generate titles first, then covers, and persist each successful task.""" @@ -132,6 +155,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No image_root = runtime.get("image_dir") or appconfig.image_dir(config) account_by_alias = runtime.get("account_by_alias") or {} on_task_update = runtime.get("on_task_update") + on_event = runtime.get("on_event") + on_error = runtime.get("on_error") title_prompt = _prompt_value(prompts, "title") cover_prompt = _prompt_value(prompts, "cover") should_stop = should_stop or (lambda: False) @@ -149,6 +174,22 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No } _emit_generation_progress(on_progress, summary) title_results = {} + step_by_task = {} + step_lock = threading.Lock() + + def set_step(task, step): + with step_lock: + step_by_task[getattr(task, "id", None)] = str(step) + + def get_step(task, fallback): + with step_lock: + return step_by_task.get(getattr(task, "id", None), fallback) + + def step_callback(task, phase): + def callback(step): + set_step(task, step) + _emit_generation_event(on_event, task, phase, step, "start") + return callback with ThreadPoolExecutor( max_workers=max(1, int(generation_cfg.get("title_concurrency", 1))) @@ -158,6 +199,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No if should_stop(): summary["cancelled"] = True break + set_step(task, "title_submit") + _emit_generation_event(on_event, task, "title", "title_submit", "start") futures[ executor.submit( gen_title, @@ -166,6 +209,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No retry=generation_cfg.get("retry"), config=config, models_path=models_path, + on_step=step_callback(task, "title"), ) ] = task for future in as_completed(futures): @@ -176,12 +220,18 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No try: title_results[task.id] = future.result() summary["title_done"] += 1 + set_step(task, "title_done") + _emit_generation_event(on_event, task, "title", "title_done", "success") except CancelledError: summary["cancelled"] = True + _emit_generation_event(on_event, task, "title", get_step(task, "title_request"), "cancelled", level="warning") except Exception as exc: summary["failed"] += 1 summary["ok"] = False - _mark_generate_failed(task, exc, db_path, on_task_update) + step = get_step(task, "title_request") + error = _mark_generate_failed(task, exc, db_path, on_task_update) + _emit_generation_event(on_event, task, "title", step, "failed", detail=error, level="error") + _emit_generation_error(on_error, task, "title", step, exc, error) _emit_generation_progress(on_progress, summary) cover_tasks = [ @@ -197,23 +247,38 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No summary["cancelled"] = True break new_title = title_results[task.id] - rendered_cover_prompt = prompt_module.render_prompt( - cover_prompt, - _prompt_context(task, new_title, account_by_alias), - ) - futures[ - executor.submit( - gen_cover, - rendered_cover_prompt, - getattr(task, "old_cover_path", "") or "", - _new_cover_path(task, account_by_alias, image_root), - resolution=generation_cfg.get("resolution"), - jpg_quality=generation_cfg.get("jpg_quality"), - retry=generation_cfg.get("retry"), - config=config, - models_path=models_path, + try: + set_step(task, "cover_prompt_render") + _emit_generation_event(on_event, task, "cover", "cover_prompt_render", "start") + rendered_cover_prompt = prompt_module.render_prompt( + cover_prompt, + _prompt_context(task, new_title, account_by_alias), ) - ] = (task, new_title) + _emit_generation_event(on_event, task, "cover", "cover_prompt_render", "success") + set_step(task, "cover_submit") + _emit_generation_event(on_event, task, "cover", "cover_submit", "start") + futures[ + executor.submit( + gen_cover, + rendered_cover_prompt, + getattr(task, "old_cover_path", "") or "", + _new_cover_path(task, account_by_alias, image_root), + resolution=generation_cfg.get("resolution"), + jpg_quality=generation_cfg.get("jpg_quality"), + retry=generation_cfg.get("retry"), + config=config, + models_path=models_path, + on_step=step_callback(task, "cover"), + ) + ] = (task, new_title) + except Exception as exc: + summary["failed"] += 1 + summary["ok"] = False + step = get_step(task, "cover_prompt_render") + error = _mark_generate_failed(task, exc, db_path, on_task_update) + _emit_generation_event(on_event, task, "cover", step, "failed", detail=error, level="error") + _emit_generation_error(on_error, task, "cover", step, exc, error) + _emit_generation_progress(on_progress, summary) for future in as_completed(futures): task, new_title = futures[future] if should_stop(): @@ -221,6 +286,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No _cancel_pending(futures) try: new_cover_path = future.result() + set_step(task, "db_write") + _emit_generation_event(on_event, task, "cover", "db_write", "start") db.set_generated(task.id, new_title, new_cover_path, path=db_path) summary["cover_done"] += 1 if on_task_update is not None: @@ -233,19 +300,23 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No "new_cover_path": new_cover_path, }, ) + _emit_generation_event(on_event, task, "cover", "db_write", "success") except CancelledError: summary["cancelled"] = True + _emit_generation_event(on_event, task, "cover", get_step(task, "cover_request"), "cancelled", level="warning") except Exception as exc: summary["failed"] += 1 summary["ok"] = False - _mark_generate_failed(task, exc, db_path, on_task_update) + step = get_step(task, "cover_request") + error = _mark_generate_failed(task, exc, db_path, on_task_update) + _emit_generation_event(on_event, task, "cover", step, "failed", detail=error, level="error") + _emit_generation_error(on_error, task, "cover", step, exc, error) _emit_generation_progress(on_progress, summary) if summary["cancelled"]: summary["ok"] = False return summary - def _role_model(category, name, models_path): if not name: raise AIError("未配置默认 %s 模型" % category) @@ -332,10 +403,45 @@ def _new_cover_path(task, account_by_alias, image_root): def _mark_generate_failed(task, exc, db_path, on_task_update): - error = str(exc) or exc.__class__.__name__ + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) db.mark_failed(task.id, "generate", error, path=db_path) if on_task_update is not None: on_task_update(task.id, {"status": "failed", "last_error": error}) + return error + + +def _emit_generation_event(callback, task, phase, step, result, detail=None, level="info"): + if callback is None: + return + payload = { + "task": task, + "phase": phase, + "step": str(step), + "result": result, + "level": level, + } + if detail is not None: + payload["detail"] = diagnostics.redact_log_text(detail) + try: + callback(payload) + except Exception: + return + + +def _emit_generation_error(callback, task, phase, step, exc, error): + if callback is None: + return + payload = { + "task": task, + "phase": phase, + "step": str(step), + "error": diagnostics.redact_log_text(error), + "exception": exc, + } + try: + callback(payload) + except Exception: + return def _cancel_pending(futures): @@ -401,7 +507,7 @@ def _call_once(model, body, config, request_kind, content_type=None): else: data = body request = urllib.request.Request( - model["url"], + appconfig.model_request_url(model), data=data, headers=_headers(model, content_type), method="POST", diff --git a/app/appconfig.py b/app/appconfig.py index f20d7ba..412e2c4 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -425,6 +425,33 @@ def get_model(name, path=AI_MODELS_PATH) -> dict: return copy.deepcopy(models[_model_index(models, name)]) + +def model_request_url(model) -> str: + """Return the HTTP endpoint used for a configured AI model.""" + + raw_url = str(model.get("url", "") or "").strip() + api_type = str(model.get("api_type", "auto") or "auto").strip() + if api_type == "images_edits": + return _append_default_endpoint(raw_url, "images/edits") + return _append_default_endpoint(raw_url, "chat/completions") + + +def _append_default_endpoint(raw_url, endpoint): + if not raw_url: + return raw_url + parts = urllib.parse.urlsplit(raw_url) + path = parts.path.rstrip("/") + lowered = path.lower() + endpoint_path = "/" + endpoint.strip("/") + if lowered.endswith(endpoint_path): + return raw_url + base_markers = ("", "/v1", "/v1beta", "/api/v1", "/api/v1beta") + if lowered in base_markers or lowered.endswith(base_markers[1:]): + path = path + endpoint_path + return urllib.parse.urlunsplit( + (parts.scheme, parts.netloc, path, parts.query, parts.fragment) + ) + return raw_url def _test_request_payload(model): if model["api_type"] == "images_edits": payload = {"model": model["model"], "prompt": "ping"} @@ -455,7 +482,7 @@ def test_ai_model(name, path=AI_MODELS_PATH) -> dict: "utf-8" ) request = urllib.request.Request( - model["url"], + model_request_url(model), data=data, headers={ "Authorization": "Bearer " + model["api_key"], diff --git a/app/diagnostics.py b/app/diagnostics.py new file mode 100644 index 0000000..fa42a53 --- /dev/null +++ b/app/diagnostics.py @@ -0,0 +1,118 @@ +"""Diagnostic logging helpers for local debug logs.""" + +from __future__ import annotations + +import datetime as _dt +import json +import os +import re +import traceback + +from . import appconfig + +DEFAULT_LOG_DIR = "logs" +DEFAULT_LOG_FILE = "cmshopee.log" +DEFAULT_MAX_BYTES = 2 * 1024 * 1024 +DEFAULT_BACKUPS = 3 + +_SECRET_ASSIGNMENT_RE = re.compile( + r"(?i)\b(api[_-]?key|apikey|token|password|cookie|authorization)\b\s*[:=]\s*([^\s,;]+)" +) +_BEARER_RE = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-]+") + + +def _redact_text(value): + text = str(value) + text = _SECRET_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}=***", text) + return _BEARER_RE.sub("Bearer ***", text) + + +def _sanitize_log_value(value): + value = appconfig.sanitize_for_log(value) + if isinstance(value, dict): + return {key: _sanitize_log_value(child) for key, child in value.items()} + if isinstance(value, list): + return [_sanitize_log_value(item) for item in value] + if isinstance(value, tuple): + return tuple(_sanitize_log_value(item) for item in value) + if isinstance(value, str): + return _redact_text(value) + return value + + +def redact_log_text(value): + """Return free-form diagnostic text with common secret assignments redacted.""" + + return _redact_text(value) + + +def diagnostic_log_path(log_dir=None, filename=DEFAULT_LOG_FILE): + directory = os.path.abspath(log_dir or DEFAULT_LOG_DIR) + return os.path.join(directory, filename) + + +def write_diagnostic_log( + message, + *, + level="INFO", + step=None, + task_id=None, + alias=None, + item_id=None, + elapsed_ms=None, + payload=None, + exc=None, + log_dir=None, + log_path=None, + max_bytes=DEFAULT_MAX_BYTES, + backups=DEFAULT_BACKUPS, +): + """Append a sanitized diagnostic log entry and return the log path.""" + + path = os.path.abspath(log_path or diagnostic_log_path(log_dir)) + os.makedirs(os.path.dirname(path), exist_ok=True) + _rotate_if_needed(path, max_bytes=max_bytes, backups=backups) + + entry = { + "time": _dt.datetime.now().isoformat(timespec="seconds"), + "level": str(level or "INFO").upper(), + "message": _redact_text(message), + } + if step is not None: + entry["step"] = _redact_text(step) + if task_id is not None: + entry["task_id"] = task_id + if alias is not None: + entry["alias"] = _redact_text(alias) + if item_id is not None: + entry["item_id"] = _redact_text(item_id) + if elapsed_ms is not None: + entry["elapsed_ms"] = int(elapsed_ms) + if payload is not None: + entry["payload"] = _sanitize_log_value(payload) + if exc is not None: + entry["exception"] = exc.__class__.__name__ + tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + entry["traceback"] = _redact_text(appconfig.redact_secrets(tb)) + + safe_entry = _sanitize_log_value(entry) + with open(path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(safe_entry, ensure_ascii=False, sort_keys=True)) + fh.write("\n") + return path + + +def _rotate_if_needed(path, max_bytes=DEFAULT_MAX_BYTES, backups=DEFAULT_BACKUPS): + if max_bytes <= 0 or backups <= 0: + return + if not os.path.exists(path) or os.path.getsize(path) < max_bytes: + return + oldest = f"{path}.{int(backups)}" + if os.path.exists(oldest): + os.remove(oldest) + for index in range(int(backups) - 1, 0, -1): + source = f"{path}.{index}" + target = f"{path}.{index + 1}" + if os.path.exists(source): + os.replace(source, target) + os.replace(path, f"{path}.1") \ No newline at end of file diff --git a/app/editor.py b/app/editor.py index dd547d3..55d6d36 100644 --- a/app/editor.py +++ b/app/editor.py @@ -305,9 +305,10 @@ def is_logged_in(account) -> bool: return bool(login_status(account).get("logged_in")) -def open_product(account, item_id) -> CDP: +def open_product(account, item_id, on_step=None) -> CDP: """Open or reuse a product edit tab, navigate to a clean edit URL, and wait ready.""" + _notify_collect_step(on_step, "open_product") host = _cdp_host(account) item_id = str(item_id) url = _product_url(account, item_id) @@ -326,6 +327,7 @@ def open_product(account, item_id) -> CDP: except Exception: pass cdp.send("Page.navigate", {"url": url}) + _notify_collect_step(on_step, "wait_ready") _wait_ready(cdp) return cdp @@ -366,17 +368,20 @@ def download_cover(src, out_path) -> str: return out_path -def collect(account, task) -> dict: +def collect(account, task, on_step=None) -> dict: """Collect current title and cover snapshot before any edits.""" item_id = _item_id(task) - cdp = open_product(account, item_id) + cdp = open_product(account, item_id, on_step=on_step) try: + _notify_collect_step(on_step, "read_title") old_title = read_title(cdp) + _notify_collect_step(on_step, "read_cover") old_cover_src = read_cover_src(cdp) out_path = _get(task, "old_cover_path") if not out_path: out_path = os.path.join(_image_root(account), f"{item_id}_old.jpg") + _notify_collect_step(on_step, "download_cover") old_cover_path = download_cover(old_cover_src, out_path) return { "old_title": old_title, @@ -387,6 +392,15 @@ def collect(account, task) -> dict: _close_collected_product(cdp) + +def _notify_collect_step(callback, step): + if callback is None: + return + try: + callback(step) + 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)) diff --git a/app/gui.py b/app/gui.py index 8c609f4..9f68792 100644 --- a/app/gui.py +++ b/app/gui.py @@ -5,6 +5,7 @@ from __future__ import annotations import os import sys import threading +import time from concurrent.futures import ThreadPoolExecutor, as_completed try: @@ -84,7 +85,7 @@ QTabBar::tab:hover:!selected { if QT_IMPORT_ERROR is None: - from . import accounts, ai, appconfig, chrome, db, editor, excel, prompts + from . import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, prompts from . import config as account_config @@ -477,6 +478,9 @@ if QT_IMPORT_ERROR is None: self.batch_filter.setObjectName("batchFilter") self.shop_filter = QComboBox() self.shop_filter.setObjectName("shopFilter") + self.item_filter = QLineEdit() + self.item_filter.setObjectName("generateItemFilter") + self.item_filter.setPlaceholderText("商品ID") self.status_filter = QComboBox() self.status_filter.setObjectName("statusFilter") for label, value in self.STATUS_FILTERS: @@ -488,6 +492,8 @@ if QT_IMPORT_ERROR is None: filter_layout.addWidget(self.batch_filter, 2) filter_layout.addWidget(QLabel("店铺")) filter_layout.addWidget(self.shop_filter, 1) + filter_layout.addWidget(QLabel("商品ID")) + filter_layout.addWidget(self.item_filter, 1) filter_layout.addWidget(QLabel("状态")) filter_layout.addWidget(self.status_filter, 1) filter_layout.addWidget(self.refresh_button) @@ -502,12 +508,20 @@ if QT_IMPORT_ERROR is None: self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.task_table.verticalHeader().setVisible(False) + self.run_log_view = QPlainTextEdit() + self.run_log_view.setObjectName("generateRunLogView") + self.run_log_view.setReadOnly(True) + self.run_log_view.setMaximumHeight(128) + self.run_log_view.setPlaceholderText("AI生成运行日志") + right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(12, 0, 0, 0) right_layout.addLayout(filter_layout) right_layout.addWidget(self.summary_label) right_layout.addWidget(self.task_table, 1) + right_layout.addWidget(QLabel("AI生成运行日志")) + right_layout.addWidget(self.run_log_view) self.splitter = QSplitter(Qt.Horizontal) self.splitter.addWidget(left_panel) @@ -529,6 +543,7 @@ if QT_IMPORT_ERROR is None: self.batch_filter.currentIndexChanged.connect(self.refresh_tasks) self.shop_filter.currentIndexChanged.connect(self.refresh_tasks) + self.item_filter.textChanged.connect(self.refresh_tasks) self.status_filter.currentIndexChanged.connect(self.refresh_tasks) self.refresh_button.clicked.connect(self.refresh_tasks) self.save_title_button.clicked.connect(self.save_title_prompt) @@ -546,11 +561,33 @@ if QT_IMPORT_ERROR is None: self.refresh_cover_templates() self.refresh_tasks() + self._load_latest_generate_run_log() def _set_status(self, message): if self.status_callback is not None: self.status_callback(message) + def _on_generate_log(self, message): + self._append_generate_log(message) + self._set_status(message) + + def _append_generate_log(self, message): + self.run_log_view.appendPlainText(str(message)) + + def _load_latest_generate_run_log(self): + try: + logs = db.list_run_logs(limit=1, run_type="generate", path=self.db_path) + if not logs: + return + events = db.list_run_log_events(logs[0].id, limit=40, path=self.db_path) + except Exception: + return + lines = [ + f"{event.created_at} [{event.level}] {event.message}" + for event in events + ] + self.run_log_view.setPlainText("\n".join(lines)) + def save_title_prompt(self, checked=False): try: prompts.save_title_prompt( @@ -711,13 +748,15 @@ if QT_IMPORT_ERROR is None: prompt_values, db_path=self.db_path, config=self.config, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, ) worker.progress.connect(self._on_generate_progress) worker.row_updated.connect(self._on_generate_row_updated) - worker.log.connect(self._set_status) + worker.log.connect(self._on_generate_log) worker.failed.connect(self._on_generate_failed) worker.finished.connect(self._on_generate_finished) worker.cancelled.connect(self._on_generate_cancelled) + self.run_log_view.clear() thread = run_worker(worker, thread_name="GenerateWorker", start=False) thread.finished.connect(lambda: self._forget_generate_thread(thread)) self.generate_worker = worker @@ -784,6 +823,7 @@ if QT_IMPORT_ERROR is None: self.refresh_button.setEnabled(not running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) + self.item_filter.setEnabled(not running) self.status_filter.setEnabled(not running) self.save_title_button.setEnabled(not running) self.save_cover_template_button.setEnabled(not running) @@ -809,6 +849,7 @@ if QT_IMPORT_ERROR is None: def _on_generate_finished(self, payload): self._set_generate_running(False) self.refresh_tasks() + self._load_latest_generate_run_log() self._update_generate_progress(payload) if payload.get("error"): self._set_status(f"AI 生成失败:{payload.get('error')}") @@ -818,6 +859,7 @@ if QT_IMPORT_ERROR is None: def _on_generate_cancelled(self, payload): self._set_generate_running(False) self.refresh_tasks() + self._load_latest_generate_run_log() self._update_generate_progress(payload) self._set_status("AI 生成已停止:" + self._generate_progress_text(payload)) @@ -874,6 +916,7 @@ if QT_IMPORT_ERROR is None: selected_batch = self.batch_filter.currentData() selected_shop = self.shop_filter.currentData() selected_status = self.status_filter.currentData() or "all" + item_query = self.item_filter.text().strip() self._populate_batch_filter(batches, selected_batch) selected_batch = self.batch_filter.currentData() batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path) @@ -882,6 +925,7 @@ if QT_IMPORT_ERROR is None: filtered_tasks = [ task for task in batch_tasks if self._matches_shop(task, selected_shop) + and self._matches_item(task, item_query) and self._matches_status(task, selected_status) ] except Exception as exc: @@ -940,6 +984,11 @@ if QT_IMPORT_ERROR is None: def _matches_shop(self, task, selected_shop): return selected_shop is None or str(task.alias).strip() == selected_shop + def _matches_item(self, task, item_query): + if not item_query: + return True + return item_query in str(getattr(task, "item_id", "")) + def _matches_status(self, task, selected_status): if selected_status in (None, "all"): return True @@ -990,6 +1039,9 @@ if QT_IMPORT_ERROR is None: self.batch_filter.setObjectName("applyBatchFilter") self.shop_filter = QComboBox() self.shop_filter.setObjectName("applyShopFilter") + self.item_filter = QLineEdit() + self.item_filter.setObjectName("applyItemFilter") + self.item_filter.setPlaceholderText("商品ID") self.status_filter = QComboBox() self.status_filter.setObjectName("applyStatusFilter") for label, value in self.STATUS_FILTERS: @@ -1001,6 +1053,8 @@ if QT_IMPORT_ERROR is None: filter_layout.addWidget(self.batch_filter, 2) filter_layout.addWidget(QLabel("店铺")) filter_layout.addWidget(self.shop_filter, 1) + filter_layout.addWidget(QLabel("商品ID")) + filter_layout.addWidget(self.item_filter, 1) filter_layout.addWidget(QLabel("状态")) filter_layout.addWidget(self.status_filter, 1) filter_layout.addWidget(self.refresh_button) @@ -1045,6 +1099,7 @@ if QT_IMPORT_ERROR is None: self.batch_filter.currentIndexChanged.connect(self.refresh_tasks) self.shop_filter.currentIndexChanged.connect(self.refresh_tasks) + self.item_filter.textChanged.connect(self.refresh_tasks) self.status_filter.currentIndexChanged.connect(self.refresh_tasks) self.refresh_button.clicked.connect(self.refresh_tasks) self.start_update_button.clicked.connect(self.start_update) @@ -1066,6 +1121,7 @@ if QT_IMPORT_ERROR is None: selected_batch = self.batch_filter.currentData() selected_shop = self.shop_filter.currentData() selected_status = self.status_filter.currentData() or "generated" + item_query = self.item_filter.text().strip() self._populate_batch_filter(batches, selected_batch) selected_batch = self.batch_filter.currentData() batch_tasks = [ @@ -1077,6 +1133,7 @@ if QT_IMPORT_ERROR is None: filtered_tasks = [ task for task in batch_tasks if self._matches_shop(task, selected_shop) + and self._matches_item(task, item_query) and self._matches_status(task, selected_status) ] except Exception as exc: @@ -1219,6 +1276,9 @@ if QT_IMPORT_ERROR is None: def _shop_filter_label(self): return self.shop_filter.currentText() or "全部店铺" + def _item_filter_label(self): + return self.item_filter.text().strip() or "全部商品" + def _is_update_task(self, task): if task.stage in {"generated", "applied"}: return True @@ -1227,6 +1287,11 @@ if QT_IMPORT_ERROR is None: def _matches_shop(self, task, selected_shop): return selected_shop is None or str(task.alias).strip() == selected_shop + def _matches_item(self, task, item_query): + if not item_query: + return True + return item_query in str(getattr(task, "item_id", "")) + def _matches_status(self, task, selected_status): if selected_status in (None, "all"): return True @@ -1254,6 +1319,7 @@ if QT_IMPORT_ERROR is None: "即将按当前筛选结果开始更新 Shopee 线上商品。\n\n" f"批次:{self._batch_filter_label()}\n" f"店铺:{self._shop_filter_label()}\n" + f"商品ID:{self._item_filter_label()}\n" f"状态:{self._status_label()}\n" f"任务数:{len(tasks)}\n\n" "安全设置:" @@ -1315,6 +1381,7 @@ if QT_IMPORT_ERROR is None: self.refresh_button.setEnabled(not running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) + self.item_filter.setEnabled(not running) self.status_filter.setEnabled(not running) self._update_write_back_button() @@ -1323,6 +1390,7 @@ if QT_IMPORT_ERROR is None: self.refresh_button.setEnabled(not running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) + self.item_filter.setEnabled(not running) self.status_filter.setEnabled(not running) self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids())) @@ -1596,6 +1664,7 @@ if QT_IMPORT_ERROR is None: self.collect_thread = None self.write_back_worker = None self.write_back_thread = None + self.last_collect_run_id = None self.import_button = QPushButton("导入 Excel...") self.refresh_button = QPushButton("刷新") @@ -1632,6 +1701,12 @@ if QT_IMPORT_ERROR is None: self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.table.verticalHeader().setVisible(False) + self.run_log_view = QPlainTextEdit() + self.run_log_view.setObjectName("collectRunLogView") + self.run_log_view.setReadOnly(True) + self.run_log_view.setMaximumHeight(128) + self.run_log_view.setPlaceholderText("采集运行日志") + self.empty_label = QLabel("") layout = QVBoxLayout(self) @@ -1640,6 +1715,8 @@ if QT_IMPORT_ERROR is None: layout.addLayout(summary_layout) layout.addWidget(self.match_detail_label) layout.addWidget(self.table, 1) + layout.addWidget(QLabel("采集运行日志")) + layout.addWidget(self.run_log_view) layout.addWidget(self.empty_label) self.import_button.clicked.connect(self.import_excel) @@ -1651,11 +1728,41 @@ if QT_IMPORT_ERROR is None: self.show_unmatched_button.clicked.connect(self.show_unmatched_tasks) self.refresh_tasks() + self._load_latest_collect_run_log() def _set_status(self, message): if self.status_callback is not None: self.status_callback(message) + def _on_collect_log(self, message): + self._append_collect_log(message) + self._set_status(message) + + def _append_collect_log(self, message): + self.run_log_view.appendPlainText(str(message)) + + def _load_latest_collect_run_log(self): + try: + logs = db.list_run_logs(limit=1, run_type="collect", path=self.db_path) + if not logs: + return + events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path) + except Exception: + return + lines = [ + f"{event.created_at} [{event.level}] {event.message}" + for event in events + ] + self.run_log_view.setPlainText("\n".join(lines)) + + def _log_collect_run_event(self, run_id, message, level="info"): + safe_message = diagnostics.redact_log_text(message) + try: + db.add_run_log_event(run_id, safe_message, level=level, path=self.db_path) + except Exception: + return + self._append_collect_log(safe_message) + def _show_error(self, message): QMessageBox.warning(self, "导入采集", str(message)) self._set_status(str(message)) @@ -1723,13 +1830,19 @@ if QT_IMPORT_ERROR is None: if not tasks: self._set_status("没有可采集任务") return - worker = CollectWorker(tasks, db_path=self.db_path, config=self.config) + worker = CollectWorker( + tasks, + db_path=self.db_path, + config=self.config, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) worker.progress.connect(self._on_collect_progress) worker.row_updated.connect(self._on_collect_row_updated) - worker.log.connect(self._set_status) + worker.log.connect(self._on_collect_log) worker.failed.connect(self._on_collect_failed) worker.finished.connect(self._on_collect_finished) worker.cancelled.connect(self._on_collect_cancelled) + self.run_log_view.clear() thread = run_worker(worker, thread_name="CollectWorker", start=False) thread.finished.connect(lambda: self._forget_collect_thread(thread)) self.collect_worker = worker @@ -1830,7 +1943,9 @@ if QT_IMPORT_ERROR is None: def _on_collect_finished(self, payload): self._set_collect_running(False) + self.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id self.refresh_tasks() + self._load_latest_collect_run_log() if payload.get("blocked"): self._show_collect_blocked(payload) return @@ -1842,6 +1957,11 @@ if QT_IMPORT_ERROR is None: if payload.get("collected", 0) > 0: batch_id = self._active_batch_id() if batch_id and self._start_write_back(batch_id, auto=True): + if self.last_collect_run_id: + self._log_collect_run_event( + self.last_collect_run_id, + "step=excel_write_back result=start detail=采集成功后自动回写旧数据到 Excel", + ) self._set_status(f"{message},正在自动回写 Excel...") return if not batch_id: @@ -1900,6 +2020,12 @@ if QT_IMPORT_ERROR is None: message += "\n请关闭原 Excel 后重试;SQLite 已保留采集结果,也可另存副本。" QMessageBox.warning(self, "回写旧数据", message) self._set_status(message.replace("\n", " ")) + if auto and self.last_collect_run_id: + self._log_collect_run_event( + self.last_collect_run_id, + f"step=excel_write_back result=failed detail={error}", + level="error", + ) def _on_write_back_finished(self, payload, auto=False): self._set_write_back_running(False) @@ -1907,6 +2033,12 @@ if QT_IMPORT_ERROR is None: error = payload.get("error") or "未知错误" retry_hint = ",可点击「回写旧数据到 Excel」手动重试" if auto else "" self._set_status(f"Excel {'自动' if auto else ''}回写失败:{error}{retry_hint}") + if auto and self.last_collect_run_id: + self._log_collect_run_event( + self.last_collect_run_id, + f"step=excel_write_back result=failed detail={error}", + level="error", + ) return self.refresh_tasks() self._set_status( @@ -1916,6 +2048,11 @@ if QT_IMPORT_ERROR is None: rows=payload.get("rows", 0), ) ) + if auto and self.last_collect_run_id: + self._log_collect_run_event( + self.last_collect_run_id, + "step=excel_write_back result=success detail=旧数据已回写 Excel", + ) def show_all_tasks(self, checked=False): self.model.set_filter_mode("all") @@ -2072,12 +2209,21 @@ if QT_IMPORT_ERROR is None: class GenerateWorker(BaseWorker): """Generate titles and covers for collected tasks.""" - def __init__(self, tasks, prompt_values, db_path=None, config=None): + def __init__( + self, + tasks, + prompt_values, + db_path=None, + config=None, + diagnostic_log_dir=None, + ): super().__init__() self.tasks = list(tasks) self.prompt_values = dict(prompt_values or {}) self.db_path = db_path self.config = config + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None def execute(self): account_rows = accounts.list_accounts(path=self.db_path, config=self.config) @@ -2086,23 +2232,174 @@ if QT_IMPORT_ERROR is None: for account in account_rows if str(account.alias).strip() } - return ai.generate_batch( - self.tasks, - self.prompt_values, - ai_cfg={ - "config": self.config, - "db_path": self.db_path, - "image_dir": appconfig.image_dir(self.config), - "account_by_alias": account_by_alias, - "on_task_update": self._emit_row_update, - }, - on_progress=self.progress.emit, - should_stop=self.should_cancel, + eligible = [ + task for task in self.tasks + if getattr(task, "stage", None) == "collected" + ] + batch_ids = self._batch_ids(eligible) + self._run_id = self._create_run_log(eligible, batch_ids) + self._log_run_event( + f"phase=preflight step=start result=start detail=AI生成开始 total={len(eligible)}" ) + try: + summary = ai.generate_batch( + self.tasks, + self.prompt_values, + ai_cfg={ + "config": self.config, + "db_path": self.db_path, + "image_dir": appconfig.image_dir(self.config), + "account_by_alias": account_by_alias, + "on_task_update": self._emit_row_update, + "on_event": self._on_generation_event, + "on_error": self._on_generation_error, + }, + on_progress=self.progress.emit, + should_stop=self.should_cancel, + ) + except Exception as exc: + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + summary = { + "ok": False, + "error": error, + "total": len(eligible), + "title_done": 0, + "cover_done": 0, + "failed": len(eligible), + "cancelled": self.should_cancel(), + } + self._log_run_event( + f"phase=worker step=execute result=failed detail={error}", + level="error", + ) + self._write_diagnostic_log( + "AI生成运行失败", + level="ERROR", + step="execute", + payload={"error": error}, + exc=exc, + ) + summary["run_id"] = self._run_id + summary["batch_ids"] = batch_ids + status = "cancelled" if summary.get("cancelled") else "done" + self._finish_run_log(status, summary) + return summary def _emit_row_update(self, task_id, fields): self.row_updated.emit(int(task_id), dict(fields or {})) + def _on_generation_event(self, payload): + task = payload.get("task") + phase = payload.get("phase") or "generate" + step = payload.get("step") or "unknown" + result = payload.get("result") or "start" + detail = payload.get("detail") + message = f"phase={phase} step={step} result={result}" + if detail: + message += f" detail={detail}" + self._log_run_event(message, task=task, level=payload.get("level") or "info") + + def _on_generation_error(self, payload): + task = payload.get("task") + phase = payload.get("phase") or "generate" + step = payload.get("step") or "unknown" + error = diagnostics.redact_log_text(payload.get("error") or "未知错误") + self._write_diagnostic_log( + "AI生成任务失败", + level="ERROR", + step=step, + task=task, + payload={"phase": phase, "error": error}, + exc=payload.get("exception"), + ) + + def _batch_ids(self, tasks): + batch_ids = [] + for task in tasks: + batch_id = getattr(task, "batch_id", None) + if batch_id and batch_id not in batch_ids: + batch_ids.append(batch_id) + return batch_ids + + def _create_run_log(self, eligible, batch_ids): + try: + ai_cfg = appconfig.ai_config(self.config) + return db.create_run_log( + "generate", + dry_run=False, + total=len(eligible), + options={ + "batch_ids": batch_ids, + "default_text_model": ai_cfg.get("default_text_model"), + "default_image_model": ai_cfg.get("default_image_model"), + "resolution": ai_cfg.get("resolution"), + "title_concurrency": ai_cfg.get("title_concurrency"), + "image_concurrency": ai_cfg.get("image_concurrency"), + }, + path=self.db_path, + ) + except Exception: + return None + + def _finish_run_log(self, status, summary): + if self._run_id is None: + return + try: + done = int(summary.get("cover_done", 0) or 0) + int(summary.get("failed", 0) or 0) + db.finish_run_log( + self._run_id, + status=status, + done=done, + success_count=summary.get("cover_done", 0), + skipped_count=0, + failed_count=summary.get("failed", 0), + summary_json=summary, + path=self.db_path, + ) + except Exception: + return + + def _log_run_event(self, message, task=None, level="info"): + safe_message = diagnostics.redact_log_text(message) + self.log.emit(str(safe_message)) + if self._run_id is None: + return + try: + db.add_run_log_event( + self._run_id, + safe_message, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + level=level, + path=self.db_path, + ) + except Exception: + return + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + task=None, + payload=None, + exc=None, + ): + try: + diagnostics.write_diagnostic_log( + message, + level=level, + step=step, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + except Exception: + return class ApplyWorker(BaseWorker): """Apply generated title/cover changes, optionally previewing or grouping by account.""" @@ -2522,13 +2819,14 @@ if QT_IMPORT_ERROR is None: return def _log_run_event(self, message, task=None, level="info"): - self.log.emit(str(message)) + safe_message = diagnostics.redact_log_text(message) + self.log.emit(str(safe_message)) if self._run_id is None: return try: db.add_run_log_event( self._run_id, - message, + safe_message, task_id=getattr(task, "id", None), alias=getattr(task, "alias", None), item_id=getattr(task, "item_id", None), @@ -2542,12 +2840,21 @@ if QT_IMPORT_ERROR is None: class CollectWorker(BaseWorker): """Collect old title and cover for imported tasks.""" - def __init__(self, tasks, db_path=None, config=None, preflight=True): + def __init__( + self, + tasks, + db_path=None, + config=None, + preflight=True, + diagnostic_log_dir=None, + ): super().__init__() self.tasks = list(tasks) self.db_path = db_path self.config = config self.preflight = preflight + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None def execute(self): account_rows = accounts.list_accounts(path=self.db_path, config=self.config) @@ -2560,27 +2867,41 @@ if QT_IMPORT_ERROR is None: task for task in self.tasks if getattr(task, "stage", None) == "imported" ] + batch_ids = self._batch_ids(eligible) total = len(eligible) collected = 0 skipped = 0 failed = 0 done = 0 + self._run_id = self._create_run_log(eligible, batch_ids) + self._log_run_event( + f"step=preflight result=start detail=采集运行开始 total={total}" + ) + if self.preflight: blocked = self._preflight_block(eligible, account_rows, account_by_alias) if blocked: - blocked.update( - { - "ok": False, - "blocked": True, - "total": total, - "done": 0, - "collected": 0, - "skipped": 0, - "failed": 0, - } + self._log_preflight_blocked(blocked) + summary = self._summary( + ok=False, + total=total, + done=done, + collected=collected, + skipped=skipped, + failed=failed, + batch_ids=batch_ids, + blocked=True, + extra=blocked, ) - return blocked + self._finish_run_log("blocked", summary) + return summary + self._log_run_event("step=preflight result=success detail=账号检查通过") + else: + self._log_run_event( + "step=preflight result=skipped detail=测试模式跳过采集前检查", + level="warning", + ) for task in eligible: if self.should_cancel(): @@ -2592,6 +2913,15 @@ if QT_IMPORT_ERROR is None: reason = "别名未匹配账号" 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( + "step=preflight result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( + task_id=task.id, + item_id=task.item_id, + reason=reason, + ), + task=task, + level="warning", + ) self._emit_progress(done, total, collected, skipped, failed) continue @@ -2602,10 +2932,41 @@ if QT_IMPORT_ERROR is None: reason = self._login_skip_reason(status) 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( + "step=preflight result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( + task_id=task.id, + item_id=task.item_id, + reason=reason, + ), + task=task, + level="warning", + ) self._emit_progress(done, total, collected, skipped, failed) continue + started = time.monotonic() + current_step = "db_write" + + def on_step(step): + nonlocal current_step + current_step = str(step) + self._log_run_event( + "step={step} result=start detail=任务 {task_id} 商品 {item_id}".format( + step=current_step, + task_id=task.id, + item_id=task.item_id, + ), + task=task, + ) + try: + self._log_run_event( + "step=db_write result=start detail=任务 {task_id} 商品 {item_id} 标记采集运行".format( + task_id=task.id, + item_id=task.item_id, + ), + task=task, + ) db.mark_running(task.id, "collect", path=self.db_path) self.row_updated.emit(task.id, {"status": "running"}) result = editor.collect( @@ -2614,6 +2975,15 @@ if QT_IMPORT_ERROR is None: "item_id": task.item_id, "old_cover_path": self._old_cover_path(account, task), }, + on_step=on_step, + ) + current_step = "db_write" + self._log_run_event( + "step=db_write result=start detail=任务 {task_id} 商品 {item_id} 保存采集结果".format( + task_id=task.id, + item_id=task.item_id, + ), + task=task, ) db.set_collected( task.id, @@ -2622,6 +2992,7 @@ if QT_IMPORT_ERROR is None: path=self.db_path, ) collected += 1 + elapsed_ms = self._elapsed_ms(started) self.row_updated.emit( task.id, { @@ -2631,24 +3002,55 @@ if QT_IMPORT_ERROR is None: "old_cover_path": result.get("old_cover_path", ""), }, ) + self._log_run_event( + "step=db_write result=success detail=任务 {task_id} 商品 {item_id} 采集成功 elapsed_ms={elapsed_ms}".format( + task_id=task.id, + item_id=task.item_id, + elapsed_ms=elapsed_ms, + ), + task=task, + ) except Exception as exc: failed += 1 error = str(exc) or exc.__class__.__name__ - db.mark_failed(task.id, "collect", error, path=self.db_path) - self.failed.emit(task.id, error) - self.row_updated.emit(task.id, {"status": "failed", "last_error": error}) + safe_error = diagnostics.redact_log_text(error) + elapsed_ms = self._elapsed_ms(started) + db.mark_failed(task.id, "collect", safe_error, path=self.db_path) + self.failed.emit(task.id, safe_error) + self.row_updated.emit(task.id, {"status": "failed", "last_error": safe_error}) + self._log_run_event( + "step={step} result=failed detail={error} elapsed_ms={elapsed_ms}".format( + step=current_step, + error=safe_error, + elapsed_ms=elapsed_ms, + ), + task=task, + level="error", + ) + self._write_diagnostic_log( + "采集任务失败", + level="ERROR", + step=current_step, + task=task, + elapsed_ms=elapsed_ms, + payload={"error": safe_error}, + exc=exc, + ) finally: done += 1 self._emit_progress(done, total, collected, skipped, failed) - return { - "ok": failed == 0, - "total": total, - "done": done, - "collected": collected, - "skipped": skipped, - "failed": failed, - } + summary = self._summary( + ok=failed == 0, + total=total, + done=done, + collected=collected, + skipped=skipped, + failed=failed, + batch_ids=batch_ids, + ) + self._finish_run_log("cancelled" if self.should_cancel() else "done", summary) + return summary def _preflight_block(self, eligible, account_rows, account_by_alias): if not account_rows: @@ -2727,6 +3129,143 @@ if QT_IMPORT_ERROR is None: ) ) + def _batch_ids(self, tasks): + batch_ids = [] + for task in tasks: + batch_id = getattr(task, "batch_id", None) + if batch_id and batch_id not in batch_ids: + batch_ids.append(batch_id) + return batch_ids + + def _summary( + self, + ok, + total, + done, + collected, + skipped, + failed, + batch_ids, + blocked=False, + extra=None, + ): + summary = { + "ok": ok, + "total": total, + "done": done, + "collected": collected, + "skipped": skipped, + "failed": failed, + "batch_ids": batch_ids, + "run_id": self._run_id, + } + if blocked: + summary["blocked"] = True + if extra: + summary.update(extra) + return summary + + def _create_run_log(self, eligible, batch_ids): + try: + return db.create_run_log( + "collect", + dry_run=False, + total=len(eligible), + options={ + "batch_ids": batch_ids, + "preflight": self.preflight, + }, + path=self.db_path, + ) + except Exception: + return None + + def _finish_run_log(self, status, summary): + if self._run_id is None: + return + try: + db.finish_run_log( + self._run_id, + status=status, + done=summary.get("done", 0), + success_count=summary.get("collected", 0), + skipped_count=summary.get("skipped", 0), + failed_count=summary.get("failed", 0), + summary_json=summary, + path=self.db_path, + ) + except Exception: + return + + def _log_run_event(self, message, task=None, level="info"): + safe_message = diagnostics.redact_log_text(message) + self.log.emit(str(safe_message)) + if self._run_id is None: + return + try: + db.add_run_log_event( + self._run_id, + safe_message, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + level=level, + path=self.db_path, + ) + except Exception: + 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("not_running") or []: + self._log_run_event( + "step=preflight 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=preflight result=blocked detail=账号 {alias} 未登录 Shopee: {reason}".format( + alias=item.get("alias") or "", + reason=item.get("reason") or "", + ), + level="warning", + ) + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + task=None, + elapsed_ms=None, + payload=None, + exc=None, + ): + try: + diagnostics.write_diagnostic_log( + message, + level=level, + step=step, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + except Exception: + return + + def _elapsed_ms(self, started): + return int((time.monotonic() - started) * 1000) class WriteBackWorker(BaseWorker): """Write Excel fields back in a background thread.""" diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 80162f1..f84b305 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -276,6 +276,22 @@ CREATE TABLE run_log_events ( ); ``` +### 5.2c 诊断日志分层(T-207 已接入 ①,② AI生成已先补诊断,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。 +- **本地诊断 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 模型测试连接。 + +原则:数据库日志给运营和 GUI 看“哪个商品、哪个步骤、为什么失败”;本地 log 文件给开发看完整错误栈。两者都必须脱敏。 关键事实: - `alias` 是账号↔任务**唯一关联键**;找不到账号 → stage=skipped,error=别名未匹配,最后弹窗汇总。 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 9c327bc..4c561d0 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -87,7 +87,7 @@ python prototypes/demo.py # 单账号闭环验证(不提交) ## 8. 安全与合规 - 登录凭证只存在于各账号 user-data-dir;不导出、不外传、不写入配置或日志。 -- 写日志、状态 payload、导出调试信息前,结构化数据先过 `appconfig.sanitize_for_log()`;自由文本只有在掌握明文值时才用 `appconfig.redact_secrets()` 替换,不要把原始密码/API Key 拼进异常或状态栏。 +- 写日志、状态 payload、导出调试信息前,结构化数据先过 `appconfig.sanitize_for_log()`;自由文本只有在掌握明文值时才用 `appconfig.redact_secrets()` 替换,不要把原始密码/API Key 拼进异常或状态栏;`logs/` 本地诊断日志必须 gitignore,不提交版本库。 - 涉及 Shopee 时,遵守 `04-architecture.md` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。 - 高风险动作(删满 9 张的封面、点击更新)必须有显式确认,并先在测试商品验证;删满 9 张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。 - ③ 的批量确认弹窗是提交线上前的确认边界;T-504 的 dry-run 只预览不提交、不改任务状态。真实更新即使开启多账号并行,也必须经过③确认和⑤安全设置。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index b5fcf62..6e69a23 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -55,6 +55,8 @@ | T-204b | 采集完成后自动回写旧字段到 Excel | T-204 | `CollectWorker` 完成后自动触发 `excel.write_back()` 回写当前批次旧字段;成功时状态栏/日志提示“已回写”;原文件被锁时不影响 SQLite,提示关闭后点「回写旧数据到 Excel」手动重试或另存副本 | DONE | | T-205 | 首次未配账号 / Chrome 未启动 / 未登录的引导保护 | T-105, T-203 | 无账号、匹配账号未启动 CDP 端口或未登录时,① 执行按钮禁用或采集前汇总提示,并引导去④;可提供“打开账号管理/启动登录”入口,但不无提示批量启动所有账号 Chrome | DONE | | T-205b | 采集后关闭程序自动新建的商品编辑页 tab | T-203 | `open_product` 区分复用旧 tab 与本次新建 tab;`CDP.close()` 仍只断开连接;采集完成后只关闭本次自动新建的商品页,不关闭用户原本打开的 tab;失败/异常也执行清理;③ 更新流程暂不自动关页,失败时保留现场便于排查 | DONE | +| T-206 | Tab① 删除指定批次(硬删除) | T-202, T-104, T-404 | 现状:导入后批次只增不减,下拉框无限膨胀、导错文件/测试导入无法清理。方案:`db.delete_batch(batch_id)` 在一个事务里删 `tasks` 再删 `batches`,返回已删任务数/其中已上线(`committed=1`)任务数/关联封面图片绝对路径;Tab① 批次筛选旁加「删除批次」按钮,仅在选中具体批次(非「全部批次」)时可用,运行中禁用。二次确认弹窗显示批次标签、任务数、已上线任务数并提示「删除本地记录不会回滚 Shopee 线上修改」,含「同时删除已下载/生成的封面图片」勾选项(默认不勾);确认后删库、按勾选清理孤儿图片、刷新①与③列表 | TODO | +| T-207 | ① 采集诊断日志(run_logs + 本地 log) | T-203, T-503, T-504 | 问题:采集少量商品时单条失败只能看到 `tasks.last_error` 和状态栏计数,无法定位卡在打开商品页/页面就绪/读标题/读封面/下载图片/写库/回写哪一步。方案:`CollectWorker` 创建 `run_type=collect` 的运行日志,逐任务写 `run_log_events`(含 `task_id/alias/item_id` 和 `step=preflight/open_product/wait_ready/read_title/read_cover/download_cover/db_write/excel_write_back`);同时新增 gitignore 的 `logs/` 本地滚动日志,写脱敏 traceback 与耗时;GUI 至少能查看最近一次采集运行日志;失败仍按现有语义写 `status=failed/last_error`,不影响其他任务继续 | DONE | ## Phase 3 · AI 生成(②) @@ -64,12 +66,13 @@ | T-302 | Tab② 左右布局:左提示词(标题/封面),右按批次/店铺/状态筛选 + 任务列表 | T-301, T-203 | 左 ~1/4 提示词多行;右筛选+列表(店铺/商品id/旧标题/新标题/状态) | DONE | | T-302p | `app/prompts.py` + Tab② 提示词管理 | T-302 | 标题保存/启动回显 title_prompt.txt;封面多模板(下拉+新建/保存/另存为/重命名/删除,存 prompts/cover/);插入 `{新标题}`;预览变量替换;render_prompt 接入生成 | DONE | | T-303 | Tab② 开始生成(单按钮)+ 停止 + 进度:**先并发标题再并发图片** | T-302, T-104b | `generate_batch` 先 title_concurrency 并发标题、再 image_concurrency 并发图片;worker/signal 回传进度;每条 set_generated 立即写库;停止可取消未开始项;进度 标题/封面/失败 计数;双击弹窗看新旧封面 | DONE | +| T-303b | ②/③ 商品ID筛选 | T-303, T-401 | ② AI生成和③ 更新shopee筛选栏增加商品ID输入框;输入内容按包含匹配 `item_id`,清空表示全部;当前筛选结果和「开始生成」/「开始更新」范围都受其约束;③确认弹窗显示商品ID筛选条件 | DONE | ## Phase 4 · 更新 shopee(③) | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-401 | Tab③ 列出已生成任务 + 按批次/店铺/状态筛选 + 开始更新确认弹窗 | T-303 | 顶部批次/店铺/状态筛选;「开始更新」仅作用于当前筛选结果;弹窗显示筛选条件/任务数/线上提交风险;取消不执行;状态=失败可重试;无常驻提交开关 | DONE | +| T-401 | Tab③ 列出已生成任务 + 按批次/店铺/商品ID/状态筛选 + 开始更新确认弹窗 | T-303 | 顶部批次/店铺/商品ID/状态筛选;「开始更新」仅作用于当前筛选结果;弹窗显示筛选条件/任务数/线上提交风险;取消不执行;状态=失败可重试;无常驻提交开关 | DONE | | T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未登录引导保护;未确认时不调用 apply | DONE | | T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE | | T-404 | 真实 Shopee 单条更新冒烟验收(设置安全开关完成后执行) | T-403, T-501c | 仅用测试商品;默认先只测标题更新,封面更新作为可选子项;必须经过 Tab③ 二次确认和设置安全开关;验证线上提交结果、Excel 更新状态、失败/取消语义;未通过前不得扩大批量真实更新 | TODO | @@ -84,6 +87,7 @@ | T-502 | 满 9 张封面:删第一张再上传 | T-001 | 已实现备份校验、删第一张、确认弹窗、再上传和拖首位;删除前必须确认该任务已有本地旧封面备份(`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 | ## 里程碑 diff --git a/docs/api.md b/docs/api.md index 1a73f37..86426df 100644 --- a/docs/api.md +++ b/docs/api.md @@ -52,11 +52,12 @@ list_ai_models(category=None) -> list[dict] # category=text/image 过滤;默 add_ai_model(model) -> None # name 唯一校验 update_ai_model(name, **fields) -> None delete_ai_model(name) -> None # 至少各留一个 text+image;删到剩一禁用 +model_request_url(model) -> str # url 为 /v1 或 /api/v1 base 时按 api_type 补 /chat/completions 或 /images/edits test_ai_model(name) -> dict # 「测试连接」:用 key/url/model 发最小请求 -> {ok, status?, error?} get_model(name) -> dict # 返回模型定义,含 api_key(调用方不得写日志) ``` -`list_ai_models()` 用于 UI/API 展示,默认不返回明文 `api_key`;`get_model()` 用于实际调用 AI,返回明文 `api_key`,调用方不得写日志或导出。`test_ai_model()` 不记录密钥,返回给 GUI 前仍由 worker 过 `sanitize_for_log()`;缺少 `url/model/api_key` 时直接返回 `{ok: False, error: ...}`。 +`list_ai_models()` 用于 UI/API 展示,默认不返回明文 `api_key`;`get_model()` 用于实际调用 AI,返回明文 `api_key`,调用方不得写日志或导出。`url` 可填完整 endpoint,也可填 OpenAI-compatible base URL(如 `https://.../v1` 或 `https://.../api/v1`),请求前由 `model_request_url()` 按 `api_type` 补齐。`test_ai_model()` 不记录密钥,返回给 GUI 前仍由 worker 过 `sanitize_for_log()`;缺少 `url/model/api_key` 时直接返回 `{ok: False, error: ...}`。 ## db 模块(`app/db.py`,已建) @@ -255,10 +256,10 @@ apply_task(account, task, close_success_tab=False) -> dict ```python class AIError(RuntimeError): ... -gen_title(title_prompt, old_title, retry=None, config=None, models_path="config/ai_models.json") -> str +gen_title(title_prompt, old_title, retry=None, config=None, models_path="config/ai_models.json", on_step=None) -> str # 文本生成:读取 default_text_model,chat JSON 请求;提示词 + 旧标题 → 新标题 -gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="config/ai_models.json") -> str +gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="config/ai_models.json", on_step=None) -> str # 图像生成(image-to-image):读取 default_image_model;chat 多模态 JSON 或 images_edits multipart; # 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径 @@ -266,6 +267,7 @@ generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) # 编排:先以 title_concurrency 线程池并发跑 gen_title,再以 image_concurrency 并发跑 gen_cover # 每条完成即 db.set_generated(实时落库);should_stop() 为真则取消未开始项 # on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度 +# ai_cfg 可传 on_event/on_error 回调,逐条报告 title/cover 阶段 step/result/detail,供 GUI run_logs 与本地诊断日志使用 # 返回同结构 summary;失败任务 mark_failed(..., "generate", error),不阻塞其余 ``` @@ -315,7 +317,7 @@ render_prompt(template_text, task) -> str main() -> int # 创建 QApplication + MainWindow class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 -class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览 +class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志 class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 安全开关拦截 + 确认后 dry-run/真实更新 + 运行日志 class SettingsTab(QWidget) # ⑤ 设置:AI 模型管理 + 角色/生成参数/路径端口 + Shopee 更新安全 class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed @@ -370,27 +372,27 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 「回写旧数据到 Excel」通过 `WriteBackWorker` 后台调用 `excel.write_back()`,把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。 - 采集前由 `CollectWorker` 做账号就绪预检:无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;不无提示批量启动所有账号 Chrome。匹配账号未登录属于预检阻断,不是逐条 skipped。 -② AI生成当前要点(T-302/T-302p/T-303): +② AI生成当前要点(T-302/T-302p/T-303/T-303b + 诊断日志补丁): - 左右 `QSplitter`:左侧约 1/4 为标题提示词、封面提示词两个多行输入;右侧为筛选栏 + 任务列表。 - 标题提示词启动时从 `title_prompt.txt` 回显;点击「保存标题提示词」写回该文件。 - 封面提示词模板下拉读取 `prompts/cover/*.txt`;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。 - 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」使用当前选中任务(无选择则用第一条)调用 `prompts.render_prompt()` 并弹窗展示。 -- 筛选栏包含:批次、店铺、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;状态支持全部/待生成/已生成/失败/略过/已更新。 +- 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。 - 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态。 - 双击任务行弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。 -- 底部「开始生成」只处理当前筛选结果里 `stage=collected` 的任务;通过 `GenerateWorker` 调 `ai.generate_batch()`,先并发标题、再并发封面。 +- 底部「开始生成」只处理当前筛选结果里 `stage=collected` 的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;通过 `GenerateWorker` 调 `ai.generate_batch()`,先并发标题、再并发封面。 - 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。 - 进度标签显示:`标题x/n · 封面x/n · 失败z`;每条封面生成成功后立即 `db.set_generated()`,失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。 -③ 更新shopee当前要点(T-401/T-402/T-403): +③ 更新shopee当前要点(T-303b/T-401/T-402/T-403): -- `ApplyTab` 顶部筛选栏包含:批次、店铺、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名。 +- `ApplyTab` 顶部筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部。 - ③ 只列出已具备新标题/新封面、可进入更新阶段的任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务。 - 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。 - 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。 - 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库。 -- 点击「开始更新」先读取 `shopee_update`:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,弹警告并阻断;通过后才弹窗展示批次/店铺/状态/任务数、提交线上风险和当前安全设置。 +- 点击「开始更新」先读取 `shopee_update`:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,弹警告并阻断;通过后才弹窗展示批次/店铺/商品ID/状态/任务数、提交线上风险和当前安全设置。 - 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker`。dry-run 开启时只预览;dry-run 关闭时才可能真实提交。 - `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交。 - dry-run:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。 diff --git a/docs/current-state.md b/docs/current-state.md index 1da4c77..8d1e228 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,11 +6,11 @@ ## 当前快照 - 日期:2026-06-29 -- 阶段: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-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 满 9 张封面删除再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志。 +- 阶段: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-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 满 9 张封面删除再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁。 - 技术栈: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,更新提交成功且设置开启时可关闭本轮自动新建商品页;`replace_cover()` 已实现满 9 张时先校验本地旧封面备份,再点第一张删除、可见确认框、上传新图并拖到第一位的代码路径;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接,以及 `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、批次/账号/任务与阶段写库函数,以及 `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生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/更新安全开关拦截/开始更新确认弹窗/确认后 `ApplyWorker` dry-run 预览或真实更新/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、两列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全和 dry-run/多账号并行设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、满 9 张封面备份缺失阻断/删除确认/上传拖首位 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行/dry-run/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、dry-run/多账号并行设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 -- 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页;`replace_cover()` 已实现满 9 张时先校验本地旧封面备份,再点第一张删除、可见确认框、上传新图并拖到第一位的代码路径;`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、批次/账号/任务与阶段写库函数,以及 `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` dry-run 预览或真实更新/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、两列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全和 dry-run/多账号并行设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、满 9 张封面备份缺失阻断/删除确认/上传拖首位 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志与双击预览/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行/dry-run/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、dry-run/多账号并行设置/worker signal 与线程包装;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,20 +33,21 @@ | `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-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c/T-503/T-504 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、Shopee 更新安全拦截、开始更新确认弹窗、`ApplyWorker` 串行/dry-run/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、Shopee 更新安全与执行模式设置 | +| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-503/T-504 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览、AI生成运行日志与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、Shopee 更新安全拦截、开始更新确认弹窗、`ApplyWorker` 串行/dry-run/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、Shopee 更新安全与执行模式设置 | | `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-501c/T-502 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、满 9 张先校验旧封面备份再删第一张并上传、更新按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | +| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-207/T-501c/T-502 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集步骤回调、采集后关闭自动新建商品页 tab、上传拖封面、满 9 张先校验旧封面备份再删第一张并上传、更新按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | | `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全与 dry-run/多账号并行默认值;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | -| `app/ai.py` | 已有 | T-301/T-303 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;封面按 resolution/jpg_quality 保存;批量生成先标题后封面、进度回调、逐条落库、失败标记、停止取消未开始项 | +| `app/diagnostics.py` | 已有 | T-207 + AI生成诊断补丁产出:本地 `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-504 产出:batches/accounts/tasks schema;run_logs/run_log_events;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库;运行日志写入与查询 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `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-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502/T-503/T-504 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/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 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/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` / `chrome_user_data_dir/` / `images/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库;密码/API Key 保存或变更时提示,展示/日志/导出脱敏 | +| `config.json` / `config/ai_models.json` / `cmshopee.db` / `cmshopee.db-*` / `chrome_user_data_dir/` / `images/` / `logs/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片、本地诊断日志,不提交版本库;密码/API Key 保存或变更时提示,展示/日志/导出脱敏 | ## 已验证能力(单账号) @@ -61,14 +62,16 @@ 任务状态以 [`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-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(满 9 张封面删除再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)。 -- 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟验收)**。仅用测试商品,默认先只测标题更新,必须经过 ⑤ 安全开关和 ③ 二次确认。 +- 已完成: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-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(满 9 张封面删除再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁。 +- 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟)**。T-206 删除批次依赖 T-404,T-505 全流程诊断日志扩展依赖 T-207/T-504,建议先完成真实更新单条验收。 ## 当前已知限制 +- ① 采集已接入 `run_type=collect`,② AI生成已接入 `run_type=generate` 与本地 `logs/cmshopee.log`;T-505 仍待把同类步骤级诊断扩展到 Excel 导入/回写、③更新、Chrome/登录检测与 AI 模型测试连接。 + - ① 采集依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205 已在采集前拦截未配置账号、Chrome 未启动、未登录,并引导去④账号管理,但不会无提示批量启动所有账号 Chrome。 - T-501/T-501b/T-501c 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置和 Shopee 更新安全开关。 -- T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测。 +- T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 - 本地 `config/ai_models.json` 若由旧版本或手工维护,可能缺少 `category`;启动报 “AI 模型 category 必须是 text 或 image” 时,按 [`troubleshooting.md`](troubleshooting.md) 只补 `category` / `enabled` 等非密钥字段,保留 API Key,且不要提交该文件。 - T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;真实 Shopee 更新冒烟仍未执行,下一步 T-404 只允许在测试商品范围内做单条验收,默认先只测标题更新,封面更新作为可选子项。 - T-502 满 9 张封面删除流程已完成代码路径、mock 单测和真实 9 图商品不提交流程实测;删除第一张前必须已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失备份时拒绝删除线上图片。本轮实测只操作编辑页并关闭测试 tab,未点击「更新」保存线上。 diff --git a/docs/routes.md b/docs/routes.md index 2ba8b85..1902a42 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -11,7 +11,7 @@ | Tab | 职责 | 风险 | | --- | --- | --- | | ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 | -| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺筛选任务列表;AI 生成新标题/新封面;双击看新旧封面 | 不触线上,中 | +| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题/新封面;双击看新旧封面 | 不触线上,中 | | ③ 更新shopee | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** | | ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式 | 中 | | ⑤ 设置 | AI 模型/API Key、本地图片目录、Chrome 路径、默认端口、Shopee 更新安全开关等 | — | @@ -46,7 +46,7 @@ │ │ 主店A 女装店 51100639510 待采集 — — │ │ │ └───────────────────────────────────────────────────────────┘ │ │ [▶ 采集旧标题/旧封面] [■停止] [回写旧数据到 Excel] │ -│ 日志:逐条 文件→匹配账号、采集结果 │ +│ 日志:逐条 文件→匹配账号、采集步骤、失败原因 │ └───────────────────────────────────────────────────────────────┘ ``` @@ -54,7 +54,7 @@ - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 - 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到本地图片目录,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 -- 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。 +- 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `logs/`,用于定位失败卡在哪个步骤。 ## ② AI生成 @@ -63,13 +63,14 @@ ``` ┌ AI生成 ───────────────────────────────────────────────────────┐ │ ┌─左 ~1/4─┐ ┌──────────────── 右 3/4 ──────────────────────┐ │ -│ │标题提示词│ │ 批次[本次▼] 店铺[全部▼] 状态[全部▼] [筛选] │ │ +│ │标题提示词│ │ 批次[本次▼] 店铺[全部▼] 商品ID[____] 状态[全部▼] [筛选] │ │ │ │[ ]│ │ ┌──────────────────────────────────────────┐ │ │ │ │[ ]│ │ │ 店铺名 商品id 旧标题 新标题 状态 │ │ │ │ │ │ │ │ 女装店 511..639 …T恤 …百搭 已生成 │ │ │ │ │封面提示词│ │ │ 女装店 511..640 … — 待生成 │ │ │ │ │[ ]│ │ └──────────────────────────────────────────┘ │ │ │ │[ ]│ │ (双击某行 → 弹窗看 旧封面 | 新封面) │ │ +│ │ │ │ 运行日志:逐条记录标题/封面生成步骤与失败原因 │ │ │ └──────────┘ └───────────────────────────────────────────────┘ │ │ 进度:标题30/30 · 封面12/30 · 失败1 [▶ 开始生成] [■停止] │ └───────────────────────────────────────────────────────────────┘ @@ -79,18 +80,18 @@ - **标题提示词**:多行输入 + 「保存」(写 `title_prompt.txt`);启动时自动加载回显。 - **封面提示词**:模板下拉(读 `prompts/cover/*.txt`)+ 图标工具栏(新建/保存/另存为/重命名/删除)+ 多行输入 + 「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。 - 变量:`{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。 -- 右上:按导入批次 / 店铺 / 状态筛选任务。 -- 右下:任务列表(店铺名、商品id、旧标题、新标题、状态);**双击某条 → 弹窗展示旧封面 | 新封面**(纯查看)。 +- 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 +- 右下:任务列表(店铺名、商品id、旧标题、新标题、状态)+ AI生成运行日志;**双击某条 → 弹窗展示旧封面 | 新封面**(纯查看)。 - 底部**单个「开始生成」+「停止」**:开始生成 = **先按 `title_concurrency` 并发生成标题,接着按 `image_concurrency` 并发生成封面**;进度实时显示 标题/封面/失败 计数。 - 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、模型/Key)在 **⑤ 设置**,不在本 Tab 重复。 -- 每条/每张完成即写库;「停止」取消未开始项,可再次「开始生成」对剩余继续。 +- 每条/每张完成即写库;`run_type=generate` 写 `run_logs/run_log_events`,图片生成失败同时写本地 `logs/cmshopee.log` 脱敏 traceback;「停止」取消未开始项,可再次「开始生成」对剩余继续。 - **无逐条人工审核环节**;新标题直接用 AI 输出(不可编辑);可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。 ## ③ 更新shopee ``` ┌ 更新shopee ───────────────────────────────────────────────────┐ -│ 批次[本次▼] 店铺[全部▼] 状态[已生成▼] [筛选] │ +│ 批次[本次▼] 店铺[全部▼] 商品ID[____] 状态[已生成▼] [筛选] │ │ ⚠ 点击「开始更新」后先过安全开关,再确认【当前筛选结果】并提交 │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │ @@ -100,7 +101,7 @@ └───────────────────────────────────────────────────────────────┘ ``` -- 顶部**按批次 / 店铺 / 状态筛选**(与 ①②一致);「开始更新」作用于**当前筛选结果**,是一道范围控制。 +- 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。 - 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。 - 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。 - 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断;dry-run 开启时只预览,不进入 Shopee。 @@ -130,7 +131,7 @@ ## ⑤ 设置 - AI 模型管理(T-501 已接入):同列上下布局,AI 模型下拉 + 新增/删除在“模型详情”上方;详情与设置表单尽量两项一行,长字段独占整行。 - - 字段:启用、服务商名、类别(文本/图像)、api_type、模型ID、网址、密钥、连接超时。 + - 字段:启用、服务商名、类别(文本/图像)、api_type、模型ID、网址、密钥、连接超时。网址可填完整 endpoint,也可填 OpenAI-compatible base URL(如 `https://.../v1`),请求时按 `api_type` 自动补齐。 - 密钥存在 `config/ai_models.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。 - 保存复用 `appconfig` 模型清单后端,保持 name 唯一、至少 text/image 各一个模型。 - 「测试连接」通过后台 worker 调 `appconfig.test_ai_model()`,不阻塞主线程。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 15d60e9..22a64a3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -92,3 +92,53 @@ git status --short --ignored config/ai_models.json ```powershell python main.py ``` + +## ⑤ 设置里测试连接提示 “HTTP 404” + +### 现象 + +在⑤设置里填写文本大模型信息后,点击「测试连接」,结果显示: + +```text +HTTP 404 +``` + +### 原因 + +HTTP 404 表示服务端已响应,但当前请求 URL 路径不存在。旧代码会把「网址」字段原样作为 POST 地址;如果只填 OpenAI-compatible base URL,例如: + +```text +https://api.vectorengine.ai/v1 +``` + +程序会直接 POST 到 `/v1`,部分服务商会返回 404。当前代码已兼容 base URL: + +- `api_type=chat` 或 `auto`:`/v1`、`/api/v1` 会自动补成 `/chat/completions`。 +- `api_type=images_edits`:`/v1`、`/api/v1` 会自动补成 `/images/edits`。 +- 已经填写完整 endpoint(如 `/v1/chat/completions`)时保持不变。 + +### 推荐修复 + +更新到包含该修复的代码后,保留当前配置即可;也可以手工把「网址」改成完整 endpoint: + +```text +https://api.vectorengine.ai/v1/chat/completions +``` + +如果仍然 404,优先检查服务商文档要求的 endpoint 路径和 `api_type`,不要把 `config/ai_models.json` 或 API Key 贴到日志、文档或聊天里。 + +### 非敏感检查 + +只输出非密钥字段确认 URL 形态: + +```powershell +python -c "import json; from urllib.parse import urlsplit,urlunsplit; p='config/ai_models.json'; data=json.load(open(p,encoding='utf-8')); [print({'name':m.get('name'),'category':m.get('category'),'api_type':m.get('api_type'),'url_without_query':urlunsplit((urlsplit(m.get('url','')).scheme,urlsplit(m.get('url','')).netloc,urlsplit(m.get('url','')).path,'','')),'has_api_key':bool(m.get('api_key'))}) for m in data.get('models', [])]" +``` +## AI生成:标题成功但图片生成失败,且看不到原因 + +当前代码会为 ② AI生成写两层日志: + +- 页面右下「AI生成运行日志」显示最近一次 `run_type=generate` 的逐条事件,例如 `phase=cover step=cover_request result=failed detail=...`。 +- 本地 `logs/cmshopee.log` 保存脱敏后的 traceback、任务 id、alias、item_id、phase 和 step,用于判断卡在模型配置、封面请求、图片解析、保存文件还是写库。 + +排查顺序:先看 ② 页面运行日志里的 `phase` / `step` / `detail`;如果只看到简短错误,再查看本地 `logs/cmshopee.log`。不要把 `config/ai_models.json` 或 API Key 发到聊天、文档或提交里。 diff --git a/progress.md b/progress.md index 5782bc3..52f9948 100644 --- a/progress.md +++ b/progress.md @@ -667,3 +667,60 @@ - 变更:`app/gui.py` 将⑤设置页的 AI 模型下拉/新增/删除从左侧分栏移到“模型详情”上方,设置页改为单列滚动布局;模型详情、角色与生成参数、路径与端口、Shopee 更新安全表单尽量两项一行,URL/API Key/Chrome 路径等长字段独占整行。 - 文档:同步 `docs/routes.md`、`docs/api.md`、`docs/current-state.md` 中的设置页布局描述,并更新 `docs/ui/tab5-settings.svg` UI 草图。 - 验证:`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(48 tests)。 + +## 【2026-06-29】T-206 删除指定批次(设计登记) + +- 状态:TODO(设计登记;硬删除语义仍待用户最终确认,排在 T-404 之后实现) +- 问题:①导入采集仅有批次筛选下拉,没有任何删除入口;`db` 有 `delete_account` 但无 `delete_batch`。导错文件 / 测试导入 / 处理完的历史批次都无法清理,批次下拉只增不减,长期必然劣化使用体验。 +- 数据模型:`tasks.batch_id REFERENCES batches(id)`;每条 task 关联磁盘封面图片 `old_cover_path`(采集下载的旧封面)、`new_cover_path`(AI 生成的新封面),均为本工具产出,硬删后会成为孤儿文件。 +- 方案(硬删除): + - `app/db.py` 新增 `delete_batch(batch_id)`:单事务内先 `DELETE FROM tasks WHERE batch_id=?` 再 `DELETE FROM batches WHERE id=?`;返回 `{"tasks": 删除任务数, "committed": 其中已上线数, "images": 关联封面绝对路径列表}`。 + - `app/gui.py` ①导入采集页批次筛选旁加「删除批次」按钮:仅选中具体批次时可用,运行中随其他控件一起禁用。 + - 二次确认弹窗(不可逆动作,符合安全红线):展示批次标签 + 任务数 + 已上线(`committed=1`)任务数,明确提示「删除本地记录不会回滚 Shopee 线上已提交的修改」;提供「同时删除已下载/生成的封面图片」勾选项,默认不勾。 + - 确认后调用 `db.delete_batch`,按勾选 `os.remove` 清理 images 列表中的孤儿图片(容错,缺失/占用不报错只记日志),刷新①与③的批次下拉和任务列表。 +- 安全:删除前显式弹窗确认;提示线上修改不可回滚;图片清理默认不开启需用户主动勾选。 +- 测试计划:`tests/test_db.py` 覆盖 `delete_batch` 删任务+批次、返回计数/图片路径、外键不残留;`tests/test_gui.py` 覆盖未选具体批次时按钮禁用、确认后调用删除并刷新、取消不删除。 + +## 【2026-06-29】T-303b ②/③ 商品ID筛选 + +- 状态:DONE +- 文档:先同步 `docs/routes.md`、`docs/api.md`、`docs/06-tasks.md`,明确 ② AI生成与 ③ 更新shopee 的商品ID输入框按包含匹配 `item_id`,清空表示全部;当前筛选结果约束「开始生成」和「开始更新」范围;③确认弹窗显示商品ID筛选条件。 +- 实现:`app/gui.py` 在 `GenerateTab` 和 `ApplyTab` 增加商品ID输入框,刷新时与批次/店铺/状态共同过滤;运行中禁用该输入框,避免生成/更新过程中改变作用范围。 +- 测试:`tests/test_gui.py` 覆盖 ② 商品ID筛选、③ 商品ID筛选,以及③确认弹窗显示商品ID。 +- 任务顺序:T-206 删除批次仍为设计登记,依赖 T-404,避免抢在真实 Shopee 单条冒烟前执行。 +- 验证:`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(48 tests);`python -m unittest discover -s tests` 通过(112 tests)。 + +## 【2026-06-29】文档补充 · 采集失败诊断日志方案 + +- 背景:用户本次导入 5 个商品 ID 采集旧标题/旧封面时有 1 条失败;当前实现会把该任务写成 `status=failed` 并保存 `last_error`,但不会写 ① 采集的持久运行日志,也没有本地 traceback,因此无法判断卡在打开商品页、页面就绪、读标题、读封面、下载图片、写库还是 Excel 回写。 +- 方案:采用两层日志。SQLite `run_logs/run_log_events` 保存业务可读事件(run_type、task_id、alias、item_id、step、简短错误);本地 gitignore 的 `logs/` 保存脱敏 traceback、异常类型、步骤、耗时和必要上下文。 +- 任务:新增 T-207「① 采集诊断日志(run_logs + 本地 log)」作为下一步可领取任务;新增 T-505「全流程诊断日志扩展」覆盖 ② AI生成、Excel 导入/回写、③ 更新shopee、④ Chrome/登录检测、⑤ AI 模型测试连接。 +- 安全:日志不得记录 Cookie、密码、API Key、token;结构化 payload 过 `sanitize_for_log()`,自由文本异常按需 `redact_secrets()`。 +- 文档:同步 `docs/04-architecture.md`、`docs/06-tasks.md`、`docs/api.md`、`docs/routes.md`、`docs/05-coding-rules.md`、`docs/current-state.md`。 +- 验证:仅文档更新,未运行测试。 + +## 【2026-06-29】T-207 ①采集诊断日志(run_logs + 本地 log) + +- 状态:DONE +- 文档:`docs/06-tasks.md` 将 T-207 标为 DONE;`docs/04-architecture.md` 更新为 T-207 已接入 ①采集、T-505 待扩展;`docs/current-state.md` 同步当前快照、目录清单、下一步和已知限制。 +- 实现:新增 `app/diagnostics.py`,写入 gitignore 的 `logs/cmshopee.log`,支持大小滚动、异常类型、traceback、step、task_id/alias/item_id、耗时和 payload;结构化 payload 与自由文本统一脱敏。 +- 采集链路:`app/editor.py` 为采集流程增加 `on_step` 回调,覆盖 `open_product`、`wait_ready`、`read_title`、`read_cover`、`download_cover`;`CollectWorker` 创建 `run_type=collect` 的 `run_logs`,逐任务写 `run_log_events`,失败时保留最后 step、简短错误并继续后续任务。 +- UI:①导入采集页新增最近一次采集运行日志视图;采集完成自动回写 Excel 时记录 `excel_write_back` 开始/成功/失败事件。 +- 安全:DB 运行日志、状态栏错误、本地诊断日志均不记录 Cookie、密码、API Key、token;自由文本中的 `token/password/cookie/authorization/api_key` 形式会写入前脱敏。 +- 测试:`tests/test_gui.py` 覆盖采集成功/略过写 run log、采集失败写 DB 事件与本地诊断 log、token 脱敏、①日志视图加载最近一次 collect run log。 +- 验证:`python -m py_compile app\diagnostics.py app\editor.py app\gui.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(50 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(114 tests);`git diff --check` 通过(仅 LF/CRLF 提示)。 +- 下一步:按 `docs/current-state.md`,建议领取 T-404(真实 Shopee 单条更新冒烟)。 +## 【2026-06-29】修复⑤设置测试连接 base URL 返回 HTTP 404 + +- 问题:本地文本模型 URL 配置为 OpenAI-compatible base URL(如 `https://api.vectorengine.ai/v1`)时,⑤「测试连接」原样 POST 到 `/v1`,服务端返回 HTTP 404;实际 AI 生成请求也存在同类风险。 +- 修复:`app/appconfig.py` 新增 `model_request_url()`,当 `url` 是 `/v1`、`/v1beta`、`/api/v1`、`/api/v1beta` 这类 base URL 时,按 `api_type` 自动补 `/chat/completions` 或 `/images/edits`;完整 endpoint 和自定义路径保持不变。 +- 接入:`appconfig.test_ai_model()` 与 `app/ai.py` 的真实 AI HTTP 调用统一使用解析后的 endpoint。 +- 文档:更新 `docs/api.md`、`docs/routes.md`、`docs/troubleshooting.md`、`docs/current-state.md`,说明⑤网址可填完整 endpoint 或 OpenAI-compatible base URL,并记录 HTTP 404 排查方法。 +- 测试:`tests/test_appconfig.py` 覆盖 base URL/full endpoint/custom path 解析和测试连接请求 URL;`tests/test_ai.py` 覆盖标题生成使用 base URL 自动补 endpoint。 +- 验证:`python -m unittest discover -s tests -p test_appconfig.py` 通过(7 tests);`python -m unittest discover -s tests -p test_ai.py` 通过(7 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(117 tests);`git diff --check` 通过(仅 LF/CRLF 提示)。 +## 【2026-06-29】② AI生成图片失败诊断日志补丁 + +- 背景:用户实测 ② AI生成已能按商品 ID 和提示词生成标题,但生成图片失败时页面与本地都找不到可定位原因的日志。 +- 代码:`app/ai.py` 为标题/封面生成增加 `on_step` 与 `on_event/on_error` 回调,覆盖模型加载、请求构建、请求发送、响应解析、图片保存、写库等步骤;失败错误进入任务 `last_error` 前先脱敏。 +- UI/日志:`GenerateWorker` 创建 `run_type=generate` 的 `run_logs/run_log_events`,② 页面新增「AI生成运行日志」;图片生成异常同时写本地 `logs/cmshopee.log` 脱敏 traceback。 +- 测试:新增覆盖 GenerateWorker 图片生成失败时 DB 运行日志、本地诊断日志和 token 脱敏,以及 ② 页面加载最近一次 generate 日志。 diff --git a/tests/test_ai.py b/tests/test_ai.py index 3f96b15..0ca10b9 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -123,6 +123,46 @@ class AITests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + + def test_gen_title_accepts_openai_compatible_base_url(self): + with self.make_temp_dir() as temp_dir: + models_path = os.path.join(temp_dir, "ai_models.json") + self._write_models( + models_path, + text={ + "name": "Text", + "category": "text", + "enabled": True, + "url": "https://example.invalid/v1", + "model": "text-model", + "api_key": "sk-text-secret", + "api_type": "chat", + "connect_timeout_seconds": 1, + "timeout_seconds": 1, + "extra_body": {}, + }, + ) + calls = [] + + def fake_urlopen(request, timeout=None): + calls.append(request) + return _Response({"choices": [{"message": {"content": "新标题"}}]}) + + with mock.patch("app.ai.urllib.request.urlopen", side_effect=fake_urlopen): + title = ai.gen_title( + "优化标题", + "旧标题", + config=self._config(), + models_path=models_path, + ) + + self.assertEqual("新标题", title) + self.assertEqual( + "https://example.invalid/v1/chat/completions", + calls[0].full_url, + ) + + self.assert_removed(temp_dir) def test_missing_model_fields_raise_clear_error_without_secret(self): with self.make_temp_dir() as temp_dir: models_path = os.path.join(temp_dir, "ai_models.json") diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index 2a5b363..ce8e5b3 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -1,6 +1,7 @@ import os import sys import unittest +from unittest import mock sys.path.insert(0, os.path.dirname(__file__)) @@ -113,6 +114,110 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + + def test_model_request_url_accepts_base_and_full_endpoint(self): + self.assertEqual( + "https://api.example.com/v1/chat/completions", + appconfig.model_request_url( + {"url": "https://api.example.com/v1", "api_type": "chat"} + ), + ) + self.assertEqual( + "https://openrouter.ai/api/v1/chat/completions", + appconfig.model_request_url( + {"url": "https://openrouter.ai/api/v1/", "api_type": "auto"} + ), + ) + self.assertEqual( + "https://api.example.com/v1/chat/completions?region=tw", + appconfig.model_request_url( + {"url": "https://api.example.com/v1?region=tw", "api_type": "chat"} + ), + ) + self.assertEqual( + "https://api.example.com/v1/chat/completions", + appconfig.model_request_url( + { + "url": "https://api.example.com/v1/chat/completions", + "api_type": "chat", + } + ), + ) + self.assertEqual( + "https://api.example.com/v1/images/edits", + appconfig.model_request_url( + {"url": "https://api.example.com/v1", "api_type": "images_edits"} + ), + ) + self.assertEqual( + "https://api.example.com/custom/generate", + appconfig.model_request_url( + {"url": "https://api.example.com/custom/generate", "api_type": "chat"} + ), + ) + + def test_ai_model_test_uses_resolved_base_url(self): + with self.make_temp_dir() as temp_dir: + models_path = os.path.join(temp_dir, "ai_models.json") + appconfig.save_ai_models_config( + { + "models": [ + { + "name": "Text", + "category": "text", + "enabled": True, + "url": "https://api.example.com/v1", + "model": "text-model", + "api_key": "sk-text-secret", + "api_type": "chat", + "connect_timeout_seconds": 1, + "extra_body": {}, + }, + { + "name": "Image", + "category": "image", + "enabled": True, + "url": "https://api.example.com/v1/chat/completions", + "model": "image-model", + "api_key": "sk-image-secret", + "api_type": "auto", + "connect_timeout_seconds": 1, + "extra_body": {}, + }, + ] + }, + path=models_path, + ) + calls = [] + + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self, size=-1): + return b"{}" + + def fake_urlopen(request, timeout=None): + calls.append((request, timeout)) + return Response() + + with mock.patch("app.appconfig.urllib.request.urlopen", side_effect=fake_urlopen): + result = appconfig.test_ai_model("Text", path=models_path) + + self.assertTrue(result["ok"]) + self.assertEqual(200, result["status"]) + self.assertEqual( + "https://api.example.com/v1/chat/completions", + calls[0][0].full_url, + ) + self.assertEqual(1, calls[0][1]) + + self.assert_removed(temp_dir) def test_sanitize_for_log_masks_secret_fields(self): payload = { "name": "demo", diff --git a/tests/test_gui.py b/tests/test_gui.py index a3a5865..f6d8fbf 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1,3 +1,4 @@ +import json import unittest import os import sys @@ -624,8 +625,18 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual({"title": "标题提示", "cover": "封面提示"}, prompt_values) self.assertEqual(account, ai_cfg["account_by_alias"]["alias-a"]) self.assertEqual(cfg["db_path"], ai_cfg["db_path"]) + self.assertIn("on_event", ai_cfg) + self.assertIn("on_error", ai_cfg) self.assertFalse(should_stop()) on_progress({"total": 1, "title_done": 1, "cover_done": 0, "failed": 0}) + ai_cfg["on_event"]( + { + "task": tasks[0], + "phase": "cover", + "step": "cover_request", + "result": "start", + } + ) ai_cfg["on_task_update"](tasks[0].id, {"stage": "generated"}) return {"ok": True, "total": 1, "title_done": 1, "cover_done": 1, "failed": 0} @@ -642,8 +653,117 @@ class GuiTests(TempDirMixin, unittest.TestCase): summary = worker.execute() self.assertEqual(1, summary["cover_done"]) + self.assertIsInstance(summary["run_id"], int) + self.assertEqual([batch_id], summary["batch_ids"]) self.assertEqual([{"total": 1, "title_done": 1, "cover_done": 0, "failed": 0}], progress) self.assertEqual([(tasks[0].id, {"stage": "generated"})], rows) + run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0] + self.assertEqual(summary["run_id"], run_log.id) + events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) + self.assertTrue(any("phase=cover step=cover_request result=start" in event.message for event in events)) + + self.assert_removed(temp_dir) + + def test_generate_worker_writes_run_log_and_diagnostic_log_on_image_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] + old_cover = os.path.join(temp_dir, "old.jpg") + with open(old_cover, "wb") as fh: + fh.write(b"jpeg") + db.set_collected(task.id, "旧标题", old_cover, path=cfg["db_path"]) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + diagnostic_log_dir = os.path.join(temp_dir, "logs") + + def fake_title(title_prompt, old_title, **kwargs): + callback = kwargs.get("on_step") + if callback: + callback("title_request") + return "新标题" + + def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs): + callback = kwargs.get("on_step") + if callback: + callback("cover_request") + raise RuntimeError("图片生成失败 token=SECRET-TOKEN") + + with mock.patch("app.ai.gen_title", side_effect=fake_title), \ + mock.patch("app.ai.gen_cover", side_effect=fake_cover): + summary = GenerateWorker( + tasks, + {"title": "标题提示", "cover": "封面提示"}, + db_path=cfg["db_path"], + config=cfg, + diagnostic_log_dir=diagnostic_log_dir, + ).execute() + + self.assertFalse(summary["ok"]) + self.assertEqual(1, summary["title_done"]) + self.assertEqual(0, summary["cover_done"]) + self.assertEqual(1, summary["failed"]) + failed_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + self.assertEqual("failed", failed_task.status) + self.assertIn("图片生成失败", failed_task.last_error) + self.assertNotIn("SECRET-TOKEN", failed_task.last_error) + + events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) + messages = "\n".join(event.message for event in events) + self.assertIn("phase=cover step=cover_request result=failed", messages) + self.assertIn("token=***", messages) + self.assertNotIn("SECRET-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.assertIn("cover_request", raw_log) + self.assertIn("AI生成任务失败", raw_log) + self.assertIn("token=***", raw_log) + self.assertNotIn("SECRET-TOKEN", raw_log) + + self.assert_removed(temp_dir) + + def test_generate_tab_loads_latest_generate_run_log(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + run_id = db.create_run_log("generate", total=1, path=cfg["db_path"]) + db.add_run_log_event( + run_id, + "phase=cover step=cover_request result=failed detail=图片生成失败", + level="error", + path=cfg["db_path"], + ) + db.finish_run_log( + run_id, + status="done", + done=1, + failed_count=1, + summary_json={"failed": 1}, + path=cfg["db_path"], + ) + + tab = GenerateTab(config=cfg) + self.addCleanup(tab.close) + + self.assertEqual("generateRunLogView", tab.run_log_view.objectName()) + self.assertIn("phase=cover step=cover_request result=failed", tab.run_log_view.toPlainText()) self.assert_removed(temp_dir) @@ -725,6 +845,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIsInstance(tab.task_table, QTableView) self.assertEqual(["店铺", "商品ID", "新标题", "新封面", "阶段", "结果"], tab.model.HEADERS) self.assertEqual("开始更新", tab.start_update_button.text()) + self.assertEqual("applyItemFilter", tab.item_filter.objectName()) self.assertEqual("停止", tab.stop_update_button.text()) self.assertFalse(tab.stop_update_button.isEnabled()) self.assertEqual(2, tab.model.rowCount()) @@ -756,6 +877,17 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("已更新", tab.model.index(0, 4).data()) self.assertEqual("成功", tab.model.index(0, 5).data()) + tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None)) + tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) + tab.item_filter.setText("51100639512") + self.assertEqual(1, tab.model.rowCount()) + self.assertEqual("51100639512", tab.model.index(0, 1).data()) + self.assertEqual("任务 1/4 条", tab.summary_label.text()) + self.assertIn( + "商品ID:51100639512", + tab._confirmation_message(list(tab.model.tasks)), + ) + self.assert_removed(temp_dir) def test_apply_tab_start_update_requires_confirmation_before_starting_worker(self): @@ -784,6 +916,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) + tab.item_filter.setText("51100639510") with mock.patch( "app.gui.QMessageBox.question", @@ -795,6 +928,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIn("任务数:1", message) self.assertIn("提交线上", message) self.assertIn("状态:已生成", message) + self.assertIn("商品ID:51100639510", message) self.assertIn("测试商品ID=51100639510", message) self.assertEqual("已取消开始更新", statuses[-1]) apply_task.assert_not_called() @@ -960,6 +1094,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) + tab.item_filter.setText("51100639510") with mock.patch("app.gui.QMessageBox.warning") as warning, \ mock.patch("app.gui.QMessageBox.question") as question, \ @@ -1560,6 +1695,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab = GenerateTab(config=cfg) self.addCleanup(tab.close) + self.assertEqual("generateItemFilter", tab.item_filter.objectName()) self.assertEqual(4, tab.model.rowCount()) self.assertEqual("主店", tab.model.index(0, 0).data()) self.assertEqual("51100639510", tab.model.index(0, 1).data()) @@ -1586,6 +1722,13 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab.model.data(tab.model.index(0, 0), gui.Qt.ToolTipRole), ) + tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None)) + tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) + tab.item_filter.setText("639512") + self.assertEqual(1, tab.model.rowCount()) + self.assertEqual("51100639512", tab.model.index(0, 1).data()) + self.assertEqual("任务 1/4 条", tab.summary_label.text()) + self.assert_removed(temp_dir) def test_tab_switch_updates_status_bar(self): @@ -1924,12 +2067,14 @@ class GuiTests(TempDirMixin, unittest.TestCase): return {"logged_in": False, "reason": "LOGIN_PAGE"} raise AssertionError(account.alias) - def fake_collect(account, task): + def fake_collect(account, task, on_step=None): self.assertEqual(logged.alias, account.alias) self.assertEqual("51100639510", task["item_id"]) self.assertTrue(task["old_cover_path"].endswith( os.path.join(logged.slug, "51100639510_old.jpg") )) + self.assertTrue(callable(on_step)) + on_step("download_cover") return { "old_title": "旧标题", "old_cover_path": task["old_cover_path"], @@ -1944,7 +2089,14 @@ class GuiTests(TempDirMixin, unittest.TestCase): preflight=False, ).execute() - self.assertEqual({"ok": True, "total": 3, "done": 3, "collected": 1, "skipped": 2, "failed": 0}, summary) + self.assertTrue(summary["ok"]) + self.assertEqual(3, summary["total"]) + self.assertEqual(3, summary["done"]) + self.assertEqual(1, summary["collected"]) + self.assertEqual(2, summary["skipped"]) + self.assertEqual(0, summary["failed"]) + self.assertEqual([batch_id], summary["batch_ids"]) + self.assertIsInstance(summary["run_id"], int) collect.assert_called_once() updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) by_alias = {task.alias: task for task in updated} @@ -1958,6 +2110,120 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("skipped", by_alias["missing"].status) self.assertEqual("别名未匹配账号", by_alias["missing"].last_error) + run_logs = db.list_run_logs(limit=1, run_type="collect", path=cfg["db_path"]) + self.assertEqual(summary["run_id"], run_logs[0].id) + self.assertEqual("done", run_logs[0].status) + self.assertEqual(3, run_logs[0].done) + self.assertEqual(1, run_logs[0].success_count) + self.assertEqual(2, run_logs[0].skipped_count) + events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) + messages = [event.message for event in events] + self.assertTrue(any("step=download_cover result=start" in item for item in messages)) + self.assertTrue(any("别名未匹配账号" in item for item in messages)) + + self.assert_removed(temp_dir) + + def test_collect_worker_writes_run_log_and_diagnostic_log_on_failure(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + logged = 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"], + ) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + diagnostic_log_dir = os.path.join(temp_dir, "logs") + + def fake_login(account, path=None, config=None): + self.assertEqual(logged.alias, account.alias) + return {"logged_in": True, "reason": None} + + def fake_collect(account, task, on_step=None): + on_step("wait_ready") + raise RuntimeError("页面未就绪 token=SECRET-TOKEN") + + with mock.patch("app.gui.accounts.detect_login", side_effect=fake_login), \ + mock.patch("app.gui.editor.collect", side_effect=fake_collect): + summary = CollectWorker( + tasks, + 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"]) + self.assertIsInstance(summary["run_id"], int) + failed_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + self.assertEqual("failed", failed_task.status) + self.assertIn("页面未就绪", failed_task.last_error) + self.assertNotIn("SECRET-TOKEN", failed_task.last_error) + + run_log = db.list_run_logs(limit=1, run_type="collect", path=cfg["db_path"])[0] + self.assertEqual(summary["run_id"], run_log.id) + self.assertEqual("done", run_log.status) + self.assertEqual(1, run_log.failed_count) + events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) + messages = [event.message for event in events] + joined_messages = "\n".join(messages) + self.assertTrue(any("step=wait_ready result=failed" in item for item in messages)) + self.assertTrue(any("页面未就绪" in item for item in messages)) + self.assertNotIn("SECRET-TOKEN", joined_messages) + self.assertIn("token=***", joined_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-TOKEN", raw_log) + self.assertIn("token=***", raw_log) + entry = json.loads(raw_log.strip().splitlines()[-1]) + self.assertEqual("wait_ready", entry["step"]) + self.assertEqual("alias-a", entry["alias"]) + self.assertEqual("51100639510", entry["item_id"]) + self.assertEqual("RuntimeError", entry["exception"]) + + self.assert_removed(temp_dir) + + def test_collect_tab_loads_latest_collect_run_log(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + run_id = db.create_run_log("collect", total=1, path=cfg["db_path"]) + db.add_run_log_event( + run_id, + "step=download_cover result=failed detail=旧封面下载超时", + level="error", + path=cfg["db_path"], + ) + db.finish_run_log( + run_id, + status="done", + done=1, + failed_count=1, + summary_json={"failed": 1}, + path=cfg["db_path"], + ) + + tab = CollectTab(config=cfg) + self.addCleanup(tab.close) + + self.assertEqual("collectRunLogView", tab.run_log_view.objectName()) + self.assertIn("step=download_cover result=failed", tab.run_log_view.toPlainText()) + self.assertIn("旧封面下载超时", tab.run_log_view.toPlainText()) + self.assert_removed(temp_dir) def test_collect_tab_auto_starts_write_back_after_collect_success(self):