feat(apply): block abnormal product statuses

This commit is contained in:
chengma
2026-07-18 17:10:12 +08:00
parent 3a92119187
commit d64e3c7404
9 changed files with 499 additions and 51 deletions
+96 -21
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from ... import product_status
from ..models import ApplyTaskTableModel
from ..widgets import *
from ..workers import ApplyWorker as _RealApplyWorker, WriteBackWorker as _RealWriteBackWorker
@@ -244,21 +245,23 @@ class ApplyTab(QWidget):
if self.apply_thread is not None:
self._set_status("更新正在进行...")
return
tasks = [
task for task in self.model.tasks
if self._is_update_candidate_task(task)
]
if not tasks:
base_candidates = self._update_candidates()
if not base_candidates:
self._set_status("当前筛选结果没有可更新任务")
return
update_cfg = self._shopee_update_config()
update_mode = self._current_update_mode()
content_plan = self._partition_update_content_tasks(tasks, update_mode)
executable_tasks = content_plan["executable"]
update_plan = product_status.build_apply_plan(base_candidates, update_mode)
executable_tasks = update_plan["executable"]
if not executable_tasks:
content_error = self._update_content_error(content_plan, update_mode)
QMessageBox.warning(self, "更新内容未生成", content_error)
self._set_status(content_error.replace("\n", " "))
preflight_error = self._update_preflight_error(update_plan, update_mode)
title = (
"商品状态不允许更新"
if update_plan["status_scope_excluded"]
else "更新内容未生成"
)
QMessageBox.warning(self, title, preflight_error)
self._set_status(preflight_error.replace("\n", " "))
return
dry_run = bool(dry_run)
answer = QMessageBox.question(
@@ -267,7 +270,7 @@ class ApplyTab(QWidget):
self._confirmation_message(
executable_tasks,
dry_run=dry_run,
content_plan=content_plan,
content_plan=update_plan,
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
@@ -275,6 +278,18 @@ class ApplyTab(QWidget):
if answer != QMessageBox.Yes:
self._set_status("已取消检查本轮更新" if dry_run else "已取消开始更新")
return
current_plan = product_status.build_apply_plan(
self._update_candidates(),
update_mode,
)
if current_plan["fingerprint"] != update_plan["fingerprint"]:
self._set_status("当前任务数据已变化,请重新开始更新")
return
executable_tasks = current_plan["executable"]
if not executable_tasks:
preflight_error = self._update_preflight_error(current_plan, update_mode)
self._set_status(preflight_error.replace("\n", " "))
return
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
worker = ApplyWorker(
executable_tasks,
@@ -288,6 +303,10 @@ class ApplyTab(QWidget):
),
batch_size=batch_size,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
product_status_counts=current_plan["status_counts"],
status_scope_excluded=current_plan["status_scope_excluded"],
content_scope_excluded=current_plan["content_scope_excluded"],
apply_plan_fingerprint=current_plan["fingerprint"],
)
worker.progress.connect(self._on_apply_progress)
worker.row_updated.connect(self._on_apply_row_updated)
@@ -304,9 +323,9 @@ class ApplyTab(QWidget):
executable_tasks,
dry_run=dry_run,
batch_size=batch_size,
content_plan=content_plan,
content_plan=current_plan,
)
skip_status = self._content_skip_status(content_plan)
skip_status = self._content_skip_status(current_plan)
if dry_run:
self._set_status(f"开始检查本轮更新:{len(executable_tasks)} 条{skip_status}")
else:
@@ -407,6 +426,13 @@ class ApplyTab(QWidget):
and getattr(task, "status", None) in {"success", "pending", "failed"}
)
def _update_candidates(self):
return [
task
for task in self.model.tasks
if self._is_update_candidate_task(task)
]
def _populate_batch_filter(self, batches, selected_batch):
had_previous_items = self.batch_filter.count() > 0
self.batch_filter.blockSignals(True)
@@ -617,6 +643,22 @@ class ApplyTab(QWidget):
"missing_cover": missing_cover,
}
def _update_preflight_error(self, update_plan, update_mode):
if update_plan["executable"]:
return None
lines = []
status_text = self._status_skip_text(update_plan)
if status_text:
lines.append("当前筛选结果没有状态正常且可更新的商品。")
lines.append(status_text)
lines.append("请先回到①导入采集重新确认商品状态。")
content_error = self._update_content_error(update_plan, update_mode)
if content_error:
if lines:
lines.append("")
lines.append(content_error)
return "\n".join(lines) or "当前筛选结果没有可更新任务。"
def _update_content_error(self, tasks_or_plan, update_mode):
if isinstance(tasks_or_plan, dict):
content_plan = tasks_or_plan
@@ -643,12 +685,13 @@ class ApplyTab(QWidget):
def _content_skip_text(self, content_plan):
if not content_plan:
return ""
status_text = self._status_skip_text(content_plan)
missing_title = content_plan.get("missing_title", [])
missing_cover = content_plan.get("missing_cover", [])
skipped_count = len({id(task) for task in missing_title + missing_cover})
if not skipped_count:
return ""
lines = [f"本轮将跳过 {skipped_count} 条缺少所选更新内容的记录:"]
lines = [status_text] if status_text else []
if skipped_count:
lines.append(f"本轮将跳过 {skipped_count} 条缺少所选更新内容的记录:")
if missing_title:
lines.append(
f"缺少新标题:{len(missing_title)} 条(示例商品ID:{self._sample_item_ids(missing_title)})"
@@ -662,16 +705,43 @@ class ApplyTab(QWidget):
def _content_skip_status(self, content_plan):
if not content_plan:
return ""
status_skipped = int(content_plan.get("status_scope_excluded", 0) or 0)
missing_title = content_plan.get("missing_title", [])
missing_cover = content_plan.get("missing_cover", [])
skipped_count = len({id(task) for task in missing_title + missing_cover})
if not skipped_count:
if not skipped_count and not status_skipped:
return ""
return (
f",跳过 {skipped_count} 条(缺标题 {len(missing_title)} / "
f"缺封面 {len(missing_cover)})"
parts = []
if status_skipped:
parts.append(f"状态异常 {status_skipped}")
if skipped_count:
parts.append(
f"缺标题 {len(missing_title)} / 缺封面 {len(missing_cover)}"
)
return ",跳过 {total} 条({details})".format(
total=status_skipped + skipped_count,
details=";".join(parts),
)
def _status_skip_text(self, update_plan):
if not update_plan:
return ""
rows = [
("未上架", update_plan.get("unlisted", [])),
("审核中", update_plan.get("reviewing", [])),
("状态未知", update_plan.get("unknown", [])),
]
skipped = sum(len(tasks) for _label, tasks in rows)
if not skipped:
return ""
lines = [f"本轮将排除 {skipped} 条商品状态异常记录:"]
for label, tasks in rows:
if tasks:
lines.append(
f"{label}:{len(tasks)} 条(示例商品ID:{self._sample_item_ids(tasks)})"
)
return "\n".join(lines)
def _sample_item_ids(self, tasks):
values = [
str(getattr(task, "item_id", "") or "").strip()
@@ -784,8 +854,13 @@ class ApplyTab(QWidget):
)
skip_status = self._content_skip_status(content_plan)
if skip_status:
excluded_label = (
"预检排除记录"
if content_plan and content_plan.get("status_scope_excluded")
else "缺失记录"
)
self._append_run_log(
f"本轮预检:可执行 {len(tasks)} 条{skip_status};缺失记录未进入本轮执行。"
f"本轮预检:可执行 {len(tasks)} 条{skip_status};{excluded_label}未进入本轮执行。"
)
def _load_latest_run_log(self):
+23 -1
View File
@@ -1251,6 +1251,10 @@ class ApplyWorker(BaseWorker):
max_parallel_accounts=1,
batch_size=None,
diagnostic_log_dir=None,
product_status_counts=None,
status_scope_excluded=0,
content_scope_excluded=0,
apply_plan_fingerprint=None,
):
super().__init__()
self.tasks = list(tasks)
@@ -1271,6 +1275,13 @@ class ApplyWorker(BaseWorker):
self._batch_count = 0
self._progress_lock = threading.Lock()
self.diagnostic_log_dir = diagnostic_log_dir
self.product_status_counts = {
status: int((product_status_counts or {}).get(status, 0) or 0)
for status in product_status.VALID_PRODUCT_STATUSES
}
self.status_scope_excluded = max(0, int(status_scope_excluded or 0))
self.content_scope_excluded = max(0, int(content_scope_excluded or 0))
self.apply_plan_fingerprint = str(apply_plan_fingerprint or "") or None
self._run_id = None
def execute(self):
@@ -1295,7 +1306,7 @@ class ApplyWorker(BaseWorker):
}
self._run_id = self._create_run_log(eligible, batch_ids)
self._log_run_event(
"step=start result=start detail=运行开始:{mode},更新内容{update_mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format(
"step=start result=start detail=运行开始:{mode},更新内容{update_mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel};商品状态异常排除{status_excluded}条;内容缺失排除{content_excluded}条".format(
mode="检查本轮更新" if self.dry_run else "真实更新",
update_mode=_update_mode_label(self.update_mode),
total=total,
@@ -1306,6 +1317,8 @@ class ApplyWorker(BaseWorker):
if self.max_parallel_accounts > 1
else "串行"
),
status_excluded=self.status_scope_excluded,
content_excluded=self.content_scope_excluded,
)
)
@@ -1363,6 +1376,7 @@ class ApplyWorker(BaseWorker):
return (
getattr(task, "stage", None) == "generated"
and getattr(task, "status", None) in {"success", "pending", "failed"}
and product_status.is_normal(getattr(task, "product_status", None))
and (
(
appconfig.update_mode_includes_title(self.update_mode)
@@ -1766,6 +1780,10 @@ class ApplyWorker(BaseWorker):
"batch_count": self._batch_count,
"update_mode": self.update_mode,
"run_id": self._run_id,
"product_status_counts": dict(self.product_status_counts),
"status_scope_excluded": self.status_scope_excluded,
"content_scope_excluded": self.content_scope_excluded,
"apply_plan_fingerprint": self.apply_plan_fingerprint,
}
if blocked:
summary["blocked"] = True
@@ -1787,6 +1805,10 @@ class ApplyWorker(BaseWorker):
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
"update_mode": self.update_mode,
"product_status_counts": dict(self.product_status_counts),
"status_scope_excluded": self.status_scope_excluded,
"content_scope_excluded": self.content_scope_excluded,
"apply_plan_fingerprint": self.apply_plan_fingerprint,
},
path=self.db_path,
)
+57 -3
View File
@@ -125,7 +125,61 @@ def build_generation_plan(tasks, generate_mode, scope=SCOPE_NORMAL_ONLY) -> dict
"execution_tasks": execution_tasks,
"scope": normalized_scope,
"scope_excluded": len(base_candidates) - len(execution_tasks),
"fingerprint": _generation_fingerprint(base_candidates, generate_mode),
"fingerprint": _task_plan_fingerprint(base_candidates, generate_mode),
}
def build_apply_plan(tasks, update_mode) -> dict:
"""Return a status-first, content-aware plan for Shopee update actions."""
base_candidates = _deduplicate_tasks(tasks)
status_counts = {status: 0 for status in VALID_PRODUCT_STATUSES}
status_excluded = {
STATUS_UNLISTED: [],
STATUS_REVIEWING: [],
STATUS_UNKNOWN: [],
}
normal_candidates = []
for task in base_candidates:
status = _task_status(task)
status_counts[status] += 1
if status == STATUS_NORMAL:
normal_candidates.append(task)
else:
status_excluded[status].append(task)
mode = str(update_mode or "").strip().lower()
requires_title = mode in {"title", "title_cover"}
requires_cover = mode in {"cover", "title_cover"}
missing_title = [
task
for task in normal_candidates
if requires_title and not str(_task_value(task, "new_title") or "").strip()
]
missing_cover = [
task
for task in normal_candidates
if requires_cover and not str(_task_value(task, "new_cover_path") or "").strip()
]
content_excluded_ids = {id(task) for task in missing_title + missing_cover}
executable_tasks = [
task for task in normal_candidates if id(task) not in content_excluded_ids
]
status_excluded_count = sum(len(rows) for rows in status_excluded.values())
return {
"base_candidates": base_candidates,
"status_counts": status_counts,
"executable": executable_tasks,
"unlisted": status_excluded[STATUS_UNLISTED],
"reviewing": status_excluded[STATUS_REVIEWING],
"unknown": status_excluded[STATUS_UNKNOWN],
"missing_title": missing_title,
"missing_cover": missing_cover,
"status_scope_excluded": status_excluded_count,
"content_scope_excluded": len(content_excluded_ids),
"scope_excluded": status_excluded_count + len(content_excluded_ids),
"fingerprint": _task_plan_fingerprint(base_candidates, update_mode),
}
@@ -159,7 +213,7 @@ def _deduplicate_tasks(tasks) -> list:
return unique
def _generation_fingerprint(tasks, generate_mode) -> str:
def _task_plan_fingerprint(tasks, mode) -> str:
snapshots = [
{
"task_id": _task_value(task, "id"),
@@ -167,7 +221,7 @@ def _generation_fingerprint(tasks, generate_mode) -> str:
"product_status": _task_status(task),
"new_title": _task_value(task, "new_title"),
"new_cover_path": _task_value(task, "new_cover_path"),
"generate_mode": str(generate_mode or ""),
"mode": str(mode or ""),
}
for task in tasks
]
+2 -1
View File
@@ -476,7 +476,8 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
### 6.3 应用更新(③ Tab)
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,先按当前模式分出具备 `new_title` / `new_cover_path` 的可执行任务与缺失记录;有可执行任务时缺失记录只在确认框和本轮日志中列为预检跳过,不创建其 `ApplyWorker` 执行项、不打开其 Chrome、不写其失败状态。仅当全部记录缺少所选内容时,才弹「更新内容未生成」并整体阻断。
- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,`product_status.build_apply_plan()` 先把候选按商品状态冻结:仅 `normal` 可继续检查 `new_title` / `new_cover_path`,`unlisted`、`reviewing`、`unknown` 与历史 NULL 均从 Worker 输入剔除;再对正常商品分出可执行项和内容缺失项。状态异常与内容缺失仅在确认框、状态栏和本轮日志列为预检排除,不打开其 Chrome、不写其失败状态、Excel 或运行内 worker 统计。无可执行项时,优先展示真实商品状态原因;只有没有状态异常时才沿用「更新内容未生成」文案。
- 计划指纹覆盖 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式`;用户确认后重新计算,任何变化均废弃旧计划并要求重新开始。`ApplyWorker` 仍在执行层再次验证 `product_status=normal`,防止直接构造 Worker 绕过 GUI 预检。①、②本轮的“所有状态”范围选择不向③传递更新授权。
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新内容和略过原因。
- 弹确认前先读取 `data/config.json` 的 `shopee_update` 执行参数。普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
- 用户在③确认弹窗点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
+5 -5
View File
@@ -455,13 +455,13 @@ 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生成:提示词管理 + 筛选任务 + 状态范围确认 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 缺失内容预检剔除 + 检查本轮更新 + 确认后分批真实更新 + 运行日志
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 商品状态/内容预检剔除 + 检查本轮更新 + 确认后分批真实更新 + 运行日志
class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果
class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建
class CollectWorker(BaseWorker) # ① 后台采集:范围 normal_only/all + 账号预检 -> editor.collect -> 状态独立落库,采集或略过
class GenerateWorker(BaseWorker) # ② 后台生成:确认后的 normal_only/all 精确任务 -> ai.generate_batch -> 写库 + 进度
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
class ApplyWorker(BaseWorker) # ③ 后台更新:再次拒绝非正常状态 -> 账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
class AIModelTestWorker(BaseWorker) # 设置 后台测试 AI 模型连接:appconfig.test_ai_model
class ImageStudioPullImagesWorker(BaseWorker) # 商品套图 后台只读拉蝦皮原主图 URL;安全边界协作停止
@@ -546,14 +546,14 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
③ 更新蝦皮当前要点(T-303b/T-401/T-402/T-403):
- `ApplyTab` 顶部筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部。
- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前再按③「更新内容」模式分出可执行任务与缺少 `new_title` / `new_cover_path` 的预检跳过记录。
- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前先按 `product_status` 分出可进入线上更新的 `normal` 与必须排除的 `unlisted/reviewing/unknown`(历史 NULL 归入未知),再按③「更新内容」模式分出可执行任务与缺少 `new_title` / `new_cover_path` 的预检跳过记录。
- 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。
- 「检查本轮更新」只读取当前筛选结果并创建检查运行日志,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel;检查内容包含任务总数、店铺分布、每批最大条数、预计批次数、更新内容、会更新字段和略过原因。
- 点击「开始更新」先按③「更新内容」模式预检当前筛选任务:只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新标题和封面要求两者都有。部分缺失时将这些记录从本轮 `ApplyWorker` 任务中剔除,在确认框、状态栏摘要和本轮日志列出缺标题/缺封面条数与示例商品ID;不打开其 Chrome、不写其失败状态或 Excel。只有全部记录缺失时才弹「更新内容未生成」并阻断。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前可执行任务超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/可执行任务数、预检跳过项、每批最大条数、预计批次数、提交线上风险和当前执行设置。
- 点击「开始更新」和「检查本轮更新」都先使用同一 `build_apply_plan()` 预检当前筛选任务:仅商品状态 `normal` 继续按③「更新内容」模式检查,只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新标题和封面要求两者都有。未上架、审核中、状态未知和历史 NULL 均不能传入正式或检查模式的 `ApplyWorker`;状态异常与内容缺失在确认框、状态栏摘要和本轮日志分类列出数量与示例商品ID,不打开其 Chrome、不写其失败状态或 Excel,也不计入 worker 成功/略过/失败统计。所有记录被排除时,若存在状态异常则弹「商品状态不允许更新」,否则弹「更新内容未生成」。确认后按 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式` 重新验证计划指纹,变化即中止。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前可执行任务超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/可执行任务数、预检跳过项、每批最大条数、预计批次数、提交线上风险和当前执行设置。
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
- `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
- `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务,并在执行层再次拒绝商品状态不是 `normal` 的记录:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
- 预检通过后默认串行;若 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
+4 -4
View File
@@ -129,7 +129,7 @@
- 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。
- 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前先按当前模式检查 `new_title` / `new_cover_path`。有合格记录时,缺失记录从本轮 worker 任务中剔除,确认框与日志列出缺标题/缺封面条数和示例商品ID;仅全部记录缺失时才中文弹窗阻断,不打开 Chrome、不改任务状态。
- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前统一用状态优先的预检计划:仅 `product_status=normal` 可进入标题/封面完整性检查,`unlisted`、`reviewing`、`unknown` 及历史 NULL 一律排除,不传给 `ApplyWorker`。有可执行记录时,状态异常和缺失内容记录都在确认框、状态栏和本轮日志列出分类、数量与示例商品ID;全部被排除时按真实原因中文弹窗阻断,不打开 Chrome、不改任务状态。用户确认后重新计算计划指纹,任务状态、更新时间、新标题或新封面变化则中止并要求重新开始。
- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。
- 完成缺失内容预检并剔除不合格记录后,点击「开始更新」读取设置中的 `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前可执行任务超过该值时不阻断,而是自动分批执行。
- 弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、执行设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
@@ -178,7 +178,7 @@
- 路径与端口(T-501b/T-506/T-539/T-580 已接入):组件组改为 3 个组件一组;普通设置页只显示 Chrome 路径、默认端口、端口起止、Chrome 就绪超时。T-538 后账号数据根目录、图片目录、DB 路径固定解析到 `data/` 下,普通 UI 不再提供输入框,避免用户误改后数据分裂;`config.json` 中 `user_data_root` / `image_dir` / `db_path` 字段继续作为内部兼容字段保留,手工配置值仍会被读取和保存。
- 蝦皮更新执行(T-580 已接入):组件组改为 3 个组件一组;普通设置页只保留「每批最大更新条数」和「同时更新蝦皮账号(1..5)」两个执行参数。`1` 表示逐个账号串行,`2..5` 表示按账号分组并行;“dry-run”不作为用户可见开关,改到③成为「检查本轮更新」按钮;测试商品 ID 和旧封面开关仅作为历史/调试兼容字段读取,普通设置页无入口,保存后不再写回。
- ③「更新内容」默认只更新标题,每批最大更新条数默认 1,同时更新蝦皮账号默认 1。
- ③ 点击「开始更新」会先按「更新内容」剔除缺失内容记录并在确认框说明;仅无可执行记录时中止。
- ③ 点击「开始更新」和「检查本轮更新」会先排除非正常商品状态,再按「更新内容」剔除缺失内容记录并在确认框说明;仅无可执行记录时中止。①的“采集所有状态”和②的“生成所有状态”不构成③线上更新授权。
## 商品套图
@@ -242,13 +242,13 @@
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;先确认商品状态生成范围,再按本轮「生成内容」下拉接入 `GenerateWorker` |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 商品状态优先/内容完整性预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `AccountsTab(QWidget)` | 账号管理 | 账号增删改、启动登录、检测登录、生成快捷方式;首次启动复用初始卖家中心页,启动中防重复触发,登录检测把 Shopee accounts 登录页判为未登录 |
| `SettingsTab(QWidget)` | 设置 | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 |
| `ProductSuiteTab(QWidget)` | 商品套图 | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、AI帮写、cmhub 异步生成、结果历史与删除撤销 |
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志 |
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志;执行层再次拒绝非正常商品状态 |
| `AIModelTestWorker(BaseWorker)` | 设置 | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker / CMHubModelCatalogWorker` | 商品套图 | 后台执行只读拉主图、远程原图下载、本地图片校验复制、cmhub 套图生成、AI帮写和只读模型目录;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget |
+6 -2
View File
@@ -1,7 +1,7 @@
---
id: T-662d
title: 更新蝦皮商品状态后台拦截与T-659预检整合
status: TODO
status: DONE
phase: 7
deps: [T-662a, T-659]
created: 2026-07-18
@@ -55,4 +55,8 @@ git diff --check
## 执行记录
- 待实现。
- 新增 `product_status.build_apply_plan()`:先按商品状态去重分组,仅 `normal` 进入标题/封面完整性检查;未上架、审核中、未知与历史 NULL 均不传入执行列表。
- `ApplyTab` 的「检查本轮更新」和「开始更新」共用该计划器;确认框、状态栏与本轮日志展示状态异常和内容缺失的分类排除,确认后重新核对 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式` 指纹。
- `ApplyWorker` 记录预检元数据,并在执行层再次拒绝非正常商品状态,避免直接构造 Worker 绕过 GUI 预检。
- 同步更新 `docs/routes.md`、`docs/04-architecture.md`、`docs/api.md`;补充状态优先、混合排除、确认期变更和计划指纹测试。
- 验证通过:`py -3.10 -m unittest tests.test_product_status tests.test_gui`(207 项)、`py -3.10 -m unittest discover -s tests`(616 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
+241 -14
View File
@@ -6682,7 +6682,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
statuses = []
tab = ApplyTab(config=cfg, status_callback=statuses.append)
@@ -6730,7 +6736,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
statuses = []
tab = ApplyTab(config=cfg, status_callback=statuses.append)
@@ -6813,7 +6825,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
statuses = []
tab = ApplyTab(config=cfg, status_callback=statuses.append)
@@ -6877,7 +6895,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
statuses = []
tab = ApplyTab(config=cfg, status_callback=statuses.append)
@@ -6933,7 +6957,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
db.set_generated(task.id, "新标题", None, path=cfg["db_path"])
tab = ApplyTab(config=cfg)
self.addCleanup(tab.close)
@@ -6983,6 +7013,149 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_apply_tab_blocks_all_abnormal_statuses_before_content_or_worker(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": index + 2,
"alias": "alias-a",
"item_id": str(51100639510 + index),
}
for index in range(3)
],
path=cfg["db_path"],
)
statuses = ["unlisted", "reviewing", None]
for task, status in zip(
db.list_tasks(batch_id=batch_id, path=cfg["db_path"]),
statuses,
):
db.set_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value=status,
path=cfg["db_path"],
)
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
states = []
tab = ApplyTab(config=cfg, status_callback=states.append)
self.addCleanup(tab.close)
with mock.patch("app.gui.QMessageBox.warning") as warning, mock.patch(
"app.gui.QMessageBox.question"
) as question, mock.patch("app.gui.run_worker") as run_worker:
tab.preview_update()
self.assertEqual("商品状态不允许更新", warning.call_args[0][1])
message = warning.call_args[0][2]
self.assertIn("未上架:1 条", message)
self.assertIn("审核中:1 条", message)
self.assertIn("状态未知:1 条", message)
self.assertNotIn("缺少新标题", message)
question.assert_not_called()
run_worker.assert_not_called()
self.assertIn("状态正常且可更新", states[-1])
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.assert_removed(temp_dir)
def test_apply_tab_confirmation_combines_status_and_content_exclusions(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": index + 2,
"alias": "alias-a",
"item_id": str(51100639510 + index),
}
for index in range(3)
],
path=cfg["db_path"],
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
for task, status in zip(tasks, ["normal", "reviewing", "normal"]):
db.set_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value=status,
path=cfg["db_path"],
)
db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"])
db.set_generated(tasks[1].id, "新标题", "new.jpg", path=cfg["db_path"])
db.set_generated(tasks[2].id, None, "new.jpg", path=cfg["db_path"])
tab = ApplyTab(config=cfg)
self.addCleanup(tab.close)
thread = FakeThread()
with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question, mock.patch(
"app.gui.run_worker",
return_value=thread,
):
tab.preview_update()
message = question.call_args[0][2]
self.assertIn("商品状态异常记录", message)
self.assertIn("审核中:1 条", message)
self.assertIn("缺少新标题:1 条", message)
self.assertEqual([tasks[0].id], [task.id for task in tab.apply_worker.tasks])
self.assertTrue(tab.apply_worker.dry_run)
self.assertTrue(thread.started)
self.assert_removed(temp_dir)
def test_apply_tab_discards_plan_when_candidates_change_during_confirmation(self):
with self.make_temp_dir() as temp_dir:
states = []
tab = ApplyTab(
config=self.make_config(temp_dir),
status_callback=states.append,
)
self.addCleanup(tab.close)
before = SimpleNamespace(
id=1,
item_id="51100639510",
updated_at="2026-07-18T10:00:00",
product_status="normal",
new_title="新标题",
new_cover_path="new.jpg",
)
after = SimpleNamespace(
id=1,
item_id="51100639510",
updated_at="2026-07-18T10:01:00",
product_status="normal",
new_title="新标题",
new_cover_path="new.jpg",
)
with mock.patch.object(tab, "_update_candidates", side_effect=[[before], [after]]), mock.patch(
"app.gui.QMessageBox.question",
return_value=gui.QMessageBox.Yes,
), mock.patch("app.gui.run_worker") as run_worker:
tab.preview_update()
run_worker.assert_not_called()
self.assertIn("当前任务数据已变化", states[-1])
self.assert_removed(temp_dir)
def test_apply_tab_skips_missing_content_but_starts_eligible_update(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
@@ -7020,7 +7193,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
for task in tasks:
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
db.set_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"])
db.set_generated(tasks[1].id, "新标题", None, path=cfg["db_path"])
db.set_generated(tasks[2].id, None, "new.jpg", path=cfg["db_path"])
@@ -7080,7 +7259,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
for task in tasks:
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
db.set_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"])
db.set_generated(tasks[1].id, None, "new.jpg", path=cfg["db_path"])
statuses = []
@@ -7176,7 +7361,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
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"])
applied_aliases = []
@@ -7280,7 +7471,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
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"])
foreground_flags = []
@@ -7335,7 +7532,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
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 = []
@@ -7402,7 +7605,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
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()}
@@ -7469,7 +7678,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
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"])
@@ -7604,7 +7819,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
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"])
@@ -9595,7 +9816,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
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_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
diagnostic_log_dir = os.path.join(temp_dir, "logs")
+65
View File
@@ -132,3 +132,68 @@ class ProductStatusTests(unittest.TestCase):
)
self.assertEqual([1, 2, 3], [task.id if hasattr(task, "id") else task["id"] for task in all_statuses["execution_tasks"]])
self.assertNotEqual(normal_only["fingerprint"], changed["fingerprint"])
def test_apply_plan_excludes_abnormal_before_content_and_deduplicates_skips(self):
tasks = [
SimpleNamespace(
id=1,
updated_at="2026-07-18T10:00:00",
product_status="normal",
new_title="新标题",
new_cover_path="new.jpg",
),
SimpleNamespace(
id=2,
updated_at="2026-07-18T10:00:01",
product_status="unlisted",
new_title=None,
new_cover_path=None,
),
SimpleNamespace(
id=3,
updated_at="2026-07-18T10:00:02",
product_status="reviewing",
new_title=None,
new_cover_path="new.jpg",
),
SimpleNamespace(
id=4,
updated_at="2026-07-18T10:00:03",
product_status=None,
new_title="新标题",
new_cover_path=None,
),
SimpleNamespace(
id=5,
updated_at="2026-07-18T10:00:04",
product_status="normal",
new_title=None,
new_cover_path=None,
),
]
plan = product_status.build_apply_plan(tasks + [tasks[0]], "title_cover")
changed = product_status.build_apply_plan(
tasks[:4]
+ [
SimpleNamespace(
id=5,
updated_at="2026-07-18T10:01:00",
product_status="normal",
new_title=None,
new_cover_path=None,
)
],
"title_cover",
)
self.assertEqual([1], [task.id for task in plan["executable"]])
self.assertEqual([2], [task.id for task in plan["unlisted"]])
self.assertEqual([3], [task.id for task in plan["reviewing"]])
self.assertEqual([4], [task.id for task in plan["unknown"]])
self.assertEqual([5], [task.id for task in plan["missing_title"]])
self.assertEqual([5], [task.id for task in plan["missing_cover"]])
self.assertEqual(3, plan["status_scope_excluded"])
self.assertEqual(1, plan["content_scope_excluded"])
self.assertEqual(4, plan["scope_excluded"])
self.assertNotEqual(plan["fingerprint"], changed["fingerprint"])