diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 254c3a1..f3f609f 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -18,6 +18,7 @@ func MigratedModels() []any { &models.ShopeeProduct{}, &models.SYBProduct{}, &models.SYBSession{}, + &models.SYBShop{}, &models.CollectionRule{}, &models.CollectionTask{}, &models.CollectionDimension{}, diff --git a/server/app/goauto/migrations/migrate_test.go b/server/app/goauto/migrations/migrate_test.go index 3ef859b..e33e489 100644 --- a/server/app/goauto/migrations/migrate_test.go +++ b/server/app/goauto/migrations/migrate_test.go @@ -402,3 +402,37 @@ func TestVerifyTablesNamesMissingTablesAndTheFix(t *testing.T) { t.Fatalf("错误信息应说明修法: %v", err) } } + +// 归一化名在**存活行之间**必须唯一。带 deleted_at 的复合唯一索引是静默失效的 +// (唯一索引把每个 NULL 都当作不同值),所以这里靠 DeletedFlag 哨兵。 +// 这条之前在 ShopeeProduct 上真的写错过,必须对每张软删除表都验一遍。 +func TestSYBShopNormalizedNameUniqueAmongLiveRowsOnly(t *testing.T) { + db := openDatabase(t) + + first := models.SYBShop{DisplayName: "晨曦優選服飾", NormalizedName: "晨曦優選服飾"} + if err := db.Create(&first).Error; err != nil { + t.Fatalf("首次创建失败: %v", err) + } + if err := db.Create(&models.SYBShop{DisplayName: "重复", NormalizedName: "晨曦優選服飾"}).Error; err == nil { + t.Fatal("存活行之间的归一化名应当唯一") + } + + // 软删除后同名可以重新创建;且可以删除多次而不互相冲突。 + if err := db.Model(&models.SYBShop{}).Where("id = ?", first.ID). + Updates(map[string]any{"deleted_flag": first.ID, "deleted_at": time.Now()}).Error; err != nil { + t.Fatalf("软删除失败: %v", err) + } + second := models.SYBShop{DisplayName: "晨曦優選服飾", NormalizedName: "晨曦優選服飾"} + if err := db.Create(&second).Error; err != nil { + t.Fatalf("软删除后应能重新创建同名店铺: %v", err) + } + if err := db.Model(&models.SYBShop{}).Where("id = ?", second.ID). + Updates(map[string]any{"deleted_flag": second.ID, "deleted_at": time.Now()}).Error; err != nil { + t.Fatalf("再次软删除失败: %v", err) + } + var deleted int64 + db.Unscoped().Model(&models.SYBShop{}).Where("deleted_at IS NOT NULL").Count(&deleted) + if deleted != 2 { + t.Fatalf("同名的已删除行应能共存,实际 %d 行", deleted) + } +} diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 962a00c..b75985a 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -382,6 +382,46 @@ func (SYBProduct) TableName() string { return "syb_product" } func (SYBSession) TableName() string { return "syb_session" } +// SYBShop is the list of SYB shops whose shipment orders are imported (#49). +// +// A SYB account carries every shop's orders; only shops enabled here are +// written to the archive. The list is deliberately small and hand-curated, but +// names are discovered from real sync data rather than typed, because matching +// is by string: a rename in SYB produces no error, just silently missing data. +// +// NormalizedName is the matching key, not DisplayName. It is DisplayName with +// surrounding whitespace removed, full-width characters folded to half-width +// and letters lowercased, so " ABC店 ", "ABC店" and "abc店" are one shop +// rather than three. The unique index is on the normalized form for that +// reason; DisplayName is only ever shown to people. +// +// Soft delete uses the same sentinel technique as ShopeeProduct: a unique index +// including a nullable deleted_at is silently inert, because unique indexes +// treat every NULL as distinct. DeletedFlag is 0 while live so live rows really +// do collide, and becomes the row's own id on delete so any number of deleted +// rows may share a name. +type SYBShop struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + // DisplayName is what people read and edit. Never used for matching. + DisplayName string `json:"displayName" gorm:"size:255;not null"` + // NormalizedName is the matching key against SYB's shopName. + NormalizedName string `json:"normalizedName" gorm:"size:255;not null;uniqueIndex:ux_syb_shop_normalized_name,priority:1"` + // Enabled decides whether this shop's orders are imported. Disabling stops + // future imports and never touches data already imported. + Enabled bool `json:"enabled" gorm:"not null;default:true;index"` + // LastSeenAt is when this shop last appeared in SYB sync data, and + // LastSeenOrderCount how many shipment orders it had in that run. Both are + // written by the sync and are nil until #50 records a run. + LastSeenAt *time.Time `json:"lastSeenAt"` + LastSeenOrderCount *int `json:"lastSeenOrderCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + DeletedFlag uint64 `json:"-" gorm:"not null;default:0;uniqueIndex:ux_syb_shop_normalized_name,priority:2"` +} + +func (SYBShop) TableName() string { return "syb_shop" } + func (product *SYBProduct) BeforeCreate(_ *gorm.DB) error { if product.RawJSON == "" { product.RawJSON = "{}" diff --git a/server/app/goauto/sybshop/normalize.go b/server/app/goauto/sybshop/normalize.go new file mode 100644 index 0000000..b49d104 --- /dev/null +++ b/server/app/goauto/sybshop/normalize.go @@ -0,0 +1,57 @@ +// Package sybshop owns the list of SYB shops whose shipment orders are +// imported (#49). +package sybshop + +import ( + "strings" + "unicode" +) + +// Normalize reduces a shop name to its matching key. +// +// The rule, agreed on the #49 prototype, is exactly three steps: +// +// 1. strip surrounding whitespace, including the ideographic space U+3000; +// 2. fold full-width ASCII (U+FF01-U+FF5E) to half-width; +// 3. lowercase. +// +// `[必须]` Interior whitespace is deliberately NOT collapsed. Two shops whose +// names differ only in internal spacing are still two shops; merging them +// silently would be worse than the duplicate this function exists to prevent. +// +// `[必须]` Every write path must store the result of this function in +// NormalizedName, and every lookup against SYB's shopName must go through it. +// The unique index is on the normalized column, so a caller that skips this +// gets duplicates that look identical on screen. +func Normalize(name string) string { + folded := make([]rune, 0, len(name)) + for _, r := range name { + switch { + case r == '\u3000': + // Ideographic space: fold to a normal space so TrimSpace sees it. + folded = append(folded, ' ') + case r >= '\uFF01' && r <= '\uFF5E': + // Full-width ASCII block maps onto U+0021-U+007E at a fixed offset. + folded = append(folded, r-0xFEE0) + default: + folded = append(folded, r) + } + } + return strings.ToLower(strings.TrimSpace(string(folded))) +} + +// IsBlank reports whether a name is empty once normalized, which is the only +// name that can never identify a shop. +func IsBlank(name string) bool { return Normalize(name) == "" } + +// ContainsControl reports whether a name holds control characters. Those cannot +// be typed deliberately and usually mean a copy-paste accident; storing one +// produces a shop that can never be matched against real SYB data. +func ContainsControl(name string) bool { + for _, r := range name { + if unicode.IsControl(r) { + return true + } + } + return false +} diff --git a/server/app/goauto/sybshop/normalize_test.go b/server/app/goauto/sybshop/normalize_test.go new file mode 100644 index 0000000..2bce075 --- /dev/null +++ b/server/app/goauto/sybshop/normalize_test.go @@ -0,0 +1,71 @@ +package sybshop_test + +import ( + "testing" + + "go-admin/app/goauto/sybshop" +) + +// 工单 #49 定稿的例子:这三个必须归一到同一个键,因而不能被添加成三家店。 +func TestNormalizeFoldsWhitespaceWidthAndCase(t *testing.T) { + same := []string{" ABC店 ", "ABC店", "abc店", "\u3000abc店\u3000", "AbC店"} + want := sybshop.Normalize(same[0]) + if want != "abc店" { + t.Fatalf("归一化结果不对: %q", want) + } + for _, name := range same[1:] { + if got := sybshop.Normalize(name); got != want { + t.Fatalf("%q 应归一到 %q,实际 %q", name, want, got) + } + } +} + +// 全角数字和标点也要折半角,否则「店铺1号」和「店铺1号」会变成两家店。 +func TestNormalizeFoldsFullWidthDigitsAndPunctuation(t *testing.T) { + if got := sybshop.Normalize("店铺1号(A)"); got != "店铺1号(a)" { + t.Fatalf("全角折叠不对: %q", got) + } +} + +// `[必须]` 中间的空格不合并:只差内部空格的两个名字仍是两家店。 +// 悄悄合并比它要防的重复更糟。 +func TestNormalizeKeepsInteriorWhitespace(t *testing.T) { + a := sybshop.Normalize(" 晨曦 优选 ") + b := sybshop.Normalize("晨曦优选") + if a == b { + t.Fatal("内部空格不应被合并") + } + if a != "晨曦 优选" { + t.Fatalf("只应去掉首尾空白: %q", a) + } +} + +// 中文店铺名不受大小写折叠影响。 +func TestNormalizeLeavesChineseUnchanged(t *testing.T) { + if got := sybshop.Normalize("韓舍佳人女裝店"); got != "韓舍佳人女裝店" { + t.Fatalf("中文名不应被改动: %q", got) + } +} + +func TestIsBlankCatchesWhitespaceOnlyNames(t *testing.T) { + for _, name := range []string{"", " ", "\u3000", " \u3000 "} { + if !sybshop.IsBlank(name) { + t.Fatalf("%q 应判为空", name) + } + } + if sybshop.IsBlank(" A ") { + t.Fatal("非空名字不应判为空") + } +} + +func TestContainsControlRejectsPastedControlCharacters(t *testing.T) { + if !sybshop.ContainsControl("晨曦\u0001优选") { + t.Fatal("应识别出控制字符") + } + if !sybshop.ContainsControl("晨曦\n优选") { + t.Fatal("换行也是控制字符") + } + if sybshop.ContainsControl("晨曦 优选") { + t.Fatal("普通空格不是控制字符") + } +} diff --git a/server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go b/server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go new file mode 100644 index 0000000..325d962 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go @@ -0,0 +1,27 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// This migration adds the syb_shop table: the list of SYB shops whose shipment +// orders are imported (#49). It is additive; no existing table is modified. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBShop) +} + +func migrateSYBShop(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +}