Files
cmautobuy/admin/repository/migrate_test.go
T

1089 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
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新增用户和WebSession表(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"} {
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 != 6 {
t.Errorf("%s user_version = %d,期望 6", c.name, version)
}
}
}
// 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, &notnull, &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/v6 补上——否则 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)
}
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)
}
}