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
+5 -9
View File
@@ -43,11 +43,11 @@ func assertRedirectQuery(t *testing.T, recorder *httptest.ResponseRecorder, want
func Test列表写操作跳转保留筛选和页码(t *testing.T) {
t.Run("蝦皮", func(t *testing.T) {
context, recorder := listPostContext(t, "/shopee/save", url.Values{
"list_goods_id": {"1001"}, "list_shop_name": {"店铺 A"}, "status": {"no_link"}, "shop": {"has"}, "image": {"missing"}, "page": {"2"},
"q": {"店铺 A"}, "search_field": {"shop_name"}, "status": {"no_link"}, "unlinked": {"1"}, "page": {"2"},
})
(&Handler{}).shopeeRedirect(context, "完成")
assertRedirectQuery(t, recorder, "/shopee", map[string]string{
"goods_id": "1001", "shop_name": "店铺 A", "status": "no_link", "shop": "has", "image": "missing", "page": "2", "msg": "完成",
"q": "店铺 A", "search_field": "shop_name", "status": "no_link", "unlinked": "1", "page": "2", "msg": "完成",
})
})
t.Run("顺运宝", func(t *testing.T) {
@@ -98,20 +98,16 @@ func Test列表写操作跳转保留筛选和页码(t *testing.T) {
})
}
func Test蝦皮分页详情和状态条保留资料筛选(t *testing.T) {
func Test蝦皮分页详情和状态条保留分类搜索(t *testing.T) {
values := url.Values{
"goods_id": {"1001"},
"shop_name": {"店铺 A"},
"status": {"has_link"},
"shop": {"has"},
"image": {"missing"},
"q": {"店铺 A"}, "search_field": {"shop_name"}, "status": {"has_link"}, "unlinked": {"1"},
}
detail, err := url.Parse("/shopee/detail?" + detailValuesForShopee(values, 3))
if err != nil {
t.Fatal(err)
}
for key, want := range map[string]string{
"goods_id": "1001", "shop_name": "店铺 A", "status": "has_link", "shop": "has", "image": "missing", "page": "3",
"q": "店铺 A", "search_field": "shop_name", "status": "has_link", "unlinked": "1", "page": "3",
} {
if got := detail.Query().Get(key); got != want {
t.Errorf("详情参数 %s=%q,期望 %q", key, got, want)
+6 -25
View File
@@ -23,8 +23,7 @@ func (h *Handler) ShopList(c *gin.Context) {
}
func (h *Handler) ShopCreate(c *gin.Context) {
err := service.CreateShop(h.db, currentUser(c), c.PostForm("display_name"),
c.PostForm("syb_alias"), c.PostForm("shopee_alias"), time.Now())
err := service.CreateShop(h.db, currentUser(c), c.PostForm("shop_name"), time.Now())
if err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
@@ -33,12 +32,11 @@ func (h *Handler) ShopCreate(c *gin.Context) {
fail(c, http.StatusInternalServerError, "新增店铺失败,已有数据没有被改动。")
return
}
redirectShops(c, "店铺已新增;历史数据已按渠道原始名称精确关联", "")
redirectShops(c, "店铺已新增;历史数据已按店铺名称精确关联", "")
}
func (h *Handler) ShopUpdate(c *gin.Context) {
err := service.UpdateShop(h.db, currentUser(c), c.PostForm("shop_id"), c.PostForm("display_name"),
c.PostForm("syb_alias"), c.PostForm("shopee_alias"), time.Now())
err := service.UpdateShop(h.db, currentUser(c), c.PostForm("shop_id"), c.PostForm("shop_name"), time.Now())
if err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
@@ -47,7 +45,7 @@ func (h *Handler) ShopUpdate(c *gin.Context) {
fail(c, http.StatusInternalServerError, "保存店铺失败,原配置保持不变。")
return
}
redirectShops(c, "店铺名称和渠道关联已保存", "")
redirectShops(c, "店铺名称已保存,历史数据关联已重建", "")
}
func (h *Handler) ShopStatus(c *gin.Context) {
@@ -60,26 +58,9 @@ func (h *Handler) ShopStatus(c *gin.Context) {
fail(c, http.StatusInternalServerError, "更新店铺状态失败,原状态保持不变。")
return
}
message := "业务店铺已停用;SYB 不再同步该店铺"
message := "店铺已停用;SYB 不再同步该店铺"
if enabled {
message = "业务店铺已启用"
}
redirectShops(c, message, "")
}
func (h *Handler) ShopSybStatus(c *gin.Context) {
enabled := c.PostForm("enabled") == "1"
if err := service.SetShopSybEnabled(h.db, currentUser(c), c.PostForm("shop_id"), enabled, time.Now()); err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "更新 SYB 同步状态失败,原状态保持不变。")
return
}
message := "SYB 同步已停用"
if enabled {
message = "SYB 同步已启用"
message = "店铺已启用"
}
redirectShops(c, message, "")
}
+41 -69
View File
@@ -18,7 +18,8 @@ import (
// **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus
// 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。
func (h *Handler) ShopeeList(c *gin.Context) {
h.renderShopeeList(c, c.Query("goods_id"), c.Query("shop_name"), c.Query("status"), c.Query("shop"), c.Query("image"), c.Query("store"), c.Query("deleted"), c.Query("page"), c.Query("msg"))
h.renderShopeeList(c, c.Query("q"), c.Query("search_field"), c.Query("status"),
c.Query("deleted"), c.Query("unlinked"), c.Query("page"), c.Query("msg"))
}
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
@@ -45,27 +46,24 @@ func (h *Handler) ShopeeDetail(c *gin.Context) {
c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{
"D": detail, "CSRFToken": csrfToken(c),
"Keyword": c.Query("goods_id"), "StatusFilter": c.Query("status"),
"ShopNameKeyword": strings.TrimSpace(c.Query("shop_name")),
"ShopFilter": service.ParseShopeePresence(c.Query("shop")),
"ImageFilter": service.ParseShopeePresence(c.Query("image")),
"StoreFilter": strings.TrimSpace(c.Query("store")),
"CurrentPage": service.ParsePage(c.Query("page")),
"Keyword": c.Query("q"), "SearchField": service.ParseShopeeSearchField(c.Query("search_field")),
"StatusFilter": service.ParseShopeeStatus(c.Query("status")),
"DeletedFilter": currentUser(c).IsAdmin() && c.Query("deleted") == "1",
"UnlinkedFilter": c.Query("unlinked") == "1",
"CurrentPage": service.ParsePage(c.Query("page")),
})
}
// renderShopeeList 统一渲染蝦皮列表;数据写入改由商品目录接口负责。
//
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留关键词和三个下拉筛选,
// 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#150)。
func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, shopRaw, imageRaw, storeRaw, deletedRaw, pageRaw, msg string) {
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留状态、分类、关键词和内部范围,
// 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#213)。
func (h *Handler) renderShopeeList(c *gin.Context, keyword, searchFieldRaw, statusRaw, deletedRaw, unlinkedRaw, pageRaw, msg string) {
filter := repository.ShopeeFilter{
Keyword: keyword,
ShopName: strings.TrimSpace(shopName),
Status: service.ParseShopeeStatus(statusRaw),
Shop: service.ParseShopeePresence(shopRaw),
Image: service.ParseShopeePresence(imageRaw),
StoreID: strings.TrimSpace(storeRaw),
Keyword: strings.TrimSpace(keyword),
SearchField: service.ParseShopeeSearchField(searchFieldRaw),
Status: service.ParseShopeeStatus(statusRaw),
Unlinked: unlinkedRaw == "1",
}
if currentUser(c).IsAdmin() && deletedRaw == "1" {
filter.Deleted = true
@@ -86,60 +84,42 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
"读取可选客户端失败,数据没有被改动。刷新页面重试。")
return
}
shopOptions, err := service.ShopOptions(h.db)
if err != nil {
fail(c, http.StatusInternalServerError, "读取业务店铺失败,数据没有被改动。")
return
}
status := msg
if status == "" {
status = shopeeStatusLine(filter.Status, result)
}
values := url.Values{}
if keyword != "" {
values.Set("goods_id", keyword)
}
if filter.ShopName != "" {
values.Set("shop_name", filter.ShopName)
if filter.Keyword != "" {
values.Set("q", filter.Keyword)
}
values.Set("search_field", filter.SearchField)
if filter.Status != "" {
values.Set("status", filter.Status)
}
if filter.Shop != "" {
values.Set("shop", filter.Shop)
}
if filter.Image != "" {
values.Set("image", filter.Image)
}
if filter.StoreID != "" {
values.Set("store", filter.StoreID)
}
if filter.Deleted {
values.Set("deleted", "1")
}
if filter.Unlinked {
values.Set("unlinked", "1")
}
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
"Keyword": keyword,
"ShopNameKeyword": filter.ShopName,
"StatusFilter": filter.Status,
"StatusOptions": service.ShopeeStatusOptions(),
"ShopFilter": filter.Shop,
"ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"),
"ImageFilter": filter.Image,
"ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"),
"StoreFilter": filter.StoreID,
"StoreOptions": shopOptions,
"DeletedFilter": filter.Deleted,
"Rows": result.Rows,
"Status": status,
"HasAnyProducts": result.HasAnyProducts,
"IsFiltered": result.IsFiltered,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"CurrentPage": result.Page,
"DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page),
"AssignableClients": assignableClients,
"Keyword": filter.Keyword,
"SearchField": filter.SearchField,
"SearchFieldOptions": service.ShopeeSearchFieldOptions(),
"StatusFilter": filter.Status,
"StatusOptions": service.ShopeeStatusOptions(),
"DeletedFilter": filter.Deleted,
"UnlinkedFilter": filter.Unlinked,
"Rows": result.Rows,
"Status": status,
"HasAnyProducts": result.HasAnyProducts,
"IsFiltered": result.IsFiltered,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"CurrentPage": result.Page,
"DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page),
"AssignableClients": assignableClients,
}))
}
@@ -250,27 +230,19 @@ func (h *Handler) ShopeeCollectBatch(c *gin.Context) {
func (h *Handler) shopeeRedirect(c *gin.Context, msg string) {
params := url.Values{}
if value := strings.TrimSpace(c.PostForm("list_goods_id")); value != "" {
params.Set("goods_id", value)
}
if value := strings.TrimSpace(c.PostForm("list_shop_name")); value != "" {
params.Set("shop_name", value)
if value := strings.TrimSpace(c.PostForm("q")); value != "" {
params.Set("q", value)
}
params.Set("search_field", service.ParseShopeeSearchField(c.PostForm("search_field")))
if value := strings.TrimSpace(c.PostForm("status")); value != "" {
params.Set("status", service.ParseShopeeStatus(value))
}
if value := service.ParseShopeePresence(c.PostForm("shop")); value != "" {
params.Set("shop", value)
}
if value := service.ParseShopeePresence(c.PostForm("image")); value != "" {
params.Set("image", value)
}
if value := strings.TrimSpace(c.PostForm("store")); value != "" {
params.Set("store", value)
}
if currentUser(c).IsAdmin() && c.PostForm("deleted") == "1" {
params.Set("deleted", "1")
}
if c.PostForm("unlinked") == "1" {
params.Set("unlinked", "1")
}
if value := strings.TrimSpace(c.PostForm("page")); value != "" {
params.Set("page", value)
}
-1
View File
@@ -94,7 +94,6 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
shops.POST("/create", h.ShopCreate)
shops.POST("/update", h.ShopUpdate)
shops.POST("/status", h.ShopStatus)
shops.POST("/syb-status", h.ShopSybStatus)
shops.POST("/delete", h.ShopDelete)
// 4. 采集采购
+10 -8
View File
@@ -65,7 +65,7 @@ func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *te
jsSource := string(js)
for _, want := range []string{
"var MODULE_QUERY_KEYS = {",
"\"/shopee\": [\"goods_id\", \"shop_name\", \"status\", \"shop\", \"image\", \"store\", \"deleted\", \"page\"]",
"\"/shopee\": [\"q\", \"search_field\", \"status\", \"deleted\", \"unlinked\", \"page\"]",
"\"/syb\": [\"order_no\", \"shop\", \"stage\", \"page\", \"date_from\", \"date_to\"]",
"var MODULE_STATE_PREFIX = \"cmautobuy:module-state:\"",
"parsed.origin !== window.location.origin || parsed.pathname !== moduleRoot",
@@ -316,7 +316,7 @@ func TestSybPage_明确展示蝦皮商品Pdd关联来源(t *testing.T) {
}
}
func TestShops_独立模块区分全局和SYB状态且保护删除(t *testing.T) {
func TestShops_单一名称和状态且保护删除(t *testing.T) {
templateContent, err := os.ReadFile("templates/shop/list.html")
if err != nil {
t.Fatal(err)
@@ -327,7 +327,7 @@ func TestShops_独立模块区分全局和SYB状态且保护删除(t *testing.T)
}
page := string(templateContent)
for _, want := range []string{
`{{if not .Enabled}}`, `action="/shops/delete"`, `action="/shops/syb-status"`,
`name="shop_name"`, `{{if not .Enabled}}`, `action="/shops/delete"`,
`data-confirm-submit="确定删除停用店铺“{{.DisplayName}}”吗?`,
`class="danger">删除`,
} {
@@ -338,6 +338,9 @@ func TestShops_独立模块区分全局和SYB状态且保护删除(t *testing.T)
if !strings.Contains(string(routes), `shops.POST("/delete", h.ShopDelete)`) {
t.Error("店铺删除没有挂到管理员路由组")
}
if strings.Contains(page, "业务店铺") || strings.Contains(page, "SYB 名称") || strings.Contains(page, "蝦皮名称") {
t.Error("店铺管理仍暴露已废弃的多名称概念")
}
}
func TestCatalogHistory_展示更新策略和分级统计(t *testing.T) {
@@ -382,7 +385,7 @@ func TestShopeePage_展示主图店铺且保留标题宽度(t *testing.T) {
}
}
func TestShopeePage_店铺图片三态筛选和状态保留(t *testing.T) {
func TestShopeePage_分类搜索回收站和状态保留(t *testing.T) {
list, err := os.ReadFile("templates/shopee/list.html")
if err != nil {
t.Fatal(err)
@@ -393,12 +396,11 @@ func TestShopeePage_店铺图片三态筛选和状态保留(t *testing.T) {
}
content := string(list) + string(detail)
for _, want := range []string{
`<label for="shop">店铺</label>`, `name="shop"`, `.ShopOptions`, `.ShopFilter`,
`<label for="image">图片</label>`, `name="image"`, `.ImageOptions`, `.ImageFilter`,
`清除筛选条件`,
`<label for="search-field">分类</label>`, `name="search_field"`,
`<label for="q">关键词</label>`, `name="q"`, `回收站`, `清除筛选条件`,
} {
if !strings.Contains(content, want) {
t.Errorf("蝦皮三态筛选界面缺少 %q", want)
t.Errorf("蝦皮分类搜索界面缺少 %q", want)
}
}
}
+5 -8
View File
@@ -165,7 +165,7 @@ func (p PddProduct) IsDeleted() bool {
// 采集结果和采集状态**不在这里**——它们属于 PDD 商品,见 PddProduct。
type ShopeeProduct struct {
GoodsID string
ShopID string // 关联全局业务店铺;原始渠道店铺名仍单独保留
ShopID string // 关联店铺管理中的稳定店铺;来源名称仍单独保留
Title string
ShopeeStatus string
MainSKUCode string
@@ -220,7 +220,7 @@ type ShopeeSKU struct {
type SybOrder struct {
SybID string
OrderNo string
ShopID string // 由 SYB 渠道别名精确解析,无法识别时为空
ShopID string // 由 SYB 店铺名精确解析,无法识别时为空
ShopName string // 顺运宝货运单级 shopName;同一货运单的商品明细相同
Title string
ProductSpec string // 规格原文,顺运宝 productSpec,原样保留,对应 shopee_skus.spec_raw
@@ -268,23 +268,20 @@ type SybSyncRun struct {
FinishedAt string
}
// Shop 是跨 SYB、Shopee 等渠道共享的业务店铺身份。
// 渠道返回的原始名称保存在各业务表和 ShopChannelAlias 中,不用显示名替代。
// Shop 是 SYB 与蝦皮共用的店铺配置。
// DisplayName 是唯一面向用户的店铺名称;各业务表仍保留来源系统返回的原始名称。
type Shop struct {
ShopID string
DisplayName string
NormalizedName string
Enabled bool
SybAlias string
SybSyncEnabled bool
ShopeeAlias string
ShopeeProductCount int
CreatedByUserID string
CreatedAt string
UpdatedAt string
}
// ShopChannelAlias 把一个渠道中的精确名称映射到稳定业务店铺。
// ShopChannelAlias 是 v18 渠道名称结构的兼容记录;v19 起完全由 Shop 派生。
type ShopChannelAlias struct {
AliasID string
ShopID string
+2 -2
View File
@@ -171,7 +171,7 @@ type CatalogProductOutcome struct {
func UpsertCatalogShopeeProduct(q Execer, in CatalogShopeeProductInput) (out CatalogProductOutcome, err error) {
var oldObserved, oldShopID, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved sql.NullString
var imageManual, shopManual int
shopID, err := FindShopIDByAlias(q, "shopee", in.ShopName)
shopID, err := FindShopIDByName(q, in.ShopName)
if err != nil {
return out, err
}
@@ -232,7 +232,7 @@ func UpsertCatalogShopeeProduct(q Execer, in CatalogShopeeProductInput) (out Cat
resolvedShopID := oldShopID.String
shopAssociationChanged := false
if shopChanged {
resolvedShopID, err = FindShopIDByAlias(q, "shopee", newShop)
resolvedShopID, err = FindShopIDByName(q, newShop)
if err != nil {
return out, err
}
+78 -2
View File
@@ -20,7 +20,7 @@ import (
"cmautobuy/admin/spec"
)
const mysqlSchemaVersion = 18
const mysqlSchemaVersion = 19
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -640,6 +640,18 @@ func MigrateMySQL(db *sql.DB) error {
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 18, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v18 失败: %w", err)
}
current = 18
}
if current < 19 {
if err := migrateMySQLV19(db); err != nil {
return fmt.Errorf("执行 MySQL schema v19 失败: %w", err)
}
if err := checkMySQLV19Shape(db); err != nil {
return fmt.Errorf("MySQL schema v19 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 19, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v19 失败: %w", err)
}
}
return CheckMySQLSchema(db)
}
@@ -795,6 +807,51 @@ func migrateMySQLV18(db *sql.DB) error {
return nil
}
// migrateMySQLV19 把 v18 的渠道别名收敛成唯一店铺名称。
// shop_channel_aliases 暂时作为兼容表保留,其内容完全由 shops 派生,不再允许独立配置。
func migrateMySQLV19(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// v18 同时存在全局启停和 SYB 启停。收敛时任一侧已停用都保持停用,
// 避免升级后意外把原本禁止同步的店铺重新放入同步范围。
if _, err := tx.Exec(`UPDATE shops s JOIN (
SELECT shop_id,MIN(enabled) AS enabled FROM shop_channel_aliases
WHERE channel='syb' GROUP BY shop_id
) a ON a.shop_id=s.shop_id
SET s.enabled=IF(s.enabled=1 AND a.enabled=1,1,0)`); err != nil {
return fmt.Errorf("收敛店铺启停状态失败: %w", err)
}
if _, err := tx.Exec(`DELETE FROM shop_channel_aliases`); err != nil {
return fmt.Errorf("清理旧渠道店铺名称失败: %w", err)
}
for _, channel := range []string{"syb", "shopee"} {
if _, err := tx.Exec(`INSERT INTO shop_channel_aliases
(alias_id,shop_id,channel,alias_name,normalized_alias,enabled,created_at,updated_at)
SELECT CONCAT('v19-',?,'-',SHA2(shop_id,256)),shop_id,?,display_name,normalized_name,enabled,created_at,updated_at
FROM shops`, channel, channel); err != nil {
return fmt.Errorf("生成 %s 店铺兼容数据失败: %w", channel, err)
}
}
if _, err := tx.Exec(`UPDATE syb_orders SET shop_id=NULL`); err != nil {
return fmt.Errorf("清理顺运宝旧店铺关联失败: %w", err)
}
if _, err := tx.Exec(`UPDATE syb_orders so JOIN shops s
ON s.normalized_name=TRIM(so.shop_name) COLLATE utf8mb4_bin SET so.shop_id=s.shop_id`); err != nil {
return fmt.Errorf("按唯一店铺名称重建顺运宝关联失败: %w", err)
}
if _, err := tx.Exec(`UPDATE shopee_products SET shop_id=NULL`); err != nil {
return fmt.Errorf("清理蝦皮旧店铺关联失败: %w", err)
}
if _, err := tx.Exec(`UPDATE shopee_products sp JOIN shops s
ON s.normalized_name=TRIM(sp.shopee_shop_name) COLLATE utf8mb4_bin SET sp.shop_id=s.shop_id`); err != nil {
return fmt.Errorf("按唯一店铺名称重建蝦皮关联失败: %w", err)
}
return tx.Commit()
}
// migrateMySQLV14 增加顺运宝同步店铺准入表和审计统计。
func migrateMySQLV14(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS syb_allowed_shops (
@@ -1790,7 +1847,10 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV17Shape(db); err != nil {
return err
}
return checkMySQLV18Shape(db)
if err := checkMySQLV18Shape(db); err != nil {
return err
}
return checkMySQLV19Shape(db)
}
func checkMySQLV15Shape(db *sql.DB) error {
@@ -1869,6 +1929,22 @@ func checkMySQLV18Shape(db *sql.DB) error {
return nil
}
func checkMySQLV19Shape(db *sql.DB) error {
var invalid int
err := db.QueryRow(`SELECT COUNT(*) FROM shops s WHERE
(SELECT COUNT(*) FROM shop_channel_aliases a WHERE a.shop_id=s.shop_id AND a.channel='syb')<>1 OR
(SELECT COUNT(*) FROM shop_channel_aliases a WHERE a.shop_id=s.shop_id AND a.channel='shopee')<>1 OR
EXISTS (SELECT 1 FROM shop_channel_aliases a WHERE a.shop_id=s.shop_id AND
(a.alias_name<>s.display_name OR a.normalized_alias<>s.normalized_name OR a.enabled<>s.enabled))`).Scan(&invalid)
if err != nil {
return fmt.Errorf("检查单一店铺名称兼容数据失败: %w", err)
}
if invalid != 0 {
return fmt.Errorf("有 %d 个店铺的渠道兼容数据与唯一店铺名称不一致", invalid)
}
return nil
}
func checkMySQLV14Shape(db *sql.DB) error {
if err := checkMySQLSchema(db, []string{"syb_allowed_shops"}); err != nil {
return err
+13 -5
View File
@@ -875,7 +875,7 @@ func TestMySQLMigrate_V14升级V17回填元数据规格并增加软删除(t *tes
}
}
func TestMySQLMigrate_V17升级V18迁移全局店铺并精确回填(t *testing.T) {
func TestMySQLMigrate_V17升级V19迁移单一店铺名称并精确回填(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
@@ -891,26 +891,34 @@ func TestMySQLMigrate_V17升级V18迁移全局店铺并精确回填(t *testing.T
mustExec(t, db, `ALTER TABLE syb_orders DROP COLUMN shop_id`)
mustExec(t, db, `DROP TABLE shop_channel_aliases`)
mustExec(t, db, `DROP TABLE shops`)
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=18`)
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=18`)
now := "2026-08-13T02:00:00Z"
mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at)
VALUES('V18-USER','v18-user','x','admin','active',?,?,?)`, now, now, now)
mustExec(t, db, `INSERT INTO syb_allowed_shops(shop_id,shop_name,normalized_name,enabled,created_by_user_id,created_at,updated_at)
VALUES('V18-SHOP','精确店铺','精确店铺',1,'V18-USER',?,?)`, now, now)
VALUES('V18-SHOP','精确店铺','精确店铺',1,'V18-USER',?,?),
('V18-OFF','停用店铺','停用店铺',0,'V18-USER',?,?)`, now, now, now, now)
mustExec(t, db, `INSERT INTO syb_orders(syb_id,order_no,shop_name,title,quantity,syb_data,created_at,updated_at)
VALUES('V18-ORDER','O-V18','精确店铺','商品',1,'{}',?,?)`, now, now)
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,shopee_shop_name,source,created_at,updated_at)
VALUES('V18-PRODUCT','商品','精确店铺','api',?,?),('V18-UNLINKED','商品','其他店铺','api',?,?)`, now, now, now, now)
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v17 升级 v18 失败: %v", err)
t.Fatalf("v17 升级 v19 失败: %v", err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v18 重放失败: %v", err)
t.Fatalf("v19 重放失败: %v", err)
}
if err := checkMySQLV18Shape(db); err != nil {
t.Fatalf("v18 结构错误: %v", err)
}
if err := checkMySQLV19Shape(db); err != nil {
t.Fatalf("v19 单一店铺名称错误: %v", err)
}
var enabled int
if err := db.QueryRow(`SELECT enabled FROM shops WHERE shop_id='V18-OFF'`).Scan(&enabled); err != nil || enabled != 0 {
t.Fatalf("旧 SYB 停用状态不得在收敛时重新启用: enabled=%d err=%v", enabled, err)
}
for _, query := range []string{
`SELECT shop_id FROM syb_orders WHERE syb_id='V18-ORDER'`,
`SELECT shop_id FROM shopee_products WHERE goods_id='V18-PRODUCT'`,
+18 -63
View File
@@ -16,15 +16,11 @@ var (
ErrShopHasReferences = errors.New("店铺仍有关联数据")
)
// ListShops 返回管理页的一店一行汇总;首版每个渠道维护一个主别名。
// ListShops 返回管理页的一店一行汇总。
func ListShops(q Execer) ([]model.Shop, error) {
rows, err := q.Query(`SELECT s.shop_id,s.display_name,s.normalized_name,s.enabled,
COALESCE(MAX(CASE WHEN a.channel='syb' THEN a.alias_name END),''),
COALESCE(MAX(CASE WHEN a.channel='syb' THEN a.enabled END),0),
COALESCE(MAX(CASE WHEN a.channel='shopee' THEN a.alias_name END),''),
COUNT(DISTINCT sp.goods_id),s.created_by_user_id,s.created_at,s.updated_at
FROM shops s
LEFT JOIN shop_channel_aliases a ON a.shop_id=s.shop_id
LEFT JOIN shopee_products sp ON sp.shop_id=s.shop_id AND sp.deleted_at IS NULL
GROUP BY s.shop_id ORDER BY s.enabled DESC,s.normalized_name`)
if err != nil {
@@ -32,34 +28,11 @@ func ListShops(q Execer) ([]model.Shop, error) {
}
defer rows.Close()
var result []model.Shop
for rows.Next() {
var item model.Shop
var enabled, sybEnabled int
if err := rows.Scan(&item.ShopID, &item.DisplayName, &item.NormalizedName, &enabled,
&item.SybAlias, &sybEnabled, &item.ShopeeAlias, &item.ShopeeProductCount,
&item.CreatedByUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
item.Enabled = enabled == 1
item.SybSyncEnabled = sybEnabled == 1
result = append(result, item)
}
return result, rows.Err()
}
// ListShopOptions 返回筛选器使用的全部业务店铺,停用项仍保留以便查历史数据。
func ListShopOptions(q Execer) ([]model.Shop, error) {
rows, err := q.Query(`SELECT shop_id,display_name,normalized_name,enabled,created_by_user_id,created_at,updated_at
FROM shops ORDER BY enabled DESC,normalized_name`)
if err != nil {
return nil, fmt.Errorf("查询店铺选项失败: %w", err)
}
defer rows.Close()
var result []model.Shop
for rows.Next() {
var item model.Shop
var enabled int
if err := rows.Scan(&item.ShopID, &item.DisplayName, &item.NormalizedName, &enabled,
&item.ShopeeProductCount,
&item.CreatedByUserID, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
@@ -85,24 +58,9 @@ func GetShop(q Execer, shopID string) (model.Shop, bool, error) {
return item, true, nil
}
func GetShopAliasEnabled(q Execer, shopID, channel string) (bool, bool, error) {
var enabled int
err := q.QueryRow(`SELECT enabled FROM shop_channel_aliases WHERE shop_id=? AND channel=? LIMIT 1`,
shopID, channel).Scan(&enabled)
if errors.Is(err, sql.ErrNoRows) {
return false, false, nil
}
if err != nil {
return false, false, fmt.Errorf("查询渠道店铺状态失败: %w", err)
}
return enabled == 1, true, nil
}
// ListEnabledSybShopMappings 返回“SYB 原始精确名称 → 业务店铺 ID”。
// ListEnabledSybShopMappings 返回“店铺精确名称 → 店铺 ID”。
func ListEnabledSybShopMappings(q Execer) (map[string]string, error) {
rows, err := q.Query(`SELECT a.normalized_alias,a.shop_id FROM shop_channel_aliases a
JOIN shops s ON s.shop_id=a.shop_id WHERE a.channel='syb' AND a.enabled=1 AND s.enabled=1
ORDER BY a.normalized_alias`)
rows, err := q.Query(`SELECT normalized_name,shop_id FROM shops WHERE enabled=1 ORDER BY normalized_name`)
if err != nil {
return nil, fmt.Errorf("查询启用的 SYB 店铺配置失败: %w", err)
}
@@ -118,19 +76,18 @@ func ListEnabledSybShopMappings(q Execer) (map[string]string, error) {
return result, rows.Err()
}
func FindShopIDByAlias(q Execer, channel, rawName string) (string, error) {
func FindShopIDByName(q Execer, rawName string) (string, error) {
name := strings.TrimSpace(rawName)
if name == "" {
return "", nil
}
var shopID string
err := q.QueryRow(`SELECT shop_id FROM shop_channel_aliases
WHERE channel=? AND normalized_alias=?`, channel, name).Scan(&shopID)
err := q.QueryRow(`SELECT shop_id FROM shops WHERE normalized_name=?`, name).Scan(&shopID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("按 %s 店铺名称查业务店铺失败: %w", channel, err)
return "", fmt.Errorf("按店铺名称查询店铺失败: %w", err)
}
return shopID, nil
}
@@ -186,14 +143,12 @@ func SetShopEnabled(q Execer, shopID string, enabled bool, updatedAt string) (bo
return n == 1, err
}
func SetShopSybEnabled(q Execer, shopID string, enabled bool, updatedAt string) (bool, error) {
result, err := q.Exec(`UPDATE shop_channel_aliases SET enabled=?,updated_at=?
WHERE shop_id=? AND channel='syb'`, enabled, updatedAt, shopID)
func SetShopCompatibilityAliasesEnabled(q Execer, shopID string, enabled bool, updatedAt string) error {
_, err := q.Exec(`UPDATE shop_channel_aliases SET enabled=?,updated_at=? WHERE shop_id=?`, enabled, updatedAt, shopID)
if err != nil {
return false, fmt.Errorf("更新 SYB 同步状态失败: %w", err)
return fmt.Errorf("同步店铺兼容数据状态失败: %w", err)
}
n, err := result.RowsAffected()
return n > 0, err
return nil
}
func ReplaceShopAlias(q Execer, aliasID, shopID, channel, aliasName, updatedAt string, enabled bool) error {
@@ -208,7 +163,7 @@ func ReplaceShopAlias(q Execer, aliasID, shopID, channel, aliasName, updatedAt s
CreatedAt: updatedAt, UpdatedAt: updatedAt})
}
// RebuildShopAssociations 按渠道原始名称精确重建一个店铺的派生关联。
// RebuildShopAssociations 按唯一店铺名称精确重建一个店铺的派生关联。
func RebuildShopAssociations(q Execer, shopID string) error {
if _, err := q.Exec(`UPDATE syb_orders SET shop_id=NULL WHERE shop_id=?`, shopID); err != nil {
return err
@@ -216,14 +171,14 @@ func RebuildShopAssociations(q Execer, shopID string) error {
if _, err := q.Exec(`UPDATE shopee_products SET shop_id=NULL WHERE shop_id=?`, shopID); err != nil {
return err
}
if _, err := q.Exec(`UPDATE syb_orders so JOIN shop_channel_aliases a
ON a.shop_id=? AND a.channel='syb' AND a.normalized_alias=TRIM(so.shop_name) COLLATE utf8mb4_bin
SET so.shop_id=a.shop_id`, shopID); err != nil {
if _, err := q.Exec(`UPDATE syb_orders so JOIN shops s
ON s.shop_id=? AND s.normalized_name=TRIM(so.shop_name) COLLATE utf8mb4_bin
SET so.shop_id=s.shop_id`, shopID); err != nil {
return err
}
if _, err := q.Exec(`UPDATE shopee_products sp JOIN shop_channel_aliases a
ON a.shop_id=? AND a.channel='shopee' AND a.normalized_alias=TRIM(sp.shopee_shop_name) COLLATE utf8mb4_bin
SET sp.shop_id=a.shop_id`, shopID); err != nil {
if _, err := q.Exec(`UPDATE shopee_products sp JOIN shops s
ON s.shop_id=? AND s.normalized_name=TRIM(sp.shopee_shop_name) COLLATE utf8mb4_bin
SET sp.shop_id=s.shop_id`, shopID); err != nil {
return err
}
return nil
+28 -46
View File
@@ -111,16 +111,15 @@ func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice stri
// NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。
type ShopeeProductRow struct {
model.ShopeeProduct
BusinessShopName string
ColorCount int
SizeCount int
SKUCount int
PendingCount int
CollectStatus sql.NullString
CollectMsg sql.NullString
ColorCount int
SizeCount int
SKUCount int
PendingCount int
CollectStatus sql.NullString
CollectMsg sql.NullString
}
// ShopeeFilter 是蝦皮商品列表页支持的筛选条件,四项都可以为空。
// ShopeeFilter 是蝦皮商品列表页支持的筛选和内部范围条件。
//
// Status 取值见 §「状态筛选的四个取值」(工单 #43):
//
@@ -132,16 +131,14 @@ type ShopeeProductRow struct {
// 认不出的取值一律当作 ""(不筛选),由 service 层的 ParseShopeeStatus 兜底,
// 这里不做校验——repository 只管拼 SQL。
type ShopeeFilter struct {
Keyword string
ShopName string
Status string
Shop string // has / missing / 空(全部)
Image string // has / missing / 空(全部)
StoreID string // 业务店铺 ID / unlinked / 空(全部)
Deleted bool // true 只看软删除数据;false 只看正常数据
Keyword string
SearchField string // goods_id / title / shop_name
Status string
Deleted bool // true 只看软删除数据;false 只看正常数据
Unlinked bool // true 只看尚未登记到店铺管理的正常商品
}
// shopeeFilterClause 把关键字、状态、店铺和图片筛选拼成 WHERE 子句,供 ListShopeeProducts
// shopeeFilterClause 把分类关键词、状态和内部范围拼成 WHERE 子句,供 ListShopeeProducts
// 和 CountShopeeProductsFiltered 共用——两处筛选逻辑必须完全一致,
// 否则底部统计会跟表格对不上(#19 踩过一次,见工单 #43)。
//
@@ -156,13 +153,17 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
var args []any
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
like := "%" + escapeLike(kw) + "%"
clauses = append(clauses, `(sp.goods_id LIKE ? ESCAPE '!' OR sp.title LIKE ? ESCAPE '!')`)
args = append(args, like, like)
}
if shopName := strings.TrimSpace(filter.ShopName); shopName != "" {
clauses = append(clauses, `sp.shopee_shop_name LIKE ? ESCAPE '!'`)
args = append(args, "%"+escapeLike(shopName)+"%")
switch filter.SearchField {
case "title":
clauses = append(clauses, `sp.title LIKE ? ESCAPE '!'`)
args = append(args, "%"+escapeLike(kw)+"%")
case "shop_name":
clauses = append(clauses, `sp.shopee_shop_name LIKE ? ESCAPE '!'`)
args = append(args, "%"+escapeLike(kw)+"%")
default:
clauses = append(clauses, `sp.goods_id = ?`)
args = append(args, kw)
}
}
switch filter.Status {
@@ -175,26 +176,8 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
clauses = append(clauses, `(sp.pdd_goods_url IS NOT NULL AND sp.pdd_goods_url <> '')`)
}
switch filter.Shop {
case "has":
clauses = append(clauses, `(sp.shopee_shop_name IS NOT NULL AND TRIM(sp.shopee_shop_name) <> '')`)
case "missing":
clauses = append(clauses, `(sp.shopee_shop_name IS NULL OR TRIM(sp.shopee_shop_name) = '')`)
}
switch filter.Image {
case "has":
clauses = append(clauses, `(sp.image_url IS NOT NULL AND TRIM(sp.image_url) <> '')`)
case "missing":
clauses = append(clauses, `(sp.image_url IS NULL OR TRIM(sp.image_url) = '')`)
}
switch strings.TrimSpace(filter.StoreID) {
case "":
case "unlinked":
if filter.Unlinked {
clauses = append(clauses, `sp.shop_id IS NULL`)
default:
clauses = append(clauses, `sp.shop_id=?`)
args = append(args, strings.TrimSpace(filter.StoreID))
}
return " WHERE " + strings.Join(clauses, " AND "), args
@@ -202,7 +185,7 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
//
// keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**——
// keyword 只匹配 SearchField 指定的商品 ID、商品名称或店铺名,**不匹配颜色/尺码**——
// 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。
//
// `[必须]` 分页用 LIMIT/OFFSET 在数据库里做,不把全量查出来在 Go 里切片
@@ -210,7 +193,7 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) {
where, args := shopeeFilterClause(filter)
sqlText := `
SELECT sp.goods_id,sp.shop_id,COALESCE(MAX(bs.display_name),''), sp.title, sp.shopee_status, sp.main_sku_code, sp.image_url,sp.shopee_shop_name,sp.image_source,sp.image_observed_at,sp.image_is_manual,sp.shop_name_source,sp.shop_name_observed_at,sp.shop_name_is_manual,sp.source,
SELECT sp.goods_id,sp.shop_id,sp.title,sp.shopee_status,sp.main_sku_code,sp.image_url,sp.shopee_shop_name,sp.image_source,sp.image_observed_at,sp.image_is_manual,sp.shop_name_source,sp.shop_name_observed_at,sp.shop_name_is_manual,sp.source,
sp.pdd_goods_url, sp.pdd_goods_id,sp.deleted_at,sp.deleted_by_user_id, sp.created_at, sp.updated_at,
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.color END) AS color_count,
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.size END) AS size_count,
@@ -218,7 +201,6 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
COUNT(CASE WHEN sk.parse_ok = 0 THEN 1 END) AS pending_count,
pp.collect_status, pp.collect_msg
FROM shopee_products sp
LEFT JOIN shops bs ON bs.shop_id=sp.shop_id
LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
LEFT JOIN pdd_products pp
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` +
@@ -237,7 +219,7 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
var shopID, title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
var imageManual, shopManual int
if err := rows.Scan(
&r.GoodsID, &shopID, &r.BusinessShopName, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
&r.GoodsID, &shopID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
&pddGoodsURL, &pddGoodsID, &deletedAt, &deletedBy, &r.CreatedAt, &r.UpdatedAt,
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
&r.CollectStatus, &r.CollectMsg,
+3 -3
View File
@@ -244,9 +244,9 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
return false, fmt.Errorf("syb_id 不能为空")
}
if strings.TrimSpace(o.ShopID) == "" && strings.TrimSpace(o.ShopName) != "" {
o.ShopID, err = FindShopIDByAlias(q, "syb", o.ShopName)
o.ShopID, err = FindShopIDByName(q, o.ShopName)
if err != nil {
return false, fmt.Errorf("解析顺运宝明细 %s 的业务店铺失败: %w", o.SybID, err)
return false, fmt.Errorf("解析顺运宝明细 %s 的店铺失败: %w", o.SybID, err)
}
}
var specKey any
@@ -411,7 +411,7 @@ func backfillSybProductMetadata(db *sql.DB) error {
return fmt.Errorf("读取历史顺运宝图片失败: %w", err)
}
for goodsID, item := range items {
shopID, err := FindShopIDByAlias(db, "syb", item.shopName)
shopID, err := FindShopIDByName(db, item.shopName)
if err != nil {
return fmt.Errorf("解析历史顺运宝店铺失败: %w", err)
}
+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)
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
都不进入 sessionStorage。链接原始 href 保持模块根地址,因此脚本失效时
仍可正常导航。 */
var MODULE_QUERY_KEYS = {
"/shopee": ["goods_id", "shop_name", "status", "shop", "image", "store", "deleted", "page"],
"/shopee": ["q", "search_field", "status", "deleted", "unlinked", "page"],
"/pdd": ["q", "status", "page"],
"/syb": ["order_no", "shop", "stage", "page", "date_from", "date_to"],
"/shops": [],
+7 -20
View File
@@ -4,31 +4,24 @@
<div class="toolbar">
<form class="inline grow" method="post" action="/shops/create">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="display-name">业务店铺</label>
<input id="display-name" type="text" name="display_name" maxlength="500" required placeholder="页面统一显示名称">
<label for="syb-alias">SYB 名称</label>
<input id="syb-alias" type="text" name="syb_alias" maxlength="500" placeholder="与 SYB shopName 完全一致">
<label for="shopee-alias">蝦皮名称</label>
<input id="shopee-alias" type="text" name="shopee_alias" maxlength="500" placeholder="与导入店铺名完全一致">
<label for="shop-name">店铺名称</label>
<input id="shop-name" type="text" name="shop_name" maxlength="500" required placeholder="与 SYB 和蝦皮店铺名称完全一致">
<button type="submit" class="primary">新增并启用</button>
</form>
</div>
{{if .Message}}<p class="hint" role="status">{{.Message}}</p>{{end}}
{{if .Error}}<p class="missing" role="alert">{{.Error}}</p>{{end}}
<p class="hint">业务店铺是统一名称;SYB 和蝦皮名称是各渠道原始名称。系统只做忽略首尾空白后的精确匹配,不会模糊猜测。当前有 {{.UnlinkedShopeeCount}} 个蝦皮商品待关联。</p>
<p class="hint">同一个名称用于 SYB 同步过滤和蝦皮商品关联。系统只忽略首尾空白并做精确匹配,不会模糊猜测。<a href="/shopee?unlinked=1">未登记商品 {{.UnlinkedShopeeCount}} 个</a>。</p>
<div class="table-wrap">
<table>
<thead><tr><th>业务店铺</th><th>全局状态</th><th>SYB 名称</th><th>SYB 同步</th><th>蝦皮名称</th><th>蝦皮商品</th><th>更新时间</th><th>操作</th></tr></thead>
<thead><tr><th>店铺名称</th><th>状态</th><th>蝦皮商品</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody>
{{range .Rows}}
<tr>
<td><input name="display_name" value="{{.DisplayName}}" maxlength="500" required form="shop-edit-{{.ShopID}}"></td>
<td><input name="shop_name" value="{{.DisplayName}}" maxlength="500" required form="shop-edit-{{.ShopID}}"></td>
<td>{{if .Enabled}}启用{{else}}停用{{end}}</td>
<td><input name="syb_alias" value="{{.SybAlias}}" maxlength="500" placeholder="未配置" form="shop-edit-{{.ShopID}}"></td>
<td>{{if .SybAlias}}{{if and .Enabled .SybSyncEnabled}}同步{{else}}不进入同步{{end}}{{else}}—{{end}}</td>
<td><input name="shopee_alias" value="{{.ShopeeAlias}}" maxlength="500" placeholder="未配置" form="shop-edit-{{.ShopID}}"></td>
<td>{{.ShopeeProductCount}}</td>
<td>{{.UpdatedAt}}</td>
<td>
@@ -40,12 +33,6 @@
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="shop_id" value="{{.ShopID}}"><input type="hidden" name="enabled" value="{{if .Enabled}}0{{else}}1{{end}}">
<button type="submit">{{if .Enabled}}停用店铺{{else}}启用店铺{{end}}</button>
</form>
{{if .SybAlias}}
<form class="inline" method="post" action="/shops/syb-status">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="shop_id" value="{{.ShopID}}"><input type="hidden" name="enabled" value="{{if .SybSyncEnabled}}0{{else}}1{{end}}">
<button type="submit">{{if .SybSyncEnabled}}停用 SYB{{else}}启用 SYB{{end}}</button>
</form>
{{end}}
{{if not .Enabled}}
<form class="inline" method="post" action="/shops/delete" data-confirm-submit="确定删除停用店铺“{{.DisplayName}}”吗?有关联商品或货运单时系统会拒绝删除。">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="shop_id" value="{{.ShopID}}"><button type="submit" class="danger">删除</button>
@@ -54,11 +41,11 @@
</td>
</tr>
{{else}}
<tr class="empty"><td colspan="8">还没有业务店铺。至少填写一个渠道名称后即可新增。</td></tr>
<tr class="empty"><td colspan="5">还没有店铺。填写与 SYB、蝦皮数据一致的店铺名称即可新增。</td></tr>
{{end}}
</tbody>
</table>
</div>
<div class="statusbar"><span class="grow"></span><span>共 {{len .Rows}} 个业务店铺</span></div>
<div class="statusbar"><span class="grow"></span><span>共 {{len .Rows}} 个店铺</span></div>
{{template "footer" .}}
{{end}}
+8 -8
View File
@@ -21,11 +21,11 @@
<form method="post" action="/shopee/save" class="form-stack">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="shopee_goods_id" value="{{.GoodsID}}">
<input type="hidden" name="list_goods_id" value="{{$.Keyword}}">
<input type="hidden" name="list_shop_name" value="{{$.ShopNameKeyword}}">
<input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="search_field" value="{{$.SearchField}}">
<input type="hidden" name="status" value="{{$.StatusFilter}}">
<input type="hidden" name="shop" value="{{$.ShopFilter}}">
<input type="hidden" name="image" value="{{$.ImageFilter}}">
<input type="hidden" name="deleted" value="{{if $.DeletedFilter}}1{{end}}">
<input type="hidden" name="unlinked" value="{{if $.UnlinkedFilter}}1{{end}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}">
<div class="field">
<label for="shopee-pdd-url">PDD 商品链接</label>
@@ -45,11 +45,11 @@
<form method="post" action="/shopee/collect" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="shopee_goods_id" value="{{.GoodsID}}">
<input type="hidden" name="list_goods_id" value="{{$.Keyword}}">
<input type="hidden" name="list_shop_name" value="{{$.ShopNameKeyword}}">
<input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="search_field" value="{{$.SearchField}}">
<input type="hidden" name="status" value="{{$.StatusFilter}}">
<input type="hidden" name="shop" value="{{$.ShopFilter}}">
<input type="hidden" name="image" value="{{$.ImageFilter}}">
<input type="hidden" name="deleted" value="{{if $.DeletedFilter}}1{{end}}">
<input type="hidden" name="unlinked" value="{{if $.UnlinkedFilter}}1{{end}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}">
{{if .CanCollect}}
<button type="submit">{{if eq .CollectStatus "failed"}}重新创建采集任务{{else}}创建采集任务{{end}}</button>
+19 -39
View File
@@ -4,21 +4,24 @@
{{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
<div class="toolbar">
{{if .CurrentUser.IsAdmin}}<a class="button" href="/integrations/catalog">导入记录</a>{{end}}
{{if .CurrentUser.IsAdmin}}
{{if .DeletedFilter}}<a class="button-link" href="/shopee">返回正常商品</a>{{else}}<a class="button-link" href="/shopee?deleted=1">回收站</a>{{end}}
{{end}}
<form id="shopee-bulk-form" method="post" action="/shopee/collect-batch" hidden></form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="shopee-bulk-form">
<input type="hidden" name="list_goods_id" value="{{.Keyword}}" form="shopee-bulk-form">
<input type="hidden" name="list_shop_name" value="{{.ShopNameKeyword}}" form="shopee-bulk-form">
<input type="hidden" name="q" value="{{.Keyword}}" form="shopee-bulk-form">
<input type="hidden" name="search_field" value="{{.SearchField}}" form="shopee-bulk-form">
<input type="hidden" name="status" value="{{.StatusFilter}}" form="shopee-bulk-form">
<input type="hidden" name="shop" value="{{.ShopFilter}}" form="shopee-bulk-form">
<input type="hidden" name="image" value="{{.ImageFilter}}" form="shopee-bulk-form">
<input type="hidden" name="store" value="{{.StoreFilter}}" form="shopee-bulk-form">
<input type="hidden" name="deleted" value="{{if .DeletedFilter}}1{{end}}" form="shopee-bulk-form">
<input type="hidden" name="unlinked" value="{{if .UnlinkedFilter}}1{{end}}" form="shopee-bulk-form">
<input type="hidden" name="page" value="{{.CurrentPage}}" form="shopee-bulk-form">
{{if not .DeletedFilter}}
<button type="button" data-modal-open="shopee-collect-modal" data-collect-open
data-collect-form="shopee-bulk-form" data-need-checked>创建 PDD 采集任务</button>
{{end}}
<form class="inline grow" method="get" action="/shopee">
{{if .DeletedFilter}}<input type="hidden" name="deleted" value="1">{{end}}
{{if .UnlinkedFilter}}<input type="hidden" name="unlinked" value="1">{{end}}
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花,
见 docs/admin/05-ui-specification.md §4.1、工单 #43。
放在关键词前面:操作员大多是"看某一类"而不是"查某个 ID"。 */}}
@@ -28,37 +31,14 @@
<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>
{{end}}
</select>
<label for="shop">店铺</label>
<select id="shop" name="shop">
{{range .ShopOptions}}
<option value="{{.Value}}" {{if eq .Value $.ShopFilter}}selected{{end}}>{{.Text}}</option>
<label for="search-field">分类</label>
<select id="search-field" name="search_field">
{{range .SearchFieldOptions}}
<option value="{{.Value}}" {{if eq .Value $.SearchField}}selected{{end}}>{{.Text}}</option>
{{end}}
</select>
<label for="image">图片</label>
<select id="image" name="image">
{{range .ImageOptions}}
<option value="{{.Value}}" {{if eq .Value $.ImageFilter}}selected{{end}}>{{.Text}}</option>
{{end}}
</select>
<label for="store">业务店铺</label>
<select id="store" name="store">
<option value="" {{if eq .StoreFilter ""}}selected{{end}}>全部业务店铺</option>
<option value="unlinked" {{if eq .StoreFilter "unlinked"}}selected{{end}}>待关联</option>
{{range .StoreOptions}}
<option value="{{.ShopID}}" {{if eq .ShopID $.StoreFilter}}selected{{end}}>{{.DisplayName}}{{if not .Enabled}}(已停用){{end}}</option>
{{end}}
</select>
{{if .CurrentUser.IsAdmin}}
<label for="deleted">数据范围</label>
<select id="deleted" name="deleted">
<option value="" {{if not .DeletedFilter}}selected{{end}}>正常商品</option>
<option value="1" {{if .DeletedFilter}}selected{{end}}>已删除</option>
</select>
{{end}}
<label for="q">商品 ID / 名称</label>
<input id="q" class="shopee-search-field" type="text" name="goods_id" value="{{.Keyword}}" placeholder="商品 ID / 名称">
<label for="shop-name">店铺名称</label>
<input id="shop-name" class="shopee-search-field" type="text" name="shop_name" value="{{.ShopNameKeyword}}" placeholder="店铺名称">
<label for="q">关键词</label>
<input id="q" class="shopee-search-field" type="text" name="q" value="{{.Keyword}}" placeholder="请输入搜索关键词">
<button type="submit">搜索</button>
</form>
@@ -72,6 +52,8 @@
{{end}}
</div>
{{if .UnlinkedFilter}}<p class="hint" role="status">当前只显示尚未登记到店铺管理的蝦皮商品。<a href="/shopee">查看全部正常商品</a></p>{{end}}
{{/* ── 第二段:带勾选的表格 ──────────────────────────── */}}
{{/* 一个商品一行(不是一个 SKU 一行),见工单 #41。
双击行打开详情弹窗,见 static/js/app.js。 */}}
@@ -83,8 +65,7 @@
<th>商品 ID</th>
<th>图片</th>
<th>商品名称</th>
<th>蝦皮店铺</th>
<th>业务店铺</th>
<th>店铺</th>
<th>颜色</th>
<th>尺码</th>
{{/* SKU 数是这个商品报表里实际出现过的规格条数。
@@ -112,7 +93,6 @@
title 属性让悬停能看完整标题,收窄之后必须留着。 */}}
<td class="truncate col-title" title="{{.Title}}">{{.Title}}</td>
<td class="truncate col-shop" title="{{.ShopName}}">{{if .ShopName}}{{.ShopName}}{{else}}—{{end}}</td>
<td class="truncate col-shop" title="{{.BusinessShopName}}">{{if .ShopUnlinked}}<span class="missing">待关联</span>{{else}}{{.BusinessShopName}}{{end}}</td>
<td>{{.ColorCount}}</td>
<td>{{.SizeCount}}</td>
<td>{{.SKUCount}}</td>
@@ -124,9 +104,9 @@
</tr>
{{else}}
<tr class="empty">
<td colspan="14">
<td colspan="13">
{{if .IsFiltered}}
当前筛选条件下没有商品。请调整状态、店铺、图片或商品 ID。<br>
当前条件下没有商品。请调整状态、分类或关键词。<br>
<small><a href="/shopee">清除筛选条件</a></small>
{{else}}
还没有数据。蝦皮与 PDD 对应数据由第三方脚本通过商品目录接口导入。<br>
+14 -13
View File
@@ -125,6 +125,8 @@ v18(#208)新增 `shops` 业务店铺和 `shop_channel_aliases` 渠道名称
`syb_orders`、`shopee_products` 增加可空 `shop_id`。迁移把 v14 旧准入项一对一
转成业务店铺,同时保留 `syb_allowed_shops` 作为回退依据;历史数据只按渠道原始
名称精确回填,无法确认的记录保持未关联。
v19(#212)将产品概念收敛为唯一店铺名称和唯一启停状态。迁移按“任一旧开关停用
则保持停用”的安全规则合并 v18 状态,重建由 `shops` 派生的兼容数据和两侧关联。
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
@@ -547,24 +549,23 @@ v14 起,`stock_count` 继续表示通过完整性校验的**原始货运单数
`shop_filter_hash` 保存同步开始时启用店铺排序后计算的 SHA-256,只用于判断两次同步
是否使用同一份快照,不保存 Cookie、密码或原始响应。
### 5.3 全局店铺与渠道名称(MySQL v18)
### 5.3 店铺(MySQL v18 / v19)
`shops` 是 Admin 内部统一展示的业务店铺;`shop_channel_aliases` 保存同一业务店铺
在 `syb`、`shopee` 渠道中的原始名称。首版每个业务店铺每个渠道维护一个主名称,
渠道和去除首尾空白后的原始名称组成唯一约束。匹配使用 `utf8mb4_bin` 精确比较,
不做模糊、正则、大小写折叠或 AI 猜测。
`shops` 是唯一店铺配置。`display_name` 是页面中的“店铺名称”,同时用于 SYB 同步
准入和蝦皮商品关联;`enabled` 是唯一启停状态。匹配会去除首尾空白,再使用
`utf8mb4_bin` 精确比较,不做模糊、正则、大小写折叠或 AI 猜测。
全局 `shops.enabled` 和 SYB 渠道名称的 `enabled` 是两个不同开关:前者停用整个业务
店铺,后者只停止该店铺进入后续 SYB 同步。蝦皮导入和 SYB 写入都保留上游原始店铺
名称,并把精确解析出的 `shop_id` 作为派生关联;未解析成功时 `shop_id` 保持 `NULL`,
由“待关联”筛选暴露给管理员处理。
蝦皮导入和 SYB 写入都保留上游原始店铺名称,并把精确解析出的 `shop_id` 作为派生
关联;未解析成功时 `shop_id` 保持 `NULL`,可从店铺管理的“未登记商品”入口查看。
`shop_channel_aliases` 是 v18 升级兼容表,不再是独立产品概念,也不在页面中编辑;
每个店铺固定保留 `syb`、`shopee` 两条与唯一店铺名称和状态完全相同的派生记录。
`[必须]` 没有任何“业务店铺启用且 SYB 渠道启用”的名称时,同步关闭失败,不请求
顺运宝、不推进游标。编辑渠道名称会在一个事务里重建该业务店铺的历史精确关联。
永久删除只允许全局停用且没有任何货运单或蝦皮商品引用的店铺。
`[必须]` 没有任何启用店铺时,同步关闭失败,不请求顺运宝、不推进游标。编辑店铺
名称会在一个事务里重建该店铺的历史精确关联。永久删除只允许已经停用且没有任何
货运单或蝦皮商品引用的店铺。
v14 的 `syb_allowed_shops` 在 v18 后不再作为运行时配置源,但为迁移回退保留,不能
在 v18 中直接改名或删除。
在 v18 / v19 中直接改名或删除。
## 6. `spec_mappings` 顺运宝规格映射
+17 -20
View File
@@ -136,7 +136,7 @@
### 4.1 工具条
```text
[导入记录] [创建 PDD 采集任务] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 数据范围[正常商品▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除]
[导入记录] [回收站] [创建 PDD 采集任务] 状态[全部▾] 分类[商品 ID▾] 关键词[____] [搜索] [删除]
```
- **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。
@@ -156,18 +156,14 @@
- `[必须]` 筛选参数认不出来的一律当"全部",不报错——地址栏是用户可以
随便改的。
- `[必须]` 筛选与关键词搜索可以叠加,翻页时都要保留,见 §3.2。
- **店铺筛选**(工单 #150):全部 / 有店铺 / 无店铺;按
`shopee_shop_name` 去除首尾空白后的非空/空判断。
- **店铺名称搜索**(工单 #182):独立的 `shop_name` 包含匹配,和店铺三态筛选
同时保留;商品 ID / 名称与店铺名称两个输入框各使用 20% 弹性基础宽度,最小宽度
160px,空间不足时随工具条换行,不影响其他模块的搜索框宽度。
- **图片筛选**(工单 #150):全部 / 有图片 / 无图片;按 `image_url`
去除首尾空白后的非空/空判断。
- `[必须]` 店铺、图片使用独立三态下拉框,不使用含义不清的复选框;两个条件可互相
组合,也可与状态和商品 ID/标题搜索叠加。未知参数按“全部”处理,翻页、详情弹窗
和写操作返回时保留全部条件。工具条在窄窗口沿用公共 `flex-wrap` 自然换行。
- **数据范围**(工单 #205):只向管理员显示“正常商品 / 已删除”。正常列表不返回
软删除商品;已删除列表只用于查看和恢复,不允许创建采集任务或打开处理详情。
- **分类搜索**(工单 #213):分类固定为商品 ID、商品名称、店铺名。商品 ID 使用精确
匹配,商品名称和店铺名使用转义后的包含匹配;后端必须用白名单分支,不能把请求参数
拼成 SQL 列名。未知分类回退到商品 ID。状态、分类和关键词在翻页、详情弹窗和写操作
返回时都要保留。
- **回收站**(工单 #213):不再显示“数据范围”筛选。管理员通过独立回收站按钮查看
软删除商品,并可返回正常商品;已删除列表不允许创建采集任务或打开处理详情。
- **未登记商品**(工单 #212):不在常用筛选中增加额外下拉框。管理员从店铺管理页的
数量链接进入内部 `unlinked=1` 视图,页面显示当前范围提示和清除入口。
- **创建 PDD 采集任务**(工单 #185):勾选蝦皮商品后打开批量确认弹窗,显示选择
数量及带可见标签的“执行客户端”,默认不指定。服务端按当前 PDD 关联和 PDD 商品 ID
去重;未关联、已删除、已采集、正常采集中分别统计并跳过。商品详情的单商品入口保留。
@@ -182,7 +178,9 @@
|---|---|
| ☐ | 勾选 |
| 商品 ID | |
| 图片 | 32×32 缩略图,可打开原图;加载失败显示文字占位 |
| 商品名称 | 截断 + 悬停完整 |
| 店铺 | 导入或同步得到的蝦皮店铺名称;为空显示 `—` |
| 数据来源 | 蝦皮报表 / 顺运宝补建;不只靠颜色区分 |
| 颜色 / 尺码 / 建议 | 解析失败的显示为 `—` 并**整行标黄**,提示需人工补 |
| PDD 链接 | 空的显示"**未填写**"并标红,这是最需要操作员注意的状态 |
@@ -479,9 +477,9 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
日期始终显示在主工具条,按 UTC+8 解释且两端都包含。首次打开页面固定默认昨天
到今天,不因覆盖游标位置自动扩大范围;需要补历史缺口时由采购员明确选择日期。
所有账号都能看到当前启用 SYB 同步店铺数量;只有管理员显示独立“店铺管理”入口。
管理页一行一个业务店铺,同时展示和编辑 SYB、蝦皮原始名称;全局启停与 SYB 同步
启停使用不同按钮和明确文字。启用店铺不显示删除入口,服务端还要拒绝删除有关联
货运单或蝦皮商品的停用店铺。
管理页一行一个店铺,只展示和编辑一个店铺名称,并使用一个启停状态。该名称同时用于
SYB 同步准入和蝦皮商品关联。启用店铺不显示删除入口,服务端还要拒绝删除有关联货运单
或蝦皮商品的停用店铺;“未登记商品 N 个”链接进入蝦皮未关联数据视图。
空状态必须明确提示先新增并启用至少一个店铺,否则同步会安全停止。
`[必须]` 「同步」是唯一同步入口:
@@ -908,12 +906,11 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
蝦皮 SKU,价格是最近货运单台币售价、不是 PDD 人民币采购价;展示规格原文、订单
数、累计数量、最近图片、最后出现时间及唯一正式 SKU 匹配结果,多条匹配交给人工。
蝦皮商品列表在商品 ID 后显示 32×32 懒加载主图,在标题后显示“蝦皮店铺”。主图可
蝦皮商品列表在商品 ID 后显示 32×32 懒加载主图,在标题后显示“店铺”。主图可
打开通用原图弹窗和新窗口,加载失败显示文字占位;无图不打开空弹窗。店铺列最大约
150px并省略,完整值放在 `title` 和详情中。商品标题继续使用 `.col-title` 的 18%,
不得因新增列再次缩窄;1366×768 下通过表格横向滚动保留可读性。详情同时显示图片、
店铺、各自来源和观测时间,顺运宝观测图仍只出现在观测区域。
蝦皮列表同时显示“业务店铺”列,并提供具体业务店铺和“待关联”筛选。蝦皮店铺列
始终显示导入或同步得到的渠道原始值,用于追溯;业务店铺列显示 `shop_id` 对应的统一
名称。两列不能互相替代,未精确关联时必须显示文字“待关联”。
店铺列显示导入或同步得到的蝦皮店铺原始值。`shop_id` 只作为精确匹配后的稳定内部关联,
不再额外显示“业务店铺”列;未精确关联的数据从店铺管理页的“未登记商品”入口查看。