feat(#49): add shop service — CRUD, enable toggle and enabled-name lookup

Stage B step 2 (service layer; HTTP handlers next).

Rename rewrites NormalizedName along with DisplayName. Leaving the key
stale would show the new name while still matching the old one, so the
archive would look correctly configured while importing a different shop.
Mutation-tested: updating only display_name makes the rename test fail.

Duplicates are reported against the name already stored, not the one just
submitted — the two can differ only in case or character width, and
echoing back what was typed reads as the system rejecting a name it does
not have.

Delete is a soft delete carrying the id into DeletedFlag, so the same
name can be added again afterwards while past sync records keep resolving
the old row.

EnabledNames returns an empty map without error. Empty is a legitimate
state that callers must turn into "refuse to sync", never "import
everything".

MarkSeen only updates shops already on the list. A sync must not grow the
allow-list as a side effect; discovery is a separate explicit action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
QiuSW
2026-08-20 11:23:02 +08:00
co-authored by Claude Opus 5
parent cda37b4a60
commit 7bd8166a30
2 changed files with 494 additions and 0 deletions
+258
View File
@@ -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
}
+236
View File
@@ -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))
}
}