From 4d949751d1be047360b31f1fe09016c3a50f5c55 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 27 Jul 2026 18:15:51 +0800 Subject: [PATCH] fix(db): migrate processed tasks with missing product status --- app/db.py | 100 ++++++++++++++++++++++++ docs/04-architecture.md | 6 +- docs/api.md | 4 +- docs/tasks/T-704.md | 20 +++-- tests/test_db.py | 163 ++++++++++++++++++++++++++++++++++++++++ tests/test_gui.py | 5 +- 6 files changed, 283 insertions(+), 15 deletions(-) diff --git a/app/db.py b/app/db.py index 12befae..2e39317 100644 --- a/app/db.py +++ b/app/db.py @@ -19,6 +19,7 @@ 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 = "历史商品状态修正:促销编辑提示不属于商品状态异常" +LEGACY_STATUS_BACKUP_TASK_ID = "T704" VALID_BATCH_FIELDS = { "source_files_json", "status", @@ -514,6 +515,10 @@ def init_db(path=None, conn=None) -> None: _ensure_image_studio_job_reference_asset_ids_column(database) _ensure_image_studio_asset_output_columns(database) _ensure_image_studio_job_run_session_column(database) + if _has_processed_tasks_missing_product_status(database): + _backup_before_processed_status_migration(database) + with database: + _migrate_processed_task_product_status_defaults(database) def _ensure_batch_delete_columns(database): @@ -586,6 +591,101 @@ def _migrate_legacy_product_status_defaults(database): ) +def _processed_task_status_condition() -> str: + return """ + (tasks.stage IN ('collected', 'generated', 'applied') + OR tasks.collected_at IS NOT NULL + OR tasks.generated_at IS NOT NULL + OR tasks.applied_at IS NOT NULL + OR NULLIF(TRIM(COALESCE(tasks.old_title, '')), '') IS NOT NULL + OR NULLIF(TRIM(COALESCE(tasks.old_cover_path, '')), '') IS NOT NULL + OR NULLIF(TRIM(COALESCE(tasks.new_title, '')), '') IS NOT NULL + OR NULLIF(TRIM(COALESCE(tasks.new_cover_path, '')), '') IS NOT NULL) + """ + + +def _has_processed_tasks_missing_product_status(database) -> bool: + row = database.execute( + f""" + SELECT 1 + FROM tasks + INNER JOIN batches ON batches.id = tasks.batch_id + WHERE (tasks.product_status IS NULL OR TRIM(tasks.product_status) = '') + AND tasks.deleted_at IS NULL + AND batches.deleted_at IS NULL + AND {_processed_task_status_condition()} + LIMIT 1 + """ + ).fetchone() + return row is not None + + +def _main_database_path(database) -> str: + for row in database.execute("PRAGMA database_list").fetchall(): + if row["name"] == "main": + return str(row["file"] or "").strip() + return "" + + +def _backup_before_processed_status_migration(database) -> Optional[str]: + """Create a consistent file backup before T-704 changes user task rows.""" + + database_path = _main_database_path(database) + if not database_path or database_path == ":memory:": + return None + + backup_dir = os.path.join(os.path.dirname(os.path.abspath(database_path)), "backups") + stem, ext = os.path.splitext(os.path.basename(database_path)) + ext = ext or ".db" + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup_path = os.path.join( + backup_dir, + f"{stem}-before-{LEGACY_STATUS_BACKUP_TASK_ID}-{timestamp}-{uuid.uuid4().hex[:8]}{ext}", + ) + try: + os.makedirs(backup_dir, exist_ok=True) + backup_database = sqlite3.connect(backup_path) + try: + database.backup(backup_database) + finally: + backup_database.close() + except (OSError, sqlite3.Error) as exc: + try: + if os.path.exists(backup_path): + os.remove(backup_path) + except OSError: + pass + raise DbError(f"兼容历史商品状态前备份数据库失败: {exc}") from exc + return backup_path + + +def _migrate_processed_task_product_status_defaults(database): + """Default only already-processed missing snapshots, regardless of batch date.""" + + database.execute( + f""" + UPDATE tasks + SET product_status = 'normal', + product_status_note = ?, + product_status_at = NULL, + updated_at = ? + WHERE id IN ( + SELECT tasks.id + FROM tasks + INNER JOIN batches ON batches.id = tasks.batch_id + WHERE (tasks.product_status IS NULL OR TRIM(tasks.product_status) = '') + AND tasks.deleted_at IS NULL + AND batches.deleted_at IS NULL + AND {_processed_task_status_condition()} + ) + """, + ( + LEGACY_PRODUCT_STATUS_DEFAULT_NOTE, + _now(), + ), + ) + + def _repair_legacy_promotion_status_defaults(database): """Repair only legacy unknown snapshots caused by a known benign warning.""" diff --git a/docs/04-architecture.md b/docs/04-architecture.md index a5c3e7a..7ab9f14 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -277,7 +277,7 @@ CREATE TABLE tasks ( alias TEXT NOT NULL, item_id TEXT NOT NULL, -- 商品状态快照(采集页面探测,不宣称实时在售) - product_status TEXT, -- normal/unlisted/reviewing/unknown;上线前活动历史 NULL 启动时迁移为 normal + product_status TEXT, -- normal/unlisted/reviewing/unknown;符合历史兼容条件的 NULL 启动时迁移为 normal product_status_note TEXT, -- 归一化 EDS 标题/说明摘要,最多 2000 字符 product_status_at TEXT, -- 本次页面状态探测时间 -- 采集输出(程序写,改前快照) @@ -368,7 +368,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` 只表示没有已知异常横幅,不等于实时“在售”。商品状态功能上线时间 `2026-07-18T16:34:37` 前的活动历史批次中,NULL/空状态在 `init_db()` 时按产品决策迁移为 `normal`,附「历史批次默认按架上商品处理(未实时检测)」备注且保持 `product_status_at=NULL`;上线时点及之后的 NULL 与非法值按 `unknown` 处理。 +- `product_status/product_status_note/product_status_at`:①每次打开商品编辑页后写入的状态快照;`normal` 只表示没有已知异常横幅,不等于实时“在售”。商品状态功能上线时间 `2026-07-18T16:34:37` 前的活动历史批次中,NULL/空状态在 `init_db()` 时按产品决策迁移为 `normal`。为兼容用户保留旧数据、复制安装目录或重新导入导致批次时间不可靠的情况,活动且未软删除的空状态任务,只要已有 `collected/generated/applied` 阶段、对应阶段时间或任一新旧标题/封面内容,也执行相同历史默认迁移;附「历史批次默认按架上商品处理(未实时检测)」备注并保持 `product_status_at=NULL`。迁移前在数据库同级 `backups/` 生成一次 SQLite 一致性备份;刚导入且没有处理证据的空状态与非法值仍按 `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`。 @@ -494,7 +494,7 @@ data/images///__new. # AI 生成的新 ### 6.3 应用更新(③ Tab) - ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。 -- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,`product_status.build_apply_plan()` 先把候选按商品状态冻结:仅 `normal` 可继续检查 `new_title` / `new_cover_path`,`unlisted`、`reviewing`、`unknown` 与未满足历史迁移条件的 NULL 均从 Worker 输入剔除;再对正常商品分出可执行项和内容缺失项。状态功能上线前活动历史批次的空状态由 `init_db()` 已迁移为默认 `normal`,可直接进入内容完整性检查。状态异常与内容缺失仅在确认框、状态栏和本轮日志列为预检排除,不打开其 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 159f55e..71f2e60 100644 --- a/docs/api.md +++ b/docs/api.md @@ -537,7 +537,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`;同一范围内仅修复由已知促销编辑提示误写的 `unknown`,不打开 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`;批次时间不可靠但已有采集、生成、更新阶段/时间或任一新旧标题封面内容的活动空状态任务,也在启动时按相同历史默认语义迁移,并在数据库同级 `backups/` 先生成一致性备份。刚导入且没有处理证据的空状态不迁移;同一范围内仅修复由已知促销编辑提示误写的 `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` 枚举。 @@ -565,7 +565,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`;同一范围内已知促销编辑提示误写的 `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/tasks/T-704.md b/docs/tasks/T-704.md index 3882444..de9e56f 100644 --- a/docs/tasks/T-704.md +++ b/docs/tasks/T-704.md @@ -3,7 +3,7 @@ id: T-704 title: 兼容用户历史已处理任务的空商品状态 phase: 7 deps: [T-667, T-673] -status: TODO +status: DONE created: 2026-07-27 --- @@ -29,12 +29,12 @@ created: 2026-07-27 ## 验收要点 -- [ ] 用户保留旧 `data/cmshopee.db` 后启动新版,批次日期晚于原固定阈值、但已有采集/生成/更新证据的空状态任务自动变为历史默认 `normal`。 -- [ ] 迁移前生成可打开的一致性数据库备份;重复初始化后不再新增备份,不重复修改 `updated_at`。 -- [ ] 仅 `stage=imported` 且没有标题、封面或阶段时间证据的空状态任务不迁移,③仍阻止更新。 -- [ ] 显式 `unknown/unlisted/reviewing/normal`、已软删除任务及已软删除批次不改动。 -- [ ] 迁移后的已生成记录可按既有标题/封面完整性规则进入③更新预检,不绕过内容完整性、账号登录或线上确认。 -- [ ] 不打开 Chrome、不访问商品页、不调用 AI、不修改 Excel、不提交线上商品。 +- [x] 用户保留旧 `data/cmshopee.db` 后启动新版,批次日期晚于原固定阈值、但已有采集/生成/更新证据的空状态任务自动变为历史默认 `normal`。 +- [x] 迁移前生成可打开的一致性数据库备份;重复初始化后不再新增备份,不重复修改 `updated_at`。 +- [x] 仅 `stage=imported` 且没有标题、封面或阶段时间证据的空状态任务不迁移,③仍阻止更新。 +- [x] 显式 `unknown/unlisted/reviewing/normal`、已软删除任务及已软删除批次不改动。 +- [x] 迁移后的已生成记录可按既有标题/封面完整性规则进入③更新预检,不绕过内容完整性、账号登录或线上确认。 +- [x] 不打开 Chrome、不访问商品页、不调用 AI、不修改 Excel、不提交线上商品。 ## 测试与文档 @@ -61,4 +61,8 @@ git diff --check ## 执行记录 -- 待实现。 +- `db.init_db()` 新增 T-704 幂等迁移:活动且未软删除的空商品状态任务,只在已有业务阶段、阶段时间或任一新旧标题/封面证据时写历史默认 `normal`;批次创建时间不再是这层兼容的判断依据。 +- 文件数据库存在真实候选时,迁移前通过 SQLite backup API 在数据库同级 `backups/` 创建一致性备份;迁移成功后候选消失,重复启动不再备份或改写。 +- 显式商品状态、未处理导入记录、软删除任务和软删除批次保持不变;③计划与 Worker 的 `normal` 防线未放宽。 +- 更新架构/API 文档,并调整两处旧 GUI 测试,使异常状态场景使用显式 `unknown/reviewing`,不再把“已生成但状态为空”当作未迁移状态。 +- 验证通过:`py -3.10 -m unittest discover -s tests -p "test_db.py"`(22 项)、`py -3.10 -m unittest discover -s tests -p "test_product_status.py"`(7 项)、`py -3.10 -m unittest discover -s tests`(703 项)、`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 7fba2f4..538dc74 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,4 +1,5 @@ import os +import sqlite3 import sys import unittest from unittest import mock @@ -259,6 +260,168 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_init_db_defaults_processed_missing_status_and_creates_one_backup(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + batch_id = db.create_batch(["processed.xlsx"], path=db_path) + deleted_batch_id = db.create_batch(["deleted.xlsx"], path=db_path) + item_ids = { + "stage": "51100639801", + "timestamp": "51100639802", + "content": "51100639803", + "unprocessed": "51100639804", + "unknown": "51100639805", + "deleted_task": "51100639806", + "deleted_batch": "51100639807", + } + rows = [] + for source_row, item_id in enumerate(item_ids.values(), start=2): + rows.append( + { + "source_file_abs": os.path.join(temp_dir, f"{item_id}.xlsx"), + "source_sheet": "Sheet1", + "source_row": source_row, + "account_name": "店铺", + "alias": "alias", + "item_id": item_id, + } + ) + db.insert_tasks(batch_id, rows[:-1], path=db_path) + db.insert_tasks(deleted_batch_id, rows[-1:], path=db_path) + + conn = db.connect(db_path) + try: + tasks = { + task.item_id: task + for task in db.list_tasks(batch_id=batch_id, conn=conn) + } + deleted_batch_task = db.list_tasks( + batch_id=deleted_batch_id, + conn=conn, + )[0] + with conn: + conn.execute( + "UPDATE batches SET created_at = ? WHERE id IN (?, ?)", + ("2026-07-27T12:00:00", batch_id, deleted_batch_id), + ) + conn.execute( + "UPDATE tasks SET stage = 'generated', new_cover_path = ? WHERE id = ?", + ( + os.path.join(temp_dir, "stage.jpg"), + tasks[item_ids["stage"]].id, + ), + ) + conn.execute( + "UPDATE tasks SET generated_at = ?, new_cover_path = ? WHERE id = ?", + ( + "2026-07-27T12:01:00", + os.path.join(temp_dir, "timestamp.jpg"), + tasks[item_ids["timestamp"]].id, + ), + ) + conn.execute( + "UPDATE tasks SET new_title = ? WHERE id = ?", + ("历史生成标题", tasks[item_ids["content"]].id), + ) + conn.execute( + """ + UPDATE tasks + SET product_status = 'unknown', + product_status_note = '已有未知状态', + new_title = '不应覆盖' + WHERE id = ? + """, + (tasks[item_ids["unknown"]].id,), + ) + conn.execute( + "UPDATE tasks SET stage = 'generated', deleted_at = ? WHERE id = ?", + ("2026-07-27T12:02:00", tasks[item_ids["deleted_task"]].id), + ) + conn.execute( + "UPDATE tasks SET stage = 'generated' WHERE id = ?", + (deleted_batch_task.id,), + ) + conn.execute( + "UPDATE batches SET deleted_at = ? WHERE id = ?", + ("2026-07-27T12:03:00", deleted_batch_id), + ) + finally: + conn.close() + + db.init_db(db_path) + + migrated = { + task.item_id: task + for task in db.list_tasks( + batch_id=batch_id, + path=db_path, + include_deleted=True, + ) + } + for key in ("stage", "timestamp", "content"): + task = migrated[item_ids[key]] + 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.assertIsNone(migrated[item_ids["unprocessed"]].product_status) + self.assertEqual("unknown", migrated[item_ids["unknown"]].product_status) + self.assertEqual( + "已有未知状态", + migrated[item_ids["unknown"]].product_status_note, + ) + self.assertIsNone(migrated[item_ids["deleted_task"]].product_status) + deleted_batch_task = db.list_tasks( + batch_id=deleted_batch_id, + path=db_path, + include_deleted=True, + )[0] + self.assertIsNone(deleted_batch_task.product_status) + + backup_dir = os.path.join(temp_dir, "backups") + backups = [ + os.path.join(backup_dir, name) + for name in os.listdir(backup_dir) + if f"before-{db.LEGACY_STATUS_BACKUP_TASK_ID}" in name + ] + self.assertEqual(1, len(backups)) + backup_conn = sqlite3.connect(backups[0]) + try: + row = backup_conn.execute( + "SELECT product_status FROM tasks WHERE item_id = ?", + (item_ids["stage"],), + ).fetchone() + self.assertIsNone(row[0]) + finally: + backup_conn.close() + + content_task = migrated[item_ids["content"]] + plan = product_status.build_apply_plan([content_task], "title") + self.assertEqual([content_task.id], [task.id for task in plan["executable"]]) + first_updated_at = content_task.updated_at + + db.init_db(db_path) + + self.assertEqual( + first_updated_at, + db.get_task(content_task.id, path=db_path).updated_at, + ) + self.assertEqual( + 1, + len( + [ + name + for name in os.listdir(backup_dir) + if f"before-{db.LEGACY_STATUS_BACKUP_TASK_ID}" in name + ] + ), + ) + + 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") diff --git a/tests/test_gui.py b/tests/test_gui.py index 5cc9658..911c2fa 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -7644,7 +7644,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): ], path=cfg["db_path"], ) - statuses = ["unlisted", "reviewing", None] + statuses = ["unlisted", "reviewing", "unknown"] for task, status in zip( db.list_tasks(batch_id=batch_id, path=cfg["db_path"]), statuses, @@ -8566,6 +8566,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): db.mark_failed(tasks_b[0].id, "generate", "生成失败", path=cfg["db_path"]) db.set_product_status(tasks_a[0].id, "normal", path=cfg["db_path"]) db.set_product_status(tasks_a[1].id, "unlisted", path=cfg["db_path"]) + db.set_product_status(tasks_a[2].id, "reviewing", path=cfg["db_path"]) db.set_product_status(tasks_b[0].id, "unknown", path=cfg["db_path"]) tab = GenerateTab(config=cfg) @@ -8628,7 +8629,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual([], unlisted_plan["execution_tasks"]) tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) tab.product_status_filter.setCurrentIndex( - tab.product_status_filter.findData("unchecked") + tab.product_status_filter.findData("reviewing") ) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("51100639512", tab.model.index(0, 1).data())