feat: 建立全局店铺跨渠道管理 (#208 #209 #210 #211)

This commit is contained in:
chengma
2026-08-13 11:15:54 +08:00
parent 901b37a8cb
commit 8f707722ba
28 changed files with 1160 additions and 500 deletions
+3 -1
View File
@@ -19,7 +19,9 @@ func newCatalogTestDB(t *testing.T) *sql.DB {
}
db.SetMaxOpenConns(1)
statements := []string{
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,deleted_at TEXT,deleted_by_user_id TEXT,created_at TEXT,updated_at TEXT)`,
`CREATE TABLE shops(shop_id TEXT PRIMARY KEY,display_name TEXT,normalized_name TEXT,enabled INTEGER,created_by_user_id TEXT,created_at TEXT,updated_at TEXT)`,
`CREATE TABLE shop_channel_aliases(alias_id TEXT PRIMARY KEY,shop_id TEXT,channel TEXT,alias_name TEXT,normalized_alias TEXT,enabled INTEGER,created_at TEXT,updated_at TEXT)`,
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,shop_id TEXT,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,deleted_at TEXT,deleted_by_user_id TEXT,created_at TEXT,updated_at TEXT)`,
`CREATE TABLE shopee_skus(sku_id TEXT PRIMARY KEY,shopee_sku_id TEXT UNIQUE,goods_id TEXT NOT NULL,spec_raw TEXT,spec_key TEXT,color TEXT,size TEXT,advice TEXT,parse_ok INTEGER,sku_code TEXT,is_manual INTEGER,source TEXT,field_sources TEXT,field_observed_at TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT,UNIQUE(goods_id,spec_key))`,
`CREATE TABLE pdd_products(id INTEGER PRIMARY KEY AUTOINCREMENT,goods_id TEXT UNIQUE,url TEXT,title TEXT,shop_name TEXT,skus_json TEXT,collect_status TEXT,collect_msg TEXT,artifact_ref TEXT,collected_at TEXT,deleted_at TEXT,source TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT)`,
`CREATE TABLE catalog_import_runs(source TEXT,batch_id TEXT,request_hash TEXT,status TEXT,request_count INTEGER,conflict_count INTEGER,observed_at TEXT,update_policy TEXT DEFAULT 'fill_missing',last_request_at TEXT,last_conflict_at TEXT,shopee_created INTEGER DEFAULT 0,shopee_updated INTEGER DEFAULT 0,shopee_fields_filled INTEGER DEFAULT 0,shopee_fields_same_source_updated INTEGER DEFAULT 0,shopee_fields_manual_skipped INTEGER DEFAULT 0,shopee_fields_stale_skipped INTEGER DEFAULT 0,sku_created INTEGER DEFAULT 0,sku_updated INTEGER DEFAULT 0,sku_filled INTEGER DEFAULT 0,sku_same_source_updated INTEGER DEFAULT 0,sku_skipped INTEGER DEFAULT 0,sku_manual_skipped INTEGER DEFAULT 0,sku_stale_skipped INTEGER DEFAULT 0,pdd_created INTEGER DEFAULT 0,pdd_updated INTEGER DEFAULT 0,association_created INTEGER DEFAULT 0,association_unchanged INTEGER DEFAULT 0,failure_count INTEGER DEFAULT 0,error_summary TEXT,response_body TEXT,created_at TEXT,finished_at TEXT,PRIMARY KEY(source,batch_id))`,
+211
View File
@@ -0,0 +1,211 @@
package service
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
const maxShopNameLength = 500
type ShopListResult struct {
Items []model.Shop
UnlinkedShopeeCount int
}
func ListShops(db *sql.DB, actor *model.User) (ShopListResult, error) {
if actor == nil || !actor.IsAdmin() {
return ShopListResult{}, ErrAdminRequired
}
items, err := repository.ListShops(db)
if err != nil {
return ShopListResult{}, err
}
unlinked, err := repository.CountUnlinkedShopeeShops(db)
if err != nil {
return ShopListResult{}, err
}
return ShopListResult{Items: items, UnlinkedShopeeCount: unlinked}, nil
}
func ShopOptions(db *sql.DB) ([]model.Shop, error) {
return repository.ListShopOptions(db)
}
func CreateShop(db *sql.DB, actor *model.User, displayName, sybAlias, shopeeAlias string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
displayName, sybAlias, shopeeAlias, err := validateShopInput(displayName, sybAlias, shopeeAlias)
if err != nil {
return err
}
shopID, err := randomID("SHOP-", 16)
if err != nil {
return fmt.Errorf("生成店铺编号失败: %w", err)
}
at := now.UTC().Format(model.TimeLayout)
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := repository.InsertShop(tx, model.Shop{ShopID: shopID, DisplayName: displayName,
NormalizedName: displayName, Enabled: true, CreatedByUserID: actor.UserID, CreatedAt: at, UpdatedAt: at}); err != nil {
return shopValidationError(err)
}
if err := insertShopAlias(tx, shopID, "syb", sybAlias, true, at); err != nil {
return shopValidationError(err)
}
if err := insertShopAlias(tx, shopID, "shopee", shopeeAlias, true, at); err != nil {
return shopValidationError(err)
}
if err := repository.RebuildShopAssociations(tx, shopID); err != nil {
return err
}
return tx.Commit()
}
func UpdateShop(db *sql.DB, actor *model.User, shopID, displayName, sybAlias, shopeeAlias string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
shopID = strings.TrimSpace(shopID)
if shopID == "" {
return &validationError{field: "shop_id", message: "店铺编号不能为空"}
}
displayName, sybAlias, shopeeAlias, err := validateShopInput(displayName, sybAlias, shopeeAlias)
if err != nil {
return err
}
at := now.UTC().Format(model.TimeLayout)
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, found, err := repository.GetShop(tx, shopID); err != nil {
return err
} else if !found {
return &validationError{field: "shop_id", message: "店铺不存在,请刷新页面后重试"}
}
sybEnabled, hasSybAlias, err := repository.GetShopAliasEnabled(tx, shopID, "syb")
if err != nil {
return err
}
if !hasSybAlias {
sybEnabled = true
}
if _, err := repository.UpdateShopName(tx, shopID, displayName, displayName, at); err != nil {
return shopValidationError(err)
}
if err := replaceShopAlias(tx, shopID, "syb", sybAlias, sybEnabled, at); err != nil {
return shopValidationError(err)
}
if err := replaceShopAlias(tx, shopID, "shopee", shopeeAlias, true, at); err != nil {
return shopValidationError(err)
}
if err := repository.RebuildShopAssociations(tx, shopID); err != nil {
return err
}
return tx.Commit()
}
func SetShopEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
found, err := repository.SetShopEnabled(db, strings.TrimSpace(shopID), enabled, now.UTC().Format(model.TimeLayout))
if err != nil {
return err
}
if !found {
return &validationError{field: "shop_id", message: "店铺不存在,请刷新页面后重试"}
}
return nil
}
func SetShopSybEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
found, err := repository.SetShopSybEnabled(db, strings.TrimSpace(shopID), enabled, now.UTC().Format(model.TimeLayout))
if err != nil {
return err
}
if !found {
return &validationError{field: "shop_id", message: "该店铺没有配置 SYB 店铺名称"}
}
return nil
}
func DeleteShop(db *sql.DB, actor *model.User, shopID string) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
deleted, err := repository.DeleteDisabledShop(db, strings.TrimSpace(shopID))
if errors.Is(err, repository.ErrShopHasReferences) {
return &validationError{field: "shop_id", message: "店铺仍有关联数据,只能保留为停用状态"}
}
if err != nil {
return err
}
if !deleted {
return &validationError{field: "shop_id", message: "店铺不存在或仍在启用,请先停用"}
}
return nil
}
func validateShopInput(displayName, sybAlias, shopeeAlias string) (string, string, string, error) {
displayName = strings.TrimSpace(displayName)
sybAlias = strings.TrimSpace(sybAlias)
shopeeAlias = strings.TrimSpace(shopeeAlias)
if displayName == "" {
return "", "", "", &validationError{field: "display_name", message: "业务店铺名称不能为空"}
}
for field, value := range map[string]string{"display_name": displayName, "syb_alias": sybAlias, "shopee_alias": shopeeAlias} {
if len([]rune(value)) > maxShopNameLength {
return "", "", "", &validationError{field: field, message: "店铺名称最多 500 个字符"}
}
}
if sybAlias == "" && shopeeAlias == "" {
return "", "", "", &validationError{field: "syb_alias", message: "SYB 或蝦皮店铺名称至少填写一个"}
}
return displayName, sybAlias, shopeeAlias, nil
}
func insertShopAlias(q repository.Execer, shopID, channel, alias string, enabled bool, at string) error {
if alias == "" {
return nil
}
aliasID, err := randomID("ALIAS-", 16)
if err != nil {
return err
}
return repository.InsertShopAlias(q, model.ShopChannelAlias{AliasID: aliasID, ShopID: shopID,
Channel: channel, AliasName: alias, NormalizedAlias: alias, Enabled: enabled, CreatedAt: at, UpdatedAt: at})
}
func replaceShopAlias(q repository.Execer, shopID, channel, alias string, enabled bool, at string) error {
aliasID, err := randomID("ALIAS-", 16)
if err != nil {
return err
}
return repository.ReplaceShopAlias(q, aliasID, shopID, channel, alias, at, enabled)
}
func shopValidationError(err error) error {
switch {
case errors.Is(err, repository.ErrShopNameExists):
return &validationError{field: "display_name", message: "业务店铺名称已经存在"}
case errors.Is(err, repository.ErrShopAliasExists):
return &validationError{field: "syb_alias", message: "该渠道店铺名称已关联到其他业务店铺"}
default:
return err
}
}
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"errors"
"testing"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
func TestShop_管理员维护渠道别名并精确关联历史数据(t *testing.T) {
db := newTestDB(t)
now := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
admin := &model.User{UserID: "SHOP-ADMIN", Username: "shop-admin", PasswordHash: "x",
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(),
CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
if err := repository.CreateUser(db, *admin); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,shopee_shop_name,source,created_at,updated_at)
VALUES('S-HISTORY','历史商品','蝦皮原名','api',?,?)`, model.NowISO(), model.NowISO()); err != nil {
t.Fatal(err)
}
if err := CreateShop(db, admin, "统一店铺", "SYB原名", "蝦皮原名", now); err != nil {
t.Fatal(err)
}
result, err := ListShops(db, admin)
if err != nil || len(result.Items) != 1 {
t.Fatalf("店铺列表错误: %+v %v", result, err)
}
shop := result.Items[0]
if !shop.Enabled || !shop.SybSyncEnabled || shop.ShopeeProductCount != 1 {
t.Fatalf("店铺状态或回填错误: %+v", shop)
}
product, err := repository.GetShopeeProductByGoodsID(db, "S-HISTORY")
if err != nil || product.ShopID != shop.ShopID {
t.Fatalf("蝦皮商品未精确关联: %+v %v", product, err)
}
if err := SetShopSybEnabled(db, admin, shop.ShopID, false, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if count, err := CountEnabledSybAllowedShops(db); err != nil || count != 0 {
t.Fatalf("SYB 停用没有生效: %d %v", count, err)
}
if err := EnsureEnabledSybAllowedShops(db); !IsValidationError(err) {
t.Fatalf("没有启用 SYB 店铺应阻止同步: %v", err)
}
if err := SetShopEnabled(db, admin, shop.ShopID, false, now.Add(2*time.Minute)); err != nil {
t.Fatal(err)
}
if err := DeleteShop(db, admin, shop.ShopID); !IsValidationError(err) {
t.Fatalf("有关联商品的店铺不能删除: %v", err)
}
}
func TestShop_渠道名称不可重复且采购员不能管理(t *testing.T) {
db := newTestDB(t)
now := time.Now()
admin := &model.User{UserID: "SHOP-ADMIN-2", Username: "shop-admin-2", PasswordHash: "x", Role: model.RoleAdmin,
Status: model.UserActive, PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
if err := repository.CreateUser(db, *admin); err != nil {
t.Fatal(err)
}
if err := CreateShop(db, admin, "店铺一", "同名", "蝦皮一", now); err != nil {
t.Fatal(err)
}
if err := CreateShop(db, admin, "店铺二", "同名", "蝦皮二", now); !IsValidationError(err) {
t.Fatalf("重复渠道名称必须拒绝: %v", err)
}
purchaser := &model.User{UserID: "BUYER", Role: model.RolePurchaser, Status: model.UserActive}
if err := CreateShop(nil, purchaser, "店铺", "SYB", "", now); !errors.Is(err, ErrAdminRequired) {
t.Fatalf("采购员新增应被拒绝: %v", err)
}
if _, err := ListShops(nil, purchaser); !errors.Is(err, ErrAdminRequired) {
t.Fatalf("采购员读取管理页应被拒绝: %v", err)
}
}
+20 -16
View File
@@ -18,10 +18,12 @@ import (
//
// 一行对应一个**商品**,不是一个 SKU——见工单 #41。
type ShopeeProductView struct {
GoodsID string
Title string
ImageURL string
ShopName string
GoodsID string
Title string
ImageURL string
ShopName string
BusinessShopName string
ShopUnlinked bool
// ColorCount / SizeCount 只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41)。
ColorCount int
@@ -101,24 +103,26 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
Rows: make([]ShopeeProductView, 0, len(rows)),
Total: total,
HasAnyProducts: hasAny > 0,
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "" || filter.Deleted,
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "" || filter.StoreID != "" || filter.Deleted,
Page: page,
PageSize: PageSize,
TotalPages: totalPages,
}
for _, r := range rows {
v := ShopeeProductView{
GoodsID: r.GoodsID,
Title: r.Title,
ImageURL: r.ImageURL,
ShopName: r.ShopeeShopName,
ColorCount: r.ColorCount,
SizeCount: r.SizeCount,
SKUCount: r.SKUCount,
PendingCount: r.PendingCount,
SourceText: shopeeSourceText(r.Source),
UpdatedAt: formatLocalTime(r.UpdatedAt),
Deleted: r.IsDeleted(),
GoodsID: r.GoodsID,
Title: r.Title,
ImageURL: r.ImageURL,
ShopName: r.ShopeeShopName,
BusinessShopName: r.BusinessShopName,
ShopUnlinked: r.ShopID == "",
ColorCount: r.ColorCount,
SizeCount: r.SizeCount,
SKUCount: r.SKUCount,
PendingCount: r.PendingCount,
SourceText: shopeeSourceText(r.Source),
UpdatedAt: formatLocalTime(r.UpdatedAt),
Deleted: r.IsDeleted(),
}
if r.PddGoodsID == "" {
+17 -13
View File
@@ -18,6 +18,7 @@ import (
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
@@ -553,21 +554,22 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
// 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。
func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time, options SybSyncOptions) SyncReport {
report := SyncReport{StartedAt: now, Specified: options.IsSpecified()}
allowedNames, err := repository.ListEnabledSybShopNames(db)
allowedShops, err := repository.ListEnabledSybShopMappings(db)
if err != nil {
report.Err = fmt.Errorf("读取顺运宝允许店铺失败: %w", err)
report.FinishedAt = time.Now().UTC()
return report
}
if len(allowedNames) == 0 {
report.Err = fmt.Errorf("没有启用的顺运宝同步店铺,请先由管理员在“同步店铺”中配置并启用至少一个店铺")
if len(allowedShops) == 0 {
report.Err = fmt.Errorf("没有启用的顺运宝同步店铺,请先由管理员在“店铺管理”中配置并启用至少一个 SYB 店铺")
report.FinishedAt = time.Now().UTC()
return report
}
allowedShops := make(map[string]struct{}, len(allowedNames))
for _, name := range allowedNames {
allowedShops[name] = struct{}{}
allowedNames := make([]string, 0, len(allowedShops))
for name := range allowedShops {
allowedNames = append(allowedNames, name)
}
sort.Strings(allowedNames)
report.ShopFilterHash = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(allowedNames, "\x00"))))
pageSize := cfg.PageSize
@@ -754,7 +756,7 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
report.StockCount += len(rawIDs)
orderedIDs := make([]int64, 0, len(rawIDs))
for _, id := range rawIDs {
if !sybShopAllowed(allowedShops, stockByID[id].Raw, nil) {
if _, ok := sybShopID(allowedShops, stockByID[id].Raw, nil); !ok {
report.ShopSkipped++
continue
}
@@ -782,12 +784,13 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
}
for _, d := range details {
stockRow := stockByID[d.ID]
if !sybShopAllowed(allowedShops, stockRow.Raw, d.Raw) {
shopID, ok := sybShopID(allowedShops, stockRow.Raw, d.Raw)
if !ok {
report.AcceptedCount--
report.ShopSkipped++
continue
}
if err := writeStockDetail(db, cfg.BaseURL, stockRow, d, &report); err != nil {
if err := writeStockDetail(db, cfg.BaseURL, shopID, stockRow, d, &report); err != nil {
report.Err = fmt.Errorf("写入货运单 %s(id=%d)失败(本次同步整体作废,"+
"已写入的数据保留): %w", d.Code, d.ID, err)
report.FinishedAt = time.Now().UTC()
@@ -820,10 +823,10 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
// sybShopAllowed 用明细字段覆盖列表字段后再核对,防止列表通过但明细在同步
// 期间已变成其他店铺。detail 为空时只检查列表快照。
func sybShopAllowed(allowed map[string]struct{}, listRaw, detailRaw map[string]any) bool {
func sybShopID(allowed map[string]string, listRaw, detailRaw map[string]any) (string, bool) {
name := trimmedStringField(mergeRaw(listRaw, detailRaw), "shopName")
_, ok := allowed[name]
return ok
shopID, ok := allowed[name]
return shopID, ok
}
// validateDetailBatch 确认批量明细响应与请求 ID 一一对应。任何缺失、重复、
@@ -856,7 +859,7 @@ func validateDetailBatch(requested []int64, details []syb.StockDetail) error {
// writeStockDetail 把一张货运单的全部商品明细写进 syb_orders,
// 一张货运单一个事务(工单 #46「按货运单为单位提交」)。
func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail syb.StockDetail, report *SyncReport) error {
func writeStockDetail(db *sql.DB, baseURL, shopID string, stockRow syb.StockRow, detail syb.StockDetail, report *SyncReport) error {
if len(detail.Details) == 0 {
return nil
}
@@ -888,6 +891,7 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
order := model.SybOrder{
SybID: sybID,
OrderNo: detail.Code,
ShopID: shopID,
ShopName: trimmedStringField(stockRaw, "shopName"),
Title: item.ProductTitle,
ProductSpec: item.ProductSpec,
+3 -79
View File
@@ -2,27 +2,13 @@ package service
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
const maxSybShopNameLength = 500
func ListSybAllowedShops(db *sql.DB, actor *model.User) ([]model.SybAllowedShop, error) {
if actor == nil || !actor.IsAdmin() {
return nil, ErrAdminRequired
}
return repository.ListSybAllowedShops(db)
}
func CountEnabledSybAllowedShops(db *sql.DB) (int, error) {
names, err := repository.ListEnabledSybShopNames(db)
return len(names), err
mappings, err := repository.ListEnabledSybShopMappings(db)
return len(mappings), err
}
func EnsureEnabledSybAllowedShops(db *sql.DB) error {
@@ -31,69 +17,7 @@ func EnsureEnabledSybAllowedShops(db *sql.DB) error {
return err
}
if count == 0 {
return &validationError{field: "shop_name", message: "没有启用的顺运宝同步店铺,请先由管理员配置并启用至少一个店铺"}
}
return nil
}
func CreateSybAllowedShop(db *sql.DB, actor *model.User, rawName string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
name := strings.TrimSpace(rawName)
if name == "" {
return &validationError{field: "shop_name", message: "店铺名称不能为空"}
}
if len([]rune(name)) > maxSybShopNameLength {
return &validationError{field: "shop_name", message: "店铺名称最多 500 个字符"}
}
id, err := randomID("SHOP-", 16)
if err != nil {
return fmt.Errorf("生成店铺编号失败: %w", err)
}
at := now.UTC().Format(model.TimeLayout)
err = repository.InsertSybAllowedShop(db, model.SybAllowedShop{
ShopID: id, ShopName: name, NormalizedName: name, Enabled: true,
CreatedByUserID: actor.UserID, CreatedAt: at, UpdatedAt: at,
})
if errors.Is(err, repository.ErrSybAllowedShopExists) {
return &validationError{field: "shop_name", message: "该店铺已经在允许列表中,可直接重新启用"}
}
return err
}
func SetSybAllowedShopEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
if strings.TrimSpace(shopID) == "" {
return &validationError{field: "shop_id", message: "店铺编号不能为空"}
}
found, err := repository.SetSybAllowedShopEnabled(db, shopID, enabled, now.UTC().Format(model.TimeLayout))
if err != nil {
return err
}
if !found {
return &validationError{field: "shop_id", message: "店铺不存在,请刷新页面后重试"}
}
return nil
}
// DeleteDisabledSybAllowedShop 永久删除一条已停用配置,不影响历史货运单和同步记录。
func DeleteDisabledSybAllowedShop(db *sql.DB, actor *model.User, shopID string) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
shopID = strings.TrimSpace(shopID)
if shopID == "" {
return &validationError{field: "shop_id", message: "店铺编号不能为空"}
}
deleted, err := repository.DeleteDisabledSybAllowedShop(db, shopID)
if err != nil {
return err
}
if !deleted {
return &validationError{field: "shop_id", message: "店铺不存在或仍在启用,请刷新页面并先停用后再删除"}
return &validationError{field: "shop_name", message: "没有启用的顺运宝同步店铺,请先由管理员在店铺管理中配置并启用至少一个 SYB 店铺"}
}
return nil
}
-85
View File
@@ -1,85 +0,0 @@
package service
import (
"errors"
"testing"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
func TestSybAllowedShop_管理员维护与精确去重(t *testing.T) {
db := newTestDB(t)
now := time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC)
admin := &model.User{UserID: "SHOP-ADMIN", Username: "shop-admin", PasswordHash: "x",
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(),
CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
if err := repository.CreateUser(db, *admin); err != nil {
t.Fatal(err)
}
if err := CreateSybAllowedShop(db, admin, " qwg8fkb044 ", now); err != nil {
t.Fatal(err)
}
if _, err := repository.UpsertSybOrder(db, model.SybOrder{
SybID: "SHOP-HISTORY", OrderNo: "ORDER-HISTORY", ShopName: "qwg8fkb044",
Quantity: 1, SybData: `{}`, CreatedAt: model.NowISO(), UpdatedAt: model.NowISO(),
}); err != nil {
t.Fatal(err)
}
if err := repository.CreateSybSyncRun(db, model.SybSyncRun{
RunID: "SHOP-RUN-HISTORY", UserID: admin.UserID, DateFrom: "2026-08-12",
DateTo: "2026-08-12", Status: model.SybSyncRunning, StartedAt: model.NowISO(),
}); err != nil {
t.Fatal(err)
}
rows, err := ListSybAllowedShops(db, admin)
if err != nil || len(rows) != 1 || rows[0].ShopName != "qwg8fkb044" || !rows[0].Enabled {
t.Fatalf("新增结果错误: rows=%+v err=%v", rows, err)
}
if err := CreateSybAllowedShop(db, admin, "qwg8fkb044", now); !IsValidationError(err) {
t.Fatalf("去除首尾空白后的重名应是表单错误,实际 %v", err)
}
if err := SetSybAllowedShopEnabled(db, admin, rows[0].ShopID, false, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if count, err := CountEnabledSybAllowedShops(db); err != nil || count != 0 {
t.Fatalf("停用后启用数错误: count=%d err=%v", count, err)
}
if err := EnsureEnabledSybAllowedShops(db); !IsValidationError(err) {
t.Fatalf("空白名单应阻止同步: %v", err)
}
if err := DeleteDisabledSybAllowedShop(db, admin, rows[0].ShopID); err != nil {
t.Fatalf("删除已停用店铺失败: %v", err)
}
if list, err := ListSybAllowedShops(db, admin); err != nil || len(list) != 0 {
t.Fatalf("删除后仍存在: rows=%+v err=%v", list, err)
}
if count, err := repository.CountSybOrdersTotal(db); err != nil || count != 1 {
t.Fatalf("删除配置不应影响历史货运单: count=%d err=%v", count, err)
}
if count, err := repository.CountSybSyncRuns(db); err != nil || count != 1 {
t.Fatalf("删除配置不应影响同步记录: count=%d err=%v", count, err)
}
if err := CreateSybAllowedShop(db, admin, "qwg8fkb044", now.Add(2*time.Minute)); err != nil {
t.Fatalf("删除后应允许重新新增同名店铺: %v", err)
}
newRows, _ := ListSybAllowedShops(db, admin)
if err := DeleteDisabledSybAllowedShop(db, admin, newRows[0].ShopID); !IsValidationError(err) {
t.Fatalf("启用店铺必须拒绝删除: %v", err)
}
}
func TestSybAllowedShop_采购员不能管理(t *testing.T) {
purchaser := &model.User{UserID: "BUYER", Role: model.RolePurchaser, Status: model.UserActive}
if err := CreateSybAllowedShop(nil, purchaser, "店铺", time.Now()); !errors.Is(err, ErrAdminRequired) {
t.Fatalf("采购员新增应被拒绝: %v", err)
}
if _, err := ListSybAllowedShops(nil, purchaser); !errors.Is(err, ErrAdminRequired) {
t.Fatalf("采购员读取管理列表应被拒绝: %v", err)
}
if err := DeleteDisabledSybAllowedShop(nil, purchaser, "SHOP-1"); !errors.Is(err, ErrAdminRequired) {
t.Fatalf("采购员删除应被拒绝: %v", err)
}
}
+13 -8
View File
@@ -373,18 +373,23 @@ func newSyncTestDB(t *testing.T) *sql.DB {
}); err != nil {
t.Fatalf("准备同步测试管理员失败: %v", err)
}
if err := repository.InsertSybAllowedShop(db, model.SybAllowedShop{
ShopID: "SYB-TEST-SHOP", ShopName: "测试店铺", NormalizedName: "测试店铺", Enabled: true,
if err := repository.InsertShop(db, model.Shop{
ShopID: "SYB-TEST-SHOP", DisplayName: "测试店铺", NormalizedName: "测试店铺", Enabled: true,
CreatedByUserID: "SYB-TEST-ADMIN", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("准备同步测试允许店铺失败: %v", err)
t.Fatalf("准备同步测试业务店铺失败: %v", err)
}
if err := repository.InsertShopAlias(db, model.ShopChannelAlias{AliasID: "SYB-TEST-ALIAS",
ShopID: "SYB-TEST-SHOP", Channel: "syb", AliasName: "测试店铺", NormalizedAlias: "测试店铺",
Enabled: true, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("准备同步测试渠道名称失败: %v", err)
}
return db
}
func TestRunSybSync_没有启用店铺时不请求顺运宝(t *testing.T) {
db := newSyncTestDB(t)
if _, err := db.Exec(`UPDATE syb_allowed_shops SET enabled=0`); err != nil {
if _, err := db.Exec(`UPDATE shop_channel_aliases SET enabled=0 WHERE channel='syb'`); err != nil {
t.Fatal(err)
}
requests := 0
@@ -409,14 +414,14 @@ func TestRunSybSync_没有启用店铺时不请求顺运宝(t *testing.T) {
}
func TestSybShopAllowed_明细店铺覆盖列表后重新拦截(t *testing.T) {
allowed := map[string]struct{}{"测试店铺": {}}
if !sybShopAllowed(allowed, map[string]any{"shopName": " 测试店铺 "}, nil) {
allowed := map[string]string{"测试店铺": "SHOP-1"}
if shopID, ok := sybShopID(allowed, map[string]any{"shopName": " 测试店铺 "}, nil); !ok || shopID != "SHOP-1" {
t.Fatal("应忽略允许店铺名称首尾空白")
}
if sybShopAllowed(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": "其他店铺"}) {
if _, ok := sybShopID(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": "其他店铺"}); ok {
t.Fatal("明细店铺变化后必须重新拦截")
}
if sybShopAllowed(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": " "}) {
if _, ok := sybShopID(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": " "}); ok {
t.Fatal("明细店铺变为空值时必须拦截")
}
}