fix: 修复不完整的商品目录迁移 (#144)
This commit is contained in:
@@ -19,7 +19,7 @@ import (
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const mysqlSchemaVersion = 9
|
||||
const mysqlSchemaVersion = 10
|
||||
|
||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||
@@ -531,10 +531,39 @@ func MigrateMySQL(db *sql.DB) error {
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 9, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v9 失败: %w", err)
|
||||
}
|
||||
current = 9
|
||||
}
|
||||
if current < 10 {
|
||||
if err := migrateMySQLV10(db); err != nil {
|
||||
return fmt.Errorf("执行 MySQL schema v10 失败: %w", err)
|
||||
}
|
||||
if err := checkMySQLV10Shape(db); err != nil {
|
||||
return fmt.Errorf("MySQL schema v10 自检失败,未记录版本: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 10, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return fmt.Errorf("记录 MySQL schema v10 失败: %w", err)
|
||||
}
|
||||
}
|
||||
return CheckMySQLSchema(db)
|
||||
}
|
||||
|
||||
// migrateMySQLV10 修复曾在 v8 开发中间状态记录过版本的数据库。
|
||||
// v8 迁移本身逐项检查列和索引且回填只处理空值,因此可安全重放;
|
||||
// 历史 v8/v9 版本记录保持不变,完整自检通过后再追加 v10。
|
||||
func migrateMySQLV10(db *sql.DB) error {
|
||||
if err := migrateMySQLV8(db); err != nil {
|
||||
return fmt.Errorf("修复商品目录 SKU v8 结构失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMySQLV10Shape(db *sql.DB) error {
|
||||
if err := checkMySQLV8Shape(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkMySQLV9Shape(db)
|
||||
}
|
||||
|
||||
func migrateMySQLV9(db *sql.DB) error {
|
||||
columns := []struct{ table, name, ddl string }{
|
||||
{"shopee_products", "image_url", `ALTER TABLE shopee_products ADD COLUMN image_url VARCHAR(2048) NULL AFTER main_sku_code`},
|
||||
|
||||
@@ -468,6 +468,85 @@ func TestMySQLMigrate_V9形状错误不记版本(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLMigrate_已记录V9但缺少SKU来源字段时由V10修复(t *testing.T) {
|
||||
db := openMySQLMigrationTestDB(t)
|
||||
defer db.Close()
|
||||
cleanMySQLTestSchema(t, db)
|
||||
defer cleanMySQLTestSchema(t, db)
|
||||
prepareMySQLV7(t, db)
|
||||
now := "2026-08-11T00:00:00Z"
|
||||
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('S-1','商品','report',?,?)`, now, now)
|
||||
mustExec(t, db, `INSERT INTO shopee_skus(sku_id,goods_id,spec_raw,color,size,advice,parse_ok,sku_code,is_manual,source_observed_at,created_at,updated_at)
|
||||
VALUES('REAL-1','S-1',' 黑色, M ','黑色','M','建议',1,'CODE-1',1,?,?,?)`, now, now, now)
|
||||
if err := migrateMySQLV8(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟 #141 开发中间状态:版本已继续推进到 v9,但两个后加入的 v8 字段不存在。
|
||||
mustExec(t, db, `ALTER TABLE shopee_skus DROP COLUMN field_observed_at, DROP COLUMN field_sources`)
|
||||
if err := migrateMySQLV9(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES
|
||||
(8,'2026-08-11T00:00:00Z'),(9,'2026-08-11T00:00:00Z')`)
|
||||
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v10 重放失败: %v", err)
|
||||
}
|
||||
|
||||
var internalID, externalID, key, color, size, advice, skuCode, colorSource, colorObserved string
|
||||
var manual int
|
||||
err := db.QueryRow(`SELECT sku_id,shopee_sku_id,spec_key,color,size,advice,sku_code,is_manual,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(field_sources,'$.color')),
|
||||
JSON_UNQUOTE(JSON_EXTRACT(field_observed_at,'$.color')) FROM shopee_skus WHERE sku_id='REAL-1'`).
|
||||
Scan(&internalID, &externalID, &key, &color, &size, &advice, &skuCode, &manual, &colorSource, &colorObserved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if internalID != "REAL-1" || externalID != "REAL-1" || key != "黑色, M" || color != "黑色" || size != "M" || advice != "建议" || skuCode != "CODE-1" || manual != 1 {
|
||||
t.Fatalf("v10 修改了既有 SKU 业务字段:id=%q external=%q key=%q color=%q size=%q advice=%q code=%q manual=%d",
|
||||
internalID, externalID, key, color, size, advice, skuCode, manual)
|
||||
}
|
||||
if colorSource != "report" || colorObserved != now {
|
||||
t.Fatalf("v10 来源回填不正确:source=%q observed=%q", colorSource, colorObserved)
|
||||
}
|
||||
var versionCount int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=10`).Scan(&versionCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versionCount != 1 {
|
||||
t.Fatalf("v10 应只记录一次,实际 %d", versionCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLMigrate_V10执行后未记版本可继续收敛(t *testing.T) {
|
||||
db := openMySQLMigrationTestDB(t)
|
||||
defer db.Close()
|
||||
cleanMySQLTestSchema(t, db)
|
||||
defer cleanMySQLTestSchema(t, db)
|
||||
prepareMySQLV8(t, db)
|
||||
if err := migrateMySQLV9(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES(9,'2026-08-11T00:00:00Z')`)
|
||||
// 模拟 v10 的 DDL/回填已隐式提交、但版本号尚未记录时进程退出。
|
||||
if err := migrateMySQLV10(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
t.Fatalf("v10 中断后重跑失败: %v", err)
|
||||
}
|
||||
var versionCount int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=10`).Scan(&versionCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versionCount != 1 {
|
||||
t.Fatalf("v10 应只记录一次,实际 %d", versionCount)
|
||||
}
|
||||
}
|
||||
|
||||
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
||||
|
||||
Reference in New Issue
Block a user