diff --git a/app/db.py b/app/db.py index fbf08d7..766c73e 100644 --- a/app/db.py +++ b/app/db.py @@ -16,6 +16,8 @@ from .config import make_slug DEFAULT_BUSY_TIMEOUT_MS = 5000 +PRODUCT_STATUS_FEATURE_INTRODUCED_AT = "2026-07-18T16:34:37" +LEGACY_PRODUCT_STATUS_DEFAULT_NOTE = "历史批次默认按架上商品处理(未实时检测)" VALID_BATCH_FIELDS = { "source_files_json", "status", @@ -493,6 +495,7 @@ def init_db(path=None, conn=None) -> None: _ensure_task_image_task_columns(database) _ensure_task_cover_reset_columns(database) _ensure_task_product_status_columns(database) + _migrate_legacy_product_status_defaults(database) _ensure_image_studio_project_suite_columns(database) _ensure_image_studio_project_draft_columns(database) _ensure_image_studio_job_recovery_columns(database) @@ -534,6 +537,32 @@ def _ensure_task_product_status_columns(database): database.execute("ALTER TABLE tasks ADD COLUMN product_status_at TEXT") +def _migrate_legacy_product_status_defaults(database): + """Treat pre-status-feature active batches as historically on-shelf once.""" + + database.execute( + """ + UPDATE tasks + SET product_status = 'normal', + product_status_note = ?, + product_status_at = NULL, + updated_at = ? + WHERE (product_status IS NULL OR TRIM(product_status) = '') + AND batch_id IN ( + SELECT id + FROM batches + WHERE deleted_at IS NULL + AND created_at < ? + ) + """, + ( + LEGACY_PRODUCT_STATUS_DEFAULT_NOTE, + _now(), + PRODUCT_STATUS_FEATURE_INTRODUCED_AT, + ), + ) + + def _ensure_image_studio_project_suite_columns(database): columns = { row["name"] diff --git a/app/editor.py b/app/editor.py index 078fcb0..ec8854e 100644 --- a/app/editor.py +++ b/app/editor.py @@ -1207,27 +1207,6 @@ def collect(account, task, on_step=None) -> dict: return result -def recheck_product_status(account, task, on_step=None) -> dict: - """Read one product status snapshot without changing collected content.""" - - item_id = _item_id(task) - cdp = open_product(account, item_id, on_step=on_step, bring_to_front=False) - result = None - try: - _notify_collect_step(on_step, "read_product_status") - result = read_product_status(cdp) - if result.get("product_status_error"): - raise EditorError( - "商品状态读取失败:{error}".format( - error=result["product_status_error"] - ) - ) - finally: - close_target_confirmed = _close_collected_product(cdp) - result["close_target_confirmed"] = close_target_confirmed - return result - - def read_product_status(cdp) -> dict: """Read the current product's warning state without retaining page HTML.""" diff --git a/app/gui/__init__.py b/app/gui/__init__.py index b4f72e0..082918f 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -24,7 +24,6 @@ if QT_IMPORT_ERROR is None: CMHubSettingsWorker, ApplyWorker, CollectWorker, - StatusRecheckWorker, GenerateWorker, ImageStudioDownloadOriginalWorker, ImageStudioExportWorker, diff --git a/app/gui/tabs/apply.py b/app/gui/tabs/apply.py index 290abb7..ab20145 100644 --- a/app/gui/tabs/apply.py +++ b/app/gui/tabs/apply.py @@ -651,10 +651,7 @@ class ApplyTab(QWidget): if status_text: lines.append("当前筛选结果没有状态正常且可更新的商品。") lines.append(status_text) - if update_plan.get("unverified"): - lines.append("请先到①导入采集点击「重新检测商品状态」。") - else: - lines.append("请先回到①导入采集重新确认商品状态。") + lines.append("请先回到①导入采集重新确认商品状态。") content_error = self._update_content_error(update_plan, update_mode) if content_error: if lines: @@ -730,7 +727,6 @@ class ApplyTab(QWidget): if not update_plan: return "" rows = [ - ("尚未检测商品状态", update_plan.get("unverified", [])), ("未上架", update_plan.get("unlisted", [])), ("审核中", update_plan.get("reviewing", [])), ("状态未知", update_plan.get("unknown", [])), diff --git a/app/gui/tabs/collect.py b/app/gui/tabs/collect.py index e1d27aa..e139cb9 100644 --- a/app/gui/tabs/collect.py +++ b/app/gui/tabs/collect.py @@ -11,26 +11,13 @@ from ...collect_skip import ( from ... import product_status from ..models import TaskTableModel from ..widgets import * -from ..workers import ( - CollectWorker as _RealCollectWorker, - StatusRecheckWorker as _RealStatusRecheckWorker, - WriteBackWorker as _RealWriteBackWorker, -) +from ..workers import CollectWorker as _RealCollectWorker, WriteBackWorker as _RealWriteBackWorker def CollectWorker(*args, **kwargs): return _call_package_attr("CollectWorker", _RealCollectWorker, *args, **kwargs) -def StatusRecheckWorker(*args, **kwargs): - return _call_package_attr( - "StatusRecheckWorker", - _RealStatusRecheckWorker, - *args, - **kwargs, - ) - - def WriteBackWorker(*args, **kwargs): return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs) @@ -97,19 +84,15 @@ class CollectTab(QWidget): self.last_import_stats = None self.collect_worker = None self.collect_thread = None - self.status_recheck_worker = None - self.status_recheck_thread = None self.write_back_worker = None self.write_back_thread = None self.last_collect_run_id = None - self.last_status_recheck_run_id = None self._collect_run_started_at = None self._collect_task_started_at = None self._collect_task_elapsed_seconds = 0 self._collect_activity_payload = {} self._collect_terminal_text = "" self._collect_stop_requested = False - self._collect_operation_label = "采集" self._collect_elapsed_timer = QTimer(self) self._collect_elapsed_timer.setInterval(1000) self._collect_elapsed_timer.timeout.connect(self._refresh_collect_activity) @@ -117,8 +100,6 @@ class CollectTab(QWidget): self.import_button = QPushButton("导入 Excel...") self.refresh_button = QPushButton("刷新") self.collect_button = QPushButton("采集旧标题/旧封面") - self.status_recheck_button = QPushButton("重新检测商品状态") - self.status_recheck_button.setObjectName("statusRecheckButton") self.stop_collect_button = QPushButton("停止") self.write_back_button = QPushButton("回写旧数据到 Excel") self.stop_collect_button.setEnabled(False) @@ -142,7 +123,6 @@ class CollectTab(QWidget): toolbar.addWidget(self.import_button) toolbar.addWidget(self.refresh_button) toolbar.addWidget(self.collect_button) - toolbar.addWidget(self.status_recheck_button) toolbar.addWidget(self.stop_collect_button) toolbar.addWidget(self.write_back_button) toolbar.addStretch(1) @@ -164,7 +144,7 @@ class CollectTab(QWidget): self.collect_activity_label = QLabel("") self.collect_activity_label.setObjectName("collectActivityLabel") self.collect_activity_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - activity_sample = "正在商品状态重检 999/999 · 等待商品页加载 · 本条 99:59" + activity_sample = "正在采集 999/999 · 等待商品页加载 · 本条 99:59" activity_width = self.collect_activity_label.fontMetrics().horizontalAdvance(activity_sample) + 24 self.collect_activity_label.setFixedWidth(activity_width) self.collect_activity_label.setVisible(False) @@ -231,7 +211,6 @@ class CollectTab(QWidget): self.status_filter.currentIndexChanged.connect(self.refresh_tasks) self.delete_batch_button.clicked.connect(self.delete_current_batch) self.collect_button.clicked.connect(self.collect_old_data) - self.status_recheck_button.clicked.connect(self.recheck_product_status) self.stop_collect_button.clicked.connect(self.stop_collect) self.write_back_button.clicked.connect(self.write_back_old_data) self.show_all_button.clicked.connect(self.show_all_tasks) @@ -253,9 +232,8 @@ class CollectTab(QWidget): "}" ) - def _start_collect_activity(self, operation_label="采集"): + def _start_collect_activity(self): now = time.monotonic() - self._collect_operation_label = str(operation_label or "采集") self._collect_run_started_at = now self._collect_task_started_at = None self._collect_task_elapsed_seconds = 0 @@ -328,7 +306,7 @@ class CollectTab(QWidget): level = "warning" elif state in {"task_started", "task_step"}: text = ( - f"正在{self._collect_operation_label} {progress} · {step_label} · " + f"正在采集 {progress} · {step_label} · " f"本条 {_format_collect_elapsed(task_elapsed)}" ) level = "info" @@ -344,10 +322,7 @@ class CollectTab(QWidget): ) level = "danger" if event.get("result") == "failed" else "muted" else: - if self._collect_operation_label == "采集": - text = f"正在检查账号 · {_format_collect_elapsed(run_elapsed)}" - else: - text = f"正在{self._collect_operation_label}前检查账号 · {_format_collect_elapsed(run_elapsed)}" + text = f"正在检查账号 · {_format_collect_elapsed(run_elapsed)}" level = "info" tooltip_parts = [] @@ -376,24 +351,21 @@ class CollectTab(QWidget): self._collect_stop_requested = False if outcome == "blocked": - text = f"{self._collect_operation_label}未开始 · 检查未通过" + text = "采集未开始 · 检查未通过" level = "warning" - tooltip = f"{self._collect_operation_label}前检查未通过,请按弹窗提示处理" + tooltip = "采集前检查未通过,请按弹窗提示处理" elif outcome == "cancelled": - text = f"{self._collect_operation_label}已停止 · 总用时 {_format_collect_elapsed(total_elapsed)}" + text = f"采集已停止 · 总用时 {_format_collect_elapsed(total_elapsed)}" level = "warning" - tooltip = f"本轮{self._collect_operation_label}已停止" + tooltip = "本轮采集已停止" elif outcome == "error": - text = f"{self._collect_operation_label}已结束 · 请查看运行日志" + text = "采集已结束 · 请查看运行日志" level = "danger" - if self._collect_operation_label == "采集": - tooltip = "采集异常结束,请查看下方采集运行日志" - else: - tooltip = f"{self._collect_operation_label}异常结束,请查看下方运行日志" + tooltip = "采集异常结束,请查看下方采集运行日志" else: - text = f"{self._collect_operation_label}完成 · 总用时 {_format_collect_elapsed(total_elapsed)}" + text = f"采集完成 · 总用时 {_format_collect_elapsed(total_elapsed)}" level = "success" - tooltip = f"本轮{self._collect_operation_label}已经完成" + tooltip = "本轮采集已经完成" self._collect_terminal_text = text self.collect_activity_label.setToolTip(tooltip) self._set_collect_activity_style(level) @@ -407,9 +379,9 @@ class CollectTab(QWidget): def _append_collect_log(self, message): self.run_log_view.appendPlainText(str(message)) - def _load_latest_collect_run_log(self, run_type="collect"): + def _load_latest_collect_run_log(self): try: - logs = db.list_run_logs(limit=1, run_type=run_type, path=self.db_path) + logs = db.list_run_logs(limit=1, run_type="collect", path=self.db_path) if not logs: return events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path) @@ -436,10 +408,10 @@ class CollectTab(QWidget): self._set_status(text, level="danger") QTimer.singleShot(0, lambda: QMessageBox.warning(self, "导入采集", text)) - def _show_account_guide(self, message, operation_label="采集"): + def _show_account_guide(self, message): full_message = ( f"{message}\n\n" - f"本轮{operation_label}已中止。\n" + "本轮采集已中止。\n" "请先到「账号管理」检查账号配置、Chrome 路径和登录状态。" ) QMessageBox.warning(self, "账号未就绪", full_message) @@ -691,11 +663,7 @@ class CollectTab(QWidget): return self.batch_filter.currentData() def _update_delete_batch_button(self): - running = bool( - self.collect_thread - or self.status_recheck_thread - or self.write_back_thread - ) + running = bool(self.collect_thread or self.write_back_thread) self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id())) def delete_current_batch(self, checked=False): @@ -748,9 +716,6 @@ class CollectTab(QWidget): QMessageBox.information(self, "删除批次", message) def collect_old_data(self, checked=False): - if self._collect_operation_running() or self.write_back_thread is not None: - self._set_status("当前批处理尚未结束,请稍后再试") - return tasks = list(self.model.tasks) if not tasks: self._set_status("没有可采集任务") @@ -784,51 +749,6 @@ class CollectTab(QWidget): self._start_collect_activity() thread.start() - def recheck_product_status(self, checked=False): - if self._collect_operation_running() or self.write_back_thread is not None: - self._set_status("当前批处理尚未结束,请稍后再试") - return - tasks = list(self.model.tasks) - if not tasks: - self._set_status("当前筛选结果没有可重新检测商品状态的任务") - return - answer = QMessageBox.question( - self, - "重新检测商品状态", - "将重新打开当前筛选结果中的 {count} 条商品详情页,只读取并保存商品状态。\n\n" - "不会读取或覆盖旧标题、旧封面、新标题、新封面;不会改变任务阶段、结果、线上提交标记或 Excel。\n\n" - "确定开始重新检测吗?".format(count=len(tasks)), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if answer != QMessageBox.Yes: - self._set_status("已取消重新检测商品状态") - return - worker = StatusRecheckWorker( - tasks, - db_path=self.db_path, - config=self.config, - diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, - ) - activity_signal = getattr(worker, "activity", None) - if activity_signal is not None: - activity_signal.connect(self._on_collect_activity) - worker.progress.connect(self._on_status_recheck_progress) - worker.row_updated.connect(self._on_status_recheck_row_updated) - worker.log.connect(self._on_collect_log) - worker.failed.connect(self._on_status_recheck_failed) - worker.finished.connect(self._on_status_recheck_finished) - worker.cancelled.connect(self._on_status_recheck_cancelled) - self.run_log_view.clear() - thread = run_worker(worker, thread_name="StatusRecheckWorker", start=False) - thread.finished.connect(lambda: self._forget_status_recheck_thread(thread)) - self.status_recheck_worker = worker - self.status_recheck_thread = thread - self._set_collect_running(True) - self._start_collect_activity("商品状态重检") - self._set_status(f"正在重新检测 {len(tasks)} 条商品状态...") - thread.start() - def _choose_collect_scope(self): box = ProductStatusScopeDialog( title="选择采集范围", @@ -852,12 +772,11 @@ class CollectTab(QWidget): return box.choice() def stop_collect(self, checked=False): - worker = self.collect_worker or self.status_recheck_worker - if worker is not None: - worker.cancel() + if self.collect_worker is not None: + self.collect_worker.cancel() self._collect_stop_requested = True self._refresh_collect_activity() - self._set_status(f"正在停止{self._collect_operation_label}...") + self._set_status("正在停止采集...") def write_back_old_data(self, checked=False): batch_id = self._active_batch_id() @@ -867,9 +786,6 @@ class CollectTab(QWidget): self._start_write_back(batch_id) def _start_write_back(self, batch_id, auto=False): - if self._collect_operation_running(): - self._set_status("当前批处理尚未结束,暂不能回写 Excel") - return False if self.write_back_thread is not None: self._set_status("Excel 回写正在进行...") return False @@ -916,7 +832,6 @@ class CollectTab(QWidget): self.import_button.setEnabled(not running) self.refresh_button.setEnabled(not running) self.collect_button.setEnabled(not running) - self.status_recheck_button.setEnabled(not running) self.write_back_button.setEnabled(not running) self.stop_collect_button.setEnabled(running) self.batch_filter.setEnabled(not running) @@ -929,7 +844,6 @@ class CollectTab(QWidget): self.import_button.setEnabled(not running) self.refresh_button.setEnabled(not running) self.collect_button.setEnabled(not running) - self.status_recheck_button.setEnabled(not running) self.write_back_button.setEnabled(not running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) @@ -944,16 +858,6 @@ class CollectTab(QWidget): self.collect_thread = None self.collect_worker = None - def _forget_status_recheck_thread(self, thread): - if self.status_recheck_thread is thread: - if self._collect_elapsed_timer.isActive(): - self._finish_collect_activity("error") - self.status_recheck_thread = None - self.status_recheck_worker = None - - def _collect_operation_running(self): - return bool(self.collect_thread or self.status_recheck_thread) - def _forget_write_back_thread(self, thread): if self.write_back_thread is thread: self.write_back_thread = None @@ -976,25 +880,6 @@ class CollectTab(QWidget): def _on_collect_failed(self, task_id, error): self._set_status(f"任务 {task_id} 采集失败:{error}") - def _on_status_recheck_progress(self, payload): - self._set_status( - "商品状态重检进度:{done}/{total},成功{rechecked},略过{skipped},失败{failed}".format( - done=payload.get("done", 0), - total=payload.get("total", 0), - rechecked=payload.get("rechecked", 0), - skipped=payload.get("skipped", 0), - failed=payload.get("failed", 0), - ) - ) - - def _on_status_recheck_row_updated(self, task_id, fields): - self.refresh_tasks() - if self.refresh_workflow_callback is not None: - self.refresh_workflow_callback() - - def _on_status_recheck_failed(self, task_id, error): - self._set_status(f"任务 {task_id} 商品状态重检失败:{error}") - def _on_collect_finished(self, payload): self._set_collect_running(False) if payload.get("blocked"): @@ -1043,40 +928,6 @@ class CollectTab(QWidget): return self._set_status(message) - def _on_status_recheck_finished(self, payload): - self._set_collect_running(False) - if payload.get("blocked"): - self._finish_collect_activity("blocked", payload) - elif payload.get("error"): - self._finish_collect_activity("error", payload) - else: - self._finish_collect_activity("finished", payload) - self.last_status_recheck_run_id = ( - payload.get("run_id") or self.last_status_recheck_run_id - ) - self.refresh_tasks() - if self.refresh_workflow_callback is not None: - self.refresh_workflow_callback() - self._load_latest_collect_run_log("status_recheck") - if payload.get("blocked"): - self._show_status_recheck_blocked(payload) - return - message = "商品状态重检完成:成功{rechecked},略过{skipped},失败{failed}".format( - rechecked=payload.get("rechecked", 0), - skipped=payload.get("skipped", 0), - failed=payload.get("failed", 0), - ) - counts = payload.get("product_status_counts") or {} - if counts: - message += ";正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}".format( - normal=counts.get("normal", 0), - unlisted=counts.get("unlisted", 0), - reviewing=counts.get("reviewing", 0), - unknown=counts.get("unknown", 0), - ) - self._show_status_recheck_account_summary(payload, message) - self._set_status(message) - def _show_collect_blocked(self, payload): lines = ["采集前检查未通过。"] if payload.get("no_accounts"): @@ -1101,18 +952,6 @@ class CollectTab(QWidget): ) self._show_account_guide("\n".join(lines)) - def _show_status_recheck_blocked(self, payload): - lines = ["商品状态重检前检查未通过。"] - if payload.get("no_accounts"): - lines.append("当前没有配置账号。") - launch_failed = payload.get("launch_failed") or [] - if launch_failed: - lines.append( - "以下账号 Chrome 启动失败:" - + "、".join(self._account_label(item) for item in launch_failed) - ) - self._show_account_guide("\n".join(lines), operation_label="商品状态重检") - def _show_collect_account_summary(self, payload, message): launched = payload.get("launched_accounts") or [] reused = payload.get("reused_accounts") or [] @@ -1163,41 +1002,6 @@ class CollectTab(QWidget): else: QMessageBox.information(self, "采集完成", text) - def _show_status_recheck_account_summary(self, payload, message): - launched = payload.get("launched_accounts") or [] - reused = payload.get("reused_accounts") or [] - login_required = payload.get("login_required_accounts") or [] - skip_counts = normalize_skip_reason_counts( - payload.get("skip_reason_counts"), - skipped_total=int(payload.get("skipped", 0) or 0), - ) - lines = [message] - skip_summary = format_skip_reason_summary( - skip_counts, - skipped_total=int(payload.get("skipped", 0) or 0), - ) - if skip_summary: - lines.append(skip_summary) - if launched: - lines.append( - "本轮已自动启动账号 Chrome:" - + "、".join(self._account_label(item) for item in launched) - ) - if login_required: - lines.append( - "以下账号需要补登录:" - + "、".join(self._account_label(item) for item in login_required) - ) - if login_required or skip_counts[LOGIN_REQUIRED] > 0: - lines.append("请到账号管理完成对应账号登录后,再重新检测商品状态。") - if launched or reused: - lines.append("检测结束后不会自动关闭账号 Chrome,请按需自行关闭。") - text = "\n".join(lines) - if login_required or skip_counts[LOGIN_REQUIRED] > 0: - QMessageBox.warning(self, "商品状态重检完成", text) - else: - QMessageBox.information(self, "商品状态重检完成", text) - def _account_label(self, item): if isinstance(item, dict): name = item.get("account_name") or item.get("alias") or "" @@ -1221,19 +1025,6 @@ class CollectTab(QWidget): ) ) - def _on_status_recheck_cancelled(self, payload): - self._set_collect_running(False) - self._finish_collect_activity("cancelled", payload) - self.refresh_tasks() - if self.refresh_workflow_callback is not None: - self.refresh_workflow_callback() - self._set_status( - "商品状态重检已停止:完成{done}/{total}".format( - done=payload.get("done", 0), - total=payload.get("total", 0), - ) - ) - def _on_write_back_failed(self, task_id, error, auto=False): message = f"Excel {'自动' if auto else ''}回写失败:{error}" if "被占用" in str(error): diff --git a/app/gui/workers.py b/app/gui/workers.py index 4c8b040..d1176d6 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -2738,383 +2738,6 @@ class CollectWorker(BaseWorker): def _elapsed_ms(self, started): return int((time.monotonic() - started) * 1000) - -class StatusRecheckWorker(CollectWorker): - """Re-read product status snapshots without changing task workflow fields.""" - - def __init__( - self, - tasks, - db_path=None, - config=None, - preflight=True, - diagnostic_log_dir=None, - ): - super().__init__( - tasks, - db_path=db_path, - config=config, - preflight=preflight, - diagnostic_log_dir=diagnostic_log_dir, - collect_scope=product_status.COLLECT_SCOPE_ALL, - ) - - def execute(self): - account_rows = accounts.list_accounts(path=self.db_path, config=self.config) - account_by_alias = { - str(account.alias).strip(): account - for account in account_rows - if str(account.alias).strip() - } - eligible = list(self.tasks) - batch_ids = self._batch_ids(eligible) - total = len(eligible) - rechecked = 0 - skipped = 0 - failed = 0 - done = 0 - login_skip_reasons = {} - login_required_accounts = {} - preflight_info = {} - skip_reason_counts = empty_skip_reason_counts() - product_status_counts = { - status: 0 for status in product_status.VALID_PRODUCT_STATUSES - } - - self._run_id = self._create_run_log(eligible, batch_ids) - self._emit_activity("preflight_started", total=total, step="preflight") - self._log_run_event( - f"step=preflight result=start detail=商品状态重检开始 total={total}" - ) - - if self.preflight: - blocked, preflight_info = self._preflight_prepare( - eligible, - account_rows, - account_by_alias, - ) - if blocked: - self._log_preflight_blocked(blocked) - summary = self._summary( - ok=False, - total=total, - done=done, - collected=rechecked, - skipped=skipped, - failed=failed, - batch_ids=batch_ids, - blocked=True, - extra={ - **blocked, - "rechecked": rechecked, - "skip_reason_counts": dict(skip_reason_counts), - "product_status_counts": dict(product_status_counts), - }, - ) - self._finish_run_log("blocked", summary) - return summary - for item in preflight_info.get("logged_out") or []: - alias = str(item.get("alias") or "").strip() - reason = item.get("reason") or "账号未登录" - if alias: - login_skip_reasons[alias] = reason - login_required_accounts[alias] = item - self._log_run_event("step=preflight result=success detail=账号就绪检查完成") - else: - self._log_run_event( - "step=preflight result=skipped detail=测试模式跳过商品状态重检前检查", - level="warning", - ) - - for index, task in enumerate(eligible, start=1): - if self.should_cancel(): - break - self._emit_activity( - "task_started", - task=task, - index=index, - total=total, - step="match_account", - ) - account = account_by_alias.get(str(task.alias).strip()) - if account is None: - skipped += 1 - done += 1 - skip_reason_counts[ALIAS_UNMATCHED] += 1 - reason = "别名未匹配账号" - self._log_run_event( - "step=match_account result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( - task_id=task.id, - item_id=task.item_id, - reason=reason, - ), - task=task, - level="warning", - ) - self._emit_activity( - "task_finished", - task=task, - index=index, - total=total, - step="match_account", - result="skipped", - ) - self._emit_recheck_progress(done, total, rechecked, skipped, failed) - continue - - alias = str(task.alias).strip() - if alias in login_skip_reasons: - skipped += 1 - done += 1 - skip_reason_counts[LOGIN_REQUIRED] += 1 - reason = login_skip_reasons[alias] - self._log_run_event( - "step=login_check result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( - task_id=task.id, - item_id=task.item_id, - reason=reason, - ), - task=task, - level="warning", - ) - self._emit_activity( - "task_finished", - task=task, - index=index, - total=total, - step="check_login", - result="skipped", - ) - self._emit_recheck_progress(done, total, rechecked, skipped, failed) - continue - - self._emit_activity( - "task_step", - task=task, - index=index, - total=total, - step="check_login", - ) - status = self._confirmed_login_status(account, context="status_recheck", task=task) - if self._is_definitive_logged_out(status): - skipped += 1 - done += 1 - skip_reason_counts[LOGIN_REQUIRED] += 1 - reason = self._midrun_login_skip_reason(status) - login_skip_reasons[alias] = reason - login_required_accounts[alias] = self._account_payload(account, reason) - self._log_run_event( - "step=login_check result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( - task_id=task.id, - item_id=task.item_id, - reason=reason, - ), - task=task, - level="warning", - ) - self._emit_activity( - "task_finished", - task=task, - index=index, - total=total, - step="check_login", - result="skipped", - ) - self._emit_recheck_progress(done, total, rechecked, skipped, failed) - continue - if not status.get("logged_in"): - self._log_run_event( - "step=login_check result=uncertain detail=任务 {task_id} 商品 {item_id} 登录状态检测暂时不稳定,继续尝试读取商品状态: {detail}".format( - task_id=task.id, - item_id=task.item_id, - detail=self._login_status_detail(status), - ), - task=task, - level="warning", - ) - - started = time.monotonic() - current_step = "read_product_status" - activity_result = "success" - - def on_step(step): - nonlocal current_step - current_step = str(step) - self._emit_activity( - "task_step", - task=task, - index=index, - total=total, - step=current_step, - ) - self._log_run_event( - "step={step} result=start detail=任务 {task_id} 商品 {item_id}".format( - step=current_step, - task_id=task.id, - item_id=task.item_id, - ), - task=task, - ) - - try: - result = editor.recheck_product_status( - account, - {"item_id": task.item_id}, - on_step=on_step, - ) - detected_status = product_status.normalize_status( - result.get("product_status") - ) - product_status_counts[detected_status] += 1 - current_step = "db_write" - self._emit_activity( - "task_step", - task=task, - index=index, - total=total, - step="save_result", - ) - self._log_run_event( - "step=db_write result=start detail=任务 {task_id} 商品 {item_id} 保存商品状态".format( - task_id=task.id, - item_id=task.item_id, - ), - task=task, - ) - db.set_product_status( - task.id, - detected_status, - result.get("product_status_note"), - path=self.db_path, - ) - rechecked += 1 - elapsed_ms = self._elapsed_ms(started) - self.row_updated.emit( - task.id, - { - "product_status": detected_status, - "product_status_note": result.get("product_status_note"), - }, - ) - self._log_run_event( - "step=db_write result=success detail=任务 {task_id} 商品 {item_id} 商品状态重检成功 elapsed_ms={elapsed_ms}".format( - task_id=task.id, - item_id=task.item_id, - elapsed_ms=elapsed_ms, - ), - task=task, - ) - if result.get("close_target_confirmed") is False: - self._log_run_event( - "step=close_product result=uncertain detail=任务 {task_id} 商品 {item_id} 商品页已请求关闭,但未在短时间内确认关闭;状态结果已保存,继续处理后续任务".format( - task_id=task.id, - item_id=task.item_id, - ), - task=task, - level="warning", - ) - except Exception as exc: - activity_result = "failed" - failed += 1 - error = str(exc) or exc.__class__.__name__ - safe_error = diagnostics.redact_log_text(error) - display_error = db.format_failure_error(safe_error, current_step) - elapsed_ms = self._elapsed_ms(started) - self.failed.emit(task.id, display_error) - self._log_run_event( - "step={step} result=failed detail=商品状态重检失败: {error} elapsed_ms={elapsed_ms}".format( - step=current_step, - error=safe_error, - elapsed_ms=elapsed_ms, - ), - task=task, - level="error", - ) - self._write_diagnostic_log( - "商品状态重检失败", - level="ERROR", - step=current_step, - task=task, - elapsed_ms=elapsed_ms, - payload={"error": safe_error}, - exc=exc, - ) - finally: - done += 1 - self._emit_activity( - "task_finished", - task=task, - index=index, - total=total, - step=current_step, - result=activity_result, - ) - self._emit_recheck_progress(done, total, rechecked, skipped, failed) - - summary = self._summary( - ok=failed == 0, - total=total, - done=done, - collected=rechecked, - skipped=skipped, - failed=failed, - batch_ids=batch_ids, - extra={ - **preflight_info, - "rechecked": rechecked, - "login_required_accounts": list(login_required_accounts.values()), - "skip_reason_counts": dict(skip_reason_counts), - "product_status_counts": dict(product_status_counts), - }, - ) - self._finish_run_log("cancelled" if self.should_cancel() else "done", summary) - return summary - - def _emit_recheck_progress(self, done, total, rechecked, skipped, failed): - self.progress.emit( - { - "done": done, - "total": total, - "rechecked": rechecked, - "skipped": skipped, - "failed": failed, - } - ) - - def _create_run_log(self, eligible, batch_ids): - try: - return db.create_run_log( - "status_recheck", - dry_run=False, - total=len(eligible), - options={ - "batch_ids": batch_ids, - "preflight": self.preflight, - "scope": "current_filters", - }, - 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("rechecked", 0), - skipped_count=summary.get("skipped", 0), - failed_count=summary.get("failed", 0), - summary_json=summary, - path=self.db_path, - ) - except Exception: - return - - class WriteBackWorker(BaseWorker): """Write Excel fields back in a background thread.""" diff --git a/app/product_status.py b/app/product_status.py index 5fbfee6..eefe8da 100644 --- a/app/product_status.py +++ b/app/product_status.py @@ -138,7 +138,6 @@ def build_apply_plan(tasks, update_mode) -> dict: STATUS_UNLISTED: [], STATUS_REVIEWING: [], STATUS_UNKNOWN: [], - "unverified": [], } normal_candidates = [] for task in base_candidates: @@ -146,8 +145,6 @@ def build_apply_plan(tasks, update_mode) -> dict: status_counts[status] += 1 if status == STATUS_NORMAL: normal_candidates.append(task) - elif status == STATUS_UNKNOWN and not _has_status_snapshot(task): - status_excluded["unverified"].append(task) else: status_excluded[status].append(task) @@ -177,7 +174,6 @@ def build_apply_plan(tasks, update_mode) -> dict: "unlisted": status_excluded[STATUS_UNLISTED], "reviewing": status_excluded[STATUS_REVIEWING], "unknown": status_excluded[STATUS_UNKNOWN], - "unverified": status_excluded["unverified"], "missing_title": missing_title, "missing_cover": missing_cover, "status_scope_excluded": status_excluded_count, @@ -243,10 +239,6 @@ def _task_status(task) -> str: return normalize_status(_task_value(task, "product_status")) -def _has_status_snapshot(task) -> bool: - return bool(str(_task_value(task, "product_status") or "").strip()) - - def _task_value(task, name, default=None): if isinstance(task, dict): return task.get(name, default) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 97b29a5..c79b53b 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -76,7 +76,7 @@ imported → collected → generated → applied - `accounts`:账号 CRUD 服务;生成目录、启动登录、检测登录;不自动登录/填密码。 - `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。 - `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。 -- `editor`:登录检测、读取商品状态、**采集**(读旧标题、下载旧封面)、仅重检商品状态、改标题、换封面、点更新。 +- `editor`:登录检测、读取商品状态、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。 - `product_status`:商品状态代码 `normal/unlisted/reviewing/unknown` 的归一化、中文显示、EDS 提示分类和下游任务分组;①②③只能复用该模块,不各自判断。 - `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`、`analyze_product_images(instruction, context, image_paths)`;前两者分别负责②标题/生图,后者只供商品套图中的「AI帮写」调用 cmhub 图片理解接口,读取1至8张按 `source_order` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。 - `cmhub_models`:格式化 cmhub 模型别名,并维护仅进程内有效的短期模型目录缓存;缓存键使用规整网关地址和别名,不含 API Key,不写入配置、SQLite、日志或导出文件。 @@ -268,7 +268,7 @@ CREATE TABLE tasks ( alias TEXT NOT NULL, item_id TEXT NOT NULL, -- 商品状态快照(采集页面探测,不宣称实时在售) - product_status TEXT, -- normal/unlisted/reviewing/unknown;历史 NULL 读作 unknown + product_status TEXT, -- normal/unlisted/reviewing/unknown;上线前活动历史 NULL 启动时迁移为 normal product_status_note TEXT, -- 归一化 EDS 标题/说明摘要,最多 2000 字符 product_status_at TEXT, -- 本次页面状态探测时间 -- 采集输出(程序写,改前快照) @@ -356,7 +356,7 @@ CREATE TABLE run_log_events ( - `account_name` 仅展示/参考;匹配以 `alias` 为准。 - `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。 - `row_key` 防止同一批次内重复导入同一行。 -- `product_status/product_status_note/product_status_at`:①每次打开商品编辑页后写入的状态快照;`normal` 只表示没有已知异常横幅,不等于实时“在售”。历史 NULL 与非法值在状态计算中按 `unknown` 处理,但③预检要把原始 NULL/空值单列为「尚未检测商品状态」,与实际已检测的 `unknown` 分开提示。 +- `product_status/product_status_note/product_status_at`:①每次打开商品编辑页后写入的状态快照;`normal` 只表示没有已知异常横幅,不等于实时“在售”。商品状态功能上线时间 `2026-07-18T16:34:37` 前的活动历史批次中,NULL/空状态在 `init_db()` 时按产品决策迁移为 `normal`,附「历史批次默认按架上商品处理(未实时检测)」备注且保持 `product_status_at=NULL`;上线时点及之后的 NULL 与非法值按 `unknown` 处理。 - `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。 - `new_title/new_cover_path`:**AI 生成**的两个独立组件结果(输出)。③只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新图文才同时要求二者;只生成封面允许 `new_title=NULL`,已采集的 `old_title` 只作为封面prompt语义参考,不回填 `new_title`。不设逐条确认阶段。 - `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。 @@ -407,7 +407,7 @@ data/images///__new. # AI 生成的新 ### 6.0 GUI 线程模型(PySide6) - 主线程只运行 `QApplication`、窗口、表格、弹窗和状态刷新;不得在主线程执行 CDP、AI、Excel 回写、图片下载等耗时任务。 -- 每类耗时流程封装为 `QObject` worker:`CollectWorker`、`StatusRecheckWorker`、`GenerateWorker`、`ApplyWorker`、`ImportWorker`、`WriteBackWorker`。 +- 每类耗时流程封装为 `QObject` worker:`CollectWorker`、`GenerateWorker`、`ApplyWorker`、`ImportWorker`、`WriteBackWorker`。 - Worker 通过 signal 向 GUI 汇报:`progress`、`row_updated`、`log`、`failed`、`finished`、`cancelled`;GUI 槽函数只做 UI 刷新和按钮状态切换。 - Worker 不直接操作任何 Qt widget,不弹窗;需要用户确认的动作(如 ③ 开始更新确认)必须在主线程先完成,再启动 worker。 - `停止` 使用协作式取消:GUI 调用 worker 的 cancel 标记;worker 在任务间/重试前检查,取消未开始项,正在执行的单条任务跑到安全边界后结束。 @@ -422,7 +422,7 @@ data/images///__new. # AI 生成的新 - 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在后台只读、程序自动新建的商品 tab 内,`open_product` 要关闭并执行同样的有界 target 消失确认;复用用户已有 tab 不关闭。③前台更新打开失败仍沿用既有清理路径,不引入额外等待。 - 商品状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得依赖 Vue `data-v-*`。`審核中/审核中` 归为 `reviewing`,`您的商品未上架` 归为 `unlisted`,没有 warning 为 `normal`,未识别横幅、DOM 异常或非法返回为 `unknown`。保存归一化摘要而非页面 HTML;状态探测异常只写脱敏诊断,不伪造技术采集失败。 - 每次点击采集先在主线程选择本轮范围,策略值为 `normal_only/all`,默认 `normal_only`,不跨轮记忆。两种范围都会逐条重新打开页面检测并独立保存状态,不能用旧数据库状态预过滤;`normal_only` 仅正常商品读取标题和封面,其他三类状态记为业务略过并保留旧内容,`all` 则四类状态均走现有采集流程。范围略过不是技术失败,汇总分别统计状态和略过数。 -- ①另有「重新检测商品状态」独立 Worker,面向当前筛选结果中的历史阶段任务。它复用账号就绪、登录检测、后台详情页 tab、状态 DOM 读取与自动新建 tab 关闭规则,但只调用 `editor.recheck_product_status()` 和 `db.set_product_status()`;不得调用 `db.set_collected()`、`mark_running()`、`mark_failed()`、Excel 回写或生成/更新逻辑。技术失败不覆盖已有状态快照,也不改变 `stage/status/committed`。该流程独立记录 `run_type=status_recheck`,汇总成功、略过、失败和四类状态计数。 +- 不提供历史商品状态重检按钮或批量打开详情页的补检流程。历史默认迁移只在数据库初始化时执行,条件为活动批次创建时间严格早于上线时间且状态为空;已有四类状态、软删除批次、上线时点及之后的空状态均不改写。 - 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images///__old.jpg`。 - 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。 @@ -477,7 +477,7 @@ data/images///__new. # AI 生成的新 ### 6.3 应用更新(③ Tab) - ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。 -- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,`product_status.build_apply_plan()` 先把候选按商品状态冻结:仅 `normal` 可继续检查 `new_title` / `new_cover_path`,`unlisted`、`reviewing`、已检测 `unknown` 与历史 NULL 均从 Worker 输入剔除;再对正常商品分出可执行项和内容缺失项。原始 NULL/空状态要单列为「尚未检测商品状态」并引导①重检,已检测但不能确认的 `unknown` 保持「状态未知」。状态异常与内容缺失仅在确认框、状态栏和本轮日志列为预检排除,不打开其 Chrome、不写其失败状态、Excel 或运行内 worker 统计。无可执行项时,优先展示真实商品状态原因;只有没有状态异常时才沿用「更新内容未生成」文案。 +- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,`product_status.build_apply_plan()` 先把候选按商品状态冻结:仅 `normal` 可继续检查 `new_title` / `new_cover_path`,`unlisted`、`reviewing`、`unknown` 与未满足历史迁移条件的 NULL 均从 Worker 输入剔除;再对正常商品分出可执行项和内容缺失项。状态功能上线前活动历史批次的空状态由 `init_db()` 已迁移为默认 `normal`,可直接进入内容完整性检查。状态异常与内容缺失仅在确认框、状态栏和本轮日志列为预检排除,不打开其 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` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。 diff --git a/docs/api.md b/docs/api.md index 74f8d45..de95caa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -111,6 +111,7 @@ mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进 mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进 set_product_status(task_id, product_status, product_status_note=None, product_status_at=None) -> None # 只写商品状态快照,不改变 stage/status/标题/封面;非法状态规范为 unknown +# init_db() 会将 2026-07-18T16:34:37 前活动历史批次的 NULL/空状态一次性迁移为 normal,保留 product_status_at=NULL 并写历史默认备注 set_collected(task_id, old_title, old_cover_path, path=None, conn=None, *, product_status_value=None, product_status_note=None, product_status_at=None) -> 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;new_cover_path 可为 None 表示只生成标题 @@ -266,8 +267,6 @@ open_product(account, item_id) -> CDP # 连端口、导航商品页、等 # 采集(只读) read_product_status(cdp) -> dict # {product_status, product_status_note, product_status_error};仅读 EDS warning,不保存 HTML -recheck_product_status(account, task, on_step=None) -> dict -# 只打开详情页读取状态;状态 DOM 读取失败抛中文异常,不把 unknown 快照返回给调用方;结束时沿用只读 tab 关闭规则 read_title(cdp) -> str read_cover_src(cdp) -> str # 第一张 itembox 的 img.src download_cover(src, out_path) -> str # 下载旧封面到本地 @@ -292,7 +291,7 @@ apply_task(account, task, close_success_tab=False) -> dict - `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。 - `open_product()` 进入/刷新商品编辑页后要安装 toast 监听。标题主定位是 `data-product-edit-field-unique-id="name"` 内唯一可见的 `input.eds-input__input`,主图和上传入口共同限定在 `data-product-edit-field-unique-id="images"` 内同一个 `.shopee-image-manager`;只有相应业务字段根不存在时才使用旧 DOM 回退,不得再用标题长度判断主定位。就绪检查返回标题命中数、主图数量/CDN/blob、上传 input 数量和当前 URL;超时必须先说明缺少标题、主图还是上传入口。明确商品失效/不存在/无权限 toast(如 `please input correct product id`)即使已隐藏也上浮,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;普通物流/备货等 toast 只有仍可见且属于当前页面 URL 时才作为“可能无关”的附加提示,不能覆盖就绪快照。不得记录 Cookie、密码、token。调用方只在明确商品失效类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:后台只读自动新建 tab 断开 CDP、关闭 target 并执行最多 2 秒的关闭确认;③前台更新自动新建 tab 沿用原关闭路径;复用用户已有 tab 只断开 CDP。 -- `read_product_status()` 在读取标题/封面前读取 `.eds-alert.eds-alert--warning` 下的 `.eds-alert-title/.eds-alert-desc`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`,无 warning → `normal`,未识别 warning、DOM 异常或非法响应 → `unknown`;多横幅按 DOM 顺序取第一个白名单命中,不依赖 `data-v-*`,只返回归一化 note,不保存 HTML。状态读取异常不阻断 `collect()`,由调用方写脱敏诊断日志和 `unknown` 快照;`recheck_product_status()` 则把读取异常作为技术失败,不覆盖既有快照。 +- `read_product_status()` 在读取标题/封面前读取 `.eds-alert.eds-alert--warning` 下的 `.eds-alert-title/.eds-alert-desc`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`,无 warning → `normal`,未识别 warning、DOM 异常或非法响应 → `unknown`;多横幅按 DOM 顺序取第一个白名单命中,不依赖 `data-v-*`,只返回归一化 note,不保存 HTML。状态读取异常不阻断 `collect()`,由调用方写脱敏诊断日志和 `unknown` 快照。不存在独立历史状态重检接口或批量详情页补检流程。 - `collect()` 先读取状态再决定是否读取标题/封面。`task.collection_scope=normal_only` 时,未上架、审核中、状态未知返回 `collection_skipped=True` 和中文原因,不下载封面、不覆盖旧内容;`all` 时四类状态都继续采集。两种策略都重新读取页面状态,不能用历史状态预过滤。结束时只关闭本轮自动新建的商品编辑页 tab,并通过 `close_tab_and_wait()` 在最多 2 秒内确认 target 从 `/json` 消失,结果写入 `close_target_confirmed`。确认超时只记警告,不覆盖已成功读取的标题/封面;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭、不等待。 - ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。 @@ -463,7 +462,6 @@ class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响 class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果 class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建 class CollectWorker(BaseWorker) # ① 后台采集:范围 normal_only/all + 账号预检 -> editor.collect -> 状态独立落库,采集或略过 -class StatusRecheckWorker(BaseWorker) # ① 后台状态重检:当前筛选任务 + 账号预检 -> editor.recheck_product_status -> 仅 set_product_status class GenerateWorker(BaseWorker) # ② 后台生成:确认后的 normal_only/all 精确任务 -> ai.generate_batch -> 写库 + 进度 class ApplyWorker(BaseWorker) # ③ 后台更新:再次拒绝非正常状态 -> 账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel @@ -524,8 +522,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。 - 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。 - 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。 -- 「采集旧标题/旧封面」先弹 `ProductStatusScopeDialog` 范围确认框,默认按钮为“采集架上商品”(稳定策略 `normal_only`,仅继续采集检测结果为正常的商品),扩展按钮为“采集全部商品”(`all`,还包括未上架、审核中和状态未知商品);后者是警示橙色的扩大范围操作而非删除操作。对话框最小宽度 520px,三个中文选项纵向全宽显示,取消不创建 Worker。`CollectWorker` 只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 先检测和保存页面状态;默认范围仅正常商品下载旧封面到 `image_dir///__old.jpg` 并 `db.set_collected()`,其他状态 `set_product_status()` 后按范围 `mark_skipped` 且保留原旧内容;选择全部范围时四类状态均采集。别名未匹配任务仍逐条 `mark_skipped`;单条技术失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。 -- 「重新检测商品状态」不使用 `CollectWorker` 的阶段过滤,接收当前筛选结果的历史任务,确认后创建 `StatusRecheckWorker`。逐条只调用 `editor.recheck_product_status()` 并在成功后 `db.set_product_status()`;不得调用 `set_collected/mark_running/mark_failed/mark_skipped`,不改 `stage/status/committed`、标题、封面或 Excel。详情页、登录或 DOM 技术失败只写脱敏 `run_type=status_recheck` / 本地诊断日志并保留原快照。③预检对状态原始 NULL/空值显示「尚未检测商品状态」并引导此入口;已写入 `unknown` 的记录继续显示「状态未知」。 +- 「采集旧标题/旧封面」先弹 `ProductStatusScopeDialog` 范围确认框,默认按钮为“采集架上商品”(稳定策略 `normal_only`,仅继续采集检测结果为正常的商品),扩展按钮为“采集全部商品”(`all`,还包括未上架、审核中和状态未知商品);后者是警示橙色的扩大范围操作而非删除操作。对话框最小宽度 520px,三个中文选项纵向全宽显示,取消不创建 Worker。`CollectWorker` 只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 先检测和保存页面状态;默认范围仅正常商品下载旧封面到 `image_dir///__old.jpg` 并 `db.set_collected()`,其他状态 `set_product_status()` 后按范围 `mark_skipped` 且保留原旧内容;选择全部范围时四类状态均采集。别名未匹配任务仍逐条 `mark_skipped`;单条技术失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。商品状态功能上线时间前的活动历史批次空状态由 `init_db()` 默认迁移为 `normal`,不打开 Chrome、不创建 Worker;上线时点及之后的缺失状态仍保持未知语义。 - 采集打开商品页时,若本轮自动新建 tab,采集完成后会关闭该 tab,并在最多 2 秒内确认 target 已从 `/json` 消失;确认超时只写 warning/诊断,不把采集成功改成失败。若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。 - 采集中途登录检测必须快速跳过正在销毁的旧商品 target,改连其他有效 Shopee 页面。Cookie API 调用失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,只有 Cookie API 成功返回空会话时才返回 `NO_SESSION_COOKIE`;两者都不按明确掉登录批量略过,显式 `LOGIN_PAGE` 仍按账号需登录处理。retry/recovered 运行日志包含当前任务 ID 和商品 ID,避免与上一条采集成功日志混淆。 - 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。 @@ -551,7 +548,7 @@ 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` 的任务;真正开始更新前先按 `product_status` 分出可进入线上更新的 `normal` 与必须排除的 `unlisted/reviewing/unknown`(历史 NULL 归入未知),再按③「更新内容」模式分出可执行任务与缺少 `new_title` / `new_cover_path` 的预检跳过记录。 +- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前先按 `product_status` 分出可进入线上更新的 `normal` 与必须排除的 `unlisted/reviewing/unknown`。商品状态功能上线前活动历史批次的空状态已启动时默认迁移为 `normal`;其它 NULL 仍归入未知,再按③「更新内容」模式分出可执行任务与缺少 `new_title` / `new_cover_path` 的预检跳过记录。 - 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。 - 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。 - 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。 diff --git a/docs/routes.md b/docs/routes.md index a1bdb81..f1cd39e 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -73,7 +73,7 @@ - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 - 采集:点击后先从专用范围选择框选择范围,默认「采集架上商品」(即检测结果为正常),「采集全部商品」为警示橙色描边而非删除红色,包含未上架、审核中和状态未知商品;三个选项纵向全宽显示,常见 Windows 缩放下不截断。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images///__old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。 -- 「重新检测商品状态」作用于**当前筛选结果**,可处理历史 `imported/collected/generated/applied/failed/skipped` 任务。确认框必须说明本操作只重新打开详情页并写 `product_status/product_status_note/product_status_at`,不读取或覆盖新旧标题、新旧封面,不改变 `stage/status/committed`,不回写 Excel、不生成内容、不提交线上。重检使用独立 `run_type=status_recheck` 运行日志;账号、登录、CDP 或 DOM 技术失败只记录脱敏错误并保留已有状态快照和主流程状态。 +- 商品状态功能于 `2026-07-18T16:34:37` 上线。更早创建的活动历史批次中,空状态在启动数据库时自动按「架上商品」写为 `normal`,备注「历史批次默认按架上商品处理(未实时检测)」且不写检测时间;不打开 Chrome、不访问详情页。上线时点及之后的空状态仍按未知处理,真实保存的未上架、审核中、状态未知也绝不覆盖。①不提供「重新检测商品状态」入口。 - 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 @@ -107,7 +107,7 @@ - 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 - 筛选行提供「打开图片文件夹」按钮,用于只读打开本地图片目录:选中某行时打开该商品所在账号图片文件夹(优先打开已有新/旧封面文件的真实父目录,缺失时回退到规范账号目录);未选行且选择具体批次时打开该批次图片文件夹;未选行且为全部批次时打开图片根目录。目录不存在只中文提示,不自动创建目录,不修改任务状态。 - 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面、新封面和历史候选图。 -- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。开始前先从真实候选重新分组商品状态,弹出与①共用的纵向范围确认框:默认「生成架上商品」(即仅检测结果为正常的商品),「生成全部商品」为警示橙色描边,还会处理未上架、审核中、状态未知(含历史未采集状态)商品,可能额外消耗点数;未上架、审核中、状态未知默认不入队、不请求 AI、不消耗点数,用户明确选择全部范围才可入队。范围确认不跨轮记忆,取消或候选在确认期间变化均不启动生成;无异常候选时该警示选项禁用。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。 +- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。开始前先从真实候选重新分组商品状态,弹出与①共用的纵向范围确认框:默认「生成架上商品」(即仅检测结果为正常的商品),「生成全部商品」为警示橙色描边,还会处理未上架、审核中、状态未知商品,可能额外消耗点数;未上架、审核中、状态未知默认不入队、不请求 AI、不消耗点数,用户明确选择全部范围才可入队。商品状态功能上线前的历史空状态会启动时迁移为默认正常;上线后仍为空的记录按未知处理。范围确认不跨轮记忆,取消或候选在确认期间变化均不启动生成;无异常候选时该警示选项禁用。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。 - 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **设置**;②只暴露本轮生成标题/封面/图文的内容模式。设置不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。 - 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。 - 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。 @@ -130,7 +130,7 @@ - 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。 - 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。 - 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。 -- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前统一用状态优先的预检计划:仅 `product_status=normal` 可进入标题/封面完整性检查,`unlisted`、`reviewing`、已检测的 `unknown` 及历史 NULL 一律排除,不传给 `ApplyWorker`。历史 NULL/空值在中文提示中单列为「尚未检测商品状态」,并明确引导到①点击「重新检测商品状态」;已检测但无法确认的 `unknown` 仍显示「状态未知」。有可执行记录时,状态异常和缺失内容记录都在确认框、状态栏和本轮日志列出分类、数量与示例商品ID;全部被排除时按真实原因中文弹窗阻断,不打开 Chrome、不改任务状态。用户确认后重新计算计划指纹,任务状态、更新时间、新标题或新封面变化则中止并要求重新开始。 +- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前统一用状态优先的预检计划:仅 `product_status=normal` 可进入标题/封面完整性检查,`unlisted`、`reviewing`、`unknown` 及上线后历史 NULL 一律排除,不传给 `ApplyWorker`。商品状态功能上线前的活动历史批次空状态已在数据库初始化时默认迁移为 `normal`,可进入既有内容完整性检查;该默认值会标注为未实时检测,但不要求用户额外打开商品页。状态异常和缺失内容记录都在确认框、状态栏和本轮日志列出分类、数量与示例商品ID;全部被排除时按真实原因中文弹窗阻断,不打开 Chrome、不改任务状态。用户确认后重新计算计划指纹,任务状态、更新时间、新标题或新封面变化则中止并要求重新开始。 - 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。 - 完成缺失内容预检并剔除不合格记录后,点击「开始更新」读取设置中的 `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前可执行任务超过该值时不阻断,而是自动分批执行。 - 弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、执行设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 diff --git a/docs/tasks/T-666.md b/docs/tasks/T-666.md index 1db0279..e8f5037 100644 --- a/docs/tasks/T-666.md +++ b/docs/tasks/T-666.md @@ -9,6 +9,8 @@ created: 2026-07-18 # T-666 历史批次商品状态重检与更新引导 +> 后续变更:T-667 已按产品决策移除本任务新增的状态重检入口,改为对商品状态功能上线前的活动历史批次自动默认「架上商品」。本任务保留为历史实现记录。 + ## 问题 / 背景 商品状态字段在 T-662a 才加入 SQLite。此前已采集、已生成新标题或新封面的历史任务,其 `product_status/product_status_at` 均为 NULL。T-662d 正确地将 NULL 归为状态未知并阻止③更新,避免未经状态确认的商品被提交线上;但③现有提示“回到①重新确认商品状态”无法执行,因为 `CollectWorker` 只处理 `stage=imported`,历史 `stage=generated/applied` 任务不会进入采集 Worker。直接把历史 NULL 批量写成 `normal` 没有页面事实依据,不能作为修复方案。 @@ -60,3 +62,4 @@ git diff --check - ③预检将原始 NULL/空状态单列为「尚未检测商品状态」,真实 `unknown` 继续显示「状态未知」,前者直接引导①重检;两类记录均保持安全拦截。 - 已更新 `docs/routes.md`、`docs/04-architecture.md`、`docs/api.md`,并补 worker、GUI、状态规划及编辑器测试。 - 验证通过:`py -3.10 -m unittest discover -s tests`(624 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 +- 2026-07-20:T-667 替代本任务的产品方案,移除重检入口及专用流程;历史批次改为数据库初始化时按上线时间迁移默认状态。 diff --git a/docs/tasks/T-667.md b/docs/tasks/T-667.md index 620b05c..7f039d9 100644 --- a/docs/tasks/T-667.md +++ b/docs/tasks/T-667.md @@ -1,7 +1,7 @@ --- id: T-667 title: 历史批次默认架上商品与移除状态重检入口 -status: TODO +status: DONE phase: 7 deps: [T-662a, T-662d, T-666] created: 2026-07-20 @@ -22,11 +22,11 @@ T-662a 于 2026-07-18 16:34:37(本地时间)首次上线商品状态字段 ## 验收标准 -- [ ] 启动或 `db.init_db()` 后,状态功能上线时间前的活动批次中 NULL/空状态任务变为 `normal`,备注为历史默认语义,`product_status_at` 仍为空;重复初始化不重复改写。 -- [ ] 上线时间等于或晚于阈值的 NULL/空状态任务不迁移,已有四类状态也不覆盖,软删除批次不迁移。 -- [ ] ③历史默认 `normal` 记录可按原有标题/封面完整性规则进入更新;已保存 `unknown` 仍按「状态未知」阻断。 -- [ ] ①不再显示「重新检测商品状态」,运行中控件状态、采集活动计时、回写逻辑和正常采集不回归。 -- [ ] 不打开 Chrome、不访问商品详情页、不新增 AI 调用、不修改 Excel、不更新线上商品。 +- [x] 启动或 `db.init_db()` 后,状态功能上线时间前的活动批次中 NULL/空状态任务变为 `normal`,备注为历史默认语义,`product_status_at` 仍为空;重复初始化不重复改写。 +- [x] 上线时间等于或晚于阈值的 NULL/空状态任务不迁移,已有四类状态也不覆盖,软删除批次不迁移。 +- [x] ③历史默认 `normal` 记录可按原有标题/封面完整性规则进入更新;已保存 `unknown` 仍按「状态未知」阻断。 +- [x] ①不再显示「重新检测商品状态」,运行中控件状态、采集活动计时、回写逻辑和正常采集不回归。 +- [x] 不打开 Chrome、不访问商品详情页、不新增 AI 调用、不修改 Excel、不更新线上商品。 ## 测试与文档 @@ -51,4 +51,7 @@ git diff --check ## 执行记录 -- 待实现。 +- 在 `db.init_db()` 增加幂等历史状态迁移:仅迁移上线时间前、未软删除批次的 NULL/空状态任务,写入 `normal` 和明确的历史默认备注,保留 `product_status_at=NULL`。 +- 移除 T-666 的①「重新检测商品状态」入口、专用 Worker、编辑器流程、运行日志及其③引导;常规采集的实时状态读取保持不变。 +- 更新架构、路由和 API 文档,并在 T-666 保留历史执行记录及其被替代说明。 +- 验证通过:`py -3.10 -m unittest discover -s tests`(619 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 diff --git a/tests/test_db.py b/tests/test_db.py index fede143..204259c 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -122,12 +122,125 @@ class DbTests(TempDirMixin, unittest.TestCase): task = db.get_task(1, conn=conn) self.assertEqual(0, task.cover_reset_count) self.assertIsNone(task.cover_reset_at) - self.assertIsNone(task.product_status) + self.assertEqual("normal", task.product_status) + self.assertEqual( + db.LEGACY_PRODUCT_STATUS_DEFAULT_NOTE, + task.product_status_note, + ) + self.assertIsNone(task.product_status_at) finally: conn.close() self.assert_removed(temp_dir) + def test_init_db_defaults_only_pre_feature_active_missing_status_tasks(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + batch_ids = {} + for index, name in enumerate( + ( + "legacy-null", + "legacy-blank", + "legacy-unknown", + "boundary", + "after", + "deleted", + ), + start=1, + ): + batch_id = db.create_batch([f"{name}.xlsx"], path=db_path) + batch_ids[name] = batch_id + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, f"{name}.xlsx"), + "source_sheet": "Sheet1", + "source_row": 2, + "account_name": "店铺", + "alias": "alias", + "item_id": str(51100639700 + index), + } + ], + path=db_path, + ) + + conn = db.connect(db_path) + try: + with conn: + conn.execute( + "UPDATE batches SET created_at = ? WHERE id IN (?, ?, ?, ?)", + ( + "2026-07-18T16:34:36", + batch_ids["legacy-null"], + batch_ids["legacy-blank"], + batch_ids["legacy-unknown"], + batch_ids["deleted"], + ), + ) + conn.execute( + "UPDATE batches SET created_at = ? WHERE id = ?", + (db.PRODUCT_STATUS_FEATURE_INTRODUCED_AT, batch_ids["boundary"]), + ) + conn.execute( + "UPDATE batches SET created_at = ? WHERE id = ?", + ("2026-07-18T16:34:38", batch_ids["after"]), + ) + blank_task = db.list_tasks( + batch_id=batch_ids["legacy-blank"], + conn=conn, + )[0] + unknown_task = db.list_tasks( + batch_id=batch_ids["legacy-unknown"], + conn=conn, + )[0] + conn.execute( + "UPDATE tasks SET product_status = ' ' WHERE id = ?", + (blank_task.id,), + ) + conn.execute( + "UPDATE tasks SET product_status = 'unknown', product_status_note = '已有未知状态' WHERE id = ?", + (unknown_task.id,), + ) + conn.execute( + "UPDATE batches SET deleted_at = ? WHERE id = ?", + ("2026-07-19T00:00:00", batch_ids["deleted"]), + ) + finally: + conn.close() + + db.init_db(db_path) + legacy_null = db.list_tasks(batch_id=batch_ids["legacy-null"], path=db_path)[0] + legacy_blank = db.list_tasks(batch_id=batch_ids["legacy-blank"], path=db_path)[0] + legacy_unknown = db.list_tasks(batch_id=batch_ids["legacy-unknown"], path=db_path)[0] + boundary = db.list_tasks(batch_id=batch_ids["boundary"], path=db_path)[0] + after = db.list_tasks(batch_id=batch_ids["after"], path=db_path)[0] + deleted = db.list_tasks( + batch_id=batch_ids["deleted"], + path=db_path, + include_deleted=True, + )[0] + + for task in (legacy_null, legacy_blank): + self.assertEqual("normal", task.product_status) + self.assertEqual(db.LEGACY_PRODUCT_STATUS_DEFAULT_NOTE, task.product_status_note) + self.assertIsNone(task.product_status_at) + self.assertEqual("unknown", legacy_unknown.product_status) + self.assertEqual("已有未知状态", legacy_unknown.product_status_note) + self.assertIsNone(boundary.product_status) + self.assertIsNone(after.product_status) + self.assertIsNone(deleted.product_status) + + first_updated_at = legacy_null.updated_at + db.init_db(db_path) + self.assertEqual( + first_updated_at, + db.get_task(legacy_null.id, path=db_path).updated_at, + ) + + self.assert_removed(temp_dir) + def test_account_batch_task_lifecycle(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index 254273c..ae7c75b 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -941,48 +941,6 @@ class EditorLoginTests(unittest.TestCase): self.assertIsNone(result["product_status_note"]) self.assertIn("状态读取失败", result["product_status_error"]) - def test_recheck_product_status_reads_only_status_and_closes_new_tab(self): - cdp = FakeProductCDP("ws-status-recheck", alerts=[]) - steps = [] - - with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( - "app.editor.read_title" - ) as read_title, mock.patch("app.editor.read_cover_src") as read_cover_src, mock.patch( - "app.editor.download_cover" - ) as download_cover: - result = editor.recheck_product_status( - {"debug_port": 9222}, - {"item_id": "51100639510"}, - on_step=steps.append, - ) - - self.assertEqual("normal", result["product_status"]) - self.assertIsNone(result["close_target_confirmed"]) - self.assertEqual(["read_product_status"], steps) - self.assertTrue(cdp.closed) - read_title.assert_not_called() - read_cover_src.assert_not_called() - download_cover.assert_not_called() - - def test_recheck_product_status_does_not_return_unknown_snapshot_on_read_error(self): - cdp = FakeProductCDP("ws-status-recheck", alerts=[]) - - with mock.patch("app.editor.open_product", return_value=cdp), mock.patch( - "app.editor.read_product_status", - return_value={ - "product_status": "unknown", - "product_status_note": None, - "product_status_error": "CDP 页面读取失败", - }, - ): - with self.assertRaises(editor.EditorError): - editor.recheck_product_status( - {"debug_port": 9222}, - {"item_id": "51100639510"}, - ) - - self.assertTrue(cdp.closed) - def test_collect_reads_product_status_before_title_and_returns_snapshot(self): cdp = FakeProductCDP("ws-new", alerts=[]) steps = [] diff --git a/tests/test_gui.py b/tests/test_gui.py index d1b697e..56861bc 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -7079,8 +7079,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): message = warning.call_args[0][2] self.assertIn("未上架:1 条", message) self.assertIn("审核中:1 条", message) - self.assertIn("尚未检测商品状态:1 条", message) - self.assertIn("点击「重新检测商品状态」", message) + self.assertIn("状态未知:1 条", message) self.assertNotIn("缺少新标题", message) question.assert_not_called() run_worker.assert_not_called() @@ -8375,74 +8374,6 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_status_recheck_uses_current_generated_filter_without_changing_task_fields(self): - with self.make_temp_dir() as temp_dir: - cfg = self.make_config(temp_dir) - db.init_db(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, - "alias": "alias-a", - "item_id": "51100639510", - }, - { - "source_file_abs": os.path.join(temp_dir, "input.xlsx"), - "source_sheet": "商品", - "source_row": 3, - "alias": "alias-a", - "item_id": "51100639511", - }, - ], - path=cfg["db_path"], - ) - 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_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) - tab = CollectTab(config=cfg) - self.addCleanup(tab.close) - tab.status_filter.setCurrentIndex(tab.status_filter.findData("generated")) - - class FakeWorker: - def __init__(self, worker_tasks, **kwargs): - self.tasks = list(worker_tasks) - self.kwargs = kwargs - self.progress = DummySignal() - self.row_updated = DummySignal() - self.log = DummySignal() - self.failed = DummySignal() - self.finished = DummySignal() - self.cancelled = DummySignal() - - def cancel(self): - pass - - thread = FakeThread() - with mock.patch( - "app.gui.QMessageBox.question", - return_value=gui.QMessageBox.Yes, - ) as question, mock.patch( - "app.gui.StatusRecheckWorker", FakeWorker - ), mock.patch("app.gui.run_worker", return_value=thread): - tab.recheck_product_status() - - self.assertTrue(thread.started) - self.assertEqual([task.id for task in tasks], [task.id for task in tab.status_recheck_worker.tasks]) - self.assertEqual(cfg["db_path"], tab.status_recheck_worker.kwargs["db_path"]) - self.assertIn("不会读取或覆盖旧标题", question.call_args[0][2]) - self.assertFalse(tab.collect_button.isEnabled()) - self.assertFalse(tab.status_recheck_button.isEnabled()) - 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.product_status is None for task in unchanged)) - - self.assert_removed(temp_dir) - def test_collect_scope_dialog_uses_safe_default_and_warning_all_status_choice(self): with self.make_temp_dir() as temp_dir: tab = CollectTab(config=self.make_config(temp_dir)) @@ -8487,6 +8418,18 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_collect_tab_does_not_show_legacy_status_recheck_entry(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + tab = CollectTab(config=cfg) + self.addCleanup(tab.close) + + self.assertIsNone(tab.findChild(QPushButton, "statusRecheckButton")) + self.assertFalse(hasattr(tab, "recheck_product_status")) + + self.assert_removed(temp_dir) + def test_collect_scope_dialog_accepts_default_and_escape_cancels(self): with self.make_temp_dir() as temp_dir: tab = CollectTab(config=self.make_config(temp_dir)) diff --git a/tests/test_product_status.py b/tests/test_product_status.py index 2fb4724..009321d 100644 --- a/tests/test_product_status.py +++ b/tests/test_product_status.py @@ -190,35 +190,10 @@ class ProductStatusTests(unittest.TestCase): 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([], [task.id for task in plan["unknown"]]) - self.assertEqual([4], [task.id for task in plan["unverified"]]) + 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"]) - - def test_apply_plan_separates_missing_status_from_confirmed_unknown(self): - tasks = [ - SimpleNamespace( - id=1, - updated_at="2026-07-18T10:00:00", - product_status=None, - new_title="新标题", - new_cover_path="new.jpg", - ), - SimpleNamespace( - id=2, - updated_at="2026-07-18T10:00:01", - product_status="unknown", - new_title="新标题", - new_cover_path="new.jpg", - ), - ] - - plan = product_status.build_apply_plan(tasks, "cover") - - self.assertEqual([1], [task.id for task in plan["unverified"]]) - self.assertEqual([2], [task.id for task in plan["unknown"]]) - self.assertEqual(2, plan["status_scope_excluded"]) diff --git a/tests/test_workers.py b/tests/test_workers.py index 49bdacd..1115f37 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -25,7 +25,6 @@ from app.gui.workers import ( ProductSuiteGenerateWorker, ProductSuiteHistoryExportWorker, CollectWorker, - StatusRecheckWorker, ) @@ -281,192 +280,6 @@ class WorkerTests(unittest.TestCase): all(task.stage == "collected" for task in db.list_tasks(batch_id=batch_id, path=db_path)) ) - def test_status_recheck_worker_updates_only_status_for_historical_generated_tasks(self): - with tempfile.TemporaryDirectory() as temp_dir: - db_path = os.path.join(temp_dir, "cmshopee.db") - db.init_db(db_path) - batch_id = db.create_batch(["input.xlsx"], path=db_path) - db.insert_tasks( - batch_id, - [ - { - "source_file_abs": os.path.join(temp_dir, "input.xlsx"), - "source_sheet": "Sheet1", - "source_row": index, - "account_name": "店铺", - "alias": "alias", - "item_id": str(51100639700 + index), - } - for index in range(2, 6) - ], - path=db_path, - ) - tasks = db.list_tasks(batch_id=batch_id, path=db_path) - for index, task in enumerate(tasks): - db.set_collected(task.id, f"旧标题{index}", f"old-{index}.jpg", path=db_path) - db.set_generated(task.id, f"新标题{index}", f"new-{index}.jpg", path=db_path) - db.set_applied(tasks[-1].id, committed=True, path=db_path) - statuses = ["normal", "unlisted", "reviewing", "unknown"] - account = SimpleNamespace(alias="alias", account_name="店铺", debug_port=9222) - - def fake_recheck(_account, task, on_step=None): - on_step("read_product_status") - status = statuses.pop(0) - return { - "product_status": status, - "product_status_note": f"{status} 提示", - "close_target_confirmed": True, - } - - with mock.patch( - "app.gui.workers.accounts.list_accounts", return_value=[account] - ), mock.patch( - "app.gui.workers.accounts.detect_login", - return_value={"logged_in": True, "reason": None}, - ), mock.patch( - "app.gui.workers.editor.recheck_product_status", - side_effect=fake_recheck, - ): - summary = StatusRecheckWorker( - tasks, - db_path=db_path, - preflight=False, - ).execute() - - self.assertEqual(4, summary["rechecked"]) - self.assertEqual(0, summary["failed"]) - self.assertEqual( - {"normal": 1, "unlisted": 1, "reviewing": 1, "unknown": 1}, - summary["product_status_counts"], - ) - refreshed = db.list_tasks(batch_id=batch_id, path=db_path) - self.assertEqual( - ["generated", "generated", "generated", "applied"], - [task.stage for task in refreshed], - ) - self.assertEqual( - ["success", "success", "success", "success"], - [task.status for task in refreshed], - ) - self.assertEqual( - [f"旧标题{index}" for index in range(4)], - [task.old_title for task in refreshed], - ) - self.assertEqual( - [f"新标题{index}" for index in range(4)], - [task.new_title for task in refreshed], - ) - self.assertEqual( - [f"new-{index}.jpg" for index in range(4)], - [task.new_cover_path for task in refreshed], - ) - self.assertEqual( - ["normal", "unlisted", "reviewing", "unknown"], - [task.product_status for task in refreshed], - ) - self.assertTrue(all(task.product_status_at for task in refreshed)) - run_log = db.list_run_logs(limit=1, run_type="status_recheck", path=db_path)[0] - self.assertEqual("done", run_log.status) - self.assertEqual(4, run_log.success_count) - - def test_status_recheck_worker_failure_keeps_existing_snapshot_and_workflow(self): - with tempfile.TemporaryDirectory() as temp_dir: - db_path = os.path.join(temp_dir, "cmshopee.db") - db.init_db(db_path) - batch_id = db.create_batch(["input.xlsx"], path=db_path) - db.insert_tasks( - batch_id, - [ - { - "source_file_abs": os.path.join(temp_dir, "input.xlsx"), - "source_sheet": "Sheet1", - "source_row": 2, - "account_name": "店铺", - "alias": "alias", - "item_id": "51100639801", - } - ], - path=db_path, - ) - task = db.list_tasks(batch_id=batch_id, path=db_path)[0] - db.set_collected( - task.id, - "旧标题", - "old.jpg", - product_status_value="normal", - product_status_note="历史快照", - product_status_at="2026-07-01T10:00:00", - path=db_path, - ) - db.set_generated(task.id, "新标题", "new.jpg", path=db_path) - task = db.get_task(task.id, path=db_path) - account = SimpleNamespace(alias="alias", account_name="店铺", debug_port=9222) - - with mock.patch( - "app.gui.workers.accounts.list_accounts", return_value=[account] - ), mock.patch( - "app.gui.workers.accounts.detect_login", - return_value={"logged_in": True, "reason": None}, - ), mock.patch( - "app.gui.workers.editor.recheck_product_status", - side_effect=RuntimeError("CDP 商品页读取失败"), - ): - summary = StatusRecheckWorker( - [task], - db_path=db_path, - preflight=False, - ).execute() - - refreshed = db.get_task(task.id, path=db_path) - self.assertEqual(1, summary["failed"]) - self.assertEqual("generated", refreshed.stage) - self.assertEqual("success", refreshed.status) - self.assertEqual("normal", refreshed.product_status) - self.assertEqual("历史快照", refreshed.product_status_note) - self.assertEqual("2026-07-01T10:00:00", refreshed.product_status_at) - self.assertEqual("新标题", refreshed.new_title) - self.assertEqual("new.jpg", refreshed.new_cover_path) - - def test_status_recheck_worker_cancel_keeps_generated_task_unchanged(self): - with tempfile.TemporaryDirectory() as temp_dir: - db_path = os.path.join(temp_dir, "cmshopee.db") - db.init_db(db_path) - batch_id = db.create_batch(["input.xlsx"], path=db_path) - db.insert_tasks( - batch_id, - [ - { - "source_file_abs": os.path.join(temp_dir, "input.xlsx"), - "source_sheet": "Sheet1", - "source_row": 2, - "account_name": "店铺", - "alias": "alias", - "item_id": "51100639802", - } - ], - path=db_path, - ) - task = db.list_tasks(batch_id=batch_id, path=db_path)[0] - db.set_collected(task.id, "旧标题", "old.jpg", path=db_path) - db.set_generated(task.id, "新标题", "new.jpg", path=db_path) - task = db.get_task(task.id, path=db_path) - worker = StatusRecheckWorker([task], db_path=db_path, preflight=False) - worker.cancel() - - with mock.patch("app.gui.workers.accounts.list_accounts", return_value=[]), mock.patch( - "app.gui.workers.editor.recheck_product_status" - ) as recheck: - summary = worker.execute() - - refreshed = db.get_task(task.id, path=db_path) - self.assertEqual(0, summary["done"]) - self.assertEqual(0, summary["rechecked"]) - self.assertEqual("generated", refreshed.stage) - self.assertEqual("success", refreshed.status) - recheck.assert_not_called() - run_log = db.list_run_logs(limit=1, run_type="status_recheck", path=db_path)[0] - self.assertEqual("cancelled", run_log.status) - def test_run_worker_rejects_plain_object(self): with self.assertRaises(TypeError): run_worker(object(), start=False)