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" {
|
||||
|
||||
@@ -444,6 +444,11 @@ MySQL schema v9 为 `shopee_products` 增加 `image_url` 和 `shopee_shop_name`
|
||||
能补空或更新同来源的新观测,空值、跨来源、旧观测和人工字段均不覆盖。该图片是蝦皮
|
||||
商品主图,与 `syb_orders.image_url` 的历史货运单观测图片是两个独立概念。
|
||||
|
||||
MySQL schema v10(工单 #144)是兼容修复:部分数据库曾在 v8 开发中间状态提前记录
|
||||
v8/v9,实际缺少 `field_sources` 或 `field_observed_at`。v10 幂等重放 v8 的逐项建列、
|
||||
空值回填和唯一索引逻辑,再核对 v8/v9 完整形状;只有全部通过才记录 v10。它不删除
|
||||
或改写旧迁移记录,也不清空商品、SKU 或导入记录。
|
||||
|
||||
蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”:
|
||||
订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。
|
||||
该读模型不写入 `shopee_skus`。只有同一商品下恰好一条正式 SKU 的 `spec_raw`
|
||||
|
||||
@@ -87,11 +87,15 @@ HTTP/HTTPS URL,最长 2048 字节;店铺名最长 500 个字符。Admin 只
|
||||
调用方应按 `code` 处理,不要解析中文 `message`。校验错误通常是 400/422,业务冲突
|
||||
是 409,临时服务错误是 500/503。响应和导入记录都不会回显 Token 或完整请求体。
|
||||
|
||||
## 5. schema v8 发布与回退
|
||||
## 5. schema v8~v10 发布与回退
|
||||
|
||||
发布前先备份生产库,并在 MySQL 8.4、库名以 `_test` 结尾的测试库演练 v7→v8→v9。
|
||||
发布前先备份生产库,并在 MySQL 8.4、库名以 `_test` 结尾的测试库演练 v7→v8→v9→v10。
|
||||
迁移只增加列和唯一索引,既有 `sku_id` 继续作为内部主键,因此历史引用不变;若检测
|
||||
到同商品重复 `spec_key`,迁移会停止且不记录 v8,必须人工确认,不能自动合并。
|
||||
|
||||
若数据库已经记录 v8 或 v9,但缺少 `field_sources`、`field_observed_at` 等后加入的
|
||||
v8 结构,v10 会自动补齐并只回填空值。不要通过删除迁移版本、清库或手工伪造字段来
|
||||
绕过自检;修复完成且 v8/v9 形状均正确后,程序才会记录 v10。
|
||||
|
||||
旧版 Admin 不理解无真实 SKU ID 的新记录。应用回退前应暂停目录接口并确认没有此类
|
||||
记录;不要删除 v8 列或把内部主键复制成伪造的外部 SKU ID。
|
||||
|
||||
Reference in New Issue
Block a user