feat: 完成T-504更新执行增强
- 新增 dry-run、多账号并行和最大并行账号数设置 - 增加 run_logs/run_log_events 运行日志表及读写接口 - ApplyWorker 支持 dry-run 预览、按账号并行、端口冲突阻断和日志展示 - 补充 DB/GUI 单元测试并同步任务、架构、API、路由和当前状态文档 验证: python -m compileall app main.py tests; python -m unittest discover -s tests
This commit is contained in:
@@ -46,6 +46,9 @@ DEFAULT_CONFIG = {
|
||||
"allow_cover_update": False,
|
||||
"max_items_per_run": 1,
|
||||
"close_success_tab": False,
|
||||
"dry_run": False,
|
||||
"parallel_accounts": False,
|
||||
"max_parallel_accounts": 2,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,15 @@ VALID_ACCOUNT_FIELDS = {
|
||||
"note",
|
||||
"last_login_at",
|
||||
}
|
||||
VALID_RUN_LOG_FIELDS = {
|
||||
"status",
|
||||
"done",
|
||||
"success_count",
|
||||
"skipped_count",
|
||||
"failed_count",
|
||||
"finished_at",
|
||||
"summary_json",
|
||||
}
|
||||
PHASE_ATTEMPT_FIELDS = {
|
||||
"collect": "collect_attempts",
|
||||
"collected": "collect_attempts",
|
||||
@@ -104,6 +113,43 @@ class Task:
|
||||
updated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunLog:
|
||||
id: int
|
||||
run_type: str
|
||||
dry_run: int
|
||||
status: str
|
||||
total: int
|
||||
done: int
|
||||
success_count: int
|
||||
skipped_count: int
|
||||
failed_count: int
|
||||
options_json: Optional[str]
|
||||
summary_json: Optional[str]
|
||||
started_at: str
|
||||
finished_at: Optional[str]
|
||||
|
||||
@property
|
||||
def options(self) -> dict:
|
||||
return json.loads(self.options_json or "{}")
|
||||
|
||||
@property
|
||||
def summary(self) -> dict:
|
||||
return json.loads(self.summary_json or "{}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunLogEvent:
|
||||
id: int
|
||||
run_id: int
|
||||
task_id: Optional[int]
|
||||
alias: Optional[str]
|
||||
item_id: Optional[str]
|
||||
level: str
|
||||
message: str
|
||||
created_at: str
|
||||
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS batches (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -163,6 +209,36 @@ CREATE INDEX IF NOT EXISTS idx_tasks_batch_stage_status
|
||||
ON tasks(batch_id, stage, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_item ON tasks(item_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS run_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
run_type TEXT NOT NULL,
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_count INTEGER NOT NULL DEFAULT 0,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
options_json TEXT,
|
||||
summary_json TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS run_log_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
run_id INTEGER NOT NULL REFERENCES run_logs(id) ON DELETE CASCADE,
|
||||
task_id INTEGER,
|
||||
alias TEXT,
|
||||
item_id TEXT,
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_run_logs_started ON run_logs(started_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_run_log_events_run ON run_log_events(run_id, id);
|
||||
"""
|
||||
|
||||
|
||||
@@ -561,3 +637,126 @@ def set_applied(task_id, committed, error=None, path=None, conn=None) -> None:
|
||||
""",
|
||||
(str(error or "未提交更新"), now, int(task_id)),
|
||||
)
|
||||
|
||||
|
||||
def create_run_log(
|
||||
run_type,
|
||||
dry_run=False,
|
||||
total=0,
|
||||
options=None,
|
||||
path=None,
|
||||
conn=None,
|
||||
) -> int:
|
||||
"""Create a high-level operation log and return its id."""
|
||||
|
||||
now = _now()
|
||||
options_json = json.dumps(
|
||||
appconfig.sanitize_for_log(options or {}),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
cursor = database.execute(
|
||||
"""
|
||||
INSERT INTO run_logs
|
||||
(run_type, dry_run, status, total, options_json, started_at)
|
||||
VALUES (?, ?, 'running', ?, ?, ?)
|
||||
""",
|
||||
(str(run_type), 1 if dry_run else 0, int(total), options_json, now),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def finish_run_log(run_id, path=None, conn=None, **fields) -> None:
|
||||
"""Mark a run log complete/blocked/failed with a sanitized summary."""
|
||||
|
||||
_validate_fields(fields, VALID_RUN_LOG_FIELDS)
|
||||
if "summary_json" in fields and not isinstance(fields["summary_json"], str):
|
||||
fields["summary_json"] = json.dumps(
|
||||
appconfig.sanitize_for_log(fields["summary_json"] or {}),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
if "finished_at" not in fields:
|
||||
fields["finished_at"] = _now()
|
||||
assignments = ", ".join(f"{field} = ?" for field in fields)
|
||||
params = list(fields.values()) + [int(run_id)]
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
f"UPDATE run_logs SET {assignments} WHERE id = ?",
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
def add_run_log_event(
|
||||
run_id,
|
||||
message,
|
||||
task_id=None,
|
||||
alias=None,
|
||||
item_id=None,
|
||||
level="info",
|
||||
path=None,
|
||||
conn=None,
|
||||
) -> int:
|
||||
"""Append one sanitized event line to a run log."""
|
||||
|
||||
sanitized = appconfig.sanitize_for_log(
|
||||
{
|
||||
"message": str(message),
|
||||
"alias": alias,
|
||||
"item_id": item_id,
|
||||
"level": level,
|
||||
}
|
||||
)
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
cursor = database.execute(
|
||||
"""
|
||||
INSERT INTO run_log_events
|
||||
(run_id, task_id, alias, item_id, level, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(run_id),
|
||||
int(task_id) if task_id is not None else None,
|
||||
sanitized.get("alias"),
|
||||
sanitized.get("item_id"),
|
||||
sanitized.get("level") or "info",
|
||||
sanitized.get("message") or "",
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def list_run_logs(limit=50, run_type=None, path=None, conn=None):
|
||||
clauses = []
|
||||
params = []
|
||||
if run_type is not None:
|
||||
clauses.append("run_type = ?")
|
||||
params.append(str(run_type))
|
||||
sql = "SELECT * FROM run_logs"
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY started_at DESC, id DESC LIMIT ?"
|
||||
params.append(max(1, int(limit)))
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_all(database, sql, params, RunLog)
|
||||
|
||||
|
||||
def list_run_log_events(run_id=None, limit=200, path=None, conn=None):
|
||||
clauses = []
|
||||
params = []
|
||||
if run_id is not None:
|
||||
clauses.append("run_id = ?")
|
||||
params.append(int(run_id))
|
||||
sql = "SELECT * FROM run_log_events"
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY id DESC LIMIT ?"
|
||||
params.append(max(1, int(limit)))
|
||||
with _connection(conn, path) as database:
|
||||
events = _fetch_all(database, sql, params, RunLogEvent)
|
||||
return list(reversed(events))
|
||||
|
||||
+420
-93
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
|
||||
@@ -1012,6 +1014,11 @@ if QT_IMPORT_ERROR is None:
|
||||
self.task_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||
self.task_table.verticalHeader().setVisible(False)
|
||||
self.run_log_view = QPlainTextEdit()
|
||||
self.run_log_view.setObjectName("applyRunLogView")
|
||||
self.run_log_view.setReadOnly(True)
|
||||
self.run_log_view.setMaximumHeight(128)
|
||||
self.run_log_view.setPlaceholderText("运行日志")
|
||||
|
||||
self.start_update_button = QPushButton("开始更新")
|
||||
self.stop_update_button = QPushButton("停止")
|
||||
@@ -1031,6 +1038,8 @@ if QT_IMPORT_ERROR is None:
|
||||
layout.addWidget(self.risk_label)
|
||||
layout.addWidget(self.summary_label)
|
||||
layout.addWidget(self.task_table, 1)
|
||||
layout.addWidget(QLabel("运行日志"))
|
||||
layout.addWidget(self.run_log_view)
|
||||
layout.addLayout(action_layout)
|
||||
|
||||
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
|
||||
@@ -1042,6 +1051,7 @@ if QT_IMPORT_ERROR is None:
|
||||
self.write_back_button.clicked.connect(self.write_back_results)
|
||||
|
||||
self.refresh_tasks()
|
||||
self._load_latest_run_log()
|
||||
|
||||
def _set_status(self, message):
|
||||
if self.status_callback is not None:
|
||||
@@ -1090,7 +1100,9 @@ if QT_IMPORT_ERROR is None:
|
||||
if not tasks:
|
||||
self._set_status("当前筛选结果没有可更新任务")
|
||||
return
|
||||
safety_error = self._update_safety_error(tasks)
|
||||
update_cfg = self._shopee_update_config()
|
||||
dry_run = bool(update_cfg.get("dry_run", False))
|
||||
safety_error = self._update_safety_error(tasks, dry_run=dry_run)
|
||||
if safety_error:
|
||||
QMessageBox.warning(self, "更新安全开关", safety_error)
|
||||
self._set_status(safety_error.replace("\n", " "))
|
||||
@@ -1105,16 +1117,21 @@ if QT_IMPORT_ERROR is None:
|
||||
if answer != QMessageBox.Yes:
|
||||
self._set_status("已取消开始更新")
|
||||
return
|
||||
update_cfg = self._shopee_update_config()
|
||||
worker = ApplyWorker(
|
||||
tasks,
|
||||
db_path=self.db_path,
|
||||
config=self.config,
|
||||
close_success_tab=bool(update_cfg.get("close_success_tab", False)),
|
||||
dry_run=dry_run,
|
||||
parallel_accounts=bool(update_cfg.get("parallel_accounts", False)),
|
||||
max_parallel_accounts=max(
|
||||
1,
|
||||
int(update_cfg.get("max_parallel_accounts", 1) or 1),
|
||||
),
|
||||
)
|
||||
worker.progress.connect(self._on_apply_progress)
|
||||
worker.row_updated.connect(self._on_apply_row_updated)
|
||||
worker.log.connect(self._set_status)
|
||||
worker.log.connect(self._on_apply_log)
|
||||
worker.failed.connect(self._on_apply_failed)
|
||||
worker.finished.connect(self._on_apply_finished)
|
||||
worker.cancelled.connect(self._on_apply_cancelled)
|
||||
@@ -1123,7 +1140,11 @@ if QT_IMPORT_ERROR is None:
|
||||
self.apply_worker = worker
|
||||
self.apply_thread = thread
|
||||
self._set_apply_running(True)
|
||||
self._set_status(f"开始更新:{len(tasks)} 条")
|
||||
self.run_log_view.clear()
|
||||
if dry_run:
|
||||
self._set_status(f"开始 dry-run 预览:{len(tasks)} 条")
|
||||
else:
|
||||
self._set_status(f"开始更新:{len(tasks)} 条")
|
||||
thread.start()
|
||||
|
||||
def stop_update(self, checked=False):
|
||||
@@ -1222,6 +1243,12 @@ if QT_IMPORT_ERROR is None:
|
||||
update_cfg = self._shopee_update_config()
|
||||
cover_text = "允许" if update_cfg.get("allow_cover_update") else "不允许"
|
||||
close_text = "是" if update_cfg.get("close_success_tab") else "否"
|
||||
dry_run_text = "开启" if update_cfg.get("dry_run") else "关闭"
|
||||
parallel_text = (
|
||||
f"开启,最多 {update_cfg.get('max_parallel_accounts', 1)} 个账号"
|
||||
if update_cfg.get("parallel_accounts")
|
||||
else "关闭"
|
||||
)
|
||||
return (
|
||||
"即将按当前筛选结果开始更新 Shopee 线上商品。\n\n"
|
||||
f"批次:{self._batch_filter_label()}\n"
|
||||
@@ -1232,12 +1259,20 @@ if QT_IMPORT_ERROR is None:
|
||||
f"测试商品ID={update_cfg.get('test_item_id') or '未配置'},"
|
||||
f"封面更新={cover_text},"
|
||||
f"最大条数={update_cfg.get('max_items_per_run', 1)},"
|
||||
f"成功后关闭新页={close_text}\n\n"
|
||||
"确认后后续执行会打开商品编辑页、替换标题/允许时替换封面,并点击「更新」提交线上。"
|
||||
f"成功后关闭新页={close_text},"
|
||||
f"dry-run={dry_run_text},"
|
||||
f"多账号并行={parallel_text}\n\n"
|
||||
+ (
|
||||
"dry-run 开启时只写运行日志和预览,不打开 Shopee、不点击「更新」、不改任务状态。"
|
||||
if update_cfg.get("dry_run")
|
||||
else "确认后后续执行会打开商品编辑页、替换标题/允许时替换封面,并点击「更新」提交线上。"
|
||||
)
|
||||
)
|
||||
|
||||
def _update_safety_error(self, tasks):
|
||||
def _update_safety_error(self, tasks, dry_run=False):
|
||||
update_cfg = self._shopee_update_config()
|
||||
if dry_run:
|
||||
return None
|
||||
if not update_cfg.get("allow_real_submit", False):
|
||||
return "设置未开启「允许真实提交线上商品」,已阻止本次更新。"
|
||||
max_items = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
|
||||
@@ -1304,6 +1339,10 @@ if QT_IMPORT_ERROR is None:
|
||||
def _on_apply_progress(self, payload):
|
||||
self._set_status("更新进度:" + self._apply_progress_text(payload))
|
||||
|
||||
def _on_apply_log(self, message):
|
||||
self._append_run_log(message)
|
||||
self._set_status(message)
|
||||
|
||||
def _on_apply_row_updated(self, task_id, fields):
|
||||
self.refresh_tasks()
|
||||
|
||||
@@ -1317,9 +1356,10 @@ if QT_IMPORT_ERROR is None:
|
||||
self._show_apply_blocked(payload)
|
||||
return
|
||||
self.last_apply_summary = dict(payload)
|
||||
message = "更新完成:" + self._apply_progress_text(payload)
|
||||
prefix = "dry-run 预览完成:" if payload.get("dry_run") else "更新完成:"
|
||||
message = prefix + self._apply_progress_text(payload)
|
||||
batch_ids = payload.get("batch_ids") or self._active_batch_ids()
|
||||
if payload.get("done", 0) > 0 and batch_ids:
|
||||
if (not payload.get("dry_run")) and payload.get("done", 0) > 0 and batch_ids:
|
||||
if self._start_result_write_back(
|
||||
batch_ids,
|
||||
auto=True,
|
||||
@@ -1336,18 +1376,46 @@ if QT_IMPORT_ERROR is None:
|
||||
self._set_status("更新已停止:" + self._apply_progress_text(payload))
|
||||
|
||||
def _apply_progress_text(self, payload):
|
||||
return "完成{done}/{total},成功{applied},略过{skipped},失败{failed}".format(
|
||||
success_label = "可更新" if payload.get("dry_run") else "成功"
|
||||
return "完成{done}/{total},{success_label}{applied},略过{skipped},失败{failed}".format(
|
||||
done=payload.get("done", 0),
|
||||
total=payload.get("total", 0),
|
||||
success_label=success_label,
|
||||
applied=payload.get("applied", 0),
|
||||
skipped=payload.get("skipped", 0),
|
||||
failed=payload.get("failed", 0),
|
||||
)
|
||||
|
||||
def _append_run_log(self, message):
|
||||
self.run_log_view.appendPlainText(str(message))
|
||||
|
||||
def _load_latest_run_log(self):
|
||||
try:
|
||||
logs = db.list_run_logs(limit=1, run_type="apply", 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 _show_apply_blocked(self, payload):
|
||||
lines = ["更新前检查未通过。"]
|
||||
if payload.get("no_accounts"):
|
||||
lines.append("当前没有配置账号。")
|
||||
duplicate_ports = payload.get("duplicate_ports") or []
|
||||
if duplicate_ports:
|
||||
for item in duplicate_ports:
|
||||
lines.append(
|
||||
"以下账号调试端口冲突:端口 {port} -> {aliases}".format(
|
||||
port=item.get("debug_port"),
|
||||
aliases="、".join(item.get("aliases") or []),
|
||||
)
|
||||
)
|
||||
not_running = payload.get("not_running") or []
|
||||
if not_running:
|
||||
lines.append(
|
||||
@@ -1475,14 +1543,18 @@ if QT_IMPORT_ERROR is None:
|
||||
def _show_apply_summary(self, apply_summary, write_back_payload=None):
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"更新完成",
|
||||
"dry-run 预览完成" if apply_summary.get("dry_run") else "更新完成",
|
||||
self._apply_summary_message(apply_summary, write_back_payload),
|
||||
)
|
||||
|
||||
def _apply_summary_message(self, apply_summary, write_back_payload=None, error=None):
|
||||
dry_run = bool(apply_summary.get("dry_run"))
|
||||
lines = [
|
||||
"更新完成。",
|
||||
"成功:{applied},失败:{failed},略过:{skipped}".format(
|
||||
"dry-run 预览完成,未打开 Shopee、未提交线上、未改任务状态。"
|
||||
if dry_run
|
||||
else "更新完成。",
|
||||
"{success_label}:{applied},失败:{failed},略过:{skipped}".format(
|
||||
success_label="可更新" if dry_run else "成功",
|
||||
applied=apply_summary.get("applied", 0),
|
||||
failed=apply_summary.get("failed", 0),
|
||||
skipped=apply_summary.get("skipped", 0),
|
||||
@@ -2032,7 +2104,7 @@ if QT_IMPORT_ERROR is None:
|
||||
|
||||
|
||||
class ApplyWorker(BaseWorker):
|
||||
"""Apply generated title/cover changes to Shopee one task at a time."""
|
||||
"""Apply generated title/cover changes, optionally previewing or grouping by account."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -2041,6 +2113,9 @@ if QT_IMPORT_ERROR is None:
|
||||
config=None,
|
||||
preflight=True,
|
||||
close_success_tab=False,
|
||||
dry_run=False,
|
||||
parallel_accounts=False,
|
||||
max_parallel_accounts=1,
|
||||
):
|
||||
super().__init__()
|
||||
self.tasks = list(tasks)
|
||||
@@ -2048,6 +2123,11 @@ if QT_IMPORT_ERROR is None:
|
||||
self.config = config
|
||||
self.preflight = preflight
|
||||
self.close_success_tab = close_success_tab
|
||||
self.dry_run = bool(dry_run)
|
||||
self.parallel_accounts = bool(parallel_accounts)
|
||||
self.max_parallel_accounts = max(1, int(max_parallel_accounts or 1))
|
||||
self._progress_lock = threading.Lock()
|
||||
self._run_id = None
|
||||
|
||||
def execute(self):
|
||||
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
|
||||
@@ -2063,90 +2143,62 @@ if QT_IMPORT_ERROR is None:
|
||||
skipped = 0
|
||||
failed = 0
|
||||
done = 0
|
||||
|
||||
if self.preflight:
|
||||
blocked = self._preflight_block(eligible, account_rows, account_by_alias)
|
||||
if blocked:
|
||||
blocked.update(
|
||||
{
|
||||
"ok": False,
|
||||
"blocked": True,
|
||||
"total": total,
|
||||
"done": 0,
|
||||
"applied": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"batch_ids": batch_ids,
|
||||
}
|
||||
)
|
||||
return blocked
|
||||
|
||||
for task in eligible:
|
||||
if self.should_cancel():
|
||||
break
|
||||
account = account_by_alias.get(str(task.alias).strip())
|
||||
if account is None:
|
||||
skipped += 1
|
||||
done += 1
|
||||
reason = "别名未匹配账号"
|
||||
db.mark_skipped(task.id, reason, path=self.db_path)
|
||||
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason})
|
||||
self._emit_progress(done, total, applied, skipped, failed)
|
||||
continue
|
||||
|
||||
try:
|
||||
db.mark_running(task.id, "apply", path=self.db_path)
|
||||
self.row_updated.emit(task.id, {"status": "running", "last_error": None})
|
||||
result = editor.apply_task(
|
||||
account,
|
||||
task,
|
||||
close_success_tab=self.close_success_tab,
|
||||
)
|
||||
committed = bool(result.get("committed")) and not result.get("error")
|
||||
error = result.get("error")
|
||||
if committed:
|
||||
db.set_applied(task.id, True, path=self.db_path)
|
||||
applied += 1
|
||||
self.row_updated.emit(
|
||||
task.id,
|
||||
{
|
||||
"stage": "applied",
|
||||
"status": "success",
|
||||
"committed": 1,
|
||||
"last_error": None,
|
||||
},
|
||||
)
|
||||
else:
|
||||
failed += 1
|
||||
error = error or "更新未提交"
|
||||
db.set_applied(task.id, False, error, path=self.db_path)
|
||||
self.failed.emit(task.id, str(error))
|
||||
self.row_updated.emit(
|
||||
task.id,
|
||||
{"status": "failed", "last_error": str(error), "committed": 0},
|
||||
)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
error = str(exc) or exc.__class__.__name__
|
||||
db.set_applied(task.id, False, error, path=self.db_path)
|
||||
self.failed.emit(task.id, error)
|
||||
self.row_updated.emit(
|
||||
task.id,
|
||||
{"status": "failed", "last_error": error, "committed": 0},
|
||||
)
|
||||
finally:
|
||||
done += 1
|
||||
self._emit_progress(done, total, applied, skipped, failed)
|
||||
|
||||
return {
|
||||
"ok": failed == 0,
|
||||
"total": total,
|
||||
counters = {
|
||||
"done": done,
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"batch_ids": batch_ids,
|
||||
}
|
||||
self._run_id = self._create_run_log(eligible, batch_ids)
|
||||
self._log_run_event(
|
||||
"运行开始:{mode},任务{total},{parallel}".format(
|
||||
mode="dry-run 预览" if self.dry_run else "真实更新",
|
||||
total=total,
|
||||
parallel=(
|
||||
f"多账号并行最多{self.max_parallel_accounts}"
|
||||
if self.parallel_accounts
|
||||
else "串行"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if self.preflight and not self.dry_run:
|
||||
blocked = self._preflight_block(eligible, account_rows, account_by_alias)
|
||||
if blocked:
|
||||
summary = self._summary(
|
||||
ok=False,
|
||||
total=total,
|
||||
counters=counters,
|
||||
batch_ids=batch_ids,
|
||||
blocked=True,
|
||||
extra=blocked,
|
||||
)
|
||||
self._finish_run_log("blocked", summary)
|
||||
return summary
|
||||
|
||||
if self.dry_run:
|
||||
for task in eligible:
|
||||
if self.should_cancel():
|
||||
break
|
||||
outcome = self._preview_task(task, account_by_alias)
|
||||
self._record_outcome(counters, total, outcome)
|
||||
elif self.parallel_accounts and self.max_parallel_accounts > 1:
|
||||
self._run_parallel_by_account(eligible, account_by_alias, counters, total)
|
||||
else:
|
||||
for task in eligible:
|
||||
if self.should_cancel():
|
||||
break
|
||||
outcome = self._apply_one_task(task, account_by_alias)
|
||||
self._record_outcome(counters, total, outcome)
|
||||
|
||||
summary = self._summary(
|
||||
ok=counters["failed"] == 0,
|
||||
total=total,
|
||||
counters=counters,
|
||||
batch_ids=batch_ids,
|
||||
)
|
||||
self._finish_run_log("cancelled" if self.should_cancel() else "done", summary)
|
||||
return summary
|
||||
|
||||
def _is_actionable_task(self, task):
|
||||
return (
|
||||
@@ -2161,6 +2213,12 @@ if QT_IMPORT_ERROR is None:
|
||||
"reason": "NO_ACCOUNTS",
|
||||
"no_accounts": True,
|
||||
}
|
||||
duplicate_ports = self._duplicate_debug_ports(account_rows, eligible, account_by_alias)
|
||||
if duplicate_ports:
|
||||
return {
|
||||
"reason": "DUPLICATE_DEBUG_PORT",
|
||||
"duplicate_ports": duplicate_ports,
|
||||
}
|
||||
required_accounts = []
|
||||
seen_aliases = set()
|
||||
for task in eligible:
|
||||
@@ -2188,6 +2246,183 @@ if QT_IMPORT_ERROR is None:
|
||||
}
|
||||
return None
|
||||
|
||||
def _duplicate_debug_ports(self, account_rows, eligible, account_by_alias):
|
||||
required_aliases = {
|
||||
str(task.alias).strip()
|
||||
for task in eligible
|
||||
if account_by_alias.get(str(task.alias).strip()) is not None
|
||||
}
|
||||
by_port = {}
|
||||
for account in account_rows:
|
||||
if account.alias not in required_aliases:
|
||||
continue
|
||||
by_port.setdefault(int(account.debug_port), []).append(account)
|
||||
duplicates = []
|
||||
for port, rows in by_port.items():
|
||||
if len(rows) > 1:
|
||||
duplicates.append(
|
||||
{
|
||||
"debug_port": port,
|
||||
"aliases": [row.alias for row in rows],
|
||||
}
|
||||
)
|
||||
return duplicates
|
||||
|
||||
def _run_parallel_by_account(self, eligible, account_by_alias, counters, total):
|
||||
groups = self._group_tasks_by_alias(eligible)
|
||||
max_workers = min(self.max_parallel_accounts, len(groups))
|
||||
if max_workers <= 1:
|
||||
for group_tasks in groups:
|
||||
self._run_task_group(group_tasks, account_by_alias, counters, total)
|
||||
return
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
self._run_task_group,
|
||||
group_tasks,
|
||||
account_by_alias,
|
||||
counters,
|
||||
total,
|
||||
)
|
||||
for group_tasks in groups
|
||||
]
|
||||
for future in as_completed(futures):
|
||||
future.result()
|
||||
|
||||
def _group_tasks_by_alias(self, tasks):
|
||||
groups = []
|
||||
index_by_alias = {}
|
||||
for task in tasks:
|
||||
alias = str(task.alias).strip()
|
||||
if alias not in index_by_alias:
|
||||
index_by_alias[alias] = len(groups)
|
||||
groups.append([])
|
||||
groups[index_by_alias[alias]].append(task)
|
||||
return groups
|
||||
|
||||
def _run_task_group(self, tasks, account_by_alias, counters, total):
|
||||
for task in tasks:
|
||||
if self.should_cancel():
|
||||
break
|
||||
outcome = self._apply_one_task(task, account_by_alias)
|
||||
self._record_outcome(counters, total, outcome)
|
||||
|
||||
def _preview_task(self, task, account_by_alias):
|
||||
account = account_by_alias.get(str(task.alias).strip())
|
||||
if account is None:
|
||||
reason = "别名未匹配账号"
|
||||
self._log_run_event(
|
||||
f"dry-run:任务 {task.id} 商品 {task.item_id} 将略过:{reason}",
|
||||
task=task,
|
||||
level="warning",
|
||||
)
|
||||
return "skipped"
|
||||
action_parts = []
|
||||
if getattr(task, "new_title", None):
|
||||
action_parts.append("标题")
|
||||
if getattr(task, "new_cover_path", None):
|
||||
action_parts.append("封面")
|
||||
action_text = "+".join(action_parts) or "无变更"
|
||||
self._log_run_event(
|
||||
"dry-run:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format(
|
||||
task_id=task.id,
|
||||
item_id=task.item_id,
|
||||
alias=account.alias,
|
||||
action=action_text,
|
||||
),
|
||||
task=task,
|
||||
)
|
||||
return "applied"
|
||||
|
||||
def _apply_one_task(self, task, account_by_alias):
|
||||
account = account_by_alias.get(str(task.alias).strip())
|
||||
if account is None:
|
||||
reason = "别名未匹配账号"
|
||||
db.mark_skipped(task.id, reason, path=self.db_path)
|
||||
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason})
|
||||
self._log_run_event(
|
||||
f"任务 {task.id} 商品 {task.item_id} 已略过:{reason}",
|
||||
task=task,
|
||||
level="warning",
|
||||
)
|
||||
return "skipped"
|
||||
|
||||
try:
|
||||
self._log_run_event(
|
||||
f"任务 {task.id} 商品 {task.item_id} 开始更新,账号 {account.alias}",
|
||||
task=task,
|
||||
)
|
||||
db.mark_running(task.id, "apply", path=self.db_path)
|
||||
self.row_updated.emit(task.id, {"status": "running", "last_error": None})
|
||||
result = editor.apply_task(
|
||||
account,
|
||||
task,
|
||||
close_success_tab=self.close_success_tab,
|
||||
)
|
||||
committed = bool(result.get("committed")) and not result.get("error")
|
||||
error = result.get("error")
|
||||
if committed:
|
||||
db.set_applied(task.id, True, path=self.db_path)
|
||||
self.row_updated.emit(
|
||||
task.id,
|
||||
{
|
||||
"stage": "applied",
|
||||
"status": "success",
|
||||
"committed": 1,
|
||||
"last_error": None,
|
||||
},
|
||||
)
|
||||
self._log_run_event(
|
||||
f"任务 {task.id} 商品 {task.item_id} 更新成功",
|
||||
task=task,
|
||||
)
|
||||
return "applied"
|
||||
|
||||
error = error or "更新未提交"
|
||||
db.set_applied(task.id, False, error, path=self.db_path)
|
||||
self.failed.emit(task.id, str(error))
|
||||
self.row_updated.emit(
|
||||
task.id,
|
||||
{"status": "failed", "last_error": str(error), "committed": 0},
|
||||
)
|
||||
self._log_run_event(
|
||||
f"任务 {task.id} 商品 {task.item_id} 更新失败:{error}",
|
||||
task=task,
|
||||
level="error",
|
||||
)
|
||||
return "failed"
|
||||
except Exception as exc:
|
||||
error = str(exc) or exc.__class__.__name__
|
||||
db.set_applied(task.id, False, error, path=self.db_path)
|
||||
self.failed.emit(task.id, error)
|
||||
self.row_updated.emit(
|
||||
task.id,
|
||||
{"status": "failed", "last_error": error, "committed": 0},
|
||||
)
|
||||
self._log_run_event(
|
||||
f"任务 {task.id} 商品 {task.item_id} 更新异常:{error}",
|
||||
task=task,
|
||||
level="error",
|
||||
)
|
||||
return "failed"
|
||||
|
||||
def _record_outcome(self, counters, total, outcome):
|
||||
with self._progress_lock:
|
||||
counters["done"] += 1
|
||||
if outcome == "applied":
|
||||
counters["applied"] += 1
|
||||
elif outcome == "skipped":
|
||||
counters["skipped"] += 1
|
||||
else:
|
||||
counters["failed"] += 1
|
||||
self._emit_progress(
|
||||
counters["done"],
|
||||
total,
|
||||
counters["applied"],
|
||||
counters["skipped"],
|
||||
counters["failed"],
|
||||
)
|
||||
|
||||
def _account_payload(self, account, reason=None):
|
||||
payload = {
|
||||
"account_name": account.account_name,
|
||||
@@ -2206,6 +2441,7 @@ if QT_IMPORT_ERROR is None:
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"dry_run": self.dry_run,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2230,6 +2466,77 @@ if QT_IMPORT_ERROR is None:
|
||||
batch_ids.append(batch_id)
|
||||
return batch_ids
|
||||
|
||||
def _summary(self, ok, total, counters, batch_ids, blocked=False, extra=None):
|
||||
summary = {
|
||||
"ok": ok,
|
||||
"total": total,
|
||||
"done": counters["done"],
|
||||
"applied": counters["applied"],
|
||||
"skipped": counters["skipped"],
|
||||
"failed": counters["failed"],
|
||||
"batch_ids": batch_ids,
|
||||
"dry_run": self.dry_run,
|
||||
"parallel_accounts": self.parallel_accounts,
|
||||
"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(
|
||||
"apply",
|
||||
dry_run=self.dry_run,
|
||||
total=len(eligible),
|
||||
options={
|
||||
"batch_ids": batch_ids,
|
||||
"close_success_tab": self.close_success_tab,
|
||||
"dry_run": self.dry_run,
|
||||
"parallel_accounts": self.parallel_accounts,
|
||||
"max_parallel_accounts": self.max_parallel_accounts,
|
||||
},
|
||||
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("applied", 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"):
|
||||
self.log.emit(str(message))
|
||||
if self._run_id is None:
|
||||
return
|
||||
try:
|
||||
db.add_run_log_event(
|
||||
self._run_id,
|
||||
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
|
||||
|
||||
|
||||
class CollectWorker(BaseWorker):
|
||||
"""Collect old title and cover for imported tasks."""
|
||||
@@ -2641,6 +2948,13 @@ if QT_IMPORT_ERROR is None:
|
||||
self.max_items_per_run_spin.setRange(1, 9999)
|
||||
self.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页")
|
||||
self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox")
|
||||
self.dry_run_checkbox = QCheckBox("dry-run 只预览不提交")
|
||||
self.dry_run_checkbox.setObjectName("dryRunCheckbox")
|
||||
self.parallel_accounts_checkbox = QCheckBox("多账号并行更新")
|
||||
self.parallel_accounts_checkbox.setObjectName("parallelAccountsCheckbox")
|
||||
self.max_parallel_accounts_spin = QSpinBox()
|
||||
self.max_parallel_accounts_spin.setObjectName("maxParallelAccountsSpin")
|
||||
self.max_parallel_accounts_spin.setRange(1, 16)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow("", self.enabled_checkbox)
|
||||
@@ -2687,6 +3001,9 @@ if QT_IMPORT_ERROR is None:
|
||||
update_form.addRow("", self.allow_cover_update_checkbox)
|
||||
update_form.addRow("单次最大更新条数", self.max_items_per_run_spin)
|
||||
update_form.addRow("", self.close_success_tab_checkbox)
|
||||
update_form.addRow("", self.dry_run_checkbox)
|
||||
update_form.addRow("", self.parallel_accounts_checkbox)
|
||||
update_form.addRow("最大并行账号数", self.max_parallel_accounts_spin)
|
||||
|
||||
right_panel = QWidget()
|
||||
right_layout = QVBoxLayout(right_panel)
|
||||
@@ -2926,6 +3243,9 @@ if QT_IMPORT_ERROR is None:
|
||||
"allow_cover_update": self.allow_cover_update_checkbox.isChecked(),
|
||||
"max_items_per_run": self.max_items_per_run_spin.value(),
|
||||
"close_success_tab": self.close_success_tab_checkbox.isChecked(),
|
||||
"dry_run": self.dry_run_checkbox.isChecked(),
|
||||
"parallel_accounts": self.parallel_accounts_checkbox.isChecked(),
|
||||
"max_parallel_accounts": self.max_parallel_accounts_spin.value(),
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -2991,6 +3311,13 @@ if QT_IMPORT_ERROR is None:
|
||||
self.close_success_tab_checkbox.setChecked(
|
||||
bool(update_cfg.get("close_success_tab", False))
|
||||
)
|
||||
self.dry_run_checkbox.setChecked(bool(update_cfg.get("dry_run", False)))
|
||||
self.parallel_accounts_checkbox.setChecked(
|
||||
bool(update_cfg.get("parallel_accounts", False))
|
||||
)
|
||||
self.max_parallel_accounts_spin.setValue(
|
||||
max(1, int(update_cfg.get("max_parallel_accounts", 2) or 2))
|
||||
)
|
||||
self._update_response_timeout_label()
|
||||
|
||||
def _shopee_update_config(self):
|
||||
|
||||
@@ -67,7 +67,7 @@ cmshopee 是一个 Windows 本地桌面自动化工具(PySide6,5 Tab),
|
||||
|
||||
- 5 Tab 流水线:① 导入采集 → ② AI生成 → ③ 更新shopee → ④ 账号管理 → ⑤ 设置。
|
||||
- GUI 固定为 PySide6;后台采集/生成/更新用 `QObject` worker + `QThread` + signal 回传进度。
|
||||
- 多账号管理,但执行更新仍串行;账号以独立 user-data-dir 隔离。
|
||||
- 多账号管理;账号以独立 user-data-dir 隔离。③ 更新默认串行,⑤ 可开启 dry-run 预览或按账号并行。
|
||||
- Excel 导入/回写 + SQLite 实时落库 + 本地图片目录。
|
||||
- AI 生成标题/封面,不设逐条确认阶段。
|
||||
- ③ 点击「开始更新」后弹窗确认当前筛选范围和任务数量;用户确认后才批量提交线上。
|
||||
@@ -76,7 +76,7 @@ cmshopee 是一个 Windows 本地桌面自动化工具(PySide6,5 Tab),
|
||||
|
||||
- 自动登录 / 自动填账号密码。
|
||||
- 绕过验证码、风控、限流。
|
||||
- 多账号并行、dry-run、完整运行日志(属 V2 及之后)。
|
||||
- 规则模板等 V2 后续能力。
|
||||
- 商品数据批量爬取。
|
||||
|
||||
## 事实来源
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
| 提示词管理(②) | 标题提示词「保存」到 `title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 |
|
||||
| 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无逐条审核阶段);可选对单行重生成 | P0 |
|
||||
| 更新 shopee(③) | 按批次/店铺/状态筛选;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务打开编辑页换标题+封面,并逐条点「更新」提交线上;可按状态=失败重试 | P0 |
|
||||
| dry-run / 运行日志 / 多账号并行(③/⑤) | dry-run 只预览不打开 Shopee、不提交、不改任务状态;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入 |
|
||||
| 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 |
|
||||
| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,返回超时随分辨率自动);并发/重试/jpg质量;图片目录/Chrome 路径/端口 | P0 |
|
||||
| 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④ | P0 |
|
||||
@@ -54,8 +55,6 @@
|
||||
|
||||
| 功能 | 描述 | 阶段 |
|
||||
| --- | --- | --- |
|
||||
| 多账号并行 | 多个账号各自端口同时运行 | V2 |
|
||||
| 运行日志 / dry-run | 操作留痕、可先空跑预览 | V2 |
|
||||
| 标题规则模板 | 预设多种改标题规则(前缀、替换、截断等) | V3 |
|
||||
|
||||
## 四、核心用户故事(V1)
|
||||
@@ -88,14 +87,14 @@
|
||||
| 存储 | 应用设置用 `config.json`;账号/任务/结果用 SQLite;Excel 读写用 openpyxl |
|
||||
| 账号↔任务绑定 | 以 Excel“别名”列为权威(非文件名);匹配不到的略过并最后弹窗汇总 |
|
||||
| 是否提交更新 | ③ 点击「开始更新」后弹窗确认;确认后对当前筛选结果逐条点「更新」提交线上;无常驻提交开关、无逐条人工审核 |
|
||||
| 执行方式 | 多账号串行、逐任务执行,单条失败继续 |
|
||||
| 执行方式 | 默认多账号串行;⑤ 可开启多账号并行,不同账号可同时跑、同账号内仍串行;单条失败继续 |
|
||||
| 旧标题/旧封面 | 程序在「采集」阶段改前抓取的快照(输出列),运营不填 |
|
||||
| 新标题/新封面 | AI 生成(输出列),直接用于 ③;不设逐条确认阶段,本地留档+回写 Excel 供追溯 |
|
||||
| AI 服务 | 文本+图像生成,服务商/模型待定;Key 本地明文存于 `config/ai_models.json`,首次保存/变更时提示,UI 打码、不入日志;见 [技术栈](03-tech-stack.md) |
|
||||
| 本地图片 | 旧封面下载、新封面生成存本地图片目录,路径记 DB |
|
||||
| 结果落库时机 | 各阶段处理完立即写 SQLite;该 Excel 全部完成后批量回写原文件 |
|
||||
| 原文件被占用 | 回写时若原 Excel 被锁定,提示关闭重试或另存副本(SQLite 为事实来源) |
|
||||
| 暂不支持 | 自动登录、多账号并行(V2)、爬取 |
|
||||
| 暂不支持 | 自动登录、爬取 |
|
||||
|
||||
## 七、待确认 / 风险点
|
||||
|
||||
@@ -107,6 +106,8 @@
|
||||
- **AI Key 安全**:Key 本地明文存于 `config/ai_models.json`,保存/变更时提示,UI 打码,不写日志,不提交版本库。
|
||||
- **第三方平台风险**:Shopee 页面结构、class 名、接口随时可能变;限流、风控、封号风险存在,禁止高频批量。
|
||||
- **自动化边界风险**:③ 确认后会自动改标题、上传图片、拖拽并点「更新」提交线上;点「开始更新」并确认前需自行确保筛选范围、任务来源与 AI 产出可接受。
|
||||
- **dry-run 边界**:⑤ 开启 dry-run 后,③ 只预览当前筛选任务并写运行日志,不打开 Shopee、不点击更新、不改任务状态;关闭 dry-run 后才可能真实提交。
|
||||
- **多账号并行风险**:⑤ 开启多账号并行后,不同账号可同时执行更新;同一账号内仍串行。执行前检查本轮账号调试端口,端口冲突时阻断真实更新。
|
||||
- **合规风险**:仅在自有/授权账号上操作;遵守 Shopee 卖家条款;不绕过任何平台限制。
|
||||
- **GUI 选型**:V1 固定为 PySide6;后台任务通过 QThread/signal 回传进度,见 [技术栈](03-tech-stack.md) 与 [架构](04-architecture.md)。
|
||||
- **多账号并行待确认**:V1 多账号串行;并行的端口分配与资源占用在 V2 评估。
|
||||
- **多账号并行默认关闭**:默认仍串行;只在⑤明确开启后并行,建议先 dry-run 预览并确认账号 Chrome 均已登录。
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
| AI 模型注册 | `config/ai_models.json` 多模型清单(HTTP 调用) | 已定(结构) | 每模型 name/category(text/image)/url/model/key/api_type/连接超时;⑤ 设置可增删改+测试连接;Key 本地明文保存,保存/变更时提示 |
|
||||
| AI 文本生成 | `app/ai.py` 读取 `default_text_model`(category=text),通用 chat JSON HTTP | 已接入 | 提示词+旧标题→新标题;失败重试,错误脱敏 |
|
||||
| AI 图像生成 | `app/ai.py` 读取 `default_image_model`(category=image),支持 chat 多模态 JSON / images_edits multipart | 已接入 | 提示词+旧封面→新封面;分辨率 512/1k/2k/4k,jpg_quality 存盘,返回超时随分辨率 |
|
||||
| 并发 | 标准库 `concurrent.futures.ThreadPoolExecutor` | 已定 | 标题/图片分别按并发数并行;可停止、可重试 |
|
||||
| 并发 | 标准库 `concurrent.futures.ThreadPoolExecutor` | 已定 | 标题/图片分别按并发数并行;③ 可按账号并行更新;可停止、可重试 |
|
||||
| 运行日志 | SQLite `run_logs` / `run_log_events` | 已定 | ③ dry-run 与真实更新都留痕;结构化内容走脱敏 |
|
||||
| 图片处理 | `requests`(下载)+ `Pillow`(按分辨率/jpg质量存盘) | 部分待定 | 下载旧封面;新封面按 resolution 生成、jpg_quality 存盘 |
|
||||
| 测试 | `python -m compileall app main.py` + `unittest` + 手动 CDP/AI 验证 | 已定(分层) | 配置/DB/Excel/prompts 用单测;CDP/Shopee 与真实 AI 属集成验证或 mock |
|
||||
|
||||
@@ -35,6 +36,7 @@
|
||||
- **AI 服务商不写死在代码里**:T-301 已采用 `config/ai_models.json` 的通用 HTTP 接入,当前支持 OpenAI-compatible chat JSON 与 images_edits multipart;具体服务商/模型/Key 由⑤设置维护。
|
||||
- **敏感信息不加密但强提示与脱敏**:密码与 AI Key 只在本机 SQLite / `config/ai_models.json` 明文保存;保存/变更时弹窗提示,UI 打码,日志/导出必须脱敏,相关本地文件必须 gitignore。
|
||||
- **AI 产出无逐条审核**:生成的新标题/新封面经 ③ 批量确认后提交线上;无常驻提交开关,本地留档 + 回写 Excel 供追溯。
|
||||
- **T-504 更新执行增强**:③ 支持 dry-run 预览、运行日志和按账号并行;默认 dry-run 关闭、并行关闭,不引入新依赖。
|
||||
- **不引入数据库(指外部 DB)**:用 stdlib SQLite 足够;不引入 Postgres/MySQL 等。
|
||||
- **生产在 Windows 直跑**:开发期我们用过 WSL→Windows 的 `netsh portproxy`(9333→9222)连 CDP;但 GUI 与 Chrome 都在 Windows 时,直接连 `127.0.0.1:9222`,无需 portproxy。
|
||||
|
||||
|
||||
+43
-4
@@ -114,7 +114,10 @@ imported → collected → generated → applied
|
||||
"allow_real_submit": false,
|
||||
"allow_cover_update": false,
|
||||
"max_items_per_run": 1,
|
||||
"close_success_tab": false
|
||||
"close_success_tab": false,
|
||||
"dry_run": false,
|
||||
"parallel_accounts": false,
|
||||
"max_parallel_accounts": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -134,8 +137,11 @@ imported → collected → generated → applied
|
||||
- `allow_cover_update`:是否允许本次更新包含新封面路径;默认 `false`,未开启时只允许标题更新任务。
|
||||
- `max_items_per_run`:单次允许更新的最大任务数;默认 `1`,当前筛选结果超过即阻断。
|
||||
- `close_success_tab`:成功提交后是否关闭本轮程序自动新开的商品编辑页;默认 `false`。只关闭 `open_product()` 本轮新建且已提交成功的 tab,失败任务和用户原本打开的 tab 保留。
|
||||
- `dry_run`:只预览当前筛选任务,写运行日志,不打开 Shopee、不点击「更新」、不改任务状态;默认 `false`。
|
||||
- `parallel_accounts`:是否按账号并行执行③真实更新;默认 `false`,即保持串行。
|
||||
- `max_parallel_accounts`:最多同时执行的账号数;同一账号内仍按任务串行,默认 `2`。
|
||||
|
||||
该段不是 V2 dry-run,也不是替代 ③ 确认弹窗的常驻授权;③ 仍必须先通过安全开关检查,再弹窗确认,用户点是后才真实提交。
|
||||
该段不是替代 ③ 确认弹窗的常驻授权;③ 仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时仍必须先通过真实更新安全开关检查。
|
||||
|
||||
### 5.1b AI 模型清单 `config/ai_models.json`
|
||||
|
||||
@@ -239,6 +245,37 @@ CREATE INDEX idx_tasks_alias ON tasks(alias);
|
||||
CREATE INDEX idx_tasks_item ON tasks(item_id);
|
||||
```
|
||||
|
||||
运行日志(T-504):
|
||||
|
||||
```sql
|
||||
CREATE TABLE run_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
run_type TEXT NOT NULL, -- apply 等
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'running', -- running/done/blocked/cancelled
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_count INTEGER NOT NULL DEFAULT 0,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
options_json TEXT,
|
||||
summary_json TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE run_log_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
run_id INTEGER NOT NULL REFERENCES run_logs(id) ON DELETE CASCADE,
|
||||
task_id INTEGER,
|
||||
alias TEXT,
|
||||
item_id TEXT,
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
关键事实:
|
||||
|
||||
- `alias` 是账号↔任务**唯一关联键**;找不到账号 → stage=skipped,error=别名未匹配,最后弹窗汇总。
|
||||
@@ -333,11 +370,13 @@ images/<slug>/<item_id>_new.<ext> # AI 生成的新封面
|
||||
### 6.3 应用更新(③ Tab)
|
||||
|
||||
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
|
||||
- 弹确认前先读取 `config.json` 的 `shopee_update`:未开启 `allow_real_submit`、任务数超过 `max_items_per_run`、包含非 `test_item_id` 商品、或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建 `ApplyWorker`。
|
||||
- 弹确认前先读取 `config.json` 的 `shopee_update`:`dry_run=false` 且未开启 `allow_real_submit`、任务数超过 `max_items_per_run`、包含非 `test_item_id` 商品、或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建 `ApplyWorker`。`dry_run=true` 时只预览,不受真实提交开关限制。
|
||||
- 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
|
||||
- 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。
|
||||
- 满 9 张封面时,T-502 的删除流程必须先确认该任务已有本地旧封面备份:`old_cover_path` 非空且文件存在。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。
|
||||
- 串行、单条失败继续;每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。
|
||||
- 默认串行、单条失败继续;⑤ 开启 `parallel_accounts` 后按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。
|
||||
- `dry_run=true` 时只写 `run_logs/run_log_events` 和弹窗/状态栏预览,不调用 `editor.apply_task()`,不做账号登录预检,不写任务状态,不回写 Excel。
|
||||
- 真实更新每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。真实更新与 dry-run 都写运行日志,日志 payload 走脱敏工具。
|
||||
- 若 `close_success_tab=true`,且商品页是本轮程序自动新建、并已成功提交,则提交后关闭该商品编辑页;失败任务和复用的用户已有 tab 不关闭。
|
||||
|
||||
### 6.4 登录检测
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
## 3. 范围纪律
|
||||
|
||||
- V1 只做 `02-requirements.md` 中列为 P0 的当前目标功能(5 Tab、账号管理、Excel 导入采集、AI 生成、批量确认后更新 Shopee、结果回写)。
|
||||
- V2 / V3 功能(多账号并行、dry-run、完整运行日志、规则模板)只记录,不实现。
|
||||
- 除 T-504 已接入的多账号并行、dry-run、运行日志外,其他 V2 / V3 功能(规则模板等)只记录,不实现。
|
||||
- 需求明确排除的非目标(自动登录、绕风控、商品数据批量爬取、外部数据库)不得实现。
|
||||
|
||||
## 4. 架构纪律
|
||||
@@ -90,7 +90,7 @@ python prototypes/demo.py # 单账号闭环验证(不提交)
|
||||
- 写日志、状态 payload、导出调试信息前,结构化数据先过 `appconfig.sanitize_for_log()`;自由文本只有在掌握明文值时才用 `appconfig.redact_secrets()` 替换,不要把原始密码/API Key 拼进异常或状态栏。
|
||||
- 涉及 Shopee 时,遵守 `04-architecture.md` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。
|
||||
- 高风险动作(删满 9 张的封面、点击更新)必须有显式确认,并先在测试商品验证;删满 9 张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。
|
||||
- V1 无常驻提交开关;③ 的批量确认弹窗是提交线上前的确认边界。dry-run 属 V2。
|
||||
- ③ 的批量确认弹窗是提交线上前的确认边界;T-504 的 dry-run 只预览不提交、不改任务状态。真实更新即使开启多账号并行,也必须经过③确认和⑤安全设置。
|
||||
|
||||
## 9. 拿不准就问
|
||||
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@
|
||||
| T-501c | Tab⑤ 设置 · Shopee 更新安全开关 | T-501b | 配置测试商品 ID、是否允许真实提交、是否允许更新封面、单次最大更新条数、成功后是否自动关闭本次新开编辑 tab;默认关闭真实提交和封面更新;Tab③ 执行前读取并拦截不符合安全配置的更新 | 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-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | TODO |
|
||||
| T-504 | 多账号并行 / dry-run / 运行日志(V2) | T-402 | 端口不冲突;dry-run 只预览;操作留痕 | DONE |
|
||||
|
||||
## 里程碑
|
||||
|
||||
|
||||
+19
-10
@@ -32,7 +32,7 @@ ai_config(config=None) -> dict # default_text_model/default_image
|
||||
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
|
||||
```
|
||||
|
||||
`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:测试商品 ID、是否允许真实提交、是否允许封面更新、单次最大更新条数、成功后是否关闭本轮新开编辑页。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`。
|
||||
`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:测试商品 ID、是否允许真实提交、是否允许封面更新、单次最大更新条数、成功后是否关闭本轮新开编辑页、dry-run、多账号并行、最大并行账号数。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`。
|
||||
|
||||
敏感信息展示/日志辅助:
|
||||
|
||||
@@ -65,6 +65,7 @@ SQLite 读写,表见 [架构 5.2](04-architecture.md)。
|
||||
```python
|
||||
class DbError(RuntimeError): ...
|
||||
Batch / Account / Task # dataclass,字段同 SQLite schema
|
||||
RunLog / RunLogEvent # dataclass,运行日志与逐条事件
|
||||
connect(path=None) -> sqlite3.Connection # 设置 foreign_keys/WAL/busy_timeout/synchronous/row_factory
|
||||
init_db(path=None)
|
||||
create_batch(file_paths, note=None, path=None) -> str
|
||||
@@ -86,6 +87,12 @@ mark_skipped(task_id, reason) -> None # status=skipped,stage 不前
|
||||
set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success,collect_attempts+1
|
||||
set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1
|
||||
set_applied(task_id, committed, error=None) -> None # 成功 stage=applied;失败 status=failed 且 stage 不前进
|
||||
# 运行日志
|
||||
create_run_log(run_type, dry_run=False, total=0, options=None) -> int
|
||||
finish_run_log(run_id, **fields) -> None # status/done/success_count/skipped_count/failed_count/summary_json
|
||||
add_run_log_event(run_id, message, task_id=None, alias=None, item_id=None, level="info") -> int
|
||||
list_run_logs(limit=50, run_type=None) -> list[RunLog]
|
||||
list_run_log_events(run_id=None, limit=200) -> list[RunLogEvent]
|
||||
```
|
||||
|
||||
`stage` 表示最后成功业务阶段(imported→collected→generated→applied);`status` 表示当前处理结果(pending/running/success/failed/skipped/cancelled)。任意步失败写 `last_error` 且 `status=failed`,`stage` 不前进。无 confirmed 阶段。
|
||||
@@ -309,11 +316,11 @@ main() -> int # 创建 QApplication + MainWindow
|
||||
class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试
|
||||
class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
|
||||
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览
|
||||
class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 安全开关拦截 + 确认后串行执行更新
|
||||
class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 安全开关拦截 + 确认后 dry-run/真实更新 + 运行日志
|
||||
class SettingsTab(QWidget) # ⑤ 设置:AI 模型管理 + 角色/生成参数/路径端口 + Shopee 更新安全
|
||||
class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed
|
||||
class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/mark_failed + 进度
|
||||
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> editor.apply_task(close_success_tab=...) -> db.set_applied/mark_skipped
|
||||
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> dry-run 预览或 editor.apply_task(close_success_tab=...) -> db.set_applied/mark_skipped
|
||||
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
|
||||
class AIModelTestWorker(BaseWorker) # ⑤ 后台测试 AI 模型连接:appconfig.test_ai_model
|
||||
class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
|
||||
@@ -325,7 +332,7 @@ TAB_TITLES: list[str] # 固定 Tab 顺序
|
||||
TAB_STYLE: str # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态
|
||||
```
|
||||
|
||||
`MainWindow` 已实现五 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新shopee筛选列表与确认后串行更新、④ 账号管理、⑤ AI 模型管理。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
|
||||
`MainWindow` 已实现五 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新shopee筛选列表与确认后 dry-run/真实更新、④ 账号管理、⑤ AI 模型管理。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
|
||||
|
||||
主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:5 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。
|
||||
|
||||
@@ -346,7 +353,7 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式:
|
||||
- 角色与生成参数读写 `config.json`:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。
|
||||
- 分辨率下拉固定 `512/1k/2k/4k`;返回超时标签只读展示 `resolution_timeouts[resolution]`。
|
||||
- 路径与端口读写 `config.json`:Chrome 路径、账号数据根目录、图片目录、DB 路径、默认调试端口、调试端口范围、CDP 就绪超时。保存时校验端口范围和默认端口。
|
||||
- Shopee 更新安全读写 `config.json` 的 `shopee_update` 段:测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页。
|
||||
- Shopee 更新安全读写 `config.json` 的 `shopee_update` 段:测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页、dry-run、多账号并行、最大并行账号数。
|
||||
- 真实提交与封面更新默认关闭;用户在 ⑤ 保存开启后,③ 仍需要通过安全开关检查并弹窗确认,才会创建更新 worker。
|
||||
|
||||
① 导入采集当前要点(T-202/T-202b):
|
||||
@@ -383,11 +390,13 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式:
|
||||
- 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。
|
||||
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
|
||||
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库。
|
||||
- 点击「开始更新」先读取 `shopee_update`:未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,弹警告并阻断;通过后才弹窗展示批次/店铺/状态/任务数、提交线上风险和当前安全设置。
|
||||
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 串行执行更新。
|
||||
- 点击「开始更新」先读取 `shopee_update`:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,弹警告并阻断;通过后才弹窗展示批次/店铺/状态/任务数、提交线上风险和当前安全设置。
|
||||
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker`。dry-run 开启时只预览;dry-run 关闭时才可能真实提交。
|
||||
- `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交。
|
||||
- 更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态。
|
||||
- 预检通过后逐条 `db.mark_running(..., "apply")` → `editor.apply_task(account, task, close_success_tab=设置值)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
|
||||
- dry-run:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
|
||||
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态。
|
||||
- 预检通过后默认串行;若 `parallel_accounts=true` 且 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task, close_success_tab=设置值)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
|
||||
- dry-run 和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;③ 页面显示最近运行日志。
|
||||
- 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;失败和复用的用户已有 tab 不关闭。
|
||||
- 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。
|
||||
- ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。
|
||||
@@ -426,7 +435,7 @@ run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread
|
||||
- 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。
|
||||
- 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。
|
||||
- ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。
|
||||
- `ApplyWorker` 串行调用 `editor.apply_task(..., close_success_tab=...)`,逐条 `set_applied()`,失败继续;账号未就绪时整体阻断并引导④,不进入逐条提交。
|
||||
- `ApplyWorker` 支持 dry-run、默认串行和按账号并行;真实更新调用 `editor.apply_task(..., close_success_tab=...)`,逐条 `set_applied()`,失败继续;账号未就绪或端口冲突时整体阻断并引导④,不进入逐条提交。
|
||||
- `WriteBackWorker` 默认 `mode="old"` 回写旧字段;③ 使用 `mode="results"` 回写新标题/新封面/更新状态,支持单批次或多批次列表。
|
||||
- `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期: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 敏感信息本地明文保存提示与日志脱敏。
|
||||
- 阶段: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/运行日志。
|
||||
- 技术栈: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、批次/账号/任务与阶段写库函数;`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` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型 master-detail 管理、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置与 Shopee 更新安全设置并持久化 `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` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置和 Shopee 更新安全设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。
|
||||
- 生产代码:已建立 `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 模型 master-detail 管理、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全和 dry-run/多账号并行设置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。
|
||||
- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、满 9 张封面备份缺失阻断/删除确认/上传拖首位 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、Shopee 更新安全拦截、`ApplyWorker` 串行/dry-run/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、dry-run/多账号并行设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。
|
||||
- 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。
|
||||
|
||||
## 既定设计要点(文档已定)
|
||||
@@ -19,7 +19,7 @@
|
||||
- 存储:`config.json`(应用设置)+ `config/ai_models.json`(AI 模型清单与本地明文 Key)+ SQLite `cmshopee.db`(账号/任务/各阶段结果,密码本地明文仅参考)+ openpyxl(Excel)+ 本地 `images/`(旧/新封面);密码/API Key 保存或变更时提示,展示和日志/导出必须脱敏。
|
||||
- 多账号隔离:每账号独立 user-data-dir(非 profile)。
|
||||
- 账号↔任务绑定:以 Excel“别名”列为权威;未匹配略过,结束弹窗汇总。
|
||||
- 执行:多账号串行、单条失败继续;③ 点击「开始更新」后先通过 ⑤ Shopee 更新安全开关(真实提交、封面更新、测试商品 ID、最大条数),再弹窗确认当前筛选范围和任务数量,确认后逐条点「更新」提交线上。
|
||||
- 执行:默认多账号串行、单条失败继续;⑤ 可开启 dry-run 只预览不提交、不改任务状态,可开启多账号并行(不同账号并行、同账号内串行)。③ 点击「开始更新」后先按模式检查 ⑤ 设置,再弹窗确认当前筛选范围和任务数量;真实更新确认后逐条点「更新」提交线上。
|
||||
- AI:服务商/模型/Key 由 `config/ai_models.json` 配置;`app/ai.py` 支持 chat JSON 与 images_edits multipart;生成内容直接用于更新,本地留档+回写 Excel 供追溯。
|
||||
- 登录:人工登录 + 程序检测,不自动登录;无 Shopee tab 时检测入口为 `https://<region_host>/`(默认 `https://seller.shopee.tw/`);首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 应禁用或执行前预检提示,并引导去④。
|
||||
|
||||
@@ -32,17 +32,17 @@
|
||||
| `prototypes/` | 已有 | 已验证原型/探查脚本(demo/set_title/set_cover/get_title/cookies/inspect_images/grab/1.py),保留作人工回归与探查参考;见 `prototypes/README.md` |
|
||||
| `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 |
|
||||
| `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 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、Shopee 更新安全拦截、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `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-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/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 |
|
||||
| `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 |
|
||||
| `app/editor.py` | 已有 | T-001/T-103/T-205b/T-501c/T-502 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、满 9 张先校验旧封面备份再删第一张并上传、更新按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 |
|
||||
| `app/appconfig.py` | 已有 | T-002/T-501c/T-503 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全默认值;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 |
|
||||
| `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/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 |
|
||||
| `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;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/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 产出: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-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 |
|
||||
| `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 |
|
||||
| `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 |
|
||||
| `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库;密码/API Key 保存或变更时提示,展示/日志/导出脱敏 |
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
任务状态以 [`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-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(满 9 张封面删除再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)。
|
||||
- 下一个可领取任务:**T-404(真实 Shopee 单条更新冒烟验收)**。仅用测试商品,默认先只测标题更新,必须经过 ⑤ 安全开关和 ③ 二次确认。
|
||||
|
||||
## 当前已知限制
|
||||
|
||||
+9
-4
@@ -95,6 +95,7 @@
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ 运行日志:dry-run/真实更新逐条留痕 │
|
||||
│ [▶ 开始更新] [■停止] [回写结果到 Excel] │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -102,11 +103,13 @@
|
||||
- 顶部**按批次 / 店铺 / 状态筛选**(与 ①②一致);「开始更新」作用于**当前筛选结果**,是一道范围控制。
|
||||
- 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。
|
||||
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
|
||||
- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断。
|
||||
- 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断;dry-run 开启时只预览,不进入 Shopee。
|
||||
- 安全开关通过后,弹窗展示本次筛选条件、任务数量、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
|
||||
- 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点「更新」提交。
|
||||
- 满 9 张封面时,删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。
|
||||
- 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务。
|
||||
- 默认串行、单条失败继续;⑤ 可开启多账号并行,不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。
|
||||
- dry-run 只写运行日志与预览汇总,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel。
|
||||
- 真实更新每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务;dry-run 和真实更新都会写 `run_logs/run_log_events`。
|
||||
- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。
|
||||
- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;失败任务和用户原本打开的 tab 不关闭。
|
||||
- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。
|
||||
@@ -139,6 +142,8 @@
|
||||
- Shopee 更新安全(T-501c 已接入):测试商品 ID、允许真实提交、允许更新封面、单次最大更新条数、成功后关闭本次新开编辑页。
|
||||
- 默认关闭真实提交和封面更新,单次最大更新条数默认 1。
|
||||
- ③ 点击「开始更新」会读取这些设置,先拦截不符合条件的更新,再弹确认框。
|
||||
- 更新执行模式(T-504 已接入):dry-run 只预览不提交、多账号并行更新、最大并行账号数。
|
||||
- 默认 dry-run 关闭、多账号并行关闭;开启多账号并行后同一账号内仍串行。
|
||||
|
||||
## 流程导航
|
||||
|
||||
@@ -165,12 +170,12 @@
|
||||
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
|
||||
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
|
||||
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度已接入 `GenerateWorker` |
|
||||
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + Shopee 更新安全拦截 + 开始更新确认 + 确认后串行更新 + 结果回写与结束汇总 |
|
||||
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + Shopee 更新安全拦截 + 开始更新确认 + dry-run/真实更新运行日志 + 结果回写与结束汇总 |
|
||||
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式 |
|
||||
| `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理 + 角色/生成参数/路径/端口配置 + Shopee 更新安全 |
|
||||
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
|
||||
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
|
||||
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、串行调用 `editor.apply_task(..., close_success_tab=...)`、逐条 `set_applied()`,失败继续 |
|
||||
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、dry-run 预览、按账号并行或串行调用 `editor.apply_task(..., close_success_tab=...)`、逐条 `set_applied()`,失败继续,写运行日志 |
|
||||
| `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
|
||||
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<text class="ds" x="14" y="72">打开编辑页</text>
|
||||
<text class="ds" x="14" y="90">换标题 + 换封面</text>
|
||||
<text class="ds" x="14" y="108">开始更新确认后提交</text>
|
||||
<text class="st" x="14" y="134">串行 · 失败继续</text>
|
||||
<text class="st" x="14" y="134">dry-run/串行/并行</text>
|
||||
<text class="ds" x="14" y="154">实时写库 + 回写Excel</text>
|
||||
<text class="ds" x="14" y="190" fill="#20a464">→ applied</text>
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
@@ -60,7 +60,7 @@
|
||||
<circle cx="696" cy="149" r="4" fill="#c2473d"/><text class="cell" x="706" y="153">失败</text>
|
||||
<text class="cell" x="800" y="153" fill="#c2473d">更新按钮禁用</text>
|
||||
</g>
|
||||
<text class="mut" x="14" y="234">串行执行 · 单条失败继续 · 每条立即写回 SQLite</text>
|
||||
<text class="mut" x="14" y="234">dry-run/串行/按账号并行 · 单条失败继续 · 写运行日志</text>
|
||||
</g>
|
||||
|
||||
<!-- actions -->
|
||||
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
+11
@@ -642,3 +642,14 @@
|
||||
- 测试:`tests/test_appconfig.py` 覆盖结构化日志脱敏;`tests/test_gui.py` 覆盖保存 API Key 提示、账号密码保存提示、表格/状态不泄露明文、AI 测试 worker payload 脱敏。
|
||||
- 文档:`docs/06-tasks.md` 将 T-503 标为 DONE;同步 `docs/02-requirements.md`、`docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/05-coding-rules.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`。
|
||||
- 验证:`python -m unittest discover -s tests -p test_appconfig.py` 通过(5 tests);`python -m unittest discover -s tests -p test_gui.py` 通过(44 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(107 tests)。
|
||||
|
||||
## 【2026-06-29】T-504 多账号并行 / dry-run / 运行日志
|
||||
|
||||
- 状态:DONE
|
||||
- 配置:`app/appconfig.py` 的 `shopee_update` 新增 `dry_run`、`parallel_accounts`、`max_parallel_accounts`;⑤ 设置页可配置并持久化,默认 dry-run 关闭、多账号并行关闭。
|
||||
- 数据:`app/db.py` 新增 `run_logs` / `run_log_events` schema 与创建、结束、逐条事件、查询函数;日志 payload 统一走脱敏工具。
|
||||
- 执行:③ `ApplyWorker` 支持 dry-run 预览、默认串行真实更新、按账号并行真实更新;dry-run 不打开 Shopee、不调用 `editor.apply_task()`、不改任务状态、不回写 Excel,只写运行日志和汇总;真实更新前检查账号就绪和本轮账号调试端口冲突,不同账号可并行,同一账号内仍串行。
|
||||
- UI:③ 增加运行日志视图,确认弹窗展示 dry-run 与多账号并行状态;dry-run 完成只弹预览汇总,不触发结果回写。端口冲突阻断时弹窗列出冲突端口并引导去④。
|
||||
- 测试:`tests/test_db.py` 覆盖运行日志持久化;`tests/test_gui.py` 覆盖 dry-run 不变更任务、真实更新串行、按账号并行、端口冲突阻断、⑤设置读写与③共享配置。
|
||||
- 文档:`docs/06-tasks.md` 将 T-504 标为 DONE;同步 `docs/00-ai-start-here.md`、`docs/02-requirements.md`、`docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/05-coding-rules.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md` 和 UI 草图。
|
||||
- 验证:`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(112 tests);未执行真实 Shopee 提交。
|
||||
|
||||
@@ -139,6 +139,54 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_run_logs_and_events_are_persisted(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||
db.init_db(db_path)
|
||||
|
||||
run_id = db.create_run_log(
|
||||
"apply",
|
||||
dry_run=True,
|
||||
total=2,
|
||||
options={"api_key": "secret", "mode": "preview"},
|
||||
path=db_path,
|
||||
)
|
||||
db.add_run_log_event(
|
||||
run_id,
|
||||
"dry-run 预览任务",
|
||||
task_id=7,
|
||||
alias="alias",
|
||||
item_id="51100639510",
|
||||
path=db_path,
|
||||
)
|
||||
db.finish_run_log(
|
||||
run_id,
|
||||
status="done",
|
||||
done=2,
|
||||
success_count=1,
|
||||
skipped_count=1,
|
||||
failed_count=0,
|
||||
summary_json={"password": "secret", "done": 2},
|
||||
path=db_path,
|
||||
)
|
||||
|
||||
run = db.list_run_logs(path=db_path)[0]
|
||||
self.assertEqual(run_id, run.id)
|
||||
self.assertEqual("apply", run.run_type)
|
||||
self.assertEqual(1, run.dry_run)
|
||||
self.assertEqual("done", run.status)
|
||||
self.assertEqual(2, run.done)
|
||||
self.assertEqual("***", run.options["api_key"])
|
||||
self.assertEqual("***", run.summary["password"])
|
||||
|
||||
events = db.list_run_log_events(run_id, path=db_path)
|
||||
self.assertEqual(1, len(events))
|
||||
self.assertEqual("alias", events[0].alias)
|
||||
self.assertEqual("51100639510", events[0].item_id)
|
||||
self.assertIn("dry-run", events[0].message)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+294
-16
@@ -1,6 +1,7 @@
|
||||
import unittest
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from unittest import mock
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
@@ -154,6 +155,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertFalse(tab.allow_cover_update_checkbox.isChecked())
|
||||
self.assertEqual(1, tab.max_items_per_run_spin.value())
|
||||
self.assertFalse(tab.close_success_tab_checkbox.isChecked())
|
||||
self.assertFalse(tab.dry_run_checkbox.isChecked())
|
||||
self.assertFalse(tab.parallel_accounts_checkbox.isChecked())
|
||||
self.assertEqual(2, tab.max_parallel_accounts_spin.value())
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
@@ -352,6 +356,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
tab.allow_cover_update_checkbox.setChecked(True)
|
||||
tab.max_items_per_run_spin.setValue(2)
|
||||
tab.close_success_tab_checkbox.setChecked(True)
|
||||
tab.dry_run_checkbox.setChecked(True)
|
||||
tab.parallel_accounts_checkbox.setChecked(True)
|
||||
tab.max_parallel_accounts_spin.setValue(3)
|
||||
|
||||
tab.save_app_settings()
|
||||
|
||||
@@ -377,6 +384,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
"allow_cover_update": True,
|
||||
"max_items_per_run": 2,
|
||||
"close_success_tab": True,
|
||||
"dry_run": True,
|
||||
"parallel_accounts": True,
|
||||
"max_parallel_accounts": 3,
|
||||
},
|
||||
saved["shopee_update"],
|
||||
)
|
||||
@@ -403,6 +413,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
settings_tab.allow_cover_update_checkbox.setChecked(True)
|
||||
settings_tab.max_items_per_run_spin.setValue(3)
|
||||
settings_tab.close_success_tab_checkbox.setChecked(True)
|
||||
settings_tab.dry_run_checkbox.setChecked(True)
|
||||
settings_tab.parallel_accounts_checkbox.setChecked(True)
|
||||
settings_tab.max_parallel_accounts_spin.setValue(4)
|
||||
settings_tab.save_app_settings()
|
||||
|
||||
safety_cfg = apply_tab._shopee_update_config()
|
||||
@@ -411,6 +424,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertTrue(safety_cfg["allow_cover_update"])
|
||||
self.assertEqual(3, safety_cfg["max_items_per_run"])
|
||||
self.assertTrue(safety_cfg["close_success_tab"])
|
||||
self.assertTrue(safety_cfg["dry_run"])
|
||||
self.assertTrue(safety_cfg["parallel_accounts"])
|
||||
self.assertEqual(4, safety_cfg["max_parallel_accounts"])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
@@ -840,6 +856,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertIs(tab.apply_thread, fake_thread)
|
||||
self.assertTrue(fake_thread.started)
|
||||
self.assertTrue(tab.apply_worker.close_success_tab)
|
||||
self.assertFalse(tab.apply_worker.dry_run)
|
||||
self.assertFalse(tab.apply_worker.parallel_accounts)
|
||||
self.assertFalse(tab.start_update_button.isEnabled())
|
||||
self.assertTrue(tab.stop_update_button.isEnabled())
|
||||
self.assertEqual("开始更新:1 条", statuses[-1])
|
||||
@@ -850,6 +868,73 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_tab_dry_run_starts_without_real_submit_switch(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
cfg["shopee_update"] = {
|
||||
"test_item_id": "51100639510",
|
||||
"allow_real_submit": False,
|
||||
"allow_cover_update": False,
|
||||
"max_items_per_run": 1,
|
||||
"close_success_tab": False,
|
||||
"dry_run": True,
|
||||
"parallel_accounts": True,
|
||||
"max_parallel_accounts": 2,
|
||||
}
|
||||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||
db.insert_tasks(
|
||||
batch_id,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 2,
|
||||
"account_name": "Excel主店",
|
||||
"alias": "alias-a",
|
||||
"item_id": "51100639510",
|
||||
}
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||||
statuses = []
|
||||
tab = ApplyTab(config=cfg, status_callback=statuses.append)
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
class FakeSignal:
|
||||
def __init__(self):
|
||||
self.callbacks = []
|
||||
|
||||
def connect(self, callback):
|
||||
self.callbacks.append(callback)
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self):
|
||||
self.finished = FakeSignal()
|
||||
self.started = False
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
fake_thread = FakeThread()
|
||||
with mock.patch(
|
||||
"app.gui.QMessageBox.question",
|
||||
return_value=gui.QMessageBox.Yes,
|
||||
), mock.patch("app.gui.QMessageBox.warning") as warning, \
|
||||
mock.patch("app.gui.run_worker", return_value=fake_thread):
|
||||
tab.start_update()
|
||||
|
||||
warning.assert_not_called()
|
||||
self.assertTrue(tab.apply_worker.dry_run)
|
||||
self.assertTrue(tab.apply_worker.parallel_accounts)
|
||||
self.assertEqual(2, tab.apply_worker.max_parallel_accounts)
|
||||
self.assertEqual("开始 dry-run 预览:1 条", statuses[-1])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_tab_blocks_update_when_real_submit_switch_is_off(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
@@ -1022,22 +1107,22 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assertEqual(["alias-a", "alias-b"], applied_aliases)
|
||||
self.assertEqual([True, True], close_flags)
|
||||
self.assertEqual(
|
||||
{
|
||||
"ok": False,
|
||||
"total": 3,
|
||||
"done": 3,
|
||||
"applied": 1,
|
||||
"skipped": 1,
|
||||
"failed": 1,
|
||||
"batch_ids": [batch_id],
|
||||
},
|
||||
summary,
|
||||
)
|
||||
self.assertEqual(
|
||||
{"done": 3, "total": 3, "applied": 1, "skipped": 1, "failed": 1},
|
||||
progress[-1],
|
||||
)
|
||||
self.assertFalse(summary["ok"])
|
||||
self.assertEqual(3, summary["total"])
|
||||
self.assertEqual(3, summary["done"])
|
||||
self.assertEqual(1, summary["applied"])
|
||||
self.assertEqual(1, summary["skipped"])
|
||||
self.assertEqual(1, summary["failed"])
|
||||
self.assertEqual([batch_id], summary["batch_ids"])
|
||||
self.assertFalse(summary["dry_run"])
|
||||
self.assertFalse(summary["parallel_accounts"])
|
||||
self.assertIsNotNone(summary["run_id"])
|
||||
self.assertEqual(3, progress[-1]["done"])
|
||||
self.assertEqual(3, progress[-1]["total"])
|
||||
self.assertEqual(1, progress[-1]["applied"])
|
||||
self.assertEqual(1, progress[-1]["skipped"])
|
||||
self.assertEqual(1, progress[-1]["failed"])
|
||||
self.assertFalse(progress[-1]["dry_run"])
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
by_alias = {task.alias: task for task in updated}
|
||||
self.assertEqual("applied", by_alias["alias-a"].stage)
|
||||
@@ -1051,6 +1136,199 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual("别名未匹配账号", by_alias["missing"].last_error)
|
||||
self.assertTrue(any(fields.get("stage") == "applied" for _task_id, fields in rows))
|
||||
self.assertTrue(any(fields.get("status") == "failed" for _task_id, fields in rows))
|
||||
run_logs = db.list_run_logs(run_type="apply", path=cfg["db_path"])
|
||||
self.assertEqual(1, len(run_logs))
|
||||
self.assertEqual("done", run_logs[0].status)
|
||||
self.assertGreaterEqual(
|
||||
len(db.list_run_log_events(run_logs[0].id, path=cfg["db_path"])),
|
||||
3,
|
||||
)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_worker_dry_run_only_previews_and_logs_without_mutating_tasks(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",
|
||||
},
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 3,
|
||||
"account_name": "Excel未知",
|
||||
"alias": "missing",
|
||||
"item_id": "51100639511",
|
||||
},
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]):
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
logs = []
|
||||
|
||||
with mock.patch("app.gui.chrome.is_running") as is_running, \
|
||||
mock.patch("app.gui.accounts.detect_login") as detect_login, \
|
||||
mock.patch("app.gui.editor.apply_task") as apply_task:
|
||||
worker = ApplyWorker(
|
||||
tasks,
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
dry_run=True,
|
||||
parallel_accounts=True,
|
||||
max_parallel_accounts=2,
|
||||
)
|
||||
worker.log.connect(logs.append)
|
||||
summary = worker.execute()
|
||||
|
||||
is_running.assert_not_called()
|
||||
detect_login.assert_not_called()
|
||||
apply_task.assert_not_called()
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertTrue(summary["dry_run"])
|
||||
self.assertEqual(2, summary["done"])
|
||||
self.assertEqual(1, summary["applied"])
|
||||
self.assertEqual(1, summary["skipped"])
|
||||
unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
self.assertTrue(all(task.stage == "generated" for task in unchanged))
|
||||
self.assertTrue(all(task.status == "success" for task in unchanged))
|
||||
self.assertTrue(any("dry-run" in line for line in logs))
|
||||
run_log = db.list_run_logs(run_type="apply", path=cfg["db_path"])[0]
|
||||
self.assertEqual(1, run_log.dry_run)
|
||||
self.assertEqual("done", run_log.status)
|
||||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||||
self.assertTrue(any("将更新" in event.message for event in events))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_worker_parallel_accounts_runs_different_accounts_concurrently(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)
|
||||
accounts.create_account("副店", "alias-b", debug_port=9223, 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",
|
||||
},
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 3,
|
||||
"account_name": "Excel副店",
|
||||
"alias": "alias-b",
|
||||
"item_id": "51100639511",
|
||||
},
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]):
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
started = {"alias-a": threading.Event(), "alias-b": threading.Event()}
|
||||
thread_names = set()
|
||||
|
||||
def fake_apply(account, task, close_success_tab=False):
|
||||
thread_names.add(threading.current_thread().name)
|
||||
started[account.alias].set()
|
||||
other = "alias-b" if account.alias == "alias-a" else "alias-a"
|
||||
self.assertTrue(started[other].wait(2))
|
||||
return {"committed": True, "error": None}
|
||||
|
||||
with mock.patch("app.gui.chrome.is_running", return_value=True), \
|
||||
mock.patch(
|
||||
"app.gui.accounts.detect_login",
|
||||
return_value={"logged_in": True, "reason": None},
|
||||
), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply):
|
||||
summary = ApplyWorker(
|
||||
tasks,
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
parallel_accounts=True,
|
||||
max_parallel_accounts=2,
|
||||
).execute()
|
||||
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertTrue(summary["parallel_accounts"])
|
||||
self.assertEqual(2, summary["applied"])
|
||||
self.assertGreaterEqual(len(thread_names), 2)
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
self.assertTrue(all(task.stage == "applied" for task in updated))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_worker_blocks_real_update_when_required_accounts_share_debug_port(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
db.init_db(cfg["db_path"])
|
||||
db.add_account("主店", "alias-a", "seller.shopee.tw", 9222, path=cfg["db_path"])
|
||||
db.add_account("副店", "alias-b", "seller.shopee.tw", 9222, path=cfg["db_path"])
|
||||
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",
|
||||
},
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 3,
|
||||
"account_name": "Excel副店",
|
||||
"alias": "alias-b",
|
||||
"item_id": "51100639511",
|
||||
},
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]):
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
|
||||
with mock.patch("app.gui.chrome.is_running") as is_running, \
|
||||
mock.patch("app.gui.editor.apply_task") as apply_task:
|
||||
summary = ApplyWorker(
|
||||
tasks,
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
parallel_accounts=True,
|
||||
max_parallel_accounts=2,
|
||||
).execute()
|
||||
|
||||
self.assertTrue(summary["blocked"])
|
||||
self.assertEqual("DUPLICATE_DEBUG_PORT", summary["reason"])
|
||||
self.assertEqual(9222, summary["duplicate_ports"][0]["debug_port"])
|
||||
self.assertEqual(["alias-a", "alias-b"], summary["duplicate_ports"][0]["aliases"])
|
||||
is_running.assert_not_called()
|
||||
apply_task.assert_not_called()
|
||||
run_log = db.list_run_logs(run_type="apply", path=cfg["db_path"])[0]
|
||||
self.assertEqual("blocked", run_log.status)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user