fix(db): migrate processed tasks with missing product status

This commit is contained in:
chengma
2026-07-27 18:15:51 +08:00
parent 82444412ab
commit 4d949751d1
6 changed files with 283 additions and 15 deletions
+100
View File
@@ -19,6 +19,7 @@ DEFAULT_BUSY_TIMEOUT_MS = 5000
PRODUCT_STATUS_FEATURE_INTRODUCED_AT = "2026-07-18T16:34:37" PRODUCT_STATUS_FEATURE_INTRODUCED_AT = "2026-07-18T16:34:37"
LEGACY_PRODUCT_STATUS_DEFAULT_NOTE = "历史批次默认按架上商品处理(未实时检测)" LEGACY_PRODUCT_STATUS_DEFAULT_NOTE = "历史批次默认按架上商品处理(未实时检测)"
LEGACY_PROMOTION_STATUS_REPAIR_NOTE = "历史商品状态修正:促销编辑提示不属于商品状态异常" LEGACY_PROMOTION_STATUS_REPAIR_NOTE = "历史商品状态修正:促销编辑提示不属于商品状态异常"
LEGACY_STATUS_BACKUP_TASK_ID = "T704"
VALID_BATCH_FIELDS = { VALID_BATCH_FIELDS = {
"source_files_json", "source_files_json",
"status", "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_job_reference_asset_ids_column(database)
_ensure_image_studio_asset_output_columns(database) _ensure_image_studio_asset_output_columns(database)
_ensure_image_studio_job_run_session_column(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): 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): def _repair_legacy_promotion_status_defaults(database):
"""Repair only legacy unknown snapshots caused by a known benign warning.""" """Repair only legacy unknown snapshots caused by a known benign warning."""
+3 -3
View File
@@ -277,7 +277,7 @@ CREATE TABLE tasks (
alias TEXT NOT NULL, alias TEXT NOT NULL,
item_id 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_note TEXT, -- 归一化 EDS 标题/说明摘要,最多 2000 字符
product_status_at TEXT, -- 本次页面状态探测时间 product_status_at TEXT, -- 本次页面状态探测时间
-- 采集输出(程序写,改前快照) -- 采集输出(程序写,改前快照)
@@ -368,7 +368,7 @@ CREATE TABLE run_log_events (
- `account_name` 仅展示/参考;匹配以 `alias` 为准。 - `account_name` 仅展示/参考;匹配以 `alias` 为准。
- `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。 - `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。
- `row_key` 防止同一批次内重复导入同一行。 - `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`:程序**采集阶段抓取**的快照(输出)。 - `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。
- `new_title/new_cover_path`:**AI 生成**的两个独立组件结果(输出)。③只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新图文才同时要求二者;只生成封面允许 `new_title=NULL`,已采集的 `old_title` 只作为封面prompt语义参考,不回填 `new_title`。不设逐条确认阶段。 - `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`。 - `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。
@@ -494,7 +494,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
### 6.3 应用更新(③ Tab) ### 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 预检。①、②本轮的“所有状态”范围选择不向③传递更新授权。 - 计划指纹覆盖 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式`;用户确认后重新计算,任何变化均废弃旧计划并要求重新开始。`ApplyWorker` 仍在执行层再次验证 `product_status=normal`,防止直接构造 Worker 绕过 GUI 预检。①、②本轮的“所有状态”范围选择不向③传递更新授权。
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新内容和略过原因。 - ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新内容和略过原因。
- 弹确认前先读取 `data/config.json` 的 `shopee_update` 执行参数。普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。 - 弹确认前先读取 `data/config.json` 的 `shopee_update` 执行参数。普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
+2 -2
View File
@@ -537,7 +537,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。 - 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。
- 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。 - 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。
- 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。 - 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。
- 「采集旧标题/旧封面」先弹 `ProductStatusScopeDialog` 范围确认框,默认按钮为“采集架上商品”(稳定策略 `normal_only`,仅继续采集检测结果为正常的商品),扩展按钮为“采集全部商品”(`all`,还包括未上架、审核中和状态未知商品);后者是警示橙色的扩大范围操作而非删除操作。对话框最小宽度 520px,三个中文选项纵向全宽显示,取消不创建 Worker。`CollectWorker` 只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 先检测和保存页面状态;默认范围仅正常商品下载旧封面到 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_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/<batch_id>/<slug>/<task_id>_<item_id>_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 连接不关闭页面。 - 采集打开商品页时,若本轮自动新建 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,避免与上一条采集成功日志混淆。 - 采集中途登录检测必须快速跳过正在销毁的旧商品 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` 枚举。 - 采集打开商品页失败时,`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): ③ 更新蝦皮当前要点(T-303b/T-401/T-402/T-403):
- `ApplyTab` 顶部筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部。 - `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`)、失败、已更新、略过、全部状态。 - 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。 - 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。 - 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。
+12 -8
View File
@@ -3,7 +3,7 @@ id: T-704
title: 兼容用户历史已处理任务的空商品状态 title: 兼容用户历史已处理任务的空商品状态
phase: 7 phase: 7
deps: [T-667, T-673] deps: [T-667, T-673]
status: TODO status: DONE
created: 2026-07-27 created: 2026-07-27
--- ---
@@ -29,12 +29,12 @@ created: 2026-07-27
## 验收要点 ## 验收要点
- [ ] 用户保留旧 `data/cmshopee.db` 后启动新版,批次日期晚于原固定阈值、但已有采集/生成/更新证据的空状态任务自动变为历史默认 `normal`。 - [x] 用户保留旧 `data/cmshopee.db` 后启动新版,批次日期晚于原固定阈值、但已有采集/生成/更新证据的空状态任务自动变为历史默认 `normal`。
- [ ] 迁移前生成可打开的一致性数据库备份;重复初始化后不再新增备份,不重复修改 `updated_at`。 - [x] 迁移前生成可打开的一致性数据库备份;重复初始化后不再新增备份,不重复修改 `updated_at`。
- [ ] 仅 `stage=imported` 且没有标题、封面或阶段时间证据的空状态任务不迁移,③仍阻止更新。 - [x] 仅 `stage=imported` 且没有标题、封面或阶段时间证据的空状态任务不迁移,③仍阻止更新。
- [ ] 显式 `unknown/unlisted/reviewing/normal`、已软删除任务及已软删除批次不改动。 - [x] 显式 `unknown/unlisted/reviewing/normal`、已软删除任务及已软删除批次不改动。
- [ ] 迁移后的已生成记录可按既有标题/封面完整性规则进入③更新预检,不绕过内容完整性、账号登录或线上确认。 - [x] 迁移后的已生成记录可按既有标题/封面完整性规则进入③更新预检,不绕过内容完整性、账号登录或线上确认。
- [ ] 不打开 Chrome、不访问商品页、不调用 AI、不修改 Excel、不提交线上商品。 - [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`。
+163
View File
@@ -1,4 +1,5 @@
import os import os
import sqlite3
import sys import sys
import unittest import unittest
from unittest import mock from unittest import mock
@@ -259,6 +260,168 @@ class DbTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_init_db_repairs_only_legacy_promotion_status_misclassification(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db") db_path = os.path.join(temp_dir, "cmshopee.db")
+3 -2
View File
@@ -7644,7 +7644,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
], ],
path=cfg["db_path"], path=cfg["db_path"],
) )
statuses = ["unlisted", "reviewing", None] statuses = ["unlisted", "reviewing", "unknown"]
for task, status in zip( for task, status in zip(
db.list_tasks(batch_id=batch_id, path=cfg["db_path"]), db.list_tasks(batch_id=batch_id, path=cfg["db_path"]),
statuses, statuses,
@@ -8566,6 +8566,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
db.mark_failed(tasks_b[0].id, "generate", "生成失败", path=cfg["db_path"]) 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[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[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"]) db.set_product_status(tasks_b[0].id, "unknown", path=cfg["db_path"])
tab = GenerateTab(config=cfg) tab = GenerateTab(config=cfg)
@@ -8628,7 +8629,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual([], unlisted_plan["execution_tasks"]) self.assertEqual([], unlisted_plan["execution_tasks"])
tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) tab.status_filter.setCurrentIndex(tab.status_filter.findData("all"))
tab.product_status_filter.setCurrentIndex( 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(1, tab.model.rowCount())
self.assertEqual("51100639512", tab.model.index(0, 1).data()) self.assertEqual("51100639512", tab.model.index(0, 1).data())