feat: add diagnostic logs for generation flows

This commit is contained in:
chengma
2026-06-29 17:49:45 +08:00
parent 588b352eac
commit cd1256bc5e
17 changed files with 1455 additions and 104 deletions
+3
View File
@@ -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
+128 -22
View File
@@ -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",
+28 -1
View File
@@ -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"],
+118
View File
@@ -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")
+17 -3
View File
@@ -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))
+581 -42
View File
@@ -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."""
+16
View File
@@ -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=别名未匹配,最后弹窗汇总。
+1 -1
View File
@@ -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 只预览不提交、不改任务状态。真实更新即使开启多账号并行,也必须经过③确认和⑤安全设置。
+5 -1
View File
@@ -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 |
## 里程碑
+12 -10
View File
@@ -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;只把每条“将更新/将略过”写入运行日志并弹汇总。
+15 -12
View File
@@ -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,未点击「更新」保存线上。
+11 -10
View File
@@ -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()`,不阻塞主线程。
+50
View File
@@ -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 发到聊天、文档或提交里。
+57
View File
@@ -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 日志。
+40
View File
@@ -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")
+105
View File
@@ -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",
+268 -2
View File
@@ -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):