From 9c4224286aa81f51d15a6da42adcb678dce16e2d Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 20 Jul 2026 10:52:32 +0800 Subject: [PATCH] fix(status): ignore promotion edit warnings --- app/db.py | 38 +++++++++ app/gui/tabs/apply.py | 2 +- app/product_status.py | 32 +++++++- docs/04-architecture.md | 6 +- docs/api.md | 4 +- docs/routes.md | 2 +- docs/tasks/T-673.md | 8 +- tests/test_db.py | 144 ++++++++++++++++++++++++++++++++++- tests/test_gui.py | 2 + tests/test_product_status.py | 29 +++++++ 10 files changed, 253 insertions(+), 14 deletions(-) diff --git a/app/db.py b/app/db.py index e6f2447..e6da987 100644 --- a/app/db.py +++ b/app/db.py @@ -18,6 +18,7 @@ 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 = "历史批次默认按架上商品处理(未实时检测)" +LEGACY_PROMOTION_STATUS_REPAIR_NOTE = "历史商品状态修正:促销编辑提示不属于商品状态异常" VALID_BATCH_FIELDS = { "source_files_json", "status", @@ -501,6 +502,7 @@ def init_db(path=None, conn=None) -> None: _ensure_task_product_status_columns(database) _ensure_task_delete_columns(database) _migrate_legacy_product_status_defaults(database) + _repair_legacy_promotion_status_defaults(database) _ensure_image_studio_project_suite_columns(database) _ensure_image_studio_project_draft_columns(database) _ensure_image_studio_job_recovery_columns(database) @@ -578,6 +580,42 @@ def _migrate_legacy_product_status_defaults(database): ) +def _repair_legacy_promotion_status_defaults(database): + """Repair only legacy unknown snapshots caused by a known benign warning.""" + + rows = database.execute( + """ + SELECT tasks.id, tasks.product_status_note + FROM tasks + INNER JOIN batches ON batches.id = tasks.batch_id + WHERE tasks.product_status = 'unknown' + AND tasks.deleted_at IS NULL + AND batches.deleted_at IS NULL + AND batches.created_at < ? + """, + (PRODUCT_STATUS_FEATURE_INTRODUCED_AT,), + ).fetchall() + task_ids = [ + int(row["id"]) + for row in rows + if product_status.is_promotion_edit_restriction_note(row["product_status_note"]) + ] + if not task_ids: + return + + placeholders = ", ".join("?" for _task_id in task_ids) + database.execute( + f""" + UPDATE tasks + SET product_status = 'normal', + product_status_note = ?, + updated_at = ? + WHERE id IN ({placeholders}) + """, + (LEGACY_PROMOTION_STATUS_REPAIR_NOTE, _now(), *task_ids), + ) + + def _ensure_image_studio_project_suite_columns(database): columns = { row["name"] diff --git a/app/gui/tabs/apply.py b/app/gui/tabs/apply.py index 089bf59..e02cfca 100644 --- a/app/gui/tabs/apply.py +++ b/app/gui/tabs/apply.py @@ -669,7 +669,7 @@ class ApplyTab(QWidget): if status_text: lines.append("当前筛选结果没有状态正常且可更新的商品。") lines.append(status_text) - lines.append("请先回到①导入采集重新确认商品状态。") + lines.append("未上架、审核中或状态未知的商品为安全起见不会更新。") content_error = self._update_content_error(update_plan, update_mode) if content_error: if lines: diff --git a/app/product_status.py b/app/product_status.py index a49a2f4..4bab95c 100644 --- a/app/product_status.py +++ b/app/product_status.py @@ -56,6 +56,10 @@ COLLECT_SCOPE_ALL = SCOPE_ALL _WHITESPACE_RE = re.compile(r"\s+") _NOTE_LIMIT = 2000 +_PROMOTION_EDIT_RESTRICTION_MARKERS = ( + ("促銷", "無法進行編輯"), + ("促销", "无法进行编辑"), +) def normalize_status(value) -> str: @@ -97,6 +101,22 @@ def normalize_text(value) -> str: return _WHITESPACE_RE.sub(" ", str(value or "").strip()) +def is_promotion_edit_restriction_alert(title, description="") -> bool: + """Return whether an alert only describes a promotion editing limitation.""" + + text = normalize_text(f"{title or ''} {description or ''}") + return any( + promotion_marker in text and edit_marker in text + for promotion_marker, edit_marker in _PROMOTION_EDIT_RESTRICTION_MARKERS + ) + + +def is_promotion_edit_restriction_note(note) -> bool: + """Recognize the persisted summary generated from a known benign alert.""" + + return is_promotion_edit_restriction_alert(note) + + def classify_alerts(alerts) -> dict: """Classify normalized EDS warning-alert data without retaining page HTML.""" @@ -105,8 +125,8 @@ def classify_alerts(alerts) -> dict: if not alerts: return _snapshot(STATUS_NORMAL, "") - first_note = "" valid_alert_seen = False + unknown_alert_note = "" for alert in alerts: if not isinstance(alert, dict): continue @@ -114,16 +134,20 @@ def classify_alerts(alerts) -> dict: title = normalize_text(alert.get("title")) description = normalize_text(alert.get("description")) note = _alert_note(title, description) - if not first_note: - first_note = note if title in {"審核中", "审核中"}: return _snapshot(STATUS_REVIEWING, note) if "您的商品未上架" in title: return _snapshot(STATUS_UNLISTED, note) + if is_promotion_edit_restriction_alert(title, description): + continue + if not unknown_alert_note: + unknown_alert_note = note if not valid_alert_seen: return _snapshot(STATUS_UNKNOWN, "") - return _snapshot(STATUS_UNKNOWN, first_note) + if not unknown_alert_note: + return _snapshot(STATUS_NORMAL, "") + return _snapshot(STATUS_UNKNOWN, unknown_alert_note) def partition_tasks(tasks) -> dict: diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 8129b8d..6b5cb11 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -423,9 +423,9 @@ data/images///__new. # AI 生成的新 - ①采集调用 `open_product(..., bring_to_front=False)`,不主动执行 `Page.bringToFront`;新建商品 tab 时尝试 `Target.createTarget(background=true)` 降低 Chrome 抢焦点概率,若当前 Chrome/CDP 不接受该参数则退回普通新建 tab。③更新是上传、拖拽和线上提交流程,每条任务均以前台方式打开或激活当前商品 tab,优先保障页面交互稳定;后台态失败安全恢复逻辑仅为兼容直接调用保留,正常③批量路径不依赖它。 - 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。Cookie API 调用失败不能伪装成空 Cookie;登录检测若连到正在关闭的旧商品 target,应在本次检测预算内重新枚举并改连其他有效 Shopee 页面。 - 若商品 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;状态探测异常只写脱敏诊断,不伪造技术采集失败。 +- 商品状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得依赖 Vue `data-v-*`。`審核中/审核中` 归为 `reviewing`,`您的商品未上架` 归为 `unlisted`;已知的「促销期间部分字段无法编辑」横幅不是商品生命周期状态,单独出现时仍归为 `normal`。没有 warning 为 `normal`,其他未识别横幅、DOM 异常或非法返回为 `unknown`。保存归一化摘要而非页面 HTML;状态探测异常只写脱敏诊断,不伪造技术采集失败。 - 每次点击采集先在主线程选择本轮范围,策略值为 `normal_only/all`,默认 `normal_only`,不跨轮记忆。两种范围都会逐条重新打开页面检测并独立保存状态,不能用旧数据库状态预过滤;`normal_only` 仅正常商品读取标题和封面,其他三类状态记为业务略过并保留旧内容,`all` 则四类状态均走现有采集流程。范围略过不是技术失败,汇总分别统计状态和略过数。 -- 不提供历史商品状态重检按钮或批量打开详情页的补检流程。历史默认迁移只在数据库初始化时执行,条件为活动批次创建时间严格早于上线时间且状态为空;已有四类状态、软删除批次、上线时点及之后的空状态均不改写。 +- 不提供历史商品状态重检按钮或批量打开详情页的补检流程。历史默认迁移只在数据库初始化时执行,条件为活动批次创建时间严格早于上线时间且状态为空;另仅修复同一范围内由已知促销编辑提示误写的 `unknown`,保留原检测时间并注明历史修正。其他已有四类状态、软删除批次、上线时点及之后的空状态均不改写。 - 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images///__old.jpg`。 - 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。 @@ -517,7 +517,7 @@ data/images///__new. # AI 生成的新 | 前台激活 | ①采集和商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 | | SPA 就绪 | 不用 load 事件;轮询“唯一商品名称输入框 + 至少一张主图 itembox + 唯一主图上传输入框”三者都在。脚本返回标题命中数、主图/上传入口状态和当前 URL 的就绪快照;超时错误必须指出具体缺失组件,不能只报泛化超时 | | 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。明确商品失效/不存在/无权限类 toast 即使已经隐藏,也优先成为 `open_product` 失败原因并驱动①阶段列显示“商品失效”;其他普通 toast 只有仍可见且属于当前页面 URL 时,才以“页面提示(可能无关)”附加在就绪快照后。已隐藏的物流、备货、库存、价格等编辑校验提示不得覆盖真正缺失的就绪组件;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” | -| 商品状态警示 | 编辑页状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得使用 `data-v-*`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`;无 warning → `normal`;未识别 warning、DOM 异常或非法响应 → `unknown`。多个横幅按 DOM 顺序取第一个白名单命中;只保存归一化文本摘要,不保存整段 HTML | +| 商品状态警示 | 编辑页状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得使用 `data-v-*`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`;已知促销字段编辑限制不构成商品状态;无状态横幅 → `normal`;其他未识别 warning、DOM 异常或非法响应 → `unknown`。多个横幅仍优先取审核中/未上架;只保存归一化文本摘要,不保存整段 HTML | | 标题输入框 | 主定位为 `data-product-edit-field-unique-id="name"` 业务字段内唯一可见 `input.eds-input__input`,不再用标题字符数判断身份;仅当该业务字段根不存在时,才回退旧 XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]`。主定位命中多个时明确失败,不猜测写入 | | 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 | diff --git a/docs/api.md b/docs/api.md index 49d76ac..ec6b8da 100644 --- a/docs/api.md +++ b/docs/api.md @@ -524,7 +524,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。商品状态功能上线时间前的活动历史批次空状态由 `init_db()` 默认迁移为 `normal`,不打开 Chrome、不创建 Worker;上线时点及之后的缺失状态仍保持未知语义。 +- 「采集旧标题/旧封面」先弹 `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`;同一范围内仅修复由已知促销编辑提示误写的 `unknown`,不打开 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` 枚举。 @@ -550,7 +550,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`。商品状态功能上线前活动历史批次的空状态已启动时默认迁移为 `normal`;其它 NULL 仍归入未知,再按③「更新内容」模式分出可执行任务与缺少 `new_title` / `new_cover_path` 的预检跳过记录。 +- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前先按 `product_status` 分出可进入线上更新的 `normal` 与必须排除的 `unlisted/reviewing/unknown`。商品状态功能上线前活动历史批次的空状态已启动时默认迁移为 `normal`;同一范围内已知促销编辑提示误写的 `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 8943a0d..bb822ce 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -74,7 +74,7 @@ - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 - 采集:点击后先从专用范围选择框选择范围,默认「采集架上商品」(即检测结果为正常),「采集全部商品」为警示橙色描边而非删除红色,包含未上架、审核中和状态未知商品;三个选项纵向全宽显示,常见 Windows 缩放下不截断。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images///__old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。 - ①任务表显示账号、别名、商品 ID、阶段和商品状态。商品状态筛选位于「处理状态」筛选左侧,选项为全部商品状态、架上商品、未上架、审核中、状态未知、待检测;它与批次、店铺、商品 ID 和处理状态叠加。空状态在①显示为「待检测」,显式 `unknown` 显示为「状态未知」;历史默认的架上商品可在 tooltip 查看“未实时检测”备注。商品状态只影响列表可见范围,不改变采集、生成或更新的安全规则。 -- 商品状态功能于 `2026-07-18T16:34:37` 上线。更早创建的活动历史批次中,空状态在启动数据库时自动按「架上商品」写为 `normal`,备注「历史批次默认按架上商品处理(未实时检测)」且不写检测时间;不打开 Chrome、不访问详情页。上线时点及之后的空状态仍按未知处理,真实保存的未上架、审核中、状态未知也绝不覆盖。①不提供「重新检测商品状态」入口。 +- 商品状态功能于 `2026-07-18T16:34:37` 上线。更早创建的活动历史批次中,空状态在启动数据库时自动按「架上商品」写为 `normal`,备注「历史批次默认按架上商品处理(未实时检测)」且不写检测时间;同一范围内仅把由已知促销编辑提示误写的 `unknown` 修正为 `normal`,保留原检测时间并写明历史修正。其他真实保存的未上架、审核中、状态未知,以及上线时点及之后的空状态均不覆盖;不打开 Chrome、不访问详情页。①不提供「重新检测商品状态」入口。 - 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 diff --git a/docs/tasks/T-673.md b/docs/tasks/T-673.md index 4a7d0d4..3ff5662 100644 --- a/docs/tasks/T-673.md +++ b/docs/tasks/T-673.md @@ -1,7 +1,7 @@ --- id: T-673 title: 促销编辑提示商品状态误判修复 -status: TODO +status: DONE phase: 7 deps: [T-667, T-672] created: 2026-07-20 @@ -56,4 +56,8 @@ git diff --check ## 执行记录 -- 待实现。 +- 在 `product_status.classify_alerts()` 增加繁简促销编辑提示识别:该提示单独出现时按 `normal`,同页出现「审核中」或「您的商品未上架」仍优先返回真实异常状态;其他未识别横幅、DOM 异常和非法返回仍为 `unknown`。 +- 在 `db.init_db()` 增加幂等精确修复:仅处理商品状态功能上线前、未软删除批次中由该促销提示误写的 `unknown`,修正为 `normal`、保留原检测时间并写入历史修正备注;其他未知、已确认异常、上线后批次和软删除数据均不改写。 +- ③状态拦截提示改为中文安全说明,不再引导用户使用已移除的「重新确认商品状态」入口;同步更新架构、路由和 API 文档。 +- 已对本机运行时数据库执行初始化迁移核验:批次 `20260710_105614_09b176d6` 的 13 条促销误判记录均已修正为 `normal`;只更新封面预检结果为候选 13 条、可执行 13 条、状态排除 0 条、缺少封面 0 条。未打开 Chrome、未调用 AI、未提交线上更新。 +- 验证通过:`py -3.10 -m unittest discover -s tests -p "test_product_status.py"`(7 项)、`py -3.10 -m unittest discover -s tests -p "test_db.py"`(21 项)、`py -3.10 -m unittest discover -s tests -p "test_gui.py"`(206 项)、`py -3.10 -m unittest discover -s tests`(625 项)、`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 fe2aca0..7fba2f4 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -7,7 +7,7 @@ sys.path.insert(0, os.path.dirname(__file__)) from _helpers import TempDirMixin -from app import db +from app import db, product_status class DbTests(TempDirMixin, unittest.TestCase): @@ -259,6 +259,148 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_init_db_repairs_only_legacy_promotion_status_misclassification(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-promotion", + "legacy-unknown", + "after-promotion", + "legacy-unlisted", + "deleted-promotion", + ), + 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(51100639800 + index), + } + ], + path=db_path, + ) + + promotion_note = ( + "标题:由於正在進行促銷,以下一些欄位無法進行編輯," + "數值將顯示為灰色。" + ) + conn = db.connect(db_path) + try: + with conn: + legacy_ids = [ + batch_ids["legacy-promotion"], + batch_ids["legacy-unknown"], + batch_ids["legacy-unlisted"], + batch_ids["deleted-promotion"], + ] + placeholders = ", ".join("?" for _batch_id in legacy_ids) + conn.execute( + f"UPDATE batches SET created_at = ? WHERE id IN ({placeholders})", + ("2026-07-18T16:34:36", *legacy_ids), + ) + task_by_batch = { + batch_id: db.list_tasks(batch_id=batch_id, conn=conn)[0] + for batch_id in batch_ids.values() + } + conn.execute( + """ + UPDATE tasks + SET product_status = 'unknown', product_status_note = ?, + product_status_at = ?, new_cover_path = ?, + stage = 'generated', status = 'success' + WHERE id = ? + """, + ( + promotion_note, + "2026-07-20T09:18:10", + "new.jpg", + task_by_batch[batch_ids["legacy-promotion"]].id, + ), + ) + conn.execute( + "UPDATE tasks SET product_status = 'unknown', product_status_note = ? WHERE id = ?", + ( + "其他未知状态提示", + task_by_batch[batch_ids["legacy-unknown"]].id, + ), + ) + conn.execute( + "UPDATE tasks SET product_status = 'unknown', product_status_note = ? WHERE id = ?", + ( + promotion_note, + task_by_batch[batch_ids["after-promotion"]].id, + ), + ) + conn.execute( + "UPDATE tasks SET product_status = 'unlisted', product_status_note = ? WHERE id = ?", + ( + promotion_note, + task_by_batch[batch_ids["legacy-unlisted"]].id, + ), + ) + conn.execute( + "UPDATE tasks SET product_status = 'unknown', product_status_note = ? WHERE id = ?", + ( + promotion_note, + task_by_batch[batch_ids["deleted-promotion"]].id, + ), + ) + conn.execute( + "UPDATE batches SET deleted_at = ? WHERE id = ?", + ("2026-07-19T00:00:00", batch_ids["deleted-promotion"]), + ) + finally: + conn.close() + + db.init_db(db_path) + legacy_promotion = db.list_tasks( + batch_id=batch_ids["legacy-promotion"], path=db_path + )[0] + legacy_unknown = db.list_tasks( + batch_id=batch_ids["legacy-unknown"], path=db_path + )[0] + after_promotion = db.list_tasks( + batch_id=batch_ids["after-promotion"], path=db_path + )[0] + legacy_unlisted = db.list_tasks( + batch_id=batch_ids["legacy-unlisted"], path=db_path + )[0] + deleted_promotion = db.list_tasks( + batch_id=batch_ids["deleted-promotion"], + include_deleted=True, + path=db_path, + )[0] + + self.assertEqual("normal", legacy_promotion.product_status) + self.assertEqual(db.LEGACY_PROMOTION_STATUS_REPAIR_NOTE, legacy_promotion.product_status_note) + self.assertEqual("2026-07-20T09:18:10", legacy_promotion.product_status_at) + cover_plan = product_status.build_apply_plan([legacy_promotion], "cover") + self.assertEqual([legacy_promotion.id], [task.id for task in cover_plan["executable"]]) + self.assertEqual("unknown", legacy_unknown.product_status) + self.assertEqual("unknown", after_promotion.product_status) + self.assertEqual("unlisted", legacy_unlisted.product_status) + self.assertEqual("unknown", deleted_promotion.product_status) + + first_updated_at = legacy_promotion.updated_at + db.init_db(db_path) + self.assertEqual( + first_updated_at, + db.get_task(legacy_promotion.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_gui.py b/tests/test_gui.py index aadb41b..67df79a 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -7253,6 +7253,8 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIn("审核中:1 条", message) self.assertIn("状态未知:1 条", message) self.assertNotIn("缺少新标题", message) + self.assertIn("为安全起见不会更新", message) + self.assertNotIn("重新确认商品状态", message) question.assert_not_called() run_worker.assert_not_called() self.assertIn("状态正常且可更新", states[-1]) diff --git a/tests/test_product_status.py b/tests/test_product_status.py index 009321d..a737694 100644 --- a/tests/test_product_status.py +++ b/tests/test_product_status.py @@ -53,6 +53,35 @@ class ProductStatusTests(unittest.TestCase): self.assertEqual(product_status.STATUS_UNLISTED, result["product_status"]) self.assertIn("您的商品未上架", result["product_status_note"]) + def test_promotion_edit_restriction_is_not_a_product_status_alert(self): + traditional = { + "title": "由於正在進行促銷,以下一些欄位無法進行編輯,數值將顯示為灰色。", + "description": "", + } + simplified = { + "title": "由于正在进行促销,以下一些字段无法进行编辑,数值将显示为灰色。", + "description": "", + } + + for alert in (traditional, simplified): + result = product_status.classify_alerts([alert]) + self.assertEqual(product_status.STATUS_NORMAL, result["product_status"]) + self.assertIsNone(result["product_status_note"]) + self.assertTrue( + product_status.is_promotion_edit_restriction_note( + f"标题:{alert['title']}" + ) + ) + + reviewing = product_status.classify_alerts( + [traditional, {"title": "审核中", "description": "等待审核"}] + ) + unlisted = product_status.classify_alerts( + [simplified, {"title": "您的商品未上架", "description": "已下架"}] + ) + self.assertEqual(product_status.STATUS_REVIEWING, reviewing["product_status"]) + self.assertEqual(product_status.STATUS_UNLISTED, unlisted["product_status"]) + def test_invalid_values_are_unknown_and_partitioning_is_consistent(self): tasks = [ SimpleNamespace(product_status="normal"),