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