package repository import ( "bytes" "database/sql" "fmt" "log" "regexp" "sort" "strings" "testing" "cmautobuy/admin/model" ) // 本文件是工单 #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) } } func TestMigrate_v4增加可空店铺名且老数据保持NULL(t *testing.T) { db := newV2NewStructureDB(t) if _, err := db.Exec(` INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at) VALUES ('737116531267', 'https://example.invalid', 'pending', '2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z')`); err != nil { t.Fatalf("插入 v2 老数据失败: %v", err) } if err := Migrate(db); err != nil { t.Fatalf("迁移到 v4 失败: %v", err) } columns, err := tableColumnSet(db, "pdd_products") if err != nil { t.Fatalf("读取 pdd_products 列失败: %v", err) } if !columns["shop_name"] { t.Fatal("v4 必须新增 shop_name 列") } var shop sql.NullString if err := db.QueryRow( `SELECT shop_name FROM pdd_products WHERE goods_id = '737116531267'`, ).Scan(&shop); err != nil { t.Fatalf("读取老数据店铺名失败: %v", err) } if shop.Valid { t.Errorf("老数据的 shop_name 应为 NULL,实际 %q", shop.String) } } // 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()) } } // ── v5:顺运宝会话表、同步状态表、product_spec 列 ────── // TestMigrate_不同起点最终schema一致(上面已有)用的是全量 dumpSchema 对比, // v5 的新表/新列不需要改那个测试就已经被覆盖——这里再加行为断言, // 专门锁住 v5 引入的三样东西,即使将来有人改坏了三起点收敛测试本身, // 这几个测试仍然能单独发现问题。 func TestMigrate_v5新增会话表同步状态表和product_spec列(t *testing.T) { for _, c := range []struct { name string db *sql.DB }{ {"全新库", newFreshDB(t)}, {"v2 老结构库", newV2DB(t)}, {"v2 新结构库", newV2NewStructureDB(t)}, } { if c.name != "全新库" { if err := Migrate(c.db); err != nil { t.Fatalf("%s 迁移失败: %v", c.name, err) } } 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.Fatalf("%s user_version = %d,期望 %d", c.name, version, schemaVersion) } tables := existingTableSet(t, c.db) for _, table := range []string{"syb_session", "syb_sync_state"} { if !tables[table] { t.Errorf("%s:迁移后应该有表 %s", c.name, table) } } cols, err := tableColumnSet(c.db, "syb_orders") if err != nil { t.Fatalf("%s 读取 syb_orders 列失败: %v", c.name, err) } if !cols["product_spec"] { t.Errorf("%s:syb_orders 迁移后应该有 product_spec 列", c.name) } if err := CheckSchema(c.db); err != nil { t.Errorf("%s:迁移后应该通过自检: %v", c.name, err) } } } func TestMigrate_v6用户表与v7客户端归属表均存在(t *testing.T) { for _, c := range []struct { name string db *sql.DB }{ {"全新库", newFreshDB(t)}, {"v2 老结构库", newV2DB(t)}, {"v2 新结构库", newV2NewStructureDB(t)}, } { if c.name != "全新库" { if err := Migrate(c.db); err != nil { t.Fatalf("%s 迁移失败: %v", c.name, err) } } tables := existingTableSet(t, c.db) for _, table := range []string{"users", "web_sessions", "client_user_assignments"} { if !tables[table] { t.Errorf("%s:迁移后应该有表 %s", c.name, table) } } 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) } } } // 显式覆盖每一个已发布版本起点,证明现实库不会因为后续新增表而卡住。 // v2 的两种历史结构另由上面的收敛测试持续覆盖。 func TestMigrate_v1到v7均可升级到最新版本(t *testing.T) { for version := 1; version <= 7; version++ { t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) { db := newPublishedVersionDB(t, version) if err := Migrate(db); err != nil { t.Fatalf("v%d 迁移到最新版本失败: %v", version, err) } if err := CheckSchema(db); err != nil { t.Fatalf("v%d 迁移后 schema 自检失败: %v", version, err) } var gotVersion int if err := db.QueryRow(`PRAGMA user_version`).Scan(&gotVersion); err != nil { t.Fatal(err) } if gotVersion != schemaVersion { t.Fatalf("v%d 迁移后 user_version = %d,期望 %d", version, gotVersion, schemaVersion) } }) } } func TestMigrate_v8新增顺运宝同步记录表(t *testing.T) { db := newPublishedVersionDB(t, 7) if err := Migrate(db); err != nil { t.Fatalf("v7 升级到 v8 失败: %v", err) } if !existingTableSet(t, db)["syb_sync_runs"] { t.Fatal("v8 应该新增 syb_sync_runs 表") } var version int if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil || version != schemaVersion { t.Fatalf("迁移版本错误: version=%d err=%v", version, err) } user := model.User{ UserID: "USR-MIGRATE", Username: "buyer", PasswordHash: "test-hash", Role: model.RolePurchaser, Status: model.UserActive, PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO(), } if err := CreateUser(db, user); err != nil { t.Fatalf("准备同步记录外键用户失败: %v", err) } // 状态和 running/finished_at 对应关系必须由数据库兜底,不能只靠 Go 校验。 if _, err := db.Exec(`INSERT INTO syb_sync_runs (run_id, user_id, date_from, date_to, status, started_at, finished_at) VALUES ('bad', ?, '2026-08-09', '2026-08-09', 'unknown', ?, ?)`, user.UserID, model.NowISO(), model.NowISO()); err == nil { t.Fatal("非法同步状态应该被数据库约束拒绝") } if _, err := db.Exec(`INSERT INTO syb_sync_runs (run_id, user_id, date_from, date_to, status, started_at, finished_at) VALUES ('bad-running', ?, '2026-08-09', '2026-08-09', 'running', ?, ?)`, user.UserID, model.NowISO(), model.NowISO()); err == nil { t.Fatal("running 状态不应允许填写完成时间") } } func TestMigrate_v9回填顺运宝店铺名(t *testing.T) { db := newPublishedVersionDB(t, 7) if err := runSQLMigration(db, 8, migrationV8); err != nil { t.Fatal(err) } now := model.NowISO() for _, row := range []struct{ id, raw string }{ {"shop", `{"stock":{"shopName":" 测试店铺 "}}`}, {"blank", `{"stock":{"shopName":" "}}`}, {"missing", `{"stock":{}}`}, {"invalid", `{not-json`}, } { if _, err := db.Exec(`INSERT INTO syb_orders (syb_id,order_no,title,product_spec,quantity,syb_data,created_at,updated_at) VALUES (?,?,'商品','黑色,M',1,?,?,?)`, row.id, row.id, row.raw, now, now); err != nil { t.Fatal(err) } } if err := Migrate(db); err != nil { t.Fatalf("v8 升级 v9 失败: %v", err) } var shop sql.NullString if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id='shop'`).Scan(&shop); err != nil || !shop.Valid || shop.String != "测试店铺" { t.Fatalf("店铺回填错误: %+v err=%v", shop, err) } for _, id := range []string{"blank", "missing", "invalid"} { if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id=?`, id).Scan(&shop); err != nil || shop.Valid { t.Fatalf("%s 应保持 NULL: %+v err=%v", id, shop, err) } } if err := Migrate(db); err != nil { t.Fatalf("v9 重复迁移失败: %v", err) } } func newPublishedVersionDB(t *testing.T, version int) *sql.DB { t.Helper() db, err := Open(t.TempDir()) if err != nil { t.Fatalf("打开 v%d 测试库失败: %v", version, err) } t.Cleanup(func() { db.Close() }) // v1/v2 使用项目实际发布过的迁移语句。v3 起由正式迁移函数继续构造, // 避免测试复制一份容易与生产实现漂移的 schema。 for migrationIndex := 0; migrationIndex < version && migrationIndex < 2; migrationIndex++ { for statementIndex, statement := range migrations[migrationIndex] { if _, err := db.Exec(statement); err != nil { t.Fatalf("构造 v%d 时执行 v%d 第 %d 条失败: %v", version, migrationIndex+1, statementIndex+1, err) } } } if _, err := db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, min(version, 2))); err != nil { t.Fatal(err) } if version >= 3 { if err := migrateV3(db); err != nil { t.Fatalf("构造 v%d 时执行 v3 失败: %v", version, err) } } if version >= 4 { if err := runSQLMigration(db, 4, migrationV4); err != nil { t.Fatalf("构造 v%d 时执行 v4 失败: %v", version, err) } } if version >= 5 { if err := runSQLMigration(db, 5, migrationV5); err != nil { t.Fatalf("构造 v%d 时执行 v5 失败: %v", version, err) } } if version >= 6 { if err := runSQLMigration(db, 6, migrationV6); err != nil { t.Fatalf("构造 v%d 时执行 v6 失败: %v", version, err) } } if version >= 7 { if err := runSQLMigration(db, 7, migrationV7); err != nil { t.Fatalf("构造 v%d 时执行 v7 失败: %v", version, err) } } return db } // existingTableSet 返回库里当前存在的表名集合。 func existingTableSet(t *testing.T, db *sql.DB) map[string]bool { t.Helper() rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table'`) if err != nil { t.Fatalf("读取表清单失败: %v", err) } defer rows.Close() set := map[string]bool{} for rows.Next() { var name string if err := rows.Scan(&name); err != nil { t.Fatalf("读取表清单失败: %v", err) } set[name] = true } return set } // TestMigrate_v5会话表主键只允许一个用户名一行 验证 syb_session 的主键约束: // 同一个用户名重复写入应该走 upsert(由 repository 层保证),底层主键必须 // 拒绝真正的重复插入,防止同一账号在表里出现两行、读的时候不知道信哪行。 func TestMigrate_v5会话表主键约束(t *testing.T) { db := newFreshDB(t) const now = "2026-08-09T00:00:00Z" insert := func() error { _, err := db.Exec(` INSERT INTO syb_session (username, cookies, expires_at, updated_at) VALUES (?, ?, ?, ?)`, "tester", `[]`, now, now) return err } if err := insert(); err != nil { t.Fatalf("插入第一条会话失败: %v", err) } if err := insert(); err == nil { t.Fatal("同一 username 重复插入应该被主键约束拒绝") } } // TestMigrate_v5同步状态表只允许一行 验证 CHECK (id = 1) 确实生效—— // 这张表按设计只应该有一行(全局的"上次同步到哪"),如果这条 CHECK // 被误删或写错,程序里到处用 id=1 查询的代码会开始读到错误的行。 func TestMigrate_v5同步状态表只允许id为1(t *testing.T) { db := newFreshDB(t) const now = "2026-08-09T00:00:00Z" if _, err := db.Exec(` INSERT INTO syb_sync_state (id, last_synced_at, updated_at) VALUES (1, NULL, ?)`, now); err != nil { t.Fatalf("插入 id=1 失败: %v", err) } _, err := db.Exec(` INSERT INTO syb_sync_state (id, last_synced_at, updated_at) VALUES (2, NULL, ?)`, now) if err == nil { t.Fatal("id != 1 应该被 CHECK 约束拒绝") } } func TestCheckSchema_缺少syb_session表时拒绝(t *testing.T) { db := newFreshDB(t) if _, err := db.Exec(`DROP TABLE syb_session`); err != nil { t.Fatalf("删表失败: %v", err) } err := CheckSchema(db) if err == nil || !strings.Contains(err.Error(), "syb_session") { t.Fatalf("缺 syb_session 表时应该拒绝启动并指出表名,实际 %v", err) } } func TestCheckSchema_缺少syb_sync_state表时拒绝(t *testing.T) { db := newFreshDB(t) if _, err := db.Exec(`DROP TABLE syb_sync_state`); err != nil { t.Fatalf("删表失败: %v", err) } err := CheckSchema(db) if err == nil || !strings.Contains(err.Error(), "syb_sync_state") { t.Fatalf("缺 syb_sync_state 表时应该拒绝启动并指出表名,实际 %v", err) } } func TestCheckSchema_缺少product_spec列时拒绝(t *testing.T) { db := newFreshDB(t) // modernc.org/sqlite 支持 DROP COLUMN(SQLite ≥ 3.35), // 用它来模拟"迁移没有完整落地、只有旧列"的库。 if _, err := db.Exec(`ALTER TABLE syb_orders DROP COLUMN product_spec`); err != nil { t.Fatalf("模拟缺列失败: %v", err) } err := CheckSchema(db) if err == nil || !strings.Contains(err.Error(), "product_spec") { t.Fatalf("缺 product_spec 列时应该拒绝启动并指出列名,实际 %v", err) } } // ── 最终 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_缺少v4关键列时拒绝(t *testing.T) { db := newV2NewStructureDB(t) if err := migrateV3(db); err != nil { t.Fatalf("准备 v3 数据库失败: %v", err) } // 故意跳过 v4(不加 shop_name),但把 v5-v8 补上——否则 CheckSchema 会先 // 因为缺后续表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。 if err := runSQLMigration(db, 5, migrationV5); err != nil { t.Fatalf("准备 v5 数据库失败: %v", err) } if err := runSQLMigration(db, 6, migrationV6); err != nil { t.Fatalf("准备 v6 数据库失败: %v", err) } if err := runSQLMigration(db, 7, migrationV7); err != nil { t.Fatalf("准备 v7 数据库失败: %v", err) } if err := runSQLMigration(db, 8, migrationV8); err != nil { t.Fatalf("准备 v8 数据库失败: %v", err) } err := CheckSchema(db) if err == nil || !strings.Contains(err.Error(), "shop_name") { t.Fatalf("缺少 shop_name 时应拒绝启动并指出列名,实际 %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) } } func TestCheckSchema_缺少同步记录表时拒绝(t *testing.T) { db := newFreshDB(t) if _, err := db.Exec(`DROP TABLE syb_sync_runs`); err != nil { t.Fatalf("删表失败: %v", err) } err := CheckSchema(db) if err == nil || !strings.Contains(err.Error(), "syb_sync_runs") { t.Fatalf("缺少同步记录表时应拒绝启动并指出表名,实际 %v", err) } }