feat: 收敛店铺管理和蝦皮分类搜索 (#212 #213)

This commit is contained in:
chengma
2026-08-13 11:47:56 +08:00
parent 1abed648a5
commit 313e9f9151
22 changed files with 389 additions and 464 deletions
+32 -53
View File
@@ -33,15 +33,11 @@ func ListShops(db *sql.DB, actor *model.User) (ShopListResult, error) {
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 {
func CreateShop(db *sql.DB, actor *model.User, shopName string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
displayName, sybAlias, shopeeAlias, err := validateShopInput(displayName, sybAlias, shopeeAlias)
shopName, err := validateShopName(shopName)
if err != nil {
return err
}
@@ -55,14 +51,14 @@ func CreateShop(db *sql.DB, actor *model.User, displayName, sybAlias, shopeeAlia
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 {
if err := repository.InsertShop(tx, model.Shop{ShopID: shopID, DisplayName: shopName,
NormalizedName: shopName, 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 {
if err := insertShopAlias(tx, shopID, "syb", shopName, true, at); err != nil {
return shopValidationError(err)
}
if err := insertShopAlias(tx, shopID, "shopee", shopeeAlias, true, at); err != nil {
if err := insertShopAlias(tx, shopID, "shopee", shopName, true, at); err != nil {
return shopValidationError(err)
}
if err := repository.RebuildShopAssociations(tx, shopID); err != nil {
@@ -71,7 +67,7 @@ func CreateShop(db *sql.DB, actor *model.User, displayName, sybAlias, shopeeAlia
return tx.Commit()
}
func UpdateShop(db *sql.DB, actor *model.User, shopID, displayName, sybAlias, shopeeAlias string, now time.Time) error {
func UpdateShop(db *sql.DB, actor *model.User, shopID, shopName string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
@@ -79,7 +75,7 @@ func UpdateShop(db *sql.DB, actor *model.User, shopID, displayName, sybAlias, sh
if shopID == "" {
return &validationError{field: "shop_id", message: "店铺编号不能为空"}
}
displayName, sybAlias, shopeeAlias, err := validateShopInput(displayName, sybAlias, shopeeAlias)
shopName, err := validateShopName(shopName)
if err != nil {
return err
}
@@ -89,25 +85,19 @@ func UpdateShop(db *sql.DB, actor *model.User, shopID, displayName, sybAlias, sh
return err
}
defer tx.Rollback()
if _, found, err := repository.GetShop(tx, shopID); err != nil {
shop, found, err := repository.GetShop(tx, shopID)
if 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 {
if _, err := repository.UpdateShopName(tx, shopID, shopName, shopName, at); err != nil {
return shopValidationError(err)
}
if err := replaceShopAlias(tx, shopID, "syb", sybAlias, sybEnabled, at); err != nil {
if err := replaceShopAlias(tx, shopID, "syb", shopName, shop.Enabled, at); err != nil {
return shopValidationError(err)
}
if err := replaceShopAlias(tx, shopID, "shopee", shopeeAlias, true, at); err != nil {
if err := replaceShopAlias(tx, shopID, "shopee", shopName, shop.Enabled, at); err != nil {
return shopValidationError(err)
}
if err := repository.RebuildShopAssociations(tx, shopID); err != nil {
@@ -120,28 +110,24 @@ func SetShopEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool,
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
found, err := repository.SetShopEnabled(db, strings.TrimSpace(shopID), enabled, now.UTC().Format(model.TimeLayout))
shopID = strings.TrimSpace(shopID)
at := now.UTC().Format(model.TimeLayout)
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
found, err := repository.SetShopEnabled(tx, shopID, enabled, at)
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 {
if err := repository.SetShopCompatibilityAliasesEnabled(tx, shopID, enabled, at); err != nil {
return err
}
if !found {
return &validationError{field: "shop_id", message: "该店铺没有配置 SYB 店铺名称"}
}
return nil
return tx.Commit()
}
func DeleteShop(db *sql.DB, actor *model.User, shopID string) error {
@@ -161,22 +147,15 @@ func DeleteShop(db *sql.DB, actor *model.User, shopID string) error {
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: "业务店铺名称不能为空"}
func validateShopName(shopName string) (string, error) {
shopName = strings.TrimSpace(shopName)
if shopName == "" {
return "", &validationError{field: "shop_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 len([]rune(shopName)) > maxShopNameLength {
return "", &validationError{field: "shop_name", message: "店铺名称最多 500 个字符"}
}
if sybAlias == "" && shopeeAlias == "" {
return "", "", "", &validationError{field: "syb_alias", message: "SYB 或蝦皮店铺名称至少填写一个"}
}
return displayName, sybAlias, shopeeAlias, nil
return shopName, nil
}
func insertShopAlias(q repository.Execer, shopID, channel, alias string, enabled bool, at string) error {
@@ -202,9 +181,9 @@ func replaceShopAlias(q repository.Execer, shopID, channel, alias string, enable
func shopValidationError(err error) error {
switch {
case errors.Is(err, repository.ErrShopNameExists):
return &validationError{field: "display_name", message: "业务店铺名称已经存在"}
return &validationError{field: "shop_name", message: "店铺名称已经存在"}
case errors.Is(err, repository.ErrShopAliasExists):
return &validationError{field: "syb_alias", message: "该渠道店铺名称已关联到其他业务店铺"}
return &validationError{field: "shop_name", message: "店铺名称已经存在"}
default:
return err
}
+17 -13
View File
@@ -9,7 +9,7 @@ import (
"cmautobuy/admin/repository"
)
func TestShop_管理员维护渠道别名并精确关联历史数据(t *testing.T) {
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",
@@ -19,11 +19,11 @@ func TestShop_管理员维护渠道别名并精确关联历史数据(t *testing.
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 {
VALUES('S-HISTORY','历史商品','统一店铺','api',?,?)`, model.NowISO(), model.NowISO()); err != nil {
t.Fatal(err)
}
if err := CreateShop(db, admin, "统一店铺", "SYB原名", "蝦皮原名", now); err != nil {
if err := CreateShop(db, admin, "统一店铺", now); err != nil {
t.Fatal(err)
}
result, err := ListShops(db, admin)
@@ -31,31 +31,35 @@ func TestShop_管理员维护渠道别名并精确关联历史数据(t *testing.
t.Fatalf("店铺列表错误: %+v %v", result, err)
}
shop := result.Items[0]
if !shop.Enabled || !shop.SybSyncEnabled || shop.ShopeeProductCount != 1 {
if !shop.Enabled || 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 {
var aliases int
if err := db.QueryRow(`SELECT COUNT(*) FROM shop_channel_aliases WHERE shop_id=? AND alias_name='统一店铺' AND enabled=1`, shop.ShopID).Scan(&aliases); err != nil || aliases != 2 {
t.Fatalf("两个兼容渠道记录必须由同一名称派生: count=%d err=%v", aliases, err)
}
if err := SetShopEnabled(db, admin, shop.ShopID, false, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM shop_channel_aliases WHERE shop_id=? AND enabled=0`, shop.ShopID).Scan(&aliases); err != nil || aliases != 2 {
t.Fatalf("停用状态必须同步到兼容数据: count=%d err=%v", aliases, 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) {
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,
@@ -63,14 +67,14 @@ func TestShop_渠道名称不可重复且采购员不能管理(t *testing.T) {
if err := repository.CreateUser(db, *admin); err != nil {
t.Fatal(err)
}
if err := CreateShop(db, admin, "店铺一", "同名", "蝦皮一", now); err != nil {
if err := CreateShop(db, admin, "店铺一", now); err != nil {
t.Fatal(err)
}
if err := CreateShop(db, admin, "店铺二", "同名", "蝦皮二", now); !IsValidationError(err) {
t.Fatalf("重复渠道名称必须拒绝: %v", 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) {
if err := CreateShop(nil, purchaser, "店铺", now); !errors.Is(err, ErrAdminRequired) {
t.Fatalf("采购员新增应被拒绝: %v", err)
}
if _, err := ListShops(nil, purchaser); !errors.Is(err, ErrAdminRequired) {
+25 -30
View File
@@ -18,12 +18,10 @@ import (
//
// 一行对应一个**商品**,不是一个 SKU——见工单 #41。
type ShopeeProductView struct {
GoodsID string
Title string
ImageURL string
ShopName string
BusinessShopName string
ShopUnlinked bool
GoodsID string
Title string
ImageURL string
ShopName string
// ColorCount / SizeCount 只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41)。
ColorCount int
@@ -103,26 +101,24 @@ 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.StoreID != "" || filter.Deleted,
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "" || filter.Deleted || filter.Unlinked,
Page: page,
PageSize: PageSize,
TotalPages: totalPages,
}
for _, r := range rows {
v := ShopeeProductView{
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(),
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(),
}
if r.PddGoodsID == "" {
@@ -194,23 +190,22 @@ func ShopeeStatusLabel(status string) string {
return shopeeStatusTexts[status]
}
// ShopeePresenceOptions 返回资料字段的三态选项。使用下拉框而不是复选框,
// 让“全部”和“无资料”都有明确表达,不把未勾选误解成任意状态。
func ShopeePresenceOptions(hasText, missingText string) []ShopeeStatusOption {
// ShopeeSearchFieldOptions 返回关键词可以查询的三个字段。
func ShopeeSearchFieldOptions() []ShopeeStatusOption {
return []ShopeeStatusOption{
{"", "全部"},
{"has", hasText},
{"missing", missingText},
{"goods_id", "商品 ID"},
{"title", "商品名称"},
{"shop_name", "店铺名"},
}
}
// ParseShopeePresence 校验店铺/图片筛选参数;未知值按“全部”处理。
func ParseShopeePresence(s string) string {
// ParseShopeeSearchField 使用固定白名单,避免把请求参数当成 SQL 列名。
func ParseShopeeSearchField(s string) string {
value := strings.TrimSpace(s)
if value == "has" || value == "missing" {
if value == "title" || value == "shop_name" {
return value
}
return ""
return "goods_id"
}
// ---------- 弹窗 ----------
+40 -26
View File
@@ -242,7 +242,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺
}
// 按商品名称匹配
result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "背心"}, 1)
result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "背心", SearchField: "title"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -251,7 +251,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺
}
// 颜色/尺码不参与匹配:搜"黑色"应该搜不到任何商品。
result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "黑色"}, 1)
result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "黑色", SearchField: "title"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -475,7 +475,7 @@ func TestParseShopeeStatus_认不出来当全部(t *testing.T) {
}
}
func TestListShopeeProducts_店铺图片三态及组合筛选(t *testing.T) {
func TestListShopeeProducts_分类搜索(t *testing.T) {
db := newTestDB(t)
for _, product := range []struct {
id, title, shop, image string
@@ -496,15 +496,9 @@ func TestListShopeeProducts_店铺图片三态及组合筛选(t *testing.T) {
filter repository.ShopeeFilter
want []string
}{
{"有店铺", repository.ShopeeFilter{Shop: "has"}, []string{"A", "B"}},
{"无店铺", repository.ShopeeFilter{Shop: "missing"}, []string{"C", "D"}},
{"有图片", repository.ShopeeFilter{Image: "has"}, []string{"A", "C"}},
{"无图片", repository.ShopeeFilter{Image: "missing"}, []string{"B", "D"}},
{"有店铺且有图片", repository.ShopeeFilter{Shop: "has", Image: "has"}, []string{"A"}},
{"无店铺且无图片", repository.ShopeeFilter{Shop: "missing", Image: "missing"}, []string{"D"}},
{"组合关键词", repository.ShopeeFilter{Keyword: "店铺有图", Shop: "has", Image: "has"}, []string{"A"}},
{"店铺名称", repository.ShopeeFilter{ShopName: "店铺 A"}, []string{"A"}},
{"店铺名称组合", repository.ShopeeFilter{ShopName: "店铺", Shop: "has", Image: "missing"}, []string{"B"}},
{"商品ID精确", repository.ShopeeFilter{Keyword: "A", SearchField: "goods_id"}, []string{"A"}},
{"商品名称包含", repository.ShopeeFilter{Keyword: "店铺有图", SearchField: "title"}, []string{"A"}},
{"店铺名称包含", repository.ShopeeFilter{Keyword: "店铺", SearchField: "shop_name"}, []string{"A", "B"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -521,7 +515,7 @@ func TestListShopeeProducts_店铺图片三态及组合筛选(t *testing.T) {
}
}
if !result.IsFiltered {
t.Fatal("使用店铺/图片条件时 IsFiltered 应为 true")
t.Fatal("使用分类搜索时 IsFiltered 应为 true")
}
})
}
@@ -540,7 +534,7 @@ func TestListShopeeProducts_店铺名称LIKE特殊字符按原文匹配(t *testi
}
for _, keyword := range []string{"%", "_", "!", "%店_"} {
result, err := ListShopeeProducts(db, repository.ShopeeFilter{ShopName: keyword}, 1)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Keyword: keyword, SearchField: "shop_name"}, 1)
if err != nil {
t.Fatal(err)
}
@@ -553,19 +547,39 @@ func TestListShopeeProducts_店铺名称LIKE特殊字符按原文匹配(t *testi
}
}
func TestParseShopeePresence_三态参数(t *testing.T) {
for _, value := range []string{"has", "missing"} {
if got := ParseShopeePresence(value); got != value {
t.Errorf("ParseShopeePresence(%q)=%q", value, got)
}
func TestListShopeeProducts_未登记入口只显示未关联店铺(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "UNLINKED", "未登记商品")
seedShopeeProduct(t, db, "LINKED", "已登记商品")
if _, err := db.Exec(`INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at)
VALUES('SHOP-USER','shop-user','x','admin','active',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`); err != nil {
t.Fatal(err)
}
for _, value := range []string{"", "all", "HAS", "unknown", " "} {
if got := ParseShopeePresence(value); got != "" {
t.Errorf("ParseShopeePresence(%q)=%q,期望全部", value, got)
}
if _, err := db.Exec(`INSERT INTO shops(shop_id,display_name,normalized_name,enabled,created_by_user_id,created_at,updated_at)
VALUES('SHOP-1','店铺一','店铺一',1,'SHOP-USER',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`); err != nil {
t.Fatal(err)
}
options := ShopeePresenceOptions("有店铺", "无店铺")
if len(options) != 3 || options[0].Value != "" || options[1].Value != "has" || options[2].Value != "missing" {
t.Fatalf("三态选项不正确:%+v", options)
if _, err := db.Exec(`UPDATE shopee_products SET shop_id='SHOP-1' WHERE goods_id='LINKED'`); err != nil {
t.Fatal(err)
}
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Unlinked: true}, 1)
if err != nil {
t.Fatal(err)
}
if result.Total != 1 || len(result.Rows) != 1 || result.Rows[0].GoodsID != "UNLINKED" {
t.Fatalf("未登记结果错误:total=%d rows=%+v", result.Total, result.Rows)
}
}
func TestParseShopeeSearchField_固定白名单(t *testing.T) {
for _, value := range []string{"goods_id", "title", "shop_name"} {
if got := ParseShopeeSearchField(value); got != value {
t.Errorf("ParseShopeeSearchField(%q)=%q", value, got)
}
}
for _, value := range []string{"", "unknown", "title desc", " "} {
if got := ParseShopeeSearchField(value); got != "goods_id" {
t.Errorf("ParseShopeeSearchField(%q)=%q,期望 goods_id", value, got)
}
}
}