diff --git a/server/app/goauto/sybshop/reconcile.go b/server/app/goauto/sybshop/reconcile.go new file mode 100644 index 0000000..350e52b --- /dev/null +++ b/server/app/goauto/sybshop/reconcile.go @@ -0,0 +1,91 @@ +package sybshop + +import ( + "context" + "fmt" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +// ReconcileNormalizedNames repairs the matching keys of live SYB shops created +// before Normalize became the single write-path rule. It changes neither the +// display name nor the enabled state. The operation is safe to repeat. +// +// A normalised-name collision is a data ambiguity. It must be resolved by an +// operator, rather than silently merging shops or choosing one arbitrarily. +func ReconcileNormalizedNames(ctx context.Context, db *gorm.DB) (int, error) { + if db == nil { + return 0, fmt.Errorf("syb shop normalized-name repair: database is nil") + } + + updated := 0 + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var shops []models.SYBShop + if err := tx.Order("id ASC").Find(&shops).Error; err != nil { + return err + } + + desiredOwners := make(map[string]uint64, len(shops)) + currentKeys := make(map[string]struct{}, len(shops)) + type repair struct { + id uint64 + desired string + temp string + } + repairs := make([]repair, 0) + for _, shop := range shops { + desired := Normalize(shop.DisplayName) + if desired == "" { + return fmt.Errorf("syb shop normalized-name repair: shop %d has blank display name", shop.ID) + } + if owner, exists := desiredOwners[desired]; exists && owner != shop.ID { + return fmt.Errorf("syb shop normalized-name repair: normalized key conflict between shops %d and %d", owner, shop.ID) + } + desiredOwners[desired] = shop.ID + currentKeys[shop.NormalizedName] = struct{}{} + if shop.NormalizedName != desired { + repairs = append(repairs, repair{id: shop.ID, desired: desired}) + } + } + + // Keys can be swapped (for example stale "a"/"b" values). Stage every + // repair through a unique temporary key so the unique index is preserved + // throughout the transaction. + reserved := make(map[string]struct{}, len(currentKeys)+len(desiredOwners)) + for key := range currentKeys { + reserved[key] = struct{}{} + } + for key := range desiredOwners { + reserved[key] = struct{}{} + } + for index := range repairs { + base := fmt.Sprintf("__goauto_syb_shop_normalize_repair_%d__", repairs[index].id) + temp := base + for suffix := 1; ; suffix++ { + if _, exists := reserved[temp]; !exists { + break + } + temp = fmt.Sprintf("%s%d", base, suffix) + } + repairs[index].temp = temp + reserved[temp] = struct{}{} + } + for _, repair := range repairs { + if err := tx.Model(&models.SYBShop{}).Where("id = ?", repair.id). + UpdateColumn("normalized_name", repair.temp).Error; err != nil { + return err + } + } + for _, repair := range repairs { + if err := tx.Model(&models.SYBShop{}).Where("id = ?", repair.id). + UpdateColumn("normalized_name", repair.desired).Error; err != nil { + return err + } + } + updated = len(repairs) + return nil + }) + return updated, err +} diff --git a/server/app/goauto/sybshop/reconcile_test.go b/server/app/goauto/sybshop/reconcile_test.go new file mode 100644 index 0000000..be2eba7 --- /dev/null +++ b/server/app/goauto/sybshop/reconcile_test.go @@ -0,0 +1,93 @@ +package sybshop_test + +import ( + "context" + "strings" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybshop" +) + +func TestReconcileNormalizedNamesRepairsLegacyKey(t *testing.T) { + db := openDB(t) + shop := models.SYBShop{DisplayName: "樂齡樂活美學館|銀髮居家熟齡悅己壯世代優雅生活", NormalizedName: "legacy-key", Enabled: true} + if err := db.Create(&shop).Error; err != nil { + t.Fatalf("创建历史店铺失败: %v", err) + } + + updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db) + if err != nil { + t.Fatalf("回填失败: %v", err) + } + if updated != 1 { + t.Fatalf("应修复 1 条,实际 %d", updated) + } + var reloaded models.SYBShop + if err := db.First(&reloaded, shop.ID).Error; err != nil { + t.Fatalf("读取修复结果失败: %v", err) + } + if reloaded.NormalizedName != sybshop.Normalize(shop.DisplayName) { + t.Fatalf("规范化键不正确: %q", reloaded.NormalizedName) + } + if reloaded.DisplayName != shop.DisplayName || !reloaded.Enabled { + t.Fatalf("回填不应修改展示名或启用状态: %+v", reloaded) + } + names, err := sybshop.EnabledNames(context.Background(), db) + if err != nil || names[sybshop.Normalize(shop.DisplayName)] != shop.DisplayName { + t.Fatalf("修复后启用快照未命中: names=%v err=%v", names, err) + } + + updated, err = sybshop.ReconcileNormalizedNames(context.Background(), db) + if err != nil || updated != 0 { + t.Fatalf("重复回填应无变化: updated=%d err=%v", updated, err) + } +} + +func TestReconcileNormalizedNamesRejectsCollisionWithoutWriting(t *testing.T) { + db := openDB(t) + first := models.SYBShop{DisplayName: "A店", NormalizedName: "legacy-one", Enabled: true} + second := models.SYBShop{DisplayName: "A店", NormalizedName: "legacy-two", Enabled: true} + if err := db.Create(&first).Error; err != nil { + t.Fatalf("创建第一条历史店铺失败: %v", err) + } + if err := db.Create(&second).Error; err != nil { + t.Fatalf("创建第二条历史店铺失败: %v", err) + } + + updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db) + if updated != 0 || err == nil || !strings.Contains(err.Error(), "normalized key conflict") { + t.Fatalf("冲突应失败且不报告更新: updated=%d err=%v", updated, err) + } + var rows []models.SYBShop + if err := db.Order("id ASC").Find(&rows).Error; err != nil { + t.Fatalf("读取冲突结果失败: %v", err) + } + if len(rows) != 2 || rows[0].NormalizedName != "legacy-one" || rows[1].NormalizedName != "legacy-two" { + t.Fatalf("冲突时不应修改任何键: %+v", rows) + } +} + +func TestReconcileNormalizedNamesHandlesSwappedLegacyKeys(t *testing.T) { + db := openDB(t) + first := models.SYBShop{DisplayName: "A", NormalizedName: "b", Enabled: true} + second := models.SYBShop{DisplayName: "B", NormalizedName: "a", Enabled: true} + if err := db.Create(&first).Error; err != nil { + t.Fatalf("创建第一条历史店铺失败: %v", err) + } + if err := db.Create(&second).Error; err != nil { + t.Fatalf("创建第二条历史店铺失败: %v", err) + } + + updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db) + if err != nil || updated != 2 { + t.Fatalf("交换键回填失败: updated=%d err=%v", updated, err) + } + var rows []models.SYBShop + if err := db.Order("id ASC").Find(&rows).Error; err != nil { + t.Fatalf("读取回填结果失败: %v", err) + } + if rows[0].NormalizedName != "a" || rows[1].NormalizedName != "b" { + t.Fatalf("交换键未正确回填: %+v", rows) + } +} diff --git a/server/cmd/migrate/migration/version-local/1787983900000_syb_shop_normalized_name_backfill.go b/server/cmd/migrate/migration/version-local/1787983900000_syb_shop_normalized_name_backfill.go new file mode 100644 index 0000000..a2a524e --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787983900000_syb_shop_normalized_name_backfill.go @@ -0,0 +1,32 @@ +package version_local + +import ( + "context" + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/app/goauto/sybshop" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// This migration repairs historical syb_shop.normalized_name values written +// before all shop-name write paths used sybshop.Normalize (#213). +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBShopNormalizedNameBackfill) +} + +func migrateSYBShopNormalizedNameBackfill(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + if _, err := sybshop.ReconcileNormalizedNames(context.Background(), tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/server/cmd/migrate/migration/version-local/1787983900000_syb_shop_normalized_name_backfill_test.go b/server/cmd/migrate/migration/version-local/1787983900000_syb_shop_normalized_name_backfill_test.go new file mode 100644 index 0000000..80cedf6 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787983900000_syb_shop_normalized_name_backfill_test.go @@ -0,0 +1,51 @@ +package version_local + +import ( + "testing" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + common "go-admin/common/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestMigrateSYBShopNormalizedNameBackfillRepairsLegacyRows(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := db.AutoMigrate(&common.Migration{}); err != nil { + t.Fatalf("创建迁移记录表失败: %v", err) + } + if err := goautomigrations.Migrate(db); err != nil { + t.Fatalf("创建 GoAuto 表失败: %v", err) + } + shop := models.SYBShop{DisplayName: "ABC店", NormalizedName: "ABC店", Enabled: true} + if err := db.Create(&shop).Error; err != nil { + t.Fatalf("创建历史店铺失败: %v", err) + } + + const version = "1787983900000" + if err := migrateSYBShopNormalizedNameBackfill(db, version); err != nil { + t.Fatalf("执行回填迁移失败: %v", err) + } + var reloaded models.SYBShop + if err := db.First(&reloaded, shop.ID).Error; err != nil { + t.Fatalf("读取回填结果失败: %v", err) + } + if reloaded.NormalizedName != "abc店" { + t.Fatalf("迁移未回填规范化键: %q", reloaded.NormalizedName) + } + var records int64 + if err := db.Model(&common.Migration{}).Where("version = ?", version).Count(&records).Error; err != nil { + t.Fatalf("读取迁移记录失败: %v", err) + } + if records != 1 { + t.Fatalf("迁移应留下一个版本记录,实际 %d", records) + } +}