diff --git a/server/app/goauto/sybshop/service.go b/server/app/goauto/sybshop/service.go new file mode 100644 index 0000000..c9aeb9b --- /dev/null +++ b/server/app/goauto/sybshop/service.go @@ -0,0 +1,258 @@ +package sybshop + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +const ( + CodeInvalidRequest = "INVALID_REQUEST" + CodeNotFound = "SYB_SHOP_NOT_FOUND" + CodeConflict = "SYB_SHOP_DUPLICATE" + CodeInternal = "INTERNAL_ERROR" +) + +type ServiceError struct { + Code string + Message string + Cause error +} + +func (err *ServiceError) Error() string { + if err.Cause == nil { + return err.Message + } + return fmt.Sprintf("%s: %v", err.Message, err.Cause) +} +func (err *ServiceError) Unwrap() error { return err.Cause } + +func invalidRequest(message string) error { + return &ServiceError{Code: CodeInvalidRequest, Message: message} +} +func notFound() error { return &ServiceError{Code: CodeNotFound, Message: "店铺不存在"} } +func internalError(err error) error { + return &ServiceError{Code: CodeInternal, Message: "服务器内部错误", Cause: err} +} + +type Service struct{ DB *gorm.DB } + +func NewService(db *gorm.DB) *Service { return &Service{DB: db} } + +type ListRequest struct { + Page, PageSize int + Keyword string + EnabledOnly bool +} + +type ListResponse struct { + Items []models.SYBShop `json:"items"` + Total int64 `json:"total"` + EnabledCount int64 `json:"enabledCount"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) { + if request.Page < 1 { + request.Page = 1 + } + if request.PageSize < 1 || request.PageSize > 200 { + request.PageSize = 50 + } + query := service.DB.WithContext(ctx).Model(&models.SYBShop{}) + if keyword := Normalize(request.Keyword); keyword != "" { + query = query.Where("normalized_name LIKE ?", "%"+keyword+"%") + } + if request.EnabledOnly { + query = query.Where("enabled = ?", true) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return ListResponse{}, internalError(err) + } + items := make([]models.SYBShop, 0, request.PageSize) + if err := query.Order("enabled DESC, id ASC"). + Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize). + Find(&items).Error; err != nil { + return ListResponse{}, internalError(err) + } + var enabled int64 + if err := service.DB.WithContext(ctx).Model(&models.SYBShop{}). + Where("enabled = ?", true).Count(&enabled).Error; err != nil { + return ListResponse{}, internalError(err) + } + return ListResponse{Items: items, Total: total, EnabledCount: enabled, + Page: request.Page, PageSize: request.PageSize}, nil +} + +// validateName applies the checks every write path shares. +func validateName(displayName string) (string, string, error) { + displayName = strings.TrimSpace(displayName) + if IsBlank(displayName) { + return "", "", invalidRequest("店铺名不能为空") + } + if ContainsControl(displayName) { + return "", "", invalidRequest("店铺名不能包含控制字符,请检查是否从别处粘贴时带入了不可见字符") + } + if len([]rune(displayName)) > 100 { + return "", "", invalidRequest("店铺名不能超过 100 个字符") + } + return displayName, Normalize(displayName), nil +} + +type CreateRequest struct { + DisplayName string `json:"displayName"` + Enabled *bool `json:"enabled"` +} + +// Create adds one shop. +// +// `[必须]` A duplicate is reported against the EXISTING display name, not the +// submitted one. The two can differ only in case or character width, and +// echoing back what the operator typed would look like the system rejected a +// name it does not actually have. +func (service *Service) Create(ctx context.Context, request CreateRequest) (models.SYBShop, error) { + displayName, normalized, err := validateName(request.DisplayName) + if err != nil { + return models.SYBShop{}, err + } + enabled := true + if request.Enabled != nil { + enabled = *request.Enabled + } + var existing models.SYBShop + err = service.DB.WithContext(ctx).Where("normalized_name = ?", normalized).First(&existing).Error + if err == nil { + return models.SYBShop{}, &ServiceError{Code: CodeConflict, + Message: fmt.Sprintf("店铺「%s」已存在(忽略首尾空白、全半角和大小写后与它同名)", existing.DisplayName)} + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return models.SYBShop{}, internalError(err) + } + shop := models.SYBShop{DisplayName: displayName, NormalizedName: normalized, Enabled: enabled} + if err := service.DB.WithContext(ctx).Create(&shop).Error; err != nil { + return models.SYBShop{}, internalError(err) + } + return shop, nil +} + +type RenameRequest struct { + DisplayName string `json:"displayName"` +} + +// Rename changes a shop's name. +// +// `[必须]` Renaming rewrites NormalizedName too. Leaving it stale would keep +// matching the old SYB name while showing the new one — the archive would look +// correctly configured while importing the wrong shop. +func (service *Service) Rename(ctx context.Context, id uint64, request RenameRequest) (models.SYBShop, error) { + displayName, normalized, err := validateName(request.DisplayName) + if err != nil { + return models.SYBShop{}, err + } + var shop models.SYBShop + if err := service.DB.WithContext(ctx).First(&shop, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return models.SYBShop{}, notFound() + } + return models.SYBShop{}, internalError(err) + } + var clash models.SYBShop + err = service.DB.WithContext(ctx).Where("normalized_name = ? AND id <> ?", normalized, id).First(&clash).Error + if err == nil { + return models.SYBShop{}, &ServiceError{Code: CodeConflict, + Message: fmt.Sprintf("店铺「%s」已存在(忽略首尾空白、全半角和大小写后与它同名)", clash.DisplayName)} + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return models.SYBShop{}, internalError(err) + } + if err := service.DB.WithContext(ctx).Model(&models.SYBShop{}).Where("id = ?", id). + Updates(map[string]any{"display_name": displayName, "normalized_name": normalized}).Error; err != nil { + return models.SYBShop{}, internalError(err) + } + shop.DisplayName, shop.NormalizedName = displayName, normalized + return shop, nil +} + +type SetEnabledRequest struct { + Enabled bool `json:"enabled"` +} + +func (service *Service) SetEnabled(ctx context.Context, id uint64, request SetEnabledRequest) (models.SYBShop, error) { + var shop models.SYBShop + if err := service.DB.WithContext(ctx).First(&shop, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return models.SYBShop{}, notFound() + } + return models.SYBShop{}, internalError(err) + } + if err := service.DB.WithContext(ctx).Model(&models.SYBShop{}).Where("id = ?", id). + Update("enabled", request.Enabled).Error; err != nil { + return models.SYBShop{}, internalError(err) + } + shop.Enabled = request.Enabled + return shop, nil +} + +// Delete soft-deletes a shop. +// +// `[必须]` Already-imported data is untouched. Deleting only stops future +// imports; the shop name still appears in past sync records, which is why the +// row is kept rather than removed. +func (service *Service) Delete(ctx context.Context, id uint64) error { + return service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var shop models.SYBShop + if err := tx.First(&shop, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return notFound() + } + return internalError(err) + } + // DeletedFlag carries the row's own id so any number of deleted rows may + // share a normalized name while live rows stay unique. + if err := tx.Model(&models.SYBShop{}).Where("id = ?", id). + Updates(map[string]any{"deleted_flag": shop.ID, "deleted_at": time.Now()}).Error; err != nil { + return internalError(err) + } + return nil + }) +} + +// EnabledNames returns the normalized names of every enabled shop, which is +// what the sync filters on. +// +// `[必须]` An empty result is a legitimate state, not an error, and callers +// must treat it as "refuse to sync" rather than "import everything" (#49). +func EnabledNames(ctx context.Context, db *gorm.DB) (map[string]string, error) { + var shops []models.SYBShop + if err := db.WithContext(ctx).Where("enabled = ?", true).Find(&shops).Error; err != nil { + return nil, err + } + names := make(map[string]string, len(shops)) + for _, shop := range shops { + names[shop.NormalizedName] = shop.DisplayName + } + return names, nil +} + +// MarkSeen records that a shop appeared in a sync, with how many shipment +// orders it had. Unknown shops are ignored here: discovering them is a separate, +// explicit action so a sync never silently grows the allow-list. +func MarkSeen(ctx context.Context, db *gorm.DB, counts map[string]int, at time.Time) error { + for normalized, count := range counts { + orders := count + if err := db.WithContext(ctx).Model(&models.SYBShop{}). + Where("normalized_name = ?", normalized). + Updates(map[string]any{"last_seen_at": at, "last_seen_order_count": &orders}).Error; err != nil { + return err + } + } + return nil +} diff --git a/server/app/goauto/sybshop/service_test.go b/server/app/goauto/sybshop/service_test.go new file mode 100644 index 0000000..a8a1ef5 --- /dev/null +++ b/server/app/goauto/sybshop/service_test.go @@ -0,0 +1,236 @@ +package sybshop_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybshop" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func openDB(t *testing.T) *gorm.DB { + t.Helper() + db, e := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", + strings.ReplaceAll(t.Name(), "/", "_"))), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if e != nil { + t.Fatalf("打开数据库失败: %v", e) + } + if e := migrations.Migrate(db); e != nil { + t.Fatalf("迁移失败: %v", e) + } + return db +} + +func code(e error) string { + var target *sybshop.ServiceError + if errors.As(e, &target) { + return target.Code + } + return "" +} + +func onlyErr(_ models.SYBShop, e error) error { return e } + +func TestCreateStoresNormalizedKeyAndKeepsDisplayName(t *testing.T) { + service := sybshop.NewService(openDB(t)) + shop, e := service.Create(context.Background(), sybshop.CreateRequest{DisplayName: " ABC店 "}) + if e != nil { + t.Fatalf("创建失败: %v", e) + } + if shop.DisplayName != "ABC店" { + t.Fatalf("展示名应只去掉首尾空白: %q", shop.DisplayName) + } + if shop.NormalizedName != "abc店" { + t.Fatalf("匹配键应为归一化结果: %q", shop.NormalizedName) + } + if !shop.Enabled { + t.Fatal("默认应为启用") + } +} + +// 只差大小写/全半角/空白的名字必须判为重复,且提示里给出**已存在的**那个名字。 +func TestCreateRejectsNamesThatOnlyDifferByNormalization(t *testing.T) { + service := sybshop.NewService(openDB(t)) + ctx := context.Background() + if _, e := service.Create(ctx, sybshop.CreateRequest{DisplayName: "ABC店"}); e != nil { + t.Fatalf("首次创建失败: %v", e) + } + _, e := service.Create(ctx, sybshop.CreateRequest{DisplayName: " abc店 "}) + if code(e) != sybshop.CodeConflict { + t.Fatalf("应报重复,实际: %v", e) + } + if !strings.Contains(e.Error(), "ABC店") { + t.Fatalf("提示里应给出已存在的那个名字,而不是刚输入的: %v", e) + } +} + +func TestCreateRejectsBlankAndControlCharacters(t *testing.T) { + service := sybshop.NewService(openDB(t)) + ctx := context.Background() + for _, name := range []string{"", " ", " "} { + if code(onlyErr(service.Create(ctx, sybshop.CreateRequest{DisplayName: name}))) != sybshop.CodeInvalidRequest { + t.Fatalf("%q 应被拒绝", name) + } + } + pasted := "晨曦\u0001优选" + if code(onlyErr(service.Create(ctx, sybshop.CreateRequest{DisplayName: pasted}))) != sybshop.CodeInvalidRequest { + t.Fatal("含控制字符应被拒绝") + } +} + +// `[必须]` 改名必须同时改归一化键。否则界面显示新名字、匹配仍用旧名字, +// 看起来配置正确却在导入别的店铺。 +func TestRenameRewritesNormalizedKey(t *testing.T) { + service := sybshop.NewService(openDB(t)) + ctx := context.Background() + shop, _ := service.Create(ctx, sybshop.CreateRequest{DisplayName: "旧名"}) + renamed, e := service.Rename(ctx, shop.ID, sybshop.RenameRequest{DisplayName: " NEW店 "}) + if e != nil { + t.Fatalf("改名失败: %v", e) + } + if renamed.NormalizedName != "new店" { + t.Fatalf("归一化键未更新: %q", renamed.NormalizedName) + } + names, _ := sybshop.EnabledNames(ctx, service.DB) + if _, ok := names["旧名"]; ok { + t.Fatal("旧的匹配键不应仍然生效") + } + if _, ok := names["new店"]; !ok { + t.Fatalf("新的匹配键应生效: %v", names) + } +} + +func TestRenameRejectsClashWithAnotherShop(t *testing.T) { + service := sybshop.NewService(openDB(t)) + ctx := context.Background() + service.Create(ctx, sybshop.CreateRequest{DisplayName: "甲店"}) + second, _ := service.Create(ctx, sybshop.CreateRequest{DisplayName: "乙店"}) + if code(onlyErr(service.Rename(ctx, second.ID, sybshop.RenameRequest{DisplayName: "甲店"}))) != sybshop.CodeConflict { + t.Fatal("改成已有店铺名应报重复") + } + if _, e := service.Rename(ctx, second.ID, sybshop.RenameRequest{DisplayName: " 乙店 "}); e != nil { + t.Fatalf("改成自己的名字不应报错: %v", e) + } +} + +func TestSetEnabledTogglesParticipation(t *testing.T) { + service := sybshop.NewService(openDB(t)) + ctx := context.Background() + shop, _ := service.Create(ctx, sybshop.CreateRequest{DisplayName: "甲店"}) + if _, e := service.SetEnabled(ctx, shop.ID, sybshop.SetEnabledRequest{Enabled: false}); e != nil { + t.Fatalf("停用失败: %v", e) + } + names, _ := sybshop.EnabledNames(ctx, service.DB) + if len(names) != 0 { + t.Fatalf("停用后不应出现在启用集合里: %v", names) + } +} + +func TestDeleteAllowsRecreatingTheSameName(t *testing.T) { + service := sybshop.NewService(openDB(t)) + ctx := context.Background() + shop, _ := service.Create(ctx, sybshop.CreateRequest{DisplayName: "甲店"}) + if e := service.Delete(ctx, shop.ID); e != nil { + t.Fatalf("删除失败: %v", e) + } + if _, e := service.Create(ctx, sybshop.CreateRequest{DisplayName: "甲店"}); e != nil { + t.Fatalf("删除后应能重新添加同名店铺: %v", e) + } + var live int64 + service.DB.Model(&models.SYBShop{}).Count(&live) + if live != 1 { + t.Fatalf("存活行应只有 1 条,实际 %d", live) + } +} + +func TestDeleteAndSetEnabledReportNotFound(t *testing.T) { + service := sybshop.NewService(openDB(t)) + ctx := context.Background() + if code(service.Delete(ctx, 999)) != sybshop.CodeNotFound { + t.Fatal("删除不存在的店铺应报 NOT_FOUND") + } + if code(onlyErr(service.SetEnabled(ctx, 999, sybshop.SetEnabledRequest{Enabled: true}))) != sybshop.CodeNotFound { + t.Fatal("停用不存在的店铺应报 NOT_FOUND") + } +} + +// `[必须]` 空的启用集合是合法状态,不是错误;调用方须据此拒绝同步, +// 而不是当成「不过滤,全部导入」。 +func TestEnabledNamesReturnsEmptyWithoutError(t *testing.T) { + names, e := sybshop.EnabledNames(context.Background(), openDB(t)) + if e != nil { + t.Fatalf("空集合不应报错: %v", e) + } + if len(names) != 0 { + t.Fatalf("应为空: %v", names) + } +} + +func TestMarkSeenRecordsLastSyncButIgnoresUnknownShops(t *testing.T) { + service := sybshop.NewService(openDB(t)) + ctx := context.Background() + shop, _ := service.Create(ctx, sybshop.CreateRequest{DisplayName: "甲店"}) + at := time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC) + if e := sybshop.MarkSeen(ctx, service.DB, map[string]int{"甲店": 12, "查无此店": 3}, at); e != nil { + t.Fatalf("记录失败: %v", e) + } + var reloaded models.SYBShop + service.DB.First(&reloaded, shop.ID) + if reloaded.LastSeenOrderCount == nil || *reloaded.LastSeenOrderCount != 12 { + t.Fatalf("条数未记录: %v", reloaded.LastSeenOrderCount) + } + if reloaded.LastSeenAt == nil { + t.Fatal("时间未记录") + } + var total int64 + service.DB.Model(&models.SYBShop{}).Count(&total) + if total != 1 { + t.Fatalf("同步不应自动新增店铺,实际 %d 条", total) + } +} + +func TestListCountsEnabledSeparatelyFromPage(t *testing.T) { + service := sybshop.NewService(openDB(t)) + ctx := context.Background() + for _, name := range []string{"甲店", "乙店", "丙店"} { + service.Create(ctx, sybshop.CreateRequest{DisplayName: name}) + } + fourth, _ := service.Create(ctx, sybshop.CreateRequest{DisplayName: "丁店"}) + service.SetEnabled(ctx, fourth.ID, sybshop.SetEnabledRequest{Enabled: false}) + + response, e := service.List(ctx, sybshop.ListRequest{Page: 1, PageSize: 2}) + if e != nil { + t.Fatalf("列表失败: %v", e) + } + if len(response.Items) != 2 { + t.Fatalf("分页大小应生效: %d", len(response.Items)) + } + if response.Total != 4 { + t.Fatalf("总数应为 4: %d", response.Total) + } + if response.EnabledCount != 3 { + t.Fatalf("启用数是全局统计,不受分页影响,应为 3: %d", response.EnabledCount) + } +} + +func TestListSearchesByNormalizedKeyword(t *testing.T) { + service := sybshop.NewService(openDB(t)) + ctx := context.Background() + service.Create(ctx, sybshop.CreateRequest{DisplayName: "ABC店"}) + response, e := service.List(ctx, sybshop.ListRequest{Keyword: "ABC"}) + if e != nil { + t.Fatalf("搜索失败: %v", e) + } + if len(response.Items) != 1 { + t.Fatalf("大写关键字应能搜到全角店名: %d", len(response.Items)) + } +}