diff --git a/admin/handler/web/pagination_test.go b/admin/handler/web/pagination_test.go index c189ac1..8f86bbc 100644 --- a/admin/handler/web/pagination_test.go +++ b/admin/handler/web/pagination_test.go @@ -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) diff --git a/admin/handler/web/shop.go b/admin/handler/web/shop.go index 5238131..533cc3c 100644 --- a/admin/handler/web/shop.go +++ b/admin/handler/web/shop.go @@ -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, "") } diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index 1b082a4..fec7612 100644 --- a/admin/handler/web/shopee.go +++ b/admin/handler/web/shopee.go @@ -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 统一渲染蝦皮列表;数据写入改由商品目录接口负责。 // -// `[必须]` 分页控件用 纯 GET 导航,翻页要保留关键词和三个下拉筛选, -// 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#150)。 -func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, shopRaw, imageRaw, storeRaw, deletedRaw, pageRaw, msg string) { +// `[必须]` 分页控件用 纯 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) } diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 99f4ebe..954a851 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -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. 采集采购 diff --git a/admin/main_test.go b/admin/main_test.go index 4c10879..d7de274 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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{ - ``, `name="shop"`, `.ShopOptions`, `.ShopFilter`, - ``, `name="image"`, `.ImageOptions`, `.ImageFilter`, - `清除筛选条件`, + ``, `name="search_field"`, + ``, `name="q"`, `回收站`, `清除筛选条件`, } { if !strings.Contains(content, want) { - t.Errorf("蝦皮三态筛选界面缺少 %q", want) + t.Errorf("蝦皮分类搜索界面缺少 %q", want) } } } diff --git a/admin/model/model.go b/admin/model/model.go index 2abe503..5f681ee 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -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 diff --git a/admin/repository/catalog_import.go b/admin/repository/catalog_import.go index 0bc6146..4004d1d 100644 --- a/admin/repository/catalog_import.go +++ b/admin/repository/catalog_import.go @@ -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 } diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 4e565a7..99ea0c7 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -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 diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index 2c37ad3..51e4fb9 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -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'`, diff --git a/admin/repository/shop.go b/admin/repository/shop.go index b4820e1..22173b3 100644 --- a/admin/repository/shop.go +++ b/admin/repository/shop.go @@ -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 diff --git a/admin/repository/shopee.go b/admin/repository/shopee.go index f4fa6da..a19578f 100644 --- a/admin/repository/shopee.go +++ b/admin/repository/shopee.go @@ -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, diff --git a/admin/repository/syb.go b/admin/repository/syb.go index 94297cc..cec2a7d 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -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) } diff --git a/admin/service/shop.go b/admin/service/shop.go index 88cb2dd..89da889 100644 --- a/admin/service/shop.go +++ b/admin/service/shop.go @@ -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 } diff --git a/admin/service/shop_test.go b/admin/service/shop_test.go index 7ec4824..3bddff2 100644 --- a/admin/service/shop_test.go +++ b/admin/service/shop_test.go @@ -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) { diff --git a/admin/service/shopee_list.go b/admin/service/shopee_list.go index 87b6ed1..38f15f4 100644 --- a/admin/service/shopee_list.go +++ b/admin/service/shopee_list.go @@ -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" } // ---------- 弹窗 ---------- diff --git a/admin/service/shopee_list_test.go b/admin/service/shopee_list_test.go index 569ff97..e84848b 100644 --- a/admin/service/shopee_list_test.go +++ b/admin/service/shopee_list_test.go @@ -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) + } } } diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 4e1d2df..253cd85 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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": [], diff --git a/admin/templates/shop/list.html b/admin/templates/shop/list.html index ae7315a..7287ba4 100644 --- a/admin/templates/shop/list.html +++ b/admin/templates/shop/list.html @@ -4,31 +4,24 @@
- - - - - - + +
{{if .Message}}

{{.Message}}

{{end}} {{if .Error}}{{end}} -

业务店铺是统一名称;SYB 和蝦皮名称是各渠道原始名称。系统只做忽略首尾空白后的精确匹配,不会模糊猜测。当前有 {{.UnlinkedShopeeCount}} 个蝦皮商品待关联。

+

同一个名称用于 SYB 同步过滤和蝦皮商品关联。系统只忽略首尾空白并做精确匹配,不会模糊猜测。未登记商品 {{.UnlinkedShopeeCount}} 个。

- + {{range .Rows}} - + - - - {{else}} - + {{end}}
业务店铺全局状态SYB 名称SYB 同步蝦皮名称蝦皮商品更新时间操作
店铺名称状态蝦皮商品更新时间操作
{{if .Enabled}}启用{{else}}停用{{end}}{{if .SybAlias}}{{if and .Enabled .SybSyncEnabled}}同步{{else}}不进入同步{{end}}{{else}}—{{end}} {{.ShopeeProductCount}} {{.UpdatedAt}} @@ -40,12 +33,6 @@ - {{if .SybAlias}} -
- - -
- {{end}} {{if not .Enabled}}
@@ -54,11 +41,11 @@
还没有业务店铺。至少填写一个渠道名称后即可新增。
还没有店铺。填写与 SYB、蝦皮数据一致的店铺名称即可新增。
-
共 {{len .Rows}} 个业务店铺
+
共 {{len .Rows}} 个店铺
{{template "footer" .}} {{end}} diff --git a/admin/templates/shopee/detail_modal.html b/admin/templates/shopee/detail_modal.html index abdfb35..0e6856a 100644 --- a/admin/templates/shopee/detail_modal.html +++ b/admin/templates/shopee/detail_modal.html @@ -21,11 +21,11 @@ - - + + - - + +
@@ -45,11 +45,11 @@ - - + + - - + + {{if .CanCollect}} diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html index f860f0e..356e804 100644 --- a/admin/templates/shopee/list.html +++ b/admin/templates/shopee/list.html @@ -4,21 +4,24 @@ {{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
{{if .CurrentUser.IsAdmin}}导入记录{{end}} + {{if .CurrentUser.IsAdmin}} + {{if .DeletedFilter}}返回正常商品{{else}}回收站{{end}} + {{end}} - - + + - - - + {{if not .DeletedFilter}} {{end}}
+ {{if .DeletedFilter}}{{end}} + {{if .UnlinkedFilter}}{{end}} {{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花, 见 docs/admin/05-ui-specification.md §4.1、工单 #43。 放在关键词前面:操作员大多是"看某一类"而不是"查某个 ID"。 */}} @@ -28,37 +31,14 @@ {{end}} - - + {{range .SearchFieldOptions}} + {{end}} - - - - - {{if .CurrentUser.IsAdmin}} - - - {{end}} - - - - + +
@@ -72,6 +52,8 @@ {{end}}
+{{if .UnlinkedFilter}}

当前只显示尚未登记到店铺管理的蝦皮商品。查看全部正常商品

{{end}} + {{/* ── 第二段:带勾选的表格 ──────────────────────────── */}} {{/* 一个商品一行(不是一个 SKU 一行),见工单 #41。 双击行打开详情弹窗,见 static/js/app.js。 */}} @@ -83,8 +65,7 @@ 商品 ID 图片 商品名称 - 蝦皮店铺 - 业务店铺 + 店铺 颜色 尺码 {{/* SKU 数是这个商品报表里实际出现过的规格条数。 @@ -112,7 +93,6 @@ title 属性让悬停能看完整标题,收窄之后必须留着。 */}} {{.Title}} {{if .ShopName}}{{.ShopName}}{{else}}—{{end}} - {{if .ShopUnlinked}}待关联{{else}}{{.BusinessShopName}}{{end}} {{.ColorCount}} {{.SizeCount}} {{.SKUCount}} @@ -124,9 +104,9 @@ {{else}} - + {{if .IsFiltered}} - 当前筛选条件下没有商品。请调整状态、店铺、图片或商品 ID。
+ 当前条件下没有商品。请调整状态、分类或关键词。
清除筛选条件 {{else}} 还没有数据。蝦皮与 PDD 对应数据由第三方脚本通过商品目录接口导入。
diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 40cd079..b3512af 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -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` 顺运宝规格映射 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 7607614..74750ef 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -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` 只作为精确匹配后的稳定内部关联, +不再额外显示“业务店铺”列;未精确关联的数据从店铺管理页的“未登记商品”入口查看。