From fef97cf5f3644b84f1b0447d5561c40e87f76b6c Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 11 Aug 2026 11:26:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=8D=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=9A=84=E5=95=86=E5=93=81=E7=9B=AE=E5=BD=95=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/repository/mysql_db.go | 31 +++++++- admin/repository/mysql_db_integration_test.go | 79 +++++++++++++++++++ docs/admin/03-data-model.md | 5 ++ docs/admin/10-商品目录接入接口.md | 8 +- 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index e262546..2232c56 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -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`}, diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index 578080e..49f7ac3 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -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" { diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 7db929b..955d404 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -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` diff --git a/docs/admin/10-商品目录接入接口.md b/docs/admin/10-商品目录接入接口.md index ab1dd4f..5b4743c 100644 --- a/docs/admin/10-商品目录接入接口.md +++ b/docs/admin/10-商品目录接入接口.md @@ -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。