feat(#49): add syb_shop model, migration and name normalization
Stage B step 1. The shop list decides which of a SYB account's shops get imported. Matching is by name, so the normalized form is the real key: whitespace trimmed, full-width ASCII folded, letters lowercased. " ABC店 ", "ABC店" and "abc店" are one shop. DisplayName is only ever shown to people, and the unique index sits on the normalized column so a caller that skips normalization cannot create rows that look identical on screen. Interior whitespace is deliberately not collapsed: two shops differing only there are still two shops, and merging them silently would be worse than the duplicate this prevents. Soft delete uses the DeletedFlag sentinel rather than a nullable deleted_at in the unique index, which is silently inert because unique indexes treat every NULL as distinct. Mutation-tested: dropping the sentinel from the index makes the recreate-after-delete case fail. The version-local migration file is included this time — the model alone would never reach an existing database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ func MigratedModels() []any {
|
||||
&models.ShopeeProduct{},
|
||||
&models.SYBProduct{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBShop{},
|
||||
&models.CollectionRule{},
|
||||
&models.CollectionTask{},
|
||||
&models.CollectionDimension{},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "{}"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("普通空格不是控制字符")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user