From d2de7331bb7120a3d553cb2366501dd9eb9852f6 Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 7 Aug 2026 12:23:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=A2=AB=E5=8E=9F=E5=9C=B0=E6=94=B9=E5=86=99=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E8=80=81=E5=BA=93=E7=BC=BA=E8=A1=A8=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- admin/AGENTS.md | 3 + admin/main.go | 3 + admin/repository/db.go | 579 +++++++++++++++++++-- admin/repository/migrate_test.go | 864 +++++++++++++++++++++++++++++++ docs/admin/03-data-model.md | 29 ++ 5 files changed, 1433 insertions(+), 45 deletions(-) create mode 100644 admin/repository/migrate_test.go diff --git a/admin/AGENTS.md b/admin/AGENTS.md index 25b598a..2d3f5a4 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -85,6 +85,9 @@ - `[必须]` 蝦皮规格原文(`spec_raw`)永远保留,解析不出来就留空,不要瞎猜。 - `[必须]` Client 提交结果时,**不管任务是否已取消、是否已重派,一律接受**, 理由见 Client 契约 §6.1。这条最容易被顺手违反。 +- `[必须]` `repository/db.go` 里的 `migrations` 只追加,**不得修改已经发布过的条目**。 + 改了的话,已经建过库的机器 `user_version` 已经越过它,永远不会重跑, + 程序会拿着对不上的库静默启动(见 #20)。需要改结构就加新的一条。 ## 界面规则 diff --git a/admin/main.go b/admin/main.go index 856fcae..a48e549 100644 --- a/admin/main.go +++ b/admin/main.go @@ -54,6 +54,9 @@ func main() { if err := repository.Migrate(db); err != nil { log.Fatalf("数据库迁移失败: %v", err) } + if err := repository.CheckSchema(db); err != nil { + log.Fatalf("%v", err) + } log.Printf("数据库已就绪") // 3. Web 引擎 diff --git a/admin/repository/db.go b/admin/repository/db.go index 13d9fb2..83420de 100644 --- a/admin/repository/db.go +++ b/admin/repository/db.go @@ -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, "、")) +} diff --git a/admin/repository/migrate_test.go b/admin/repository/migrate_test.go new file mode 100644 index 0000000..705f2f9 --- /dev/null +++ b/admin/repository/migrate_test.go @@ -0,0 +1,864 @@ +package repository + +import ( + "bytes" + "database/sql" + "fmt" + "log" + "regexp" + "sort" + "strings" + "testing" +) + +// 本文件是工单 #20 的核心交付物:证明不管从哪种库起步,迁到最新版本后 +// schema 都完全一致。#20 的缺陷之所以发生,就是因为当初只测了"全新建库" +// 这一条路径——老库停在 user_version=2,循环条件 `v < len(migrations)` +// 一次都不进,被原地改写的 v1 永远不会在老库上重跑。 +// +// 起点不止两种。#16(commit 998c06a)把 pdd_products 等结构直接原地 +// 改写进了 v1,没有新增版本号,所以 user_version = 2 现在对应**两种不同的 +// 真实结构**: +// +// - newV2DB:原始 v1(#16 之前)建的库,没有 pdd_products, +// shopee_products 还带着 pdd_data 等四列,sku_mappings 是单列主键。 +// - newV2NewStructureDB:#16 改写后的 v1 建的库,结构已经是最终形态, +// 只是 user_version 还停在 2——#20 审查阶段用户在真实环境里实测到的 +// 正是这种库,migrateV3 一度对着已经存在的 pdd_products 又 CREATE +// 了一遍,直接报 "table already exists"。 +// +// 三个起点(加上 newFreshDB)迁到最新版本后必须完全一致。 +// assertSchemaEqual 逐条比较 sqlite_master 里的建表/建索引语句 +// (列定义、CHECK、PRIMARY KEY 全在这条语句文本里),不是只比表名—— +// 只比表名会漏掉"表名一样但主键不同"这类问题 +// (sku_mappings 的主键从单列 shopee_sku_id 变成了两列)。 +// +// 但"三条路径互相一致"防不住 migrateV3 自己写错、三条路径一起错的情况 +// (因为全新库也是靠 migrateV3 收敛到最终结构的,三条路径共用同一份 +// migrateV3 代码)。TestMigrate_最终schema符合设计要求 用行为断言 +// (插数据看报不报错)单独兜住这一类问题,见那个测试前面的注释。 + +// newFreshDB 建一个全新库,一路迁到最新版本。 +func newFreshDB(t *testing.T) *sql.DB { + t.Helper() + db, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("打开测试库失败: %v", err) + } + t.Cleanup(func() { db.Close() }) + if err := Migrate(db); err != nil { + t.Fatalf("迁移到最新版本失败: %v", err) + } + return db +} + +// newV2DB 造一个停在 v2(#20 修复之前)的老库:只跑 migrations[0:2], +// 不跑 v3,手动把 user_version 设成 2。 +// +// 用的是 migrations 这个正式变量本身(而不是抄一份 SQL), +// 这样将来 v1/v2 万一又被错误地原地改写,这个夹具也会跟着变、 +// 从而暴露出"两条路径其实没有真正独立"的局限——但那正是 +// admin/AGENTS.md 里"迁移只追加"这条规则要防的事,不是这个测试要防的事。 +func newV2DB(t *testing.T) *sql.DB { + t.Helper() + db, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("打开测试库失败: %v", err) + } + t.Cleanup(func() { db.Close() }) + + for v, stmts := range migrations[:2] { + for i, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("造 v2 老库失败(v%d 第 %d 条语句): %v", v+1, i+1, err) + } + } + } + if _, err := db.Exec("PRAGMA user_version = 2"); err != nil { + t.Fatalf("设置 user_version 失败: %v", err) + } + return db +} + +// migrations998c06a 是 #16(commit 998c06a)原地改写 v1 之后的迁移语句, +// 逐字复制自 `git show 998c06a:admin/repository/db.go`——那次改写直接把 +// pdd_products 等结构塞进了 v1,没有新增版本号。任何在 998c06a 之后、 +// #20 之前建过库的机器,user_version 都是 2,但结构已经是最终形态。 +var migrations998c06a = [][]string{ + // v1: 初始表结构,对应 docs/admin/03-data-model.md + { + `CREATE TABLE shopee_products ( + 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 + );`, + `CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);`, + `CREATE TABLE shopee_skus ( + sku_id TEXT PRIMARY KEY, + goods_id TEXT NOT NULL, + spec_raw TEXT NOT NULL, + color TEXT, + size TEXT, + advice TEXT, + parse_ok INTEGER NOT NULL DEFAULT 0, + sku_code TEXT, + is_manual INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (goods_id) REFERENCES shopee_products(goods_id) ON DELETE CASCADE + );`, + `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, + title TEXT, + shopee_goods_id TEXT, + shopee_sku_id TEXT, + quantity INTEGER NOT NULL CHECK (quantity > 0), + price_twd_cent INTEGER CHECK (price_twd_cent IS NULL OR price_twd_cent >= 0), + image_url TEXT, + syb_data TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );`, + `CREATE INDEX idx_syb_orders_order ON syb_orders(order_no);`, + `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), + 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')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'assigned', 'claimed', + 'succeeded', 'manual_review', + 'failed', 'cancelled')), + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + priority INTEGER NOT NULL DEFAULT 0, + + assigned_client TEXT, + claimed_at TEXT, + + syb_id TEXT, + order_no TEXT, + goods_id TEXT, + shopee_sku_id TEXT, + + -- Client 契约要求:pdd_goods_url 必填; + -- 采购任务的 quantity 和 max_price_cent 也必填(价格保护) + pdd_goods_url TEXT NOT NULL, + pdd_goods_id TEXT, + pdd_options TEXT, + quantity INTEGER CHECK (quantity IS NULL OR quantity > 0), + max_price_cent INTEGER CHECK (max_price_cent IS NULL OR max_price_cent > 0), + + result_data TEXT, + error_code TEXT, + error_message TEXT, + finished_at TEXT, + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );`, + `CREATE INDEX idx_tasks_claim ON tasks(assigned_client, status, priority DESC, created_at);`, + `CREATE INDEX idx_tasks_list ON tasks(updated_at DESC, task_id DESC);`, + `CREATE INDEX idx_tasks_order ON tasks(order_no);`, + `CREATE TABLE clients ( + client_id TEXT PRIMARY KEY, + name TEXT, + device_address TEXT, + platform TEXT, + pdd_package TEXT, + capabilities TEXT, + last_seen_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );`, + `CREATE TABLE idempotency_keys ( + key TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + response_body TEXT NOT NULL, + created_at TEXT NOT NULL + );`, + }, + + // v2: 领取历史。 + // + // 为什么需要它:契约要求"只有**从未分配给该客户端**的任务才返回 403" + // (docs/admin/04-client-api.md §4.1)。但 tasks.assigned_client 只记 + // **当前**归属,任务一旦重派给别人,就查不出原来那台领过—— + // 而契约又明确要求"已重派仍要接受原客户端提交的结果"。 + // 没有这张表,那条规则根本没法判断。 + // + // 顺带得到一份审计记录:这个任务被哪几台客户端领过。 + { + `CREATE TABLE task_claims ( + task_id TEXT NOT NULL, + client_id TEXT NOT NULL, + claimed_at TEXT NOT NULL, + PRIMARY KEY (task_id, client_id) + );`, + `CREATE INDEX idx_task_claims_client ON task_claims(client_id);`, + }, +} + +// newV2NewStructureDB 造一个 user_version=2、但结构已经是最终形态的库—— +// 对应 #16(commit 998c06a)改写后的 v1。这不是假设出来的边界情况: +// #20 审查阶段,用户按当时的工单指示删库重建后,得到的正是这种库 +// (migrateV3 曾经对着已经存在的 pdd_products 又 CREATE 了一遍, +// 直接报 "table already exists")。 +func newV2NewStructureDB(t *testing.T) *sql.DB { + t.Helper() + db, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("打开测试库失败: %v", err) + } + t.Cleanup(func() { db.Close() }) + + for v, stmts := range migrations998c06a { + for i, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("造 998c06a 结构的老库失败(v%d 第 %d 条语句): %v", v+1, i+1, err) + } + } + } + if _, err := db.Exec("PRAGMA user_version = 2"); err != nil { + t.Fatalf("设置 user_version 失败: %v", err) + } + return db +} + +// insertOldShopeeProduct 按 v1/v2 时代的旧 shopee_products 结构插一行 +// (带 pdd_data / collect_status 等已经在 v3 里搬走的列)。 +func insertOldShopeeProduct(t *testing.T, db *sql.DB, goodsID, pddGoodsID, pddGoodsURL, collectStatus string) { + t.Helper() + const now = "2026-08-01T00:00:00Z" + _, err := db.Exec(` + INSERT INTO shopee_products + (goods_id, title, pdd_goods_url, pdd_goods_id, collect_status, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + goodsID, "测试商品-"+goodsID, pddGoodsURL, pddGoodsID, collectStatus, now, now) + if err != nil { + t.Fatalf("插入老蝦皮商品 %s 失败: %v", goodsID, err) + } +} + +// schemaRow 是 sqlite_master 里的一行。 +type schemaRow struct { + typ string + name string + sql string +} + +// dumpSchema 读出数据库里所有"有建表/建索引语句"的对象,按 type、name 排序。 +// +// 排除 sql 为 NULL 的行(比如 UNIQUE 约束自动生成的隐藏索引)——那些不是 +// migrations 里写的语句直接产生的,两条路径不保证顺序和命名一致, +// 比较它们没有意义,也不是这个测试要防的问题。 +func dumpSchema(t *testing.T, db *sql.DB) []schemaRow { + t.Helper() + rows, err := db.Query(` + SELECT type, name, sql FROM sqlite_master + WHERE sql IS NOT NULL + ORDER BY type, name`) + if err != nil { + t.Fatalf("读取 schema 失败: %v", err) + } + defer rows.Close() + + var out []schemaRow + for rows.Next() { + var r schemaRow + if err := rows.Scan(&r.typ, &r.name, &r.sql); err != nil { + t.Fatalf("读取 schema 失败: %v", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("读取 schema 失败: %v", err) + } + sort.Slice(out, func(i, j int) bool { + if out[i].typ != out[j].typ { + return out[i].typ < out[j].typ + } + return out[i].name < out[j].name + }) + return out +} + +// quotedIdentAfterCreate 匹配 `CREATE TABLE "foo" (` / `CREATE INDEX "foo" ON` +// 这种被引号包住的标识符,用于 normalizeCreateStatement。 +var quotedIdentAfterCreate = regexp.MustCompile( + `^(CREATE (?:TABLE|UNIQUE INDEX|INDEX) )"([A-Za-z_][A-Za-z0-9_]*)"`) + +// normalizeCreateStatement 去掉 CREATE TABLE/INDEX 语句里表名/索引名外层 +// 那对可有可无的双引号,再比较文本。 +// +// 为什么需要它:SQLite 的 `ALTER TABLE ... RENAME TO` 会把 +// sqlite_master.sql 里的表名重写成**带双引号**的形式,哪怕原来没有引号: +// +// CREATE TABLE foo_new (a TEXT); ALTER TABLE foo_new RENAME TO foo; +// -> sqlite_master.sql = `CREATE TABLE "foo" (a TEXT)` +// CREATE TABLE foo (a TEXT); -- 从来没被 RENAME 过 +// -> sqlite_master.sql = `CREATE TABLE foo (a TEXT)` +// +// migrateV3RebuildShopeeProducts 按 SQLite 官方 12 步流程重建 +// shopee_products,最后一步是 RENAME,所以它产出的 shopee_products +// 一定带这层引号;而"结构已经是最终形态、v3 全程没碰过"的库 +// (newV2NewStructureDB,对应 998c06a 建的、从未被改过名的表)没有这层引号。 +// 两边是同一份列定义、同一张语义上完全等价的表,只是构造路径不同—— +// 用 RENAME 而不是直接 CREATE 得到最终表名,这是 +// migrateV3RebuildShopeeProducts 注释里解释过的、为了不破坏 shopee_skus +// 外键而必须付出的代价,不是缺陷,所以在比较时把这个无意义的差异抹平, +// 而不是反过来改生产代码去凑一个不安全的建表顺序。 +func normalizeCreateStatement(sql string) string { + sql = strings.TrimSpace(sql) + return quotedIdentAfterCreate.ReplaceAllString(sql, "$1$2") +} + +// assertSchemaEqual 逐条比较两份 schema:表和索引都要存在, +// 且**完整建表/建索引语句**(列定义、CHECK、PRIMARY KEY 都在这条语句里) +// 必须一致。只比表名的话,sku_mappings 主键从单列变两列这种问题照样漏过去。 +func assertSchemaEqual(t *testing.T, label string, got, want []schemaRow) { + t.Helper() + + toMap := func(rows []schemaRow) map[string]string { + m := make(map[string]string, len(rows)) + for _, r := range rows { + m[r.typ+":"+r.name] = normalizeCreateStatement(r.sql) + } + return m + } + gotMap := toMap(got) + wantMap := toMap(want) + + var diffs []string + for k, wantSQL := range wantMap { + gotSQL, ok := gotMap[k] + if !ok { + diffs = append(diffs, "缺少 "+k) + continue + } + if gotSQL != wantSQL { + diffs = append(diffs, k+" 定义不一致:\n--- 期望 ---\n"+wantSQL+"\n--- 实际 ---\n"+gotSQL) + } + } + for k := range gotMap { + if _, ok := wantMap[k]; !ok { + diffs = append(diffs, "多出 "+k) + } + } + + if len(diffs) > 0 { + t.Errorf("%s: schema 不一致:\n%s", label, strings.Join(diffs, "\n\n")) + } +} + +// ── 迁移收敛:核心交付物 ────────────────────────────── + +func TestMigrate_不同起点最终schema一致(t *testing.T) { + fresh := newFreshDB(t) + freshSchema := dumpSchema(t, fresh) + if err := CheckSchema(fresh); err != nil { + t.Errorf("全新库迁移后应该通过自检: %v", err) + } + + cases := []struct { + name string + db *sql.DB + }{ + {"v2 老结构库(#16 之前的 v1)", newV2DB(t)}, + {"v2 新结构库(#16 改写后的 v1,见 998c06a)", newV2NewStructureDB(t)}, + } + + for _, c := range cases { + if err := Migrate(c.db); err != nil { + t.Fatalf("%s 迁移失败: %v", c.name, err) + } + gotSchema := dumpSchema(t, c.db) + assertSchemaEqual(t, "全新库 vs "+c.name, gotSchema, freshSchema) + + var version int + if err := c.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + t.Fatalf("%s 读取 user_version 失败: %v", c.name, err) + } + if version != schemaVersion { + t.Errorf("%s 迁移后 user_version = %d,期望 %d(不能卡在中间版本)", c.name, version, schemaVersion) + } + + if err := CheckSchema(c.db); err != nil { + t.Errorf("%s 迁移后应该通过自检: %v", c.name, err) + } + } +} + +func TestMigrate_已经是最新版本再次调用不报错(t *testing.T) { + db := newFreshDB(t) + if err := Migrate(db); err != nil { + t.Fatalf("对已经是最新版本的库重复调用 Migrate 不应该报错: %v", err) + } +} + +// v2 新结构库(#16 改写后的 v1)第一次 Migrate 时,migrateV3 检测到结构 +// 已经是最终形态,只更新版本号、不改任何表——第二次调用(对应用户重启 +// 服务)应该是彻底的空操作:user_version 已经是 3,Migrate 最外层的 +// 版本号判断就会直接跳过,连 migrateV3 都不会再进去。 +func TestMigrate_v2新结构库连续调用两次不报错且第二次是空操作(t *testing.T) { + db := newV2NewStructureDB(t) + + if err := Migrate(db); err != nil { + t.Fatalf("第一次迁移失败: %v", err) + } + var version1 int + if err := db.QueryRow("PRAGMA user_version").Scan(&version1); err != nil { + t.Fatalf("读取 user_version 失败: %v", err) + } + if version1 != schemaVersion { + t.Fatalf("第一次迁移后 user_version = %d,期望 %d", version1, schemaVersion) + } + schemaAfterFirst := dumpSchema(t, db) + + if err := Migrate(db); err != nil { + t.Fatalf("第二次迁移失败: %v", err) + } + var version2 int + if err := db.QueryRow("PRAGMA user_version").Scan(&version2); err != nil { + t.Fatalf("读取 user_version 失败: %v", err) + } + if version2 != schemaVersion { + t.Errorf("第二次迁移后 user_version = %d,期望仍是 %d", version2, schemaVersion) + } + + assertSchemaEqual(t, "第一次迁移后 vs 第二次迁移后", dumpSchema(t, db), schemaAfterFirst) + + if err := CheckSchema(db); err != nil { + t.Errorf("连续迁移两次后应该通过自检: %v", err) + } +} + +// v2 新结构库结构已经是最终形态,migrateV3 应该只更新版本号, +// 不应该打"新增 pdd_products / 重建 ..."这类日志——那是假话, +// 会让操作员误以为数据被动过。 +func TestMigrate_v2新结构库只更新版本号不打误导性日志(t *testing.T) { + db := newV2NewStructureDB(t) + + var logBuf bytes.Buffer + origOutput := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(origOutput) + + if err := Migrate(db); err != nil { + t.Fatalf("迁移失败: %v", err) + } + + if !strings.Contains(logBuf.String(), "结构已是最新,仅更新版本号") { + t.Errorf("日志应该说明结构已是最新,实际日志:\n%s", logBuf.String()) + } + if strings.Contains(logBuf.String(), "新增 pdd_products") || strings.Contains(logBuf.String(), "重建") { + t.Errorf("结构已经是最终形态时不应该打\"新增/重建\"这类日志,实际日志:\n%s", logBuf.String()) + } +} + +// ── 最终 schema 的硬约束:不依赖路径比对 ────────────── +// +// TestMigrate_不同起点最终schema一致 证明的是"全新库"和"v2 老库"两条路径 +// 走到最后**互相一致**。但 Migrate 让全新库也先走 v1/v2、再靠同一份 +// migrateV3 收敛(见 Migrate 的注释),所以两条路径共用的正是 v3 这段代码。 +// v3 自己要是写错了(CHECK 少约束了一个值、UNIQUE 丢了、主键退回单列), +// 两条路径会一起错,互相比对照样"一致",抓不出来。 +// +// 这里改成直接对最终结构做**行为断言**:插一条数据看数据库报不报错, +// 不匹配 DDL 文本——字符串匹配的话,把 UNIQUE 换成等价的 +// CREATE UNIQUE INDEX 写法就会被误判成失败,但那其实是对的写法。 +func TestMigrate_最终schema符合设计要求(t *testing.T) { + t.Run("pdd_products_goods_id有UNIQUE约束", func(t *testing.T) { + db := newFreshDB(t) + const now = "2026-08-01T00:00:00Z" + if _, err := db.Exec(` + INSERT INTO pdd_products (goods_id, url, created_at, updated_at) + VALUES (?, ?, ?, ?)`, "PDD-DUP", "https://x.example/1", now, now); err != nil { + t.Fatalf("插入第一行失败: %v", err) + } + _, err := db.Exec(` + INSERT INTO pdd_products (goods_id, url, created_at, updated_at) + VALUES (?, ?, ?, ?)`, "PDD-DUP", "https://x.example/2", now, now) + if err == nil { + t.Fatal("goods_id 重复应该被 UNIQUE 约束拒绝,但插入成功了——" + + "少了这条约束,同一个 PDD 商品会被存成好几行,采好几遍,映射说不清指向哪一行") + } + }) + + t.Run("pdd_products_collect_status只接受4个值且拒绝no_link", func(t *testing.T) { + db := newFreshDB(t) + const now = "2026-08-01T00:00:00Z" + for i, status := range []string{"pending", "collecting", "collected", "failed"} { + goodsID := fmt.Sprintf("PDD-OK-%d", i) + if _, err := db.Exec(` + INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, goodsID, "https://x.example/"+goodsID, status, now, now); err != nil { + t.Errorf("collect_status=%q 应该合法,插入失败: %v", status, err) + } + } + + // `[必须]` 单独断言:pdd_products 里有这一行就说明链接已经填了, + // "未填链接"是蝦皮侧的状态(shopee_products.pdd_goods_id 为空)。 + // 放回 no_link 会让两处状态重新打架,这正是 #16 要解决的问题之一。 + _, err := db.Exec(` + INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, "PDD-NOLINK", "https://x.example/nolink", "no_link", now, now) + if err == nil { + t.Fatal("collect_status=no_link 应该被 CHECK 拒绝,但插入成功了") + } + }) + + t.Run("sku_mappings主键是shopee_sku_id和pdd_goods_id两列", func(t *testing.T) { + db := newFreshDB(t) + const now = "2026-08-01T00:00:00Z" + + if _, err := db.Exec(` + INSERT INTO shopee_products (goods_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?)`, "SP-PK", "测试商品", now, now); err != nil { + t.Fatalf("插入 shopee_products 失败: %v", err) + } + if _, err := db.Exec(` + INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, "SKU-PK", "SP-PK", "黑色,M", now, now); err != nil { + t.Fatalf("插入 shopee_skus 失败: %v", err) + } + + insert := func(pddGoodsID string) error { + _, err := db.Exec(` + INSERT INTO sku_mappings + (shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options, goods_id, mapped_at) + VALUES (?, ?, ?, ?, ?, ?)`, + "SKU-PK", pddGoodsID, `{"color":"黑色"}`, `{"color":"黑色"}`, "SP-PK", now) + return err + } + + // 同一个蝦皮 SKU 换过 PDD 商品时,换之前(PDD-A)和换之后(PDD-B) + // 的映射要能同时存在——这正是主键带上 pdd_goods_id 的意义: + // 换成 B 不需要删 A 的映射,A 补货换回来时直接复用。 + if err := insert("PDD-A"); err != nil { + t.Fatalf("插入第一条映射(PDD-A)失败: %v", err) + } + if err := insert("PDD-B"); err != nil { + t.Fatalf("同一个 shopee_sku_id、不同 pdd_goods_id 应该能共存"+ + "(主键必须带上 pdd_goods_id),插入失败: %v", err) + } + + // 完全相同的 (shopee_sku_id, pdd_goods_id) 才应该被主键拒绝。 + if err := insert("PDD-A"); err == nil { + t.Fatal("(shopee_sku_id, pdd_goods_id) 重复应该被主键约束拒绝,但插入成功了") + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM sku_mappings WHERE shopee_sku_id = ?`, + "SKU-PK").Scan(&count); err != nil { + t.Fatalf("查询失败: %v", err) + } + if count != 2 { + t.Errorf("应该有 2 条映射(PDD-A、PDD-B),实际 %d 条——"+ + "如果主键退回单列 shopee_sku_id,第二条会插入失败", count) + } + }) +} + +// ── 带数据的去重和状态映射 ──────────────────────────── + +func TestMigrate_老库两个蝦皮商品指向同一PDD链接_迁移后只有一行(t *testing.T) { + db := newV2DB(t) + insertOldShopeeProduct(t, db, "SP-A", "PDD-1", "https://a.example/1", "collected") + insertOldShopeeProduct(t, db, "SP-B", "PDD-1", "https://b.example/1?x=1", "collected") + + if err := Migrate(db); err != nil { + t.Fatalf("迁移失败: %v", err) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id = ?`, "PDD-1").Scan(&count); err != nil { + t.Fatalf("查询失败: %v", err) + } + if count != 1 { + t.Fatalf("两个蝦皮商品指向同一 PDD 链接,迁移后 pdd_products 应该只有 1 行,实际 %d 行", count) + } + + var status string + if err := db.QueryRow(`SELECT collect_status FROM pdd_products WHERE goods_id = ?`, "PDD-1").Scan(&status); err != nil { + t.Fatalf("查询失败: %v", err) + } + if status != "collected" { + t.Errorf("两行都是 collected,迁移后应该仍是 collected,实际 %q", status) + } + if status != "pending" && status != "collecting" && status != "collected" && status != "failed" { + t.Errorf("collect_status = %q 不是新 CHECK 允许的取值", status) + } +} + +func TestMigrate_老库collecting映射成pending(t *testing.T) { + db := newV2DB(t) + insertOldShopeeProduct(t, db, "SP-C", "PDD-2", "https://x.example/2", "collecting") + + if err := Migrate(db); err != nil { + t.Fatalf("迁移失败: %v", err) + } + + var status string + if err := db.QueryRow(`SELECT collect_status FROM pdd_products WHERE goods_id = ?`, "PDD-2").Scan(&status); err != nil { + t.Fatalf("查询失败: %v", err) + } + if status != "pending" { + t.Errorf("老库 collect_status=collecting,迁移后应为 pending"+ + "(否则这个商品因为 MarkCollecting 只在 pending/failed 时成功,"+ + "会永远建不了新的采集任务),实际 %q", status) + } +} + +func TestMigrate_老库no_link映射成pending_不撞CHECK(t *testing.T) { + db := newV2DB(t) + // 正常情况下 no_link 不会同时带 pdd_goods_id(那是"未填链接"的状态), + // 但历史数据不保证一致,这里刻意构造这个边界组合: + // 确认它不会撞新 CHECK——新 pdd_products.collect_status 里没有 no_link。 + insertOldShopeeProduct(t, db, "SP-D", "PDD-3", "https://x.example/3", "no_link") + + if err := Migrate(db); err != nil { + t.Fatalf("迁移失败(不应该撞 CHECK 约束): %v", err) + } + + var status string + if err := db.QueryRow(`SELECT collect_status FROM pdd_products WHERE goods_id = ?`, "PDD-3").Scan(&status); err != nil { + t.Fatalf("查询失败: %v", err) + } + if status != "pending" { + t.Errorf("老库 collect_status=no_link,迁移后应为 pending,实际 %q", status) + } +} + +// ── shopee_products 重建:列变化和外键安全 ──────────── + +func TestMigrate_shopee_products迁移后不再有旧的四个字段(t *testing.T) { + db := newV2DB(t) + if err := Migrate(db); err != nil { + t.Fatalf("迁移失败: %v", err) + } + + cols := tableColumns(t, db, "shopee_products") + for _, gone := range []string{"pdd_data", "collect_status", "collect_error", "collected_at"} { + if cols[gone] { + t.Errorf("shopee_products 迁移后不应该还有列 %s", gone) + } + } + for _, keep := range []string{"goods_id", "title", "shopee_status", "main_sku_code", + "pdd_goods_url", "pdd_goods_id", "created_at", "updated_at"} { + if !cols[keep] { + t.Errorf("shopee_products 迁移后缺少列 %s", keep) + } + } +} + +func tableColumns(t *testing.T, db *sql.DB, table string) map[string]bool { + t.Helper() + // table 只来自测试里写死的常量,不是外部输入,字符串拼接是安全的 + // (PRAGMA 本身也不支持参数化,db.go 里 PRAGMA user_version 也是这么处理的)。 + rows, err := db.Query(`PRAGMA table_info(` + table + `)`) + if err != nil { + t.Fatalf("读取 %s 的列失败: %v", table, 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 { + t.Fatalf("读取 %s 的列失败: %v", table, err) + } + cols[name] = true + } + return cols +} + +func TestMigrate_shopee_skus数据不被外键带走(t *testing.T) { + db := newV2DB(t) + insertOldShopeeProduct(t, db, "SP-E", "", "", "no_link") + const now = "2026-08-01T00:00:00Z" + _, err := db.Exec(` + INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, parse_ok, created_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?)`, + "SKU-1", "SP-E", "黑色,M", now, now) + if err != nil { + t.Fatalf("插入老 shopee_skus 失败: %v", err) + } + + if err := Migrate(db); err != nil { + t.Fatalf("迁移失败: %v", err) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM shopee_skus WHERE sku_id = ?`, "SKU-1").Scan(&count); err != nil { + t.Fatalf("查询失败: %v", err) + } + if count != 1 { + t.Fatalf("重建 shopee_products 时把 shopee_skus 的数据带丢了,剩 %d 行", count) + } + + var goodsID string + if err := db.QueryRow(`SELECT goods_id FROM shopee_skus WHERE sku_id = ?`, "SKU-1").Scan(&goodsID); err != nil { + t.Fatalf("查询失败: %v", err) + } + if goodsID != "SP-E" { + t.Errorf("shopee_skus.goods_id 应该还是 SP-E,实际 %q", goodsID) + } +} + +// ── sku_mappings 丢弃并打日志 ───────────────────────── + +func TestMigrate_旧sku_mappings有数据时丢弃并打日志(t *testing.T) { + db := newV2DB(t) + const now = "2026-08-01T00:00:00Z" + insertOldShopeeProduct(t, db, "SP-F", "", "", "no_link") + if _, err := db.Exec(` + INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, parse_ok, created_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?)`, "SKU-2", "SP-F", "黑色,M", now, now); err != nil { + t.Fatalf("插入老 shopee_skus 失败: %v", err) + } + if _, err := db.Exec(` + INSERT INTO sku_mappings (shopee_sku_id, goods_id, pdd_options, mapped_at, mapped_by) + VALUES (?, ?, ?, ?, ?)`, "SKU-2", "SP-F", `{"color":"黑色"}`, now, "tester"); err != nil { + t.Fatalf("插入老 sku_mappings 失败: %v", err) + } + + var logBuf bytes.Buffer + origOutput := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(origOutput) + + if err := Migrate(db); err != nil { + t.Fatalf("迁移失败: %v", err) + } + + if !strings.Contains(logBuf.String(), "丢弃了 1 条旧规格映射") { + t.Errorf("日志里没找到丢弃提示,实际日志:\n%s", logBuf.String()) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM sku_mappings`).Scan(&count); err != nil { + t.Fatalf("查询失败: %v", err) + } + if count != 0 { + t.Errorf("旧 sku_mappings 应该被丢弃,实际还剩 %d 行", count) + } + + // 新表必须支持新主键需要的两列,插入一条验证结构真的换过来了。 + if _, err := db.Exec(` + INSERT INTO sku_mappings + (shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options, goods_id, mapped_at) + VALUES (?, ?, ?, ?, ?, ?)`, + "SKU-2", "PDD-9", `{"color":"黑色"}`, `{"color":"黑色"}`, "SP-F", now); err != nil { + t.Errorf("新 sku_mappings 应该支持 pdd_goods_id / pdd_option_key,插入失败: %v", err) + } +} + +func TestMigrate_旧sku_mappings为空时不打日志(t *testing.T) { + db := newV2DB(t) + + var logBuf bytes.Buffer + origOutput := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(origOutput) + + if err := Migrate(db); err != nil { + t.Fatalf("迁移失败: %v", err) + } + + if strings.Contains(logBuf.String(), "丢弃了") { + t.Errorf("没有旧映射数据时不应该打丢弃日志,实际日志:\n%s", logBuf.String()) + } +} + +// ── 启动时 schema 自检 ──────────────────────────────── + +func TestCheckSchema_全新库通过(t *testing.T) { + db := newFreshDB(t) + if err := CheckSchema(db); err != nil { + t.Errorf("全新迁移后的库应该通过自检: %v", err) + } +} + +func TestCheckSchema_v2老库迁移后通过(t *testing.T) { + db := newV2DB(t) + if err := Migrate(db); err != nil { + t.Fatalf("迁移失败: %v", err) + } + if err := CheckSchema(db); err != nil { + t.Errorf("v2 老库迁移后应该通过自检: %v", err) + } +} + +func TestCheckSchema_缺表时拒绝(t *testing.T) { + db := newFreshDB(t) + if _, err := db.Exec(`DROP TABLE pdd_products`); err != nil { + t.Fatalf("删表失败: %v", err) + } + + err := CheckSchema(db) + if err == nil { + t.Fatal("缺表时 CheckSchema 应该返回错误,拒绝启动,而不是静默通过") + } + if !strings.Contains(err.Error(), "pdd_products") { + t.Errorf("错误信息应该指出缺的是哪张表,实际: %v", err) + } +} diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 495180f..5da5a4e 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -86,6 +86,35 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、 `[必须]` 加新版本时**只能往末尾追加** `migrations`,不许改动已有元素—— 已经发布出去的库是按旧语句建的,改了会导致新旧库结构不一致。 +### 2.3 迁移版本历史 + +| 版本 | 做了什么 | +|---|---| +| v1 | 初始表结构:`shopee_products`(当时还带着 `pdd_data`/`collect_status` 等采集字段)、`shopee_skus`、`syb_orders`、`sku_mappings`(当时主键只有 `shopee_sku_id`)、`tasks`、`clients`、`idempotency_keys`。 | +| v2 | 新增 `task_claims`(领取历史,见 §8)。 | +| v3 | 把 PDD 采集数据从 `shopee_products` 拆到独立的 `pdd_products`(本文档 §4 描述的最终结构);重建 `shopee_products`,去掉已经搬走的四个字段;重建 `sku_mappings`,主键改成 `(shopee_sku_id, pdd_goods_id)`(§6.1 的理由)。 | + +**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`, +这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句 +复现不了。硬凑一个键出来有两种后果:算错了会让映射静默失效,需要人工重新匹配一遍; +算的时候恰好和别的规格撞了键,会**静默买错东西,且事后查不出来**——这正是引入 +`pdd_goods_id` 做主键要防的问题,不能在迁移里重新引入。当时匹配界面还没做, +所以迁移时不可能存在真实映射数据,丢弃的代价很小。丢弃的行数会打进启动日志 +(`迁移 v3:丢弃了 N 条旧规格映射...`),不会静默丢。 + +`[必须]` v1 曾经在 #16 里被原地改写(直接把 `pdd_products` 等结构塞进 v1, +没有新增版本),导致已经建过库、`user_version` 已经越过 v1 的老机器永远不会 +重跑改写后的语句,程序拿着一个和代码对不上的库静默启动,界面点到 PDD 商品页 +才报 500。#20 把 v1 恢复成原样、改动挪进新增的 v3,并加了启动时 schema 自检 +(缺表直接拒绝启动,见下)作为兜底。 + +### 2.4 启动时 schema 自检 + +`[必须]` `Migrate` 成功后,`repository.CheckSchema` 会检查代码依赖的表是否都在, +缺了就返回错误、**拒绝启动**,不是打个警告继续跑——静默启动正是 #20 的教训: +错误要等操作员点到那个页面才暴露,如果那是个写操作页面,暴露出来的就不是报错 +而是写坏数据。自检只查表名,不逐列校验:够抓住"迁移没跑到"这一类问题,代价也低。 + ## 3. 蝦皮数据 蝦皮报表**一个文件里混了两层数据**,所以拆成两张表。