fix(db): migrate processed tasks with missing product status
This commit is contained in:
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`。
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user