fix: 修复迁移被原地改写导致老库缺表 (#20)
#16 原地改写了 migration v1 而不是新增一条。Migrate 只在
user_version < 版本数时才跑,老库版本号已越过 v1,改写后的语句
永远不会重跑——程序拿着对不上的库静默启动,点到 PDD 商品页才 500。
修法:v1 逐字恢复成 7ad82b7 的原样,#16 的结构改动全部挪进 v3。
全新库也走 v1→v2→v3,与老库升级跑的是同一份 v3 代码,
不需要维护两条路径。
v3 必须认两种 user_version=2:#16 的原地改写让这个版本号对应
两种不同结构(原始 v1 建的没有 pdd_products,改写后的 v1 建的已经有)。
所以 v3 每一步先查 PRAGMA table_info / sqlite_master 看实际结构
再决定做不做,只有版本号推进是无条件的;已是最终结构的库
只推版本号,日志也照实说,不谎称"新增 pdd_products"。
旧 sku_mappings 数据丢弃并打日志:新主键需要 pdd_option_key,
那是 Go 的 OptionKey() 用 json.Marshal 算的,SQL 复现不了。
硬凑一个键出来,轻则映射静默失效,重则撞上别的规格静默买错东西——
后者正是 #16 存在的全部意义。
collecting 映射成 pending:原样保留会让 MarkCollecting 永远不成功,
那个商品再也建不了采集任务,界面上表现为按钮永远置灰且无法解开。
表重建按 SQLite 官方 12 步顺序:先建 _new 再 RENAME。
实测 ALTER TABLE RENAME TO 会自动重写别的表里指向它的外键子句,
先 RENAME 让位会把 shopee_skus 的外键改成指向一张马上被删的表。
另加两道闸:启动时 CheckSchema 缺表即拒绝启动(不是警告后继续);
admin/AGENTS.md 写死"migrations 只追加、不得修改已发布条目"。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+534
-45
@@ -10,9 +10,12 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
// 纯 Go 的 SQLite 驱动,注册的驱动名是 "sqlite"(不是 "sqlite3")。
|
||||
// 不得换成 github.com/mattn/go-sqlite3,那个需要 cgo,
|
||||
@@ -100,6 +103,13 @@ func Open(dataDir string) (*sql.DB, error) {
|
||||
//
|
||||
// 加新版本时**只能往末尾追加**,不许改动已有元素——
|
||||
// 已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。
|
||||
//
|
||||
// [必须] 这条规则本身也写进了 admin/AGENTS.md:#20 就是因为 v1 被原地改写、
|
||||
// 而老库的 user_version 已经越过了它,那次改写永远不会在老库上重跑,
|
||||
// 程序拿着一个和代码对不上的库静默启动。v3 不在这个 slice 里
|
||||
// (见下面 schemaVersion 和 migrateV3 的注释),也是同一个教训的直接结果:
|
||||
// v3 要做的事超出"一串 SQL 顺序执行",硬塞进这个只支持纯 SQL 的结构反而
|
||||
// 掩盖了它的特殊性。
|
||||
var migrations = [][]string{
|
||||
// v1: 初始表结构,对应 docs/admin/03-data-model.md
|
||||
{
|
||||
@@ -109,16 +119,22 @@ var migrations = [][]string{
|
||||
shopee_status TEXT,
|
||||
main_sku_code TEXT,
|
||||
|
||||
-- 人工维护的,蝦皮报表里没有这两列,Excel 导入时绝不能覆盖。
|
||||
-- pdd_goods_id 指向 pdd_products.goods_id,表示"这个蝦皮商品
|
||||
-- 当前对应哪个 PDD 商品"。PDD 商品下架换新时改这里。
|
||||
-- 下面三个是人工维护的,报表里没有,导入时绝不能覆盖
|
||||
pdd_goods_url TEXT,
|
||||
pdd_goods_id TEXT,
|
||||
pdd_data TEXT,
|
||||
|
||||
collect_status TEXT NOT NULL DEFAULT 'no_link'
|
||||
CHECK (collect_status IN (
|
||||
'no_link', 'pending', 'collecting',
|
||||
'collected', 'failed'
|
||||
)),
|
||||
collect_error TEXT,
|
||||
collected_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);`,
|
||||
`CREATE INDEX idx_shopee_products_status ON shopee_products(collect_status);`,
|
||||
`CREATE TABLE shopee_skus (
|
||||
sku_id TEXT PRIMARY KEY,
|
||||
goods_id TEXT NOT NULL,
|
||||
@@ -135,36 +151,6 @@ var migrations = [][]string{
|
||||
);`,
|
||||
`CREATE INDEX idx_shopee_skus_goods ON shopee_skus(goods_id);`,
|
||||
`CREATE INDEX idx_shopee_skus_parse ON shopee_skus(parse_ok);`,
|
||||
`CREATE TABLE pdd_products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- 从 PDD 链接里解析出来。它不是主键,所以**必须加 UNIQUE**:
|
||||
-- 少了这条约束,同一个 PDD 商品会被存成好几行,
|
||||
-- 采好几遍,映射还说不清指向哪一行。
|
||||
goods_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
url TEXT NOT NULL, -- 操作员填的链接原文
|
||||
title TEXT, -- 采集回来,人工核对"是不是我要的那个商品"
|
||||
skus_json TEXT, -- schema_version + dimensions + skus
|
||||
|
||||
-- 注意这里**没有 no_link**:这张表里有这一行,就说明链接已经填了。
|
||||
-- "未填链接"是蝦皮侧的状态(shopee_products.pdd_goods_id 为空)。
|
||||
collect_status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (collect_status IN (
|
||||
'pending', 'collecting', 'collected', 'failed'
|
||||
)),
|
||||
collect_msg TEXT, -- 失败原因,要能定位问题
|
||||
artifact_ref TEXT, -- 诊断产物在哪台机器哪个目录
|
||||
collected_at TEXT,
|
||||
|
||||
-- 软删除。不硬删是因为 sku_mappings 指向它,
|
||||
-- 硬删会把人工攒了很久的匹配成果一起带走。
|
||||
deleted_at TEXT,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);`,
|
||||
`CREATE TABLE syb_orders (
|
||||
syb_id TEXT PRIMARY KEY,
|
||||
order_no TEXT NOT NULL,
|
||||
@@ -182,18 +168,14 @@ var migrations = [][]string{
|
||||
`CREATE INDEX idx_syb_orders_goods ON syb_orders(shopee_goods_id);`,
|
||||
`CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);`,
|
||||
`CREATE TABLE sku_mappings (
|
||||
shopee_sku_id TEXT NOT NULL,
|
||||
pdd_goods_id TEXT NOT NULL,
|
||||
pdd_option_key TEXT NOT NULL,
|
||||
pdd_options TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
mapped_at TEXT NOT NULL,
|
||||
mapped_by TEXT,
|
||||
PRIMARY KEY (shopee_sku_id, pdd_goods_id),
|
||||
shopee_sku_id TEXT PRIMARY KEY,
|
||||
goods_id TEXT NOT NULL,
|
||||
pdd_options TEXT NOT NULL,
|
||||
mapped_at TEXT NOT NULL,
|
||||
mapped_by TEXT,
|
||||
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
|
||||
);`,
|
||||
`CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);`,
|
||||
`CREATE INDEX idx_sku_mappings_pdd ON sku_mappings(pdd_goods_id);`,
|
||||
`CREATE TABLE tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
task_type TEXT NOT NULL CHECK (task_type IN ('collect', 'purchase')),
|
||||
@@ -270,6 +252,20 @@ var migrations = [][]string{
|
||||
},
|
||||
}
|
||||
|
||||
// schemaVersion 是当前代码支持的最新 user_version。
|
||||
//
|
||||
// 之所以不是 len(migrations),是因为 v3(把 PDD 采集结果从 shopee_products
|
||||
// 拆到独立的 pdd_products、重建 sku_mappings 主键)做的事超出了
|
||||
// "一串 SQL 顺序执行":它必须在事务外切换 PRAGMA foreign_keys、
|
||||
// 要在丢弃旧 sku_mappings 前用 Go 数出行数打日志。
|
||||
// 这些事纯 SQL 表达不了,所以 v3 单独用 migrateV3 函数实现,
|
||||
// 不放进 migrations 这个只支持"一条一条执行 SQL"的结构里。
|
||||
//
|
||||
// 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器
|
||||
// (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个
|
||||
// 和代码对不上的库静默启动。
|
||||
const schemaVersion = 3
|
||||
|
||||
// Migrate 把数据库升到最新版本。
|
||||
// 已经是最新的就什么都不做,可以重复调用。
|
||||
func Migrate(db *sql.DB) error {
|
||||
@@ -278,13 +274,14 @@ func Migrate(db *sql.DB) error {
|
||||
return fmt.Errorf("读取 user_version 失败: %w", err)
|
||||
}
|
||||
|
||||
if current > len(migrations) {
|
||||
if current > schemaVersion {
|
||||
return fmt.Errorf(
|
||||
"数据库版本 %d 高于本程序支持的 %d,"+
|
||||
"说明这个库是更新版本的程序建的,请升级程序而不是降级",
|
||||
current, len(migrations))
|
||||
current, schemaVersion)
|
||||
}
|
||||
|
||||
reached := current
|
||||
for v := current; v < len(migrations); v++ {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
@@ -304,6 +301,498 @@ func Migrate(db *sql.DB) error {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交迁移 v%d 失败: %w", v+1, err)
|
||||
}
|
||||
reached = v + 1
|
||||
}
|
||||
|
||||
// v3:见 schemaVersion 的注释,为什么它不在 migrations 里、
|
||||
// 单独用一个函数处理。全新库也会先走完 v1/v2(拿到旧版 shopee_products /
|
||||
// sku_mappings 结构),再由这一步收敛成最终结构——
|
||||
// 这样"全新库"和"老库升级"最终跑的是完全相同的 v3 代码,
|
||||
// 不需要分别维护两条路径。
|
||||
if reached < schemaVersion {
|
||||
if err := migrateV3(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV3 把库收敛成当前结构,对应工单 #20。
|
||||
//
|
||||
// # 起点不止一种,不能用 user_version 推断结构
|
||||
//
|
||||
// #16 曾经原地改写过 v1(把 pdd_products 等结构直接塞进 v1,没有新增版本号),
|
||||
// 所以 user_version = 2 现在对应两种不同的**真实**结构:
|
||||
//
|
||||
// 原始 v1 建的库 没有 pdd_products;shopee_products 还带着
|
||||
// pdd_data / collect_status 等四列;sku_mappings
|
||||
// 还是单列主键。
|
||||
// #16 改写后的 v1 建的库 结构已经是最终形态,只是 user_version 还停在 2。
|
||||
//
|
||||
// 只要用 user_version 是不是 2 来判断"要不要迁移"就会踩空——第二种库
|
||||
// 一旦被当成第一种处理,"建 pdd_products" 这一步会直接撞 "table already
|
||||
// exists"。所以下面每一步都必须先查**实际结构**(sqlite_master /
|
||||
// PRAGMA table_info),再决定做不做;已经是最终形态的库,这个函数应该
|
||||
// 什么都不改,只把版本号推到 3。
|
||||
//
|
||||
// # 四件事,各自独立判断要不要做
|
||||
//
|
||||
// 1. 建 pdd_products 和索引——表已存在就跳过。
|
||||
// 2. 把 shopee_products 上的 PDD 采集数据搬过去,按 pdd_goods_id 去重——
|
||||
// shopee_products 已经没有 pdd_data 列就跳过(数据要么搬过了,
|
||||
// 要么这张表从来就没有过)。
|
||||
// 3. 重建 sku_mappings——已经有 pdd_goods_id 列(说明已经是新结构)就跳过;
|
||||
// 否则旧数据全部丢弃(新主键需要的 pdd_option_key 是
|
||||
// service.OptionKey() 用 json.Marshal 算出来的,SQL 复现不了,硬凑
|
||||
// 有静默买错东西的风险,见 admin/AGENTS.md),丢之前先数出行数打日志。
|
||||
// 4. 重建 shopee_products——还带着旧的四列中任意一个就重建,去掉那四列;
|
||||
// 四列都已经不在就跳过。
|
||||
//
|
||||
// 重建 shopee_products 时必须按 SQLite 官方的表重建流程处理外键
|
||||
// (https://www.sqlite.org/lang_altertable.html 的 12 步):
|
||||
// shopee_skus.goods_id 有外键指向 shopee_products(goods_id) ON DELETE CASCADE,
|
||||
// 如果不先关闭外键检查就 DROP/RENAME 这张表,可能把 shopee_skus 的数据带跑。
|
||||
// 而 PRAGMA foreign_keys 只能在没有打开事务时切换,所以必须先关、
|
||||
// 再开事务,事务里全部做完再提交、最后恢复。这套开销只在真的要重建
|
||||
// shopee_products 时才需要——sku_mappings 只是外键的子表(没有别的表
|
||||
// 指向它),丢它不会牵连别的表,不需要关闭外键检查。
|
||||
func migrateV3(db *sql.DB) error {
|
||||
const toVersion = 3
|
||||
ctx := context.Background()
|
||||
|
||||
needCreatePddProducts, err := tableMissing(db, "pdd_products")
|
||||
if err != nil {
|
||||
return fmt.Errorf("迁移 v3 检查 pdd_products 是否存在失败: %w", err)
|
||||
}
|
||||
|
||||
shopeeCols, err := tableColumnSet(db, "shopee_products")
|
||||
if err != nil {
|
||||
return fmt.Errorf("迁移 v3 检查 shopee_products 结构失败: %w", err)
|
||||
}
|
||||
needDedup := shopeeCols["pdd_data"]
|
||||
needRebuildShopeeProducts := shopeeCols["pdd_data"] || shopeeCols["collect_status"] ||
|
||||
shopeeCols["collect_error"] || shopeeCols["collected_at"]
|
||||
|
||||
skuCols, err := tableColumnSet(db, "sku_mappings")
|
||||
if err != nil {
|
||||
return fmt.Errorf("迁移 v3 检查 sku_mappings 结构失败: %w", err)
|
||||
}
|
||||
needRebuildSkuMappings := !skuCols["pdd_goods_id"]
|
||||
|
||||
if !needCreatePddProducts && !needDedup && !needRebuildShopeeProducts && !needRebuildSkuMappings {
|
||||
// 结构已经是最终形态(#16 改写后的 v1 建的库),什么都不用改,
|
||||
// 只需要把版本号推到 3。不打"新增 / 重建"那行日志——那是假话,
|
||||
// 会误导操作员以为数据被动过。
|
||||
log.Printf("数据库迁移 v3:结构已是最新,仅更新版本号")
|
||||
if _, err := db.Exec(fmt.Sprintf("PRAGMA user_version = %d", toVersion)); err != nil {
|
||||
return fmt.Errorf("更新 user_version 到 %d 失败: %w", toVersion, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按**实际做了什么**打日志,不是无条件打同一行——操作员/维护者要能
|
||||
// 从启动日志一眼看出发生过一次结构迁移,不用等到点坏页面才知道库被动过,
|
||||
// 这正是 #20 要修的"静默"问题;但日志内容必须真实,不能不管做没做都
|
||||
// 打同一句。
|
||||
var actions []string
|
||||
if needCreatePddProducts {
|
||||
actions = append(actions, "新增 pdd_products")
|
||||
}
|
||||
if needRebuildShopeeProducts {
|
||||
actions = append(actions, "重建 shopee_products")
|
||||
}
|
||||
if needRebuildSkuMappings {
|
||||
actions = append(actions, "重建 sku_mappings")
|
||||
}
|
||||
log.Printf("数据库迁移 v3:%s", strings.Join(actions, "、"))
|
||||
|
||||
var conn *sql.Conn
|
||||
if needRebuildShopeeProducts {
|
||||
// 只有要重建 shopee_products 才需要这套连接和 PRAGMA 切换——见函数
|
||||
// 顶部的注释。单独拿一条连接:PRAGMA foreign_keys=OFF 之后紧接着要在
|
||||
// **同一条连接**上开事务,database/sql 的连接池不保证 db.Exec 和
|
||||
// db.Begin 用的是同一条连接。
|
||||
conn, err = db.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("迁移 v3 获取专用连接失败: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=OFF"); err != nil {
|
||||
return fmt.Errorf("迁移 v3 关闭外键检查失败: %w", err)
|
||||
}
|
||||
// 这条连接用完会回到连接池,后面别的代码还会拿到它继续用。
|
||||
// 不管上面成功还是失败都要把外键检查恢复成 ON——
|
||||
// Open() 承诺过整个连接池的外键检查是打开的,这里关了就要负责关回去。
|
||||
// 这个 defer 注册在 conn.Close() 之后,按 LIFO 顺序会先于 Close 执行。
|
||||
defer func() {
|
||||
if _, err := conn.ExecContext(context.Background(), "PRAGMA foreign_keys=ON"); err != nil {
|
||||
log.Printf("警告: 迁移 v3 后恢复外键检查失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var tx *sql.Tx
|
||||
if conn != nil {
|
||||
tx, err = conn.BeginTx(ctx, nil)
|
||||
} else {
|
||||
tx, err = db.BeginTx(ctx, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始迁移 v3 事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全
|
||||
|
||||
// ① 建 pdd_products 和索引。内容照搬当前 v1 里的定义,含全部注释——
|
||||
// 这是最终要收敛到的表结构,不能和 v1 曾经的定义有任何出入。
|
||||
if needCreatePddProducts {
|
||||
for i, stmt := range migrateV3CreatePddProducts {
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("迁移 v3 建 pdd_products 第 %d 条语句失败: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ② 把老 shopee_products 上的 PDD 数据搬过去,按 pdd_goods_id 去重。
|
||||
// 必须在下面重建 shopee_products 之前执行——一旦 shopee_products 被重建,
|
||||
// pdd_data / collect_status / collect_error / collected_at 这几列就没了。
|
||||
if needDedup {
|
||||
if _, err := tx.ExecContext(ctx, migrateV3DedupIntoPddProducts); err != nil {
|
||||
return fmt.Errorf("迁移 v3 搬运 PDD 数据失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 旧 sku_mappings 全部丢弃:新主键需要的 pdd_option_key 是
|
||||
// service.OptionKey() 用 json.Marshal 算出来的,SQL 复现不了;
|
||||
// 硬凑一个键有静默买错东西的风险,见 admin/AGENTS.md。
|
||||
// 丢之前先数出行数打日志,不能悄悄丢——N 为 0 时不打,
|
||||
// 避免每次启动都刷一行没用的日志。
|
||||
if needRebuildSkuMappings {
|
||||
var discardedMappings int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM sku_mappings`).Scan(&discardedMappings); err != nil {
|
||||
return fmt.Errorf("迁移 v3 统计旧 sku_mappings 行数失败: %w", err)
|
||||
}
|
||||
if discardedMappings > 0 {
|
||||
log.Printf("迁移 v3:丢弃了 %d 条旧规格映射(缺少 pdd_goods_id / pdd_option_key,无法安全迁移,请重新匹配)", discardedMappings)
|
||||
}
|
||||
for i, stmt := range migrateV3RebuildSkuMappings {
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("迁移 v3 重建 sku_mappings 第 %d 条语句失败: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 重建 shopee_products:去掉已经搬去 pdd_products 的四个列。
|
||||
// 按 SQLite 12 步流程:建新表 -> 搬数据 -> 删旧表 -> 改名 -> 建索引。
|
||||
if needRebuildShopeeProducts {
|
||||
for i, stmt := range migrateV3RebuildShopeeProducts {
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("迁移 v3 重建 shopee_products 第 %d 条语句失败: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 外键检查原本是开着的:重建完必须确认没有把 shopee_skus 的数据带丢
|
||||
// (比如误伤了它和 shopee_products 之间的外键关系)。
|
||||
if err := checkForeignKeys(ctx, tx); err != nil {
|
||||
return fmt.Errorf("迁移 v3 外键校验失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", toVersion)); err != nil {
|
||||
return fmt.Errorf("更新 user_version 到 %d 失败: %w", toVersion, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交迁移 v3 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableMissing 判断某张表是否不存在。
|
||||
func tableMissing(db *sql.DB, table string) (bool, error) {
|
||||
var name string
|
||||
err := db.QueryRow(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table,
|
||||
).Scan(&name)
|
||||
if err == sql.ErrNoRows {
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// tableColumnSet 返回某张表当前的列名集合,供 migrateV3 判断
|
||||
// "这张表是不是旧结构" 用——user_version 推断不出真实结构,见 migrateV3 顶部注释。
|
||||
func tableColumnSet(db *sql.DB, table string) (map[string]bool, error) {
|
||||
// table 只来自本文件里写死的表名常量,不是外部输入,字符串拼接是安全的
|
||||
// (PRAGMA 本身也不支持参数化,PRAGMA user_version 也是这么处理的)。
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid, notnull, pk int
|
||||
var name, ctype string
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols[name] = true
|
||||
}
|
||||
return cols, rows.Err()
|
||||
}
|
||||
|
||||
// migrateV3CreatePddProducts 建 pdd_products 和索引。
|
||||
//
|
||||
// [必须] 这段 SQL 文本必须和 `git show 998c06a:admin/repository/db.go`
|
||||
// 里 pdd_products 的定义逐字节一致(含缩进),不能只是"看起来一样"。
|
||||
//
|
||||
// 原因:#16(998c06a)把这张表直接建进了 v1,那批库上 sqlite_master 存的
|
||||
// 就是 998c06a 里这段文本原样的字节(连同当时 gofmt 缩进出来的那个前导
|
||||
// TAB)。这个函数只在表**不存在**时才会执行这条 CREATE(见 migrateV3 里
|
||||
// needCreatePddProducts 的判断),所以已经建过表的库不会被这段文本重新
|
||||
// 覆盖——"新建库" 和 "已经带 pdd_products 的老库" 要收敛到完全相同的
|
||||
// sqlite_master.sql,就必须让新建的这份文本和老库上躺着的那份逐字节相同,
|
||||
// 哪怕只差一个空格/TAB 都会让 TestMigrate_不同起点最终schema一致 失败
|
||||
// (这个坑已经在 #20 审查阶段被变异测试连同真实文本对比一起抓到过一次)。
|
||||
var migrateV3CreatePddProducts = []string{
|
||||
`CREATE TABLE pdd_products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- 从 PDD 链接里解析出来。它不是主键,所以**必须加 UNIQUE**:
|
||||
-- 少了这条约束,同一个 PDD 商品会被存成好几行,
|
||||
-- 采好几遍,映射还说不清指向哪一行。
|
||||
goods_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
url TEXT NOT NULL, -- 操作员填的链接原文
|
||||
title TEXT, -- 采集回来,人工核对"是不是我要的那个商品"
|
||||
skus_json TEXT, -- schema_version + dimensions + skus
|
||||
|
||||
-- 注意这里**没有 no_link**:这张表里有这一行,就说明链接已经填了。
|
||||
-- "未填链接"是蝦皮侧的状态(shopee_products.pdd_goods_id 为空)。
|
||||
collect_status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (collect_status IN (
|
||||
'pending', 'collecting', 'collected', 'failed'
|
||||
)),
|
||||
collect_msg TEXT, -- 失败原因,要能定位问题
|
||||
artifact_ref TEXT, -- 诊断产物在哪台机器哪个目录
|
||||
collected_at TEXT,
|
||||
|
||||
-- 软删除。不硬删是因为 sku_mappings 指向它,
|
||||
-- 硬删会把人工攒了很久的匹配成果一起带走。
|
||||
deleted_at TEXT,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);`,
|
||||
}
|
||||
|
||||
// migrateV3DedupIntoPddProducts 按 pdd_goods_id 去重,把老
|
||||
// shopee_products 上人工攒的 PDD 数据搬进 pdd_products。
|
||||
//
|
||||
// - MIN(COALESCE(pdd_goods_url, 空字符串)) / MIN(created_at):多行里任取一个即可,
|
||||
// 用 MIN 只是为了确定性(同一批输入每次跑结果一样,方便排查)。
|
||||
// - MAX(pdd_data) / MAX(collect_error) / MAX(collected_at):同理,
|
||||
// 只是要"取到某一行的值",用 MAX 是同一个考虑。
|
||||
// - CASE MAX(collect_status) ...:collect_status 只有全组都是
|
||||
// 'collected' 时才判定为 collected('collected' 按字符串比较是这几个
|
||||
// 取值里最小的,只要组里有任何一行不是 collected,MAX 就会取到别的值);
|
||||
// 只要没有 collected 但有 failed 就判定 failed;
|
||||
// 其余(含 collecting / no_link / pending)一律落进 ELSE,判定 pending
|
||||
// —— 这正好同时满足"collecting 映射成 pending"和
|
||||
// "no_link 映射成 pending,不撞新 CHECK(新表里没有 no_link)"两条要求。
|
||||
var migrateV3DedupIntoPddProducts = `
|
||||
INSERT INTO pdd_products
|
||||
(goods_id, url, title, skus_json, collect_status,
|
||||
collect_msg, collected_at, created_at, updated_at)
|
||||
SELECT
|
||||
pdd_goods_id,
|
||||
MIN(COALESCE(pdd_goods_url, '')),
|
||||
NULL,
|
||||
MAX(pdd_data),
|
||||
CASE MAX(collect_status)
|
||||
WHEN 'collected' THEN 'collected'
|
||||
WHEN 'failed' THEN 'failed'
|
||||
ELSE 'pending'
|
||||
END,
|
||||
MAX(collect_error),
|
||||
MAX(collected_at),
|
||||
MIN(created_at), MAX(updated_at)
|
||||
FROM shopee_products
|
||||
WHERE pdd_goods_id IS NOT NULL AND pdd_goods_id <> ''
|
||||
GROUP BY pdd_goods_id;`
|
||||
|
||||
// migrateV3RebuildShopeeProducts 重建 shopee_products:
|
||||
// 去掉已经搬去 pdd_products 的 pdd_data / collect_status / collect_error /
|
||||
// collected_at 四列,删掉跟着它们的 idx_shopee_products_status,
|
||||
// 换成新结构需要的 idx_shopee_products_pdd。
|
||||
//
|
||||
// # 步骤顺序为什么是"建 _new → 搬数据 → 删旧表 → 改名"(标准 12 步顺序)
|
||||
//
|
||||
// 这里**必须**按 SQLite 官方 12 步流程的顺序来,不能改成"先把旧表改名
|
||||
// 让开、再直接用最终表名建新表"(表面上能避开下面说的引号问题,
|
||||
// 实测过、但会坏得更彻底):
|
||||
//
|
||||
// shopee_skus.goods_id 有 `FOREIGN KEY (goods_id) REFERENCES
|
||||
// shopee_products(goods_id)`。SQLite 的 `ALTER TABLE ... RENAME TO`
|
||||
// **默认会连带更新别的表里引用这张表的外键定义**——如果改成先把
|
||||
// shopee_products RENAME 成 shopee_products_old,shopee_skus 的外键子句
|
||||
// 会被自动重写成 `REFERENCES "shopee_products_old"(goods_id)`;
|
||||
// 后面一 DROP TABLE shopee_products_old,shopee_skus 就带着一条指向
|
||||
// 不存在的表的外键,`PRAGMA foreign_key_check` 直接报错,
|
||||
// 而且这个坏结果比"多一对引号"严重得多。
|
||||
//
|
||||
// 按标准顺序(建 shopee_products_new → 搬数据 → DROP 掉的是旧的
|
||||
// shopee_products,不是被引用的名字 → RENAME shopee_products_new
|
||||
// 成 shopee_products)不会触发这个重写:shopee_skus 的外键子句
|
||||
// 全程写的都是 "shopee_products" 这个名字,没有变过,RENAME 结束后
|
||||
// 这个名字重新存在,直接就能解析上,不需要 SQLite 帮它改写任何东西。
|
||||
// 实测两种顺序的效果:
|
||||
//
|
||||
// 方案:先建 _new 再 RENAME 成正式名(本文件采用的顺序)
|
||||
// shopee_skus 的外键子句:REFERENCES shopee_products(goods_id) 不变 ✓
|
||||
// shopee_products 自己的 CREATE TABLE 文本:被 RENAME 加上引号 CREATE TABLE "shopee_products" (...)
|
||||
//
|
||||
// 方案:先把旧表 RENAME 让开,再直接用正式名建新表
|
||||
// shopee_skus 的外键子句:被自动重写成 REFERENCES "shopee_products_old"(goods_id) ← 引用悬空,PRAGMA foreign_key_check 报错
|
||||
//
|
||||
// 所以选择"忍受 shopee_products 自己的 CREATE TABLE 文本被套一层引号",
|
||||
// 而不是"外键指向一张已经被删掉的表"。引号这个副作用改在
|
||||
// assertSchemaEqual 的比较逻辑里通过归一化解决(见 migrate_test.go
|
||||
// 里 normalizeCreateStatement 的注释),不在这里想办法回避——
|
||||
// 这个坑是 #20 审查阶段三起点收敛测试跑出来才发现的,光读代码看不出来。
|
||||
//
|
||||
// [必须] 除了表名后缀 _new、以及 RENAME 带来的引号,这段 SQL 里
|
||||
// shopee_products 的列定义必须和
|
||||
// `git show 998c06a:admin/repository/db.go` 里的定义逐字节一致
|
||||
// (含缩进),理由同 migrateV3CreatePddProducts 的注释。
|
||||
var migrateV3RebuildShopeeProducts = []string{
|
||||
`CREATE TABLE shopee_products_new (
|
||||
goods_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
shopee_status TEXT,
|
||||
main_sku_code TEXT,
|
||||
|
||||
-- 人工维护的,蝦皮报表里没有这两列,Excel 导入时绝不能覆盖。
|
||||
-- pdd_goods_id 指向 pdd_products.goods_id,表示"这个蝦皮商品
|
||||
-- 当前对应哪个 PDD 商品"。PDD 商品下架换新时改这里。
|
||||
pdd_goods_url TEXT,
|
||||
pdd_goods_id TEXT,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
`INSERT INTO shopee_products_new
|
||||
(goods_id, title, shopee_status, main_sku_code,
|
||||
pdd_goods_url, pdd_goods_id, created_at, updated_at)
|
||||
SELECT
|
||||
goods_id, title, shopee_status, main_sku_code,
|
||||
pdd_goods_url, pdd_goods_id, created_at, updated_at
|
||||
FROM shopee_products;`,
|
||||
`DROP TABLE shopee_products;`,
|
||||
`ALTER TABLE shopee_products_new RENAME TO shopee_products;`,
|
||||
`CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);`,
|
||||
}
|
||||
|
||||
// migrateV3RebuildSkuMappings 重建 sku_mappings。
|
||||
// 旧数据全部丢弃(调用方已经数过行数打过日志),不搬任何数据——
|
||||
// 新主键需要的 pdd_option_key 只有 Go 的 service.OptionKey() 能算,
|
||||
// SQL 里凑不出来,硬凑有静默买错东西的风险。
|
||||
//
|
||||
// 这里是直接 DROP 旧表、CREATE 新表(同一个最终表名,不经过 RENAME),
|
||||
// 不会有 migrateV3RebuildShopeeProducts 注释里说的引号问题。
|
||||
//
|
||||
// [必须] 这段 SQL 文本必须和 `git show 998c06a:admin/repository/db.go`
|
||||
// 里 sku_mappings 的定义逐字节一致(含缩进),理由同
|
||||
// migrateV3CreatePddProducts 的注释。
|
||||
var migrateV3RebuildSkuMappings = []string{
|
||||
`DROP TABLE sku_mappings;`,
|
||||
`CREATE TABLE sku_mappings (
|
||||
shopee_sku_id TEXT NOT NULL,
|
||||
pdd_goods_id TEXT NOT NULL,
|
||||
pdd_option_key TEXT NOT NULL,
|
||||
pdd_options TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
mapped_at TEXT NOT NULL,
|
||||
mapped_by TEXT,
|
||||
PRIMARY KEY (shopee_sku_id, pdd_goods_id),
|
||||
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
|
||||
);`,
|
||||
`CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);`,
|
||||
`CREATE INDEX idx_sku_mappings_pdd ON sku_mappings(pdd_goods_id);`,
|
||||
}
|
||||
|
||||
// checkForeignKeys 跑 PRAGMA foreign_key_check,有任何一行结果
|
||||
// 就说明外键关系被破坏了(比如子表指向了一个已经不存在的父行)。
|
||||
func checkForeignKeys(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, "PRAGMA foreign_key_check")
|
||||
if err != nil {
|
||||
return fmt.Errorf("执行外键校验失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if rows.Next() {
|
||||
return fmt.Errorf("存在外键约束冲突,重建表的过程把关联数据带丢了")
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// requiredTables 是当前代码依赖的全部表。
|
||||
// Migrate 跑完之后用它做一次自检,见 CheckSchema。
|
||||
var requiredTables = []string{
|
||||
"shopee_products", "shopee_skus", "pdd_products",
|
||||
"syb_orders", "sku_mappings", "tasks", "clients",
|
||||
"idempotency_keys", "task_claims",
|
||||
}
|
||||
|
||||
// CheckSchema 在 Migrate 成功后调用,确认代码依赖的表都在。
|
||||
//
|
||||
// [必须] 缺表就返回错误,调用方要**拒绝启动**,不是打个警告继续跑。
|
||||
// #20 的教训就是静默启动:程序拿着一个和代码对不上的库正常起来了,
|
||||
// 错误要等操作员点到那个页面才暴露——如果那是个写操作页面,
|
||||
// 暴露出来的就不是报错而是写坏数据。
|
||||
//
|
||||
// [建议] 只查表名,不逐列校验:够抓住"迁移没跑到、表没建出来"这一类问题,
|
||||
// 代价也低。真出了列级别的不一致,业务 SQL 跑起来自然会报错。
|
||||
func CheckSchema(db *sql.DB) error {
|
||||
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取数据库表清单失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
existing := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return fmt.Errorf("读取数据库表清单失败: %w", err)
|
||||
}
|
||||
existing[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("读取数据库表清单失败: %w", err)
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, t := range requiredTables {
|
||||
if !existing[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"数据库结构与本程序不匹配:缺少表 %s。\n"+
|
||||
"这通常是数据库比程序旧、而迁移没有覆盖到。\n"+
|
||||
"请备份 data/admin.db 后删除它让程序重建,或联系维护者。",
|
||||
strings.Join(missing, "、"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user