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 config.json
# SQLite(账号/任务/结果,含密码) # SQLite(账号/任务/结果,含密码)
cmshopee.db cmshopee.db
cmshopee.db-*
# 各账号 Chrome 配置(含登录态) # 各账号 Chrome 配置(含登录态)
chrome_user_data_dir/ chrome_user_data_dir/
# 采集的旧封面 / AI 生成的新封面 # 采集的旧封面 / AI 生成的新封面
images/ images/
# 本地诊断日志
logs/
# 运营填写后的 Excel 业务文件;标准空模板可提交 # 运营填写后的 Excel 业务文件;标准空模板可提交
*.xlsx *.xlsx
*.xlsm *.xlsm
+128 -22
View File
@@ -6,12 +6,13 @@ import copy
import json import json
import mimetypes import mimetypes
import os import os
import threading
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import uuid import uuid
from . import appconfig, db from . import appconfig, db, diagnostics
from . import prompts as prompt_module from . import prompts as prompt_module
from .config import make_slug from .config import make_slug
@@ -34,12 +35,15 @@ def gen_title(
retry=None, retry=None,
config=None, config=None,
models_path=appconfig.AI_MODELS_PATH, models_path=appconfig.AI_MODELS_PATH,
on_step=None,
): ):
"""Generate a new product title from a prompt and the old title.""" """Generate a new product title from a prompt and the old title."""
cfg = appconfig.load_config() if config is None else config cfg = appconfig.load_config() if config is None else config
ai_cfg = appconfig.ai_config(cfg) 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) model = _role_model("text", ai_cfg.get("default_text_model"), models_path)
_notify_step(on_step, "title_build_request")
payload = _chat_payload( payload = _chat_payload(
model, model,
[ [
@@ -51,6 +55,7 @@ def gen_title(
], ],
) )
attempts = _attempt_count(ai_cfg, retry) attempts = _attempt_count(ai_cfg, retry)
_notify_step(on_step, "title_request")
data = _call_with_retry( data = _call_with_retry(
model, model,
payload, payload,
@@ -58,6 +63,7 @@ def gen_title(
attempts, attempts,
request_kind="json", request_kind="json",
) )
_notify_step(on_step, "title_parse_response")
text = _extract_text(data).strip() text = _extract_text(data).strip()
if not text: if not text:
raise AIError("AI 返回为空标题") raise AIError("AI 返回为空标题")
@@ -73,9 +79,11 @@ def gen_cover(
retry=None, retry=None,
config=None, config=None,
models_path=appconfig.AI_MODELS_PATH, models_path=appconfig.AI_MODELS_PATH,
on_step=None,
): ):
"""Generate a new cover image and save it as a JPEG file.""" """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)) old_cover_path = os.path.abspath(str(old_cover_path))
if not os.path.exists(old_cover_path): if not os.path.exists(old_cover_path):
raise FileNotFoundError("旧封面图片不存在: %s" % 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 cfg = appconfig.load_config() if config is None else config
ai_cfg = appconfig.ai_config(cfg) 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) model = _role_model("image", ai_cfg.get("default_image_model"), models_path)
resolution = str(resolution or ai_cfg.get("resolution", "1k")) 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)) quality = _jpg_quality(jpg_quality if jpg_quality is not None else ai_cfg.get("jpg_quality", 90))
attempts = _attempt_count(ai_cfg, retry) attempts = _attempt_count(ai_cfg, retry)
api_type = model.get("api_type", "auto") api_type = model.get("api_type", "auto")
_notify_step(on_step, "cover_build_request")
if api_type == "images_edits": if api_type == "images_edits":
body, content_type = _image_edit_body(model, cover_prompt, old_cover_path, resolution) body, content_type = _image_edit_body(model, cover_prompt, old_cover_path, resolution)
_notify_step(on_step, "cover_request")
data = _call_with_retry( data = _call_with_retry(
model, model,
body, body,
@@ -102,12 +113,24 @@ def gen_cover(
) )
else: else:
payload = _image_chat_payload(model, cover_prompt, old_cover_path, resolution) 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") 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) image_bytes = _extract_image_bytes(data, model, cfg)
_notify_step(on_step, "cover_save")
return _save_jpeg(image_bytes, out_path, resolution, quality) 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): def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None):
"""Generate titles first, then covers, and persist each successful task.""" """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) image_root = runtime.get("image_dir") or appconfig.image_dir(config)
account_by_alias = runtime.get("account_by_alias") or {} account_by_alias = runtime.get("account_by_alias") or {}
on_task_update = runtime.get("on_task_update") 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") title_prompt = _prompt_value(prompts, "title")
cover_prompt = _prompt_value(prompts, "cover") cover_prompt = _prompt_value(prompts, "cover")
should_stop = should_stop or (lambda: False) 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) _emit_generation_progress(on_progress, summary)
title_results = {} 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( with ThreadPoolExecutor(
max_workers=max(1, int(generation_cfg.get("title_concurrency", 1))) 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(): if should_stop():
summary["cancelled"] = True summary["cancelled"] = True
break break
set_step(task, "title_submit")
_emit_generation_event(on_event, task, "title", "title_submit", "start")
futures[ futures[
executor.submit( executor.submit(
gen_title, 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"), retry=generation_cfg.get("retry"),
config=config, config=config,
models_path=models_path, models_path=models_path,
on_step=step_callback(task, "title"),
) )
] = task ] = task
for future in as_completed(futures): 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: try:
title_results[task.id] = future.result() title_results[task.id] = future.result()
summary["title_done"] += 1 summary["title_done"] += 1
set_step(task, "title_done")
_emit_generation_event(on_event, task, "title", "title_done", "success")
except CancelledError: except CancelledError:
summary["cancelled"] = True summary["cancelled"] = True
_emit_generation_event(on_event, task, "title", get_step(task, "title_request"), "cancelled", level="warning")
except Exception as exc: except Exception as exc:
summary["failed"] += 1 summary["failed"] += 1
summary["ok"] = False 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) _emit_generation_progress(on_progress, summary)
cover_tasks = [ cover_tasks = [
@@ -197,23 +247,38 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
summary["cancelled"] = True summary["cancelled"] = True
break break
new_title = title_results[task.id] new_title = title_results[task.id]
rendered_cover_prompt = prompt_module.render_prompt( try:
cover_prompt, set_step(task, "cover_prompt_render")
_prompt_context(task, new_title, account_by_alias), _emit_generation_event(on_event, task, "cover", "cover_prompt_render", "start")
) rendered_cover_prompt = prompt_module.render_prompt(
futures[ cover_prompt,
executor.submit( _prompt_context(task, new_title, account_by_alias),
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,
) )
] = (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): for future in as_completed(futures):
task, new_title = futures[future] task, new_title = futures[future]
if should_stop(): if should_stop():
@@ -221,6 +286,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
_cancel_pending(futures) _cancel_pending(futures)
try: try:
new_cover_path = future.result() 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) db.set_generated(task.id, new_title, new_cover_path, path=db_path)
summary["cover_done"] += 1 summary["cover_done"] += 1
if on_task_update is not None: 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, "new_cover_path": new_cover_path,
}, },
) )
_emit_generation_event(on_event, task, "cover", "db_write", "success")
except CancelledError: except CancelledError:
summary["cancelled"] = True summary["cancelled"] = True
_emit_generation_event(on_event, task, "cover", get_step(task, "cover_request"), "cancelled", level="warning")
except Exception as exc: except Exception as exc:
summary["failed"] += 1 summary["failed"] += 1
summary["ok"] = False 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) _emit_generation_progress(on_progress, summary)
if summary["cancelled"]: if summary["cancelled"]:
summary["ok"] = False summary["ok"] = False
return summary return summary
def _role_model(category, name, models_path): def _role_model(category, name, models_path):
if not name: if not name:
raise AIError("未配置默认 %s 模型" % category) 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): 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) db.mark_failed(task.id, "generate", error, path=db_path)
if on_task_update is not None: if on_task_update is not None:
on_task_update(task.id, {"status": "failed", "last_error": error}) 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): def _cancel_pending(futures):
@@ -401,7 +507,7 @@ def _call_once(model, body, config, request_kind, content_type=None):
else: else:
data = body data = body
request = urllib.request.Request( request = urllib.request.Request(
model["url"], appconfig.model_request_url(model),
data=data, data=data,
headers=_headers(model, content_type), headers=_headers(model, content_type),
method="POST", 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)]) 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): def _test_request_payload(model):
if model["api_type"] == "images_edits": if model["api_type"] == "images_edits":
payload = {"model": model["model"], "prompt": "ping"} payload = {"model": model["model"], "prompt": "ping"}
@@ -455,7 +482,7 @@ def test_ai_model(name, path=AI_MODELS_PATH) -> dict:
"utf-8" "utf-8"
) )
request = urllib.request.Request( request = urllib.request.Request(
model["url"], model_request_url(model),
data=data, data=data,
headers={ headers={
"Authorization": "Bearer " + model["api_key"], "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")) 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.""" """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) host = _cdp_host(account)
item_id = str(item_id) item_id = str(item_id)
url = _product_url(account, item_id) url = _product_url(account, item_id)
@@ -326,6 +327,7 @@ def open_product(account, item_id) -> CDP:
except Exception: except Exception:
pass pass
cdp.send("Page.navigate", {"url": url}) cdp.send("Page.navigate", {"url": url})
_notify_collect_step(on_step, "wait_ready")
_wait_ready(cdp) _wait_ready(cdp)
return cdp return cdp
@@ -366,17 +368,20 @@ def download_cover(src, out_path) -> str:
return out_path 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.""" """Collect current title and cover snapshot before any edits."""
item_id = _item_id(task) item_id = _item_id(task)
cdp = open_product(account, item_id) cdp = open_product(account, item_id, on_step=on_step)
try: try:
_notify_collect_step(on_step, "read_title")
old_title = read_title(cdp) old_title = read_title(cdp)
_notify_collect_step(on_step, "read_cover")
old_cover_src = read_cover_src(cdp) old_cover_src = read_cover_src(cdp)
out_path = _get(task, "old_cover_path") out_path = _get(task, "old_cover_path")
if not out_path: if not out_path:
out_path = os.path.join(_image_root(account), f"{item_id}_old.jpg") 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) old_cover_path = download_cover(old_cover_src, out_path)
return { return {
"old_title": old_title, "old_title": old_title,
@@ -387,6 +392,15 @@ def collect(account, task) -> dict:
_close_collected_product(cdp) _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): def _close_collected_product(cdp):
target_id = getattr(cdp, "target_id", None) target_id = getattr(cdp, "target_id", None)
created_by_app = bool(getattr(cdp, "created_by_app", False)) 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 os
import sys import sys
import threading import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
try: try:
@@ -84,7 +85,7 @@ QTabBar::tab:hover:!selected {
if QT_IMPORT_ERROR is None: 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 from . import config as account_config
@@ -477,6 +478,9 @@ if QT_IMPORT_ERROR is None:
self.batch_filter.setObjectName("batchFilter") self.batch_filter.setObjectName("batchFilter")
self.shop_filter = QComboBox() self.shop_filter = QComboBox()
self.shop_filter.setObjectName("shopFilter") 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 = QComboBox()
self.status_filter.setObjectName("statusFilter") self.status_filter.setObjectName("statusFilter")
for label, value in self.STATUS_FILTERS: 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(self.batch_filter, 2)
filter_layout.addWidget(QLabel("店铺")) filter_layout.addWidget(QLabel("店铺"))
filter_layout.addWidget(self.shop_filter, 1) 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(QLabel("状态"))
filter_layout.addWidget(self.status_filter, 1) filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.refresh_button) 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.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.task_table.verticalHeader().setVisible(False) 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_panel = QWidget()
right_layout = QVBoxLayout(right_panel) right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(12, 0, 0, 0) right_layout.setContentsMargins(12, 0, 0, 0)
right_layout.addLayout(filter_layout) right_layout.addLayout(filter_layout)
right_layout.addWidget(self.summary_label) right_layout.addWidget(self.summary_label)
right_layout.addWidget(self.task_table, 1) 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 = QSplitter(Qt.Horizontal)
self.splitter.addWidget(left_panel) self.splitter.addWidget(left_panel)
@@ -529,6 +543,7 @@ if QT_IMPORT_ERROR is None:
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks) self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
self.shop_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.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks) self.refresh_button.clicked.connect(self.refresh_tasks)
self.save_title_button.clicked.connect(self.save_title_prompt) 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_cover_templates()
self.refresh_tasks() self.refresh_tasks()
self._load_latest_generate_run_log()
def _set_status(self, message): def _set_status(self, message):
if self.status_callback is not None: if self.status_callback is not None:
self.status_callback(message) 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): def save_title_prompt(self, checked=False):
try: try:
prompts.save_title_prompt( prompts.save_title_prompt(
@@ -711,13 +748,15 @@ if QT_IMPORT_ERROR is None:
prompt_values, prompt_values,
db_path=self.db_path, db_path=self.db_path,
config=self.config, config=self.config,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
) )
worker.progress.connect(self._on_generate_progress) worker.progress.connect(self._on_generate_progress)
worker.row_updated.connect(self._on_generate_row_updated) 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.failed.connect(self._on_generate_failed)
worker.finished.connect(self._on_generate_finished) worker.finished.connect(self._on_generate_finished)
worker.cancelled.connect(self._on_generate_cancelled) worker.cancelled.connect(self._on_generate_cancelled)
self.run_log_view.clear()
thread = run_worker(worker, thread_name="GenerateWorker", start=False) thread = run_worker(worker, thread_name="GenerateWorker", start=False)
thread.finished.connect(lambda: self._forget_generate_thread(thread)) thread.finished.connect(lambda: self._forget_generate_thread(thread))
self.generate_worker = worker self.generate_worker = worker
@@ -784,6 +823,7 @@ if QT_IMPORT_ERROR is None:
self.refresh_button.setEnabled(not running) self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running) self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running) self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running) self.status_filter.setEnabled(not running)
self.save_title_button.setEnabled(not running) self.save_title_button.setEnabled(not running)
self.save_cover_template_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): def _on_generate_finished(self, payload):
self._set_generate_running(False) self._set_generate_running(False)
self.refresh_tasks() self.refresh_tasks()
self._load_latest_generate_run_log()
self._update_generate_progress(payload) self._update_generate_progress(payload)
if payload.get("error"): if payload.get("error"):
self._set_status(f"AI 生成失败:{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): def _on_generate_cancelled(self, payload):
self._set_generate_running(False) self._set_generate_running(False)
self.refresh_tasks() self.refresh_tasks()
self._load_latest_generate_run_log()
self._update_generate_progress(payload) self._update_generate_progress(payload)
self._set_status("AI 生成已停止:" + self._generate_progress_text(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_batch = self.batch_filter.currentData()
selected_shop = self.shop_filter.currentData() selected_shop = self.shop_filter.currentData()
selected_status = self.status_filter.currentData() or "all" selected_status = self.status_filter.currentData() or "all"
item_query = self.item_filter.text().strip()
self._populate_batch_filter(batches, selected_batch) self._populate_batch_filter(batches, selected_batch)
selected_batch = self.batch_filter.currentData() selected_batch = self.batch_filter.currentData()
batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path) 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 = [ filtered_tasks = [
task for task in batch_tasks task for task in batch_tasks
if self._matches_shop(task, selected_shop) if self._matches_shop(task, selected_shop)
and self._matches_item(task, item_query)
and self._matches_status(task, selected_status) and self._matches_status(task, selected_status)
] ]
except Exception as exc: except Exception as exc:
@@ -940,6 +984,11 @@ if QT_IMPORT_ERROR is None:
def _matches_shop(self, task, selected_shop): def _matches_shop(self, task, selected_shop):
return selected_shop is None or str(task.alias).strip() == 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): def _matches_status(self, task, selected_status):
if selected_status in (None, "all"): if selected_status in (None, "all"):
return True return True
@@ -990,6 +1039,9 @@ if QT_IMPORT_ERROR is None:
self.batch_filter.setObjectName("applyBatchFilter") self.batch_filter.setObjectName("applyBatchFilter")
self.shop_filter = QComboBox() self.shop_filter = QComboBox()
self.shop_filter.setObjectName("applyShopFilter") 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 = QComboBox()
self.status_filter.setObjectName("applyStatusFilter") self.status_filter.setObjectName("applyStatusFilter")
for label, value in self.STATUS_FILTERS: 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(self.batch_filter, 2)
filter_layout.addWidget(QLabel("店铺")) filter_layout.addWidget(QLabel("店铺"))
filter_layout.addWidget(self.shop_filter, 1) 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(QLabel("状态"))
filter_layout.addWidget(self.status_filter, 1) filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.refresh_button) 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.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
self.shop_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.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks) self.refresh_button.clicked.connect(self.refresh_tasks)
self.start_update_button.clicked.connect(self.start_update) 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_batch = self.batch_filter.currentData()
selected_shop = self.shop_filter.currentData() selected_shop = self.shop_filter.currentData()
selected_status = self.status_filter.currentData() or "generated" selected_status = self.status_filter.currentData() or "generated"
item_query = self.item_filter.text().strip()
self._populate_batch_filter(batches, selected_batch) self._populate_batch_filter(batches, selected_batch)
selected_batch = self.batch_filter.currentData() selected_batch = self.batch_filter.currentData()
batch_tasks = [ batch_tasks = [
@@ -1077,6 +1133,7 @@ if QT_IMPORT_ERROR is None:
filtered_tasks = [ filtered_tasks = [
task for task in batch_tasks task for task in batch_tasks
if self._matches_shop(task, selected_shop) if self._matches_shop(task, selected_shop)
and self._matches_item(task, item_query)
and self._matches_status(task, selected_status) and self._matches_status(task, selected_status)
] ]
except Exception as exc: except Exception as exc:
@@ -1219,6 +1276,9 @@ if QT_IMPORT_ERROR is None:
def _shop_filter_label(self): def _shop_filter_label(self):
return self.shop_filter.currentText() or "全部店铺" return self.shop_filter.currentText() or "全部店铺"
def _item_filter_label(self):
return self.item_filter.text().strip() or "全部商品"
def _is_update_task(self, task): def _is_update_task(self, task):
if task.stage in {"generated", "applied"}: if task.stage in {"generated", "applied"}:
return True return True
@@ -1227,6 +1287,11 @@ if QT_IMPORT_ERROR is None:
def _matches_shop(self, task, selected_shop): def _matches_shop(self, task, selected_shop):
return selected_shop is None or str(task.alias).strip() == 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): def _matches_status(self, task, selected_status):
if selected_status in (None, "all"): if selected_status in (None, "all"):
return True return True
@@ -1254,6 +1319,7 @@ if QT_IMPORT_ERROR is None:
"即将按当前筛选结果开始更新 Shopee 线上商品。\n\n" "即将按当前筛选结果开始更新 Shopee 线上商品。\n\n"
f"批次:{self._batch_filter_label()}\n" f"批次:{self._batch_filter_label()}\n"
f"店铺:{self._shop_filter_label()}\n" f"店铺:{self._shop_filter_label()}\n"
f"商品ID:{self._item_filter_label()}\n"
f"状态:{self._status_label()}\n" f"状态:{self._status_label()}\n"
f"任务数:{len(tasks)}\n\n" f"任务数:{len(tasks)}\n\n"
"安全设置:" "安全设置:"
@@ -1315,6 +1381,7 @@ if QT_IMPORT_ERROR is None:
self.refresh_button.setEnabled(not running) self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running) self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running) self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running) self.status_filter.setEnabled(not running)
self._update_write_back_button() self._update_write_back_button()
@@ -1323,6 +1390,7 @@ if QT_IMPORT_ERROR is None:
self.refresh_button.setEnabled(not running) self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running) self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running) self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running) self.status_filter.setEnabled(not running)
self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids())) 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.collect_thread = None
self.write_back_worker = None self.write_back_worker = None
self.write_back_thread = None self.write_back_thread = None
self.last_collect_run_id = None
self.import_button = QPushButton("导入 Excel...") self.import_button = QPushButton("导入 Excel...")
self.refresh_button = QPushButton("刷新") self.refresh_button = QPushButton("刷新")
@@ -1632,6 +1701,12 @@ if QT_IMPORT_ERROR is None:
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False) 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("") self.empty_label = QLabel("")
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -1640,6 +1715,8 @@ if QT_IMPORT_ERROR is None:
layout.addLayout(summary_layout) layout.addLayout(summary_layout)
layout.addWidget(self.match_detail_label) layout.addWidget(self.match_detail_label)
layout.addWidget(self.table, 1) layout.addWidget(self.table, 1)
layout.addWidget(QLabel("采集运行日志"))
layout.addWidget(self.run_log_view)
layout.addWidget(self.empty_label) layout.addWidget(self.empty_label)
self.import_button.clicked.connect(self.import_excel) 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.show_unmatched_button.clicked.connect(self.show_unmatched_tasks)
self.refresh_tasks() self.refresh_tasks()
self._load_latest_collect_run_log()
def _set_status(self, message): def _set_status(self, message):
if self.status_callback is not None: if self.status_callback is not None:
self.status_callback(message) 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): def _show_error(self, message):
QMessageBox.warning(self, "导入采集", str(message)) QMessageBox.warning(self, "导入采集", str(message))
self._set_status(str(message)) self._set_status(str(message))
@@ -1723,13 +1830,19 @@ if QT_IMPORT_ERROR is None:
if not tasks: if not tasks:
self._set_status("没有可采集任务") self._set_status("没有可采集任务")
return 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.progress.connect(self._on_collect_progress)
worker.row_updated.connect(self._on_collect_row_updated) 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.failed.connect(self._on_collect_failed)
worker.finished.connect(self._on_collect_finished) worker.finished.connect(self._on_collect_finished)
worker.cancelled.connect(self._on_collect_cancelled) worker.cancelled.connect(self._on_collect_cancelled)
self.run_log_view.clear()
thread = run_worker(worker, thread_name="CollectWorker", start=False) thread = run_worker(worker, thread_name="CollectWorker", start=False)
thread.finished.connect(lambda: self._forget_collect_thread(thread)) thread.finished.connect(lambda: self._forget_collect_thread(thread))
self.collect_worker = worker self.collect_worker = worker
@@ -1830,7 +1943,9 @@ if QT_IMPORT_ERROR is None:
def _on_collect_finished(self, payload): def _on_collect_finished(self, payload):
self._set_collect_running(False) self._set_collect_running(False)
self.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id
self.refresh_tasks() self.refresh_tasks()
self._load_latest_collect_run_log()
if payload.get("blocked"): if payload.get("blocked"):
self._show_collect_blocked(payload) self._show_collect_blocked(payload)
return return
@@ -1842,6 +1957,11 @@ if QT_IMPORT_ERROR is None:
if payload.get("collected", 0) > 0: if payload.get("collected", 0) > 0:
batch_id = self._active_batch_id() batch_id = self._active_batch_id()
if batch_id and self._start_write_back(batch_id, auto=True): 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...") self._set_status(f"{message},正在自动回写 Excel...")
return return
if not batch_id: if not batch_id:
@@ -1900,6 +2020,12 @@ if QT_IMPORT_ERROR is None:
message += "\n请关闭原 Excel 后重试;SQLite 已保留采集结果,也可另存副本。" message += "\n请关闭原 Excel 后重试;SQLite 已保留采集结果,也可另存副本。"
QMessageBox.warning(self, "回写旧数据", message) QMessageBox.warning(self, "回写旧数据", message)
self._set_status(message.replace("\n", " ")) 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): def _on_write_back_finished(self, payload, auto=False):
self._set_write_back_running(False) self._set_write_back_running(False)
@@ -1907,6 +2033,12 @@ if QT_IMPORT_ERROR is None:
error = payload.get("error") or "未知错误" error = payload.get("error") or "未知错误"
retry_hint = ",可点击「回写旧数据到 Excel」手动重试" if auto else "" retry_hint = ",可点击「回写旧数据到 Excel」手动重试" if auto else ""
self._set_status(f"Excel {'自动' if auto else ''}回写失败:{error}{retry_hint}") 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 return
self.refresh_tasks() self.refresh_tasks()
self._set_status( self._set_status(
@@ -1916,6 +2048,11 @@ if QT_IMPORT_ERROR is None:
rows=payload.get("rows", 0), 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): def show_all_tasks(self, checked=False):
self.model.set_filter_mode("all") self.model.set_filter_mode("all")
@@ -2072,12 +2209,21 @@ if QT_IMPORT_ERROR is None:
class GenerateWorker(BaseWorker): class GenerateWorker(BaseWorker):
"""Generate titles and covers for collected tasks.""" """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__() super().__init__()
self.tasks = list(tasks) self.tasks = list(tasks)
self.prompt_values = dict(prompt_values or {}) self.prompt_values = dict(prompt_values or {})
self.db_path = db_path self.db_path = db_path
self.config = config self.config = config
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
def execute(self): def execute(self):
account_rows = accounts.list_accounts(path=self.db_path, config=self.config) 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 for account in account_rows
if str(account.alias).strip() if str(account.alias).strip()
} }
return ai.generate_batch( eligible = [
self.tasks, task for task in self.tasks
self.prompt_values, if getattr(task, "stage", None) == "collected"
ai_cfg={ ]
"config": self.config, batch_ids = self._batch_ids(eligible)
"db_path": self.db_path, self._run_id = self._create_run_log(eligible, batch_ids)
"image_dir": appconfig.image_dir(self.config), self._log_run_event(
"account_by_alias": account_by_alias, f"phase=preflight step=start result=start detail=AI生成开始 total={len(eligible)}"
"on_task_update": self._emit_row_update,
},
on_progress=self.progress.emit,
should_stop=self.should_cancel,
) )
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): def _emit_row_update(self, task_id, fields):
self.row_updated.emit(int(task_id), dict(fields or {})) 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): class ApplyWorker(BaseWorker):
"""Apply generated title/cover changes, optionally previewing or grouping by account.""" """Apply generated title/cover changes, optionally previewing or grouping by account."""
@@ -2522,13 +2819,14 @@ if QT_IMPORT_ERROR is None:
return return
def _log_run_event(self, message, task=None, level="info"): 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: if self._run_id is None:
return return
try: try:
db.add_run_log_event( db.add_run_log_event(
self._run_id, self._run_id,
message, safe_message,
task_id=getattr(task, "id", None), task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None), alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None), item_id=getattr(task, "item_id", None),
@@ -2542,12 +2840,21 @@ if QT_IMPORT_ERROR is None:
class CollectWorker(BaseWorker): class CollectWorker(BaseWorker):
"""Collect old title and cover for imported tasks.""" """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__() super().__init__()
self.tasks = list(tasks) self.tasks = list(tasks)
self.db_path = db_path self.db_path = db_path
self.config = config self.config = config
self.preflight = preflight self.preflight = preflight
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
def execute(self): def execute(self):
account_rows = accounts.list_accounts(path=self.db_path, config=self.config) 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 task for task in self.tasks
if getattr(task, "stage", None) == "imported" if getattr(task, "stage", None) == "imported"
] ]
batch_ids = self._batch_ids(eligible)
total = len(eligible) total = len(eligible)
collected = 0 collected = 0
skipped = 0 skipped = 0
failed = 0 failed = 0
done = 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: if self.preflight:
blocked = self._preflight_block(eligible, account_rows, account_by_alias) blocked = self._preflight_block(eligible, account_rows, account_by_alias)
if blocked: if blocked:
blocked.update( self._log_preflight_blocked(blocked)
{ summary = self._summary(
"ok": False, ok=False,
"blocked": True, total=total,
"total": total, done=done,
"done": 0, collected=collected,
"collected": 0, skipped=skipped,
"skipped": 0, failed=failed,
"failed": 0, 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: for task in eligible:
if self.should_cancel(): if self.should_cancel():
@@ -2592,6 +2913,15 @@ if QT_IMPORT_ERROR is None:
reason = "别名未匹配账号" reason = "别名未匹配账号"
db.mark_skipped(task.id, reason, path=self.db_path) db.mark_skipped(task.id, reason, path=self.db_path)
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) 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) self._emit_progress(done, total, collected, skipped, failed)
continue continue
@@ -2602,10 +2932,41 @@ if QT_IMPORT_ERROR is None:
reason = self._login_skip_reason(status) reason = self._login_skip_reason(status)
db.mark_skipped(task.id, reason, path=self.db_path) db.mark_skipped(task.id, reason, path=self.db_path)
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) 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) self._emit_progress(done, total, collected, skipped, failed)
continue 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: 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) db.mark_running(task.id, "collect", path=self.db_path)
self.row_updated.emit(task.id, {"status": "running"}) self.row_updated.emit(task.id, {"status": "running"})
result = editor.collect( result = editor.collect(
@@ -2614,6 +2975,15 @@ if QT_IMPORT_ERROR is None:
"item_id": task.item_id, "item_id": task.item_id,
"old_cover_path": self._old_cover_path(account, task), "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( db.set_collected(
task.id, task.id,
@@ -2622,6 +2992,7 @@ if QT_IMPORT_ERROR is None:
path=self.db_path, path=self.db_path,
) )
collected += 1 collected += 1
elapsed_ms = self._elapsed_ms(started)
self.row_updated.emit( self.row_updated.emit(
task.id, task.id,
{ {
@@ -2631,24 +3002,55 @@ if QT_IMPORT_ERROR is None:
"old_cover_path": result.get("old_cover_path", ""), "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: except Exception as exc:
failed += 1 failed += 1
error = str(exc) or exc.__class__.__name__ error = str(exc) or exc.__class__.__name__
db.mark_failed(task.id, "collect", error, path=self.db_path) safe_error = diagnostics.redact_log_text(error)
self.failed.emit(task.id, error) elapsed_ms = self._elapsed_ms(started)
self.row_updated.emit(task.id, {"status": "failed", "last_error": error}) 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: finally:
done += 1 done += 1
self._emit_progress(done, total, collected, skipped, failed) self._emit_progress(done, total, collected, skipped, failed)
return { summary = self._summary(
"ok": failed == 0, ok=failed == 0,
"total": total, total=total,
"done": done, done=done,
"collected": collected, collected=collected,
"skipped": skipped, skipped=skipped,
"failed": failed, 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): def _preflight_block(self, eligible, account_rows, account_by_alias):
if not account_rows: 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): class WriteBackWorker(BaseWorker):
"""Write Excel fields back in a background thread.""" """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=别名未匹配,最后弹窗汇总。 - `alias` 是账号↔任务**唯一关联键**;找不到账号 → stage=skipped,error=别名未匹配,最后弹窗汇总。
+1 -1
View File
@@ -87,7 +87,7 @@ python prototypes/demo.py # 单账号闭环验证(不提交)
## 8. 安全与合规 ## 8. 安全与合规
- 登录凭证只存在于各账号 user-data-dir;不导出、不外传、不写入配置或日志。 - 登录凭证只存在于各账号 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` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。 - 涉及 Shopee 时,遵守 `04-architecture.md` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。
- 高风险动作(删满 9 张的封面、点击更新)必须有显式确认,并先在测试商品验证;删满 9 张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。 - 高风险动作(删满 9 张的封面、点击更新)必须有显式确认,并先在测试商品验证;删满 9 张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。
- ③ 的批量确认弹窗是提交线上前的确认边界;T-504 的 dry-run 只预览不提交、不改任务状态。真实更新即使开启多账号并行,也必须经过③确认和⑤安全设置。 - ③ 的批量确认弹窗是提交线上前的确认边界;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-204b | 采集完成后自动回写旧字段到 Excel | T-204 | `CollectWorker` 完成后自动触发 `excel.write_back()` 回写当前批次旧字段;成功时状态栏/日志提示“已回写”;原文件被锁时不影响 SQLite,提示关闭后点「回写旧数据到 Excel」手动重试或另存副本 | DONE |
| T-205 | 首次未配账号 / Chrome 未启动 / 未登录的引导保护 | T-105, T-203 | 无账号、匹配账号未启动 CDP 端口或未登录时,① 执行按钮禁用或采集前汇总提示,并引导去④;可提供“打开账号管理/启动登录”入口,但不无提示批量启动所有账号 Chrome | 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-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 生成(②) ## Phase 3 · AI 生成(②)
@@ -64,12 +66,13 @@
| T-302 | Tab② 左右布局:左提示词(标题/封面),右按批次/店铺/状态筛选 + 任务列表 | T-301, T-203 | 左 ~1/4 提示词多行;右筛选+列表(店铺/商品id/旧标题/新标题/状态) | DONE | | 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-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-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(③) ## Phase 4 · 更新 shopee(③)
| ID | 任务 | 依赖 | 验收要点 | 状态 | | 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-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未登录引导保护;未确认时不调用 apply | DONE |
| T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE | | T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE |
| T-404 | 真实 Shopee 单条更新冒烟验收(设置安全开关完成后执行) | T-403, T-501c | 仅用测试商品;默认先只测标题更新,封面更新作为可选子项;必须经过 Tab③ 二次确认和设置安全开关;验证线上提交结果、Excel 更新状态、失败/取消语义;未通过前不得扩大批量真实更新 | TODO | | 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-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-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-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 唯一校验 add_ai_model(model) -> None # name 唯一校验
update_ai_model(name, **fields) -> None update_ai_model(name, **fields) -> None
delete_ai_model(name) -> None # 至少各留一个 text+image;删到剩一禁用 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?} test_ai_model(name) -> dict # 「测试连接」:用 key/url/model 发最小请求 -> {ok, status?, error?}
get_model(name) -> dict # 返回模型定义,含 api_key(调用方不得写日志) 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`,已建) ## db 模块(`app/db.py`,已建)
@@ -255,10 +256,10 @@ apply_task(account, task, close_success_tab=False) -> dict
```python ```python
class AIError(RuntimeError): ... 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 请求;提示词 + 旧标题 → 新标题 # 文本生成:读取 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; # 图像生成(image-to-image):读取 default_image_model;chat 多模态 JSON 或 images_edits multipart;
# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径 # 支持返回 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 # 编排:先以 title_concurrency 线程池并发跑 gen_title,再以 image_concurrency 并发跑 gen_cover
# 每条完成即 db.set_generated(实时落库);should_stop() 为真则取消未开始项 # 每条完成即 db.set_generated(实时落库);should_stop() 为真则取消未开始项
# on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度 # 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),不阻塞其余 # 返回同结构 summary;失败任务 mark_failed(..., "generate", error),不阻塞其余
``` ```
@@ -315,7 +317,7 @@ render_prompt(template_text, task) -> str
main() -> int # 创建 QApplication + MainWindow main() -> int # 创建 QApplication + MainWindow
class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试 class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试
class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 安全开关拦截 + 确认后 dry-run/真实更新 + 运行日志 class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 安全开关拦截 + 确认后 dry-run/真实更新 + 运行日志
class SettingsTab(QWidget) # ⑤ 设置:AI 模型管理 + 角色/生成参数/路径端口 + Shopee 更新安全 class SettingsTab(QWidget) # ⑤ 设置:AI 模型管理 + 角色/生成参数/路径端口 + Shopee 更新安全
class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed 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 采集结果不回滚。 - 「回写旧数据到 Excel」通过 `WriteBackWorker` 后台调用 `excel.write_back()`,把已采集旧标题/旧封面路径按原 Excel 行定位写回;该按钮主要作为自动回写失败后的手动重试入口。原文件被占用时弹窗提示关闭后重试,SQLite 采集结果不回滚。
- 采集前由 `CollectWorker` 做账号就绪预检:无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;不无提示批量启动所有账号 Chrome。匹配账号未登录属于预检阻断,不是逐条 skipped。 - 采集前由 `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 为标题提示词、封面提示词两个多行输入;右侧为筛选栏 + 任务列表。 - 左右 `QSplitter`:左侧约 1/4 为标题提示词、封面提示词两个多行输入;右侧为筛选栏 + 任务列表。
- 标题提示词启动时从 `title_prompt.txt` 回显;点击「保存标题提示词」写回该文件。 - 标题提示词启动时从 `title_prompt.txt` 回显;点击「保存标题提示词」写回该文件。
- 封面提示词模板下拉读取 `prompts/cover/*.txt`;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。 - 封面提示词模板下拉读取 `prompts/cover/*.txt`;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。
- 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」使用当前选中任务(无选择则用第一条)调用 `prompts.render_prompt()` 并弹窗展示。 - 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」使用当前选中任务(无选择则用第一条)调用 `prompts.render_prompt()` 并弹窗展示。
- 筛选栏包含:批次、店铺、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;状态支持全部/待生成/已生成/失败/略过/已更新。 - 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。
- 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态。 - 任务列表使用 `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()` 结果保留,可再次开始生成剩余任务。 - 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。
- 进度标签显示:`标题x/n · 封面x/n · 失败z`;每条封面生成成功后立即 `db.set_generated()`,失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。 - 进度标签显示:`标题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/applied`,或已有新字段且 `status=failed/skipped` 的任务。
- 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。 - 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。 - 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库。 - 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库。
- 点击「开始更新」先读取 `shopee_update`:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,弹警告并阻断;通过后才弹窗展示批次/店铺/状态/任务数、提交线上风险和当前安全设置。 - 点击「开始更新」先读取 `shopee_update`:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,弹警告并阻断;通过后才弹窗展示批次/店铺/商品ID/状态/任务数、提交线上风险和当前安全设置。
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker`。dry-run 开启时只预览;dry-run 关闭时才可能真实提交。 - 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker`。dry-run 开启时只预览;dry-run 关闭时才可能真实提交。
- `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交。 - `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交。
- dry-run:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。 - dry-run:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
+15 -12
View File
@@ -6,11 +6,11 @@
## 当前快照 ## 当前快照
- 日期:2026-06-29 - 日期: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(已定)。 - 技术栈: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` 启动包装。 - 生产代码:已建立 `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生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行/dry-run/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、dry-run/多账号并行设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 - 测试:`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`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 - 数据:`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 转发排查记录 | | `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 |
| `docs/troubleshooting.md` | 已有 | 常见问题排查;已记录 `config/ai_models.json` 缺失 `category` 导致 GUI 启动报 “AI 模型 category 必须是 text 或 image” 的原因、修复和验证步骤 | | `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/__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/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 |
| `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | | `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/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/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/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/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir |
| `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 | | `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 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 | | `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 |
| `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `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)。 任务状态以 [`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-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-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。 - ① 采集依赖对应账号 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-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,且不要提交该文件。 - 本地 `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-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;真实 Shopee 更新冒烟仍未执行,下一步 T-404 只允许在测试商品范围内做单条验收,默认先只测标题更新,封面更新作为可选子项。
- T-502 满 9 张封面删除流程已完成代码路径、mock 单测和真实 9 图商品不提交流程实测;删除第一张前必须已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失备份时拒绝删除线上图片。本轮实测只操作编辑页并关闭测试 tab,未点击「更新」保存线上。 - T-502 满 9 张封面删除流程已完成代码路径、mock 单测和真实 9 图商品不提交流程实测;删除第一张前必须已有本地旧封面备份(`old_cover_path` 存在且文件存在),缺失备份时拒绝删除线上图片。本轮实测只操作编辑页并关闭测试 tab,未点击「更新」保存线上。
+11 -10
View File
@@ -11,7 +11,7 @@
| Tab | 职责 | 风险 | | Tab | 职责 | 风险 |
| --- | --- | --- | | --- | --- | --- |
| ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 | | ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 |
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺筛选任务列表;AI 生成新标题/新封面;双击看新旧封面 | 不触线上,中 | | ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题/新封面;双击看新旧封面 | 不触线上,中 |
| ③ 更新shopee | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** | | ③ 更新shopee | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式 | 中 | | ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式 | 中 |
| ⑤ 设置 | AI 模型/API Key、本地图片目录、Chrome 路径、默认端口、Shopee 更新安全开关等 | — | | ⑤ 设置 | AI 模型/API Key、本地图片目录、Chrome 路径、默认端口、Shopee 更新安全开关等 | — |
@@ -46,7 +46,7 @@
│ │ 主店A 女装店 51100639510 待采集 — — │ │ │ │ 主店A 女装店 51100639510 待采集 — — │ │
│ └───────────────────────────────────────────────────────────┘ │ │ └───────────────────────────────────────────────────────────┘ │
│ [▶ 采集旧标题/旧封面] [■停止] [回写旧数据到 Excel] │ │ [▶ 采集旧标题/旧封面] [■停止] [回写旧数据到 Excel] │
│ 日志:逐条 文件→匹配账号、采集结果 │ │ 日志:逐条 文件→匹配账号、采集步骤、失败原因 │
└───────────────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────────────┘
``` ```
@@ -54,7 +54,7 @@
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
- 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到本地图片目录,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。 - 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到本地图片目录,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
- 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。 - 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `logs/`,用于定位失败卡在哪个步骤。
## ② AI生成 ## ② AI生成
@@ -63,13 +63,14 @@
``` ```
┌ AI生成 ───────────────────────────────────────────────────────┐ ┌ AI生成 ───────────────────────────────────────────────────────┐
│ ┌─左 ~1/4─┐ ┌──────────────── 右 3/4 ──────────────────────┐ │ │ ┌─左 ~1/4─┐ ┌──────────────── 右 3/4 ──────────────────────┐ │
│ │标题提示词│ │ 批次[本次▼] 店铺[全部▼] 状态[全部▼] [筛选] │ │ │ │标题提示词│ │ 批次[本次▼] 店铺[全部▼] 商品ID[____] 状态[全部▼] [筛选] │ │
│ │[ ]│ │ ┌──────────────────────────────────────────┐ │ │ │ │[ ]│ │ ┌──────────────────────────────────────────┐ │ │
│ │[ ]│ │ │ 店铺名 商品id 旧标题 新标题 状态 │ │ │ │ │[ ]│ │ │ 店铺名 商品id 旧标题 新标题 状态 │ │ │
│ │ │ │ │ 女装店 511..639 …T恤 …百搭 已生成 │ │ │ │ │ │ │ │ 女装店 511..639 …T恤 …百搭 已生成 │ │ │
│ │封面提示词│ │ │ 女装店 511..640 … — 待生成 │ │ │ │ │封面提示词│ │ │ 女装店 511..640 … — 待生成 │ │ │
│ │[ ]│ │ └──────────────────────────────────────────┘ │ │ │ │[ ]│ │ └──────────────────────────────────────────┘ │ │
│ │[ ]│ │ (双击某行 → 弹窗看 旧封面 | 新封面) │ │ │ │[ ]│ │ (双击某行 → 弹窗看 旧封面 | 新封面) │ │
│ │ │ │ 运行日志:逐条记录标题/封面生成步骤与失败原因 │ │
│ └──────────┘ └───────────────────────────────────────────────┘ │ │ └──────────┘ └───────────────────────────────────────────────┘ │
│ 进度:标题30/30 · 封面12/30 · 失败1 [▶ 开始生成] [■停止] │ │ 进度:标题30/30 · 封面12/30 · 失败1 [▶ 开始生成] [■停止] │
└───────────────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────────────┘
@@ -79,18 +80,18 @@
- **标题提示词**:多行输入 + 「保存」(写 `title_prompt.txt`);启动时自动加载回显。 - **标题提示词**:多行输入 + 「保存」(写 `title_prompt.txt`);启动时自动加载回显。
- **封面提示词**:模板下拉(读 `prompts/cover/*.txt`)+ 图标工具栏(新建/保存/另存为/重命名/删除)+ 多行输入 + 「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。 - **封面提示词**:模板下拉(读 `prompts/cover/*.txt`)+ 图标工具栏(新建/保存/另存为/重命名/删除)+ 多行输入 + 「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。
- 变量:`{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。 - 变量:`{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。
- 右上:按导入批次 / 店铺 / 状态筛选任务。 - 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。
- 右下:任务列表(店铺名、商品id、旧标题、新标题、状态);**双击某条 → 弹窗展示旧封面 | 新封面**(纯查看)。 - 右下:任务列表(店铺名、商品id、旧标题、新标题、状态)+ AI生成运行日志;**双击某条 → 弹窗展示旧封面 | 新封面**(纯查看)。
- 底部**单个「开始生成」+「停止」**:开始生成 = **先按 `title_concurrency` 并发生成标题,接着按 `image_concurrency` 并发生成封面**;进度实时显示 标题/封面/失败 计数。 - 底部**单个「开始生成」+「停止」**:开始生成 = **先按 `title_concurrency` 并发生成标题,接着按 `image_concurrency` 并发生成封面**;进度实时显示 标题/封面/失败 计数。
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、模型/Key)在 **⑤ 设置**,不在本 Tab 重复。 - 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、模型/Key)在 **⑤ 设置**,不在本 Tab 重复。
- 每条/每张完成即写库;「停止」取消未开始项,可再次「开始生成」对剩余继续。 - 每条/每张完成即写库;`run_type=generate` 写 `run_logs/run_log_events`,图片生成失败同时写本地 `logs/cmshopee.log` 脱敏 traceback;「停止」取消未开始项,可再次「开始生成」对剩余继续。
- **无逐条人工审核环节**;新标题直接用 AI 输出(不可编辑);可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。 - **无逐条人工审核环节**;新标题直接用 AI 输出(不可编辑);可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。
## ③ 更新shopee ## ③ 更新shopee
``` ```
┌ 更新shopee ───────────────────────────────────────────────────┐ ┌ 更新shopee ───────────────────────────────────────────────────┐
│ 批次[本次▼] 店铺[全部▼] 状态[已生成▼] [筛选] │ │ 批次[本次▼] 店铺[全部▼] 商品ID[____] 状态[已生成▼] [筛选] │
│ ⚠ 点击「开始更新」后先过安全开关,再确认【当前筛选结果】并提交 │ │ ⚠ 点击「开始更新」后先过安全开关,再确认【当前筛选结果】并提交 │
│ ┌───────────────────────────────────────────────────────────┐ │ │ ┌───────────────────────────────────────────────────────────┐ │
│ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │ │ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │
@@ -100,7 +101,7 @@
└───────────────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────────────┘
``` ```
- 顶部**按批次 / 店铺 / 状态筛选**(与 ①②一致);「开始更新」作用于**当前筛选结果**,是一道范围控制。 - 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。
- 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。 - 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。 - 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断;dry-run 开启时只预览,不进入 Shopee。 - 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断;dry-run 开启时只预览,不进入 Shopee。
@@ -130,7 +131,7 @@
## ⑤ 设置 ## ⑤ 设置
- AI 模型管理(T-501 已接入):同列上下布局,AI 模型下拉 + 新增/删除在“模型详情”上方;详情与设置表单尽量两项一行,长字段独占整行。 - AI 模型管理(T-501 已接入):同列上下布局,AI 模型下拉 + 新增/删除在“模型详情”上方;详情与设置表单尽量两项一行,长字段独占整行。
- 字段:启用、服务商名、类别(文本/图像)、api_type、模型ID、网址、密钥、连接超时。 - 字段:启用、服务商名、类别(文本/图像)、api_type、模型ID、网址、密钥、连接超时。网址可填完整 endpoint,也可填 OpenAI-compatible base URL(如 `https://.../v1`),请求时按 `api_type` 自动补齐。
- 密钥存在 `config/ai_models.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。 - 密钥存在 `config/ai_models.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。
- 保存复用 `appconfig` 模型清单后端,保持 name 唯一、至少 text/image 各一个模型。 - 保存复用 `appconfig` 模型清单后端,保持 name 唯一、至少 text/image 各一个模型。
- 「测试连接」通过后台 worker 调 `appconfig.test_ai_model()`,不阻塞主线程。 - 「测试连接」通过后台 worker 调 `appconfig.test_ai_model()`,不阻塞主线程。
+50
View File
@@ -92,3 +92,53 @@ git status --short --ignored config/ai_models.json
```powershell ```powershell
python main.py 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 路径等长字段独占整行。 - 变更:`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 草图。 - 文档:同步 `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)。 - 验证:`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) 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): def test_missing_model_fields_raise_clear_error_without_secret(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
models_path = os.path.join(temp_dir, "ai_models.json") models_path = os.path.join(temp_dir, "ai_models.json")
+105
View File
@@ -1,6 +1,7 @@
import os import os
import sys import sys
import unittest import unittest
from unittest import mock
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
@@ -113,6 +114,110 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_sanitize_for_log_masks_secret_fields(self):
payload = { payload = {
"name": "demo", "name": "demo",
+268 -2
View File
@@ -1,3 +1,4 @@
import json
import unittest import unittest
import os import os
import sys import sys
@@ -624,8 +625,18 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual({"title": "标题提示", "cover": "封面提示"}, prompt_values) self.assertEqual({"title": "标题提示", "cover": "封面提示"}, prompt_values)
self.assertEqual(account, ai_cfg["account_by_alias"]["alias-a"]) self.assertEqual(account, ai_cfg["account_by_alias"]["alias-a"])
self.assertEqual(cfg["db_path"], ai_cfg["db_path"]) 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()) self.assertFalse(should_stop())
on_progress({"total": 1, "title_done": 1, "cover_done": 0, "failed": 0}) 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"}) ai_cfg["on_task_update"](tasks[0].id, {"stage": "generated"})
return {"ok": True, "total": 1, "title_done": 1, "cover_done": 1, "failed": 0} 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() summary = worker.execute()
self.assertEqual(1, summary["cover_done"]) 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([{"total": 1, "title_done": 1, "cover_done": 0, "failed": 0}], progress)
self.assertEqual([(tasks[0].id, {"stage": "generated"})], rows) 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) self.assert_removed(temp_dir)
@@ -725,6 +845,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsInstance(tab.task_table, QTableView) self.assertIsInstance(tab.task_table, QTableView)
self.assertEqual(["店铺", "商品ID", "新标题", "新封面", "阶段", "结果"], tab.model.HEADERS) self.assertEqual(["店铺", "商品ID", "新标题", "新封面", "阶段", "结果"], tab.model.HEADERS)
self.assertEqual("开始更新", tab.start_update_button.text()) self.assertEqual("开始更新", tab.start_update_button.text())
self.assertEqual("applyItemFilter", tab.item_filter.objectName())
self.assertEqual("停止", tab.stop_update_button.text()) self.assertEqual("停止", tab.stop_update_button.text())
self.assertFalse(tab.stop_update_button.isEnabled()) self.assertFalse(tab.stop_update_button.isEnabled())
self.assertEqual(2, tab.model.rowCount()) 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, 4).data())
self.assertEqual("成功", tab.model.index(0, 5).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) self.assert_removed(temp_dir)
def test_apply_tab_start_update_requires_confirmation_before_starting_worker(self): def test_apply_tab_start_update_requires_confirmation_before_starting_worker(self):
@@ -784,6 +916,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
statuses = [] statuses = []
tab = ApplyTab(config=cfg, status_callback=statuses.append) tab = ApplyTab(config=cfg, status_callback=statuses.append)
self.addCleanup(tab.close) self.addCleanup(tab.close)
tab.item_filter.setText("51100639510")
with mock.patch( with mock.patch(
"app.gui.QMessageBox.question", "app.gui.QMessageBox.question",
@@ -795,6 +928,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIn("任务数:1", message) self.assertIn("任务数:1", message)
self.assertIn("提交线上", message) self.assertIn("提交线上", message)
self.assertIn("状态:已生成", message) self.assertIn("状态:已生成", message)
self.assertIn("商品ID:51100639510", message)
self.assertIn("测试商品ID=51100639510", message) self.assertIn("测试商品ID=51100639510", message)
self.assertEqual("已取消开始更新", statuses[-1]) self.assertEqual("已取消开始更新", statuses[-1])
apply_task.assert_not_called() apply_task.assert_not_called()
@@ -960,6 +1094,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
statuses = [] statuses = []
tab = ApplyTab(config=cfg, status_callback=statuses.append) tab = ApplyTab(config=cfg, status_callback=statuses.append)
self.addCleanup(tab.close) self.addCleanup(tab.close)
tab.item_filter.setText("51100639510")
with mock.patch("app.gui.QMessageBox.warning") as warning, \ with mock.patch("app.gui.QMessageBox.warning") as warning, \
mock.patch("app.gui.QMessageBox.question") as question, \ mock.patch("app.gui.QMessageBox.question") as question, \
@@ -1560,6 +1695,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab = GenerateTab(config=cfg) tab = GenerateTab(config=cfg)
self.addCleanup(tab.close) self.addCleanup(tab.close)
self.assertEqual("generateItemFilter", tab.item_filter.objectName())
self.assertEqual(4, tab.model.rowCount()) self.assertEqual(4, tab.model.rowCount())
self.assertEqual("主店", tab.model.index(0, 0).data()) self.assertEqual("主店", tab.model.index(0, 0).data())
self.assertEqual("51100639510", tab.model.index(0, 1).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.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) self.assert_removed(temp_dir)
def test_tab_switch_updates_status_bar(self): def test_tab_switch_updates_status_bar(self):
@@ -1924,12 +2067,14 @@ class GuiTests(TempDirMixin, unittest.TestCase):
return {"logged_in": False, "reason": "LOGIN_PAGE"} return {"logged_in": False, "reason": "LOGIN_PAGE"}
raise AssertionError(account.alias) 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(logged.alias, account.alias)
self.assertEqual("51100639510", task["item_id"]) self.assertEqual("51100639510", task["item_id"])
self.assertTrue(task["old_cover_path"].endswith( self.assertTrue(task["old_cover_path"].endswith(
os.path.join(logged.slug, "51100639510_old.jpg") os.path.join(logged.slug, "51100639510_old.jpg")
)) ))
self.assertTrue(callable(on_step))
on_step("download_cover")
return { return {
"old_title": "旧标题", "old_title": "旧标题",
"old_cover_path": task["old_cover_path"], "old_cover_path": task["old_cover_path"],
@@ -1944,7 +2089,14 @@ class GuiTests(TempDirMixin, unittest.TestCase):
preflight=False, preflight=False,
).execute() ).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() collect.assert_called_once()
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
by_alias = {task.alias: task for task in updated} 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("skipped", by_alias["missing"].status)
self.assertEqual("别名未匹配账号", by_alias["missing"].last_error) 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) self.assert_removed(temp_dir)
def test_collect_tab_auto_starts_write_back_after_collect_success(self): def test_collect_tab_auto_starts_write_back_after_collect_success(self):