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:
QiuSW
2026-08-20 11:18:32 +08:00
co-authored by Claude Opus 5
parent e6f533b71e
commit cda37b4a60
6 changed files with 230 additions and 0 deletions
+1
View File
@@ -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)
}
}
+40
View File
@@ -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 = "{}"
+57
View File
@@ -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
})
}