diff --git a/admin/handler/web/shop.go b/admin/handler/web/shop.go new file mode 100644 index 0000000..5238131 --- /dev/null +++ b/admin/handler/web/shop.go @@ -0,0 +1,112 @@ +package web + +import ( + "net/http" + "net/url" + "time" + + "github.com/gin-gonic/gin" + + "cmautobuy/admin/service" +) + +func (h *Handler) ShopList(c *gin.Context) { + result, err := service.ListShops(h.db, currentUser(c)) + if err != nil { + fail(c, http.StatusInternalServerError, "读取店铺管理数据失败,数据没有被改动。") + return + } + c.HTML(http.StatusOK, "shop/list", page(c, "shops", "店铺管理", gin.H{ + "Rows": result.Items, "UnlinkedShopeeCount": result.UnlinkedShopeeCount, + "Message": c.Query("msg"), "Error": c.Query("error"), + })) +} + +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()) + if err != nil { + if service.IsValidationError(err) { + redirectShops(c, "", err.Error()) + return + } + fail(c, http.StatusInternalServerError, "新增店铺失败,已有数据没有被改动。") + return + } + 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()) + if err != nil { + if service.IsValidationError(err) { + redirectShops(c, "", err.Error()) + return + } + fail(c, http.StatusInternalServerError, "保存店铺失败,原配置保持不变。") + return + } + redirectShops(c, "店铺名称和渠道关联已保存", "") +} + +func (h *Handler) ShopStatus(c *gin.Context) { + enabled := c.PostForm("enabled") == "1" + if err := service.SetShopEnabled(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, "更新店铺状态失败,原状态保持不变。") + return + } + 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 同步已启用" + } + redirectShops(c, message, "") +} + +func (h *Handler) ShopDelete(c *gin.Context) { + if err := service.DeleteShop(h.db, currentUser(c), c.PostForm("shop_id")); err != nil { + if service.IsValidationError(err) { + redirectShops(c, "", err.Error()) + return + } + fail(c, http.StatusInternalServerError, "删除店铺失败,已有数据没有被改动。") + return + } + redirectShops(c, "已删除没有关联数据的停用店铺", "") +} + +func redirectShops(c *gin.Context, message, errorMessage string) { + values := url.Values{} + if message != "" { + values.Set("msg", message) + } + if errorMessage != "" { + values.Set("error", errorMessage) + } + target := "/shops" + if encoded := values.Encode(); encoded != "" { + target += "?" + encoded + } + c.Redirect(http.StatusSeeOther, target) +} diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index 9173e2f..1b082a4 100644 --- a/admin/handler/web/shopee.go +++ b/admin/handler/web/shopee.go @@ -18,7 +18,7 @@ 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("deleted"), c.Query("page"), c.Query("msg")) + 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")) } // ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。 @@ -49,6 +49,7 @@ func (h *Handler) ShopeeDetail(c *gin.Context) { "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")), }) } @@ -57,13 +58,14 @@ func (h *Handler) ShopeeDetail(c *gin.Context) { // // `[必须]` 分页控件用 纯 GET 导航,翻页要保留关键词和三个下拉筛选, // 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#150)。 -func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, shopRaw, imageRaw, deletedRaw, pageRaw, msg string) { +func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, shopRaw, imageRaw, storeRaw, deletedRaw, 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), } if currentUser(c).IsAdmin() && deletedRaw == "1" { filter.Deleted = true @@ -84,6 +86,11 @@ 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 == "" { @@ -106,6 +113,9 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, if filter.Image != "" { values.Set("image", filter.Image) } + if filter.StoreID != "" { + values.Set("store", filter.StoreID) + } if filter.Deleted { values.Set("deleted", "1") } @@ -119,6 +129,8 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, "ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"), "ImageFilter": filter.Image, "ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"), + "StoreFilter": filter.StoreID, + "StoreOptions": shopOptions, "DeletedFilter": filter.Deleted, "Rows": result.Rows, "Status": status, @@ -253,6 +265,9 @@ func (h *Handler) shopeeRedirect(c *gin.Context, msg string) { 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") } diff --git a/admin/handler/web/syb_shop.go b/admin/handler/web/syb_shop.go deleted file mode 100644 index c2ad938..0000000 --- a/admin/handler/web/syb_shop.go +++ /dev/null @@ -1,89 +0,0 @@ -package web - -import ( - "errors" - "net/http" - "net/url" - "time" - - "github.com/gin-gonic/gin" - - "cmautobuy/admin/repository" - "cmautobuy/admin/service" -) - -func (h *Handler) SybAllowedShopList(c *gin.Context) { - rows, err := service.ListSybAllowedShops(h.db, currentUser(c)) - if err != nil { - fail(c, http.StatusInternalServerError, "读取顺运宝同步店铺失败,数据没有被改动。刷新后重试。") - return - } - enabled := 0 - for _, row := range rows { - if row.Enabled { - enabled++ - } - } - c.HTML(http.StatusOK, "syb/shops", page(c, "syb", "顺运宝同步店铺", gin.H{ - "Rows": rows, "EnabledCount": enabled, "Message": c.Query("msg"), "Error": c.Query("error"), - })) -} - -func (h *Handler) SybAllowedShopCreate(c *gin.Context) { - err := service.CreateSybAllowedShop(h.db, currentUser(c), c.PostForm("shop_name"), time.Now()) - if err != nil { - if service.IsValidationError(err) || errors.Is(err, repository.ErrSybAllowedShopExists) { - redirectSybShops(c, "", err.Error()) - return - } - fail(c, http.StatusInternalServerError, "新增同步店铺失败,已有店铺没有被改动。") - return - } - redirectSybShops(c, "店铺已加入允许列表并启用", "") -} - -func (h *Handler) SybAllowedShopStatus(c *gin.Context) { - enabled := c.PostForm("enabled") == "1" - err := service.SetSybAllowedShopEnabled(h.db, currentUser(c), c.PostForm("shop_id"), enabled, time.Now()) - if err != nil { - if service.IsValidationError(err) { - redirectSybShops(c, "", err.Error()) - return - } - fail(c, http.StatusInternalServerError, "更新同步店铺失败,原状态保持不变。") - return - } - message := "店铺已停用,后续同步会跳过该店铺" - if enabled { - message = "店铺已重新启用" - } - redirectSybShops(c, message, "") -} - -func (h *Handler) SybAllowedShopDelete(c *gin.Context) { - err := service.DeleteDisabledSybAllowedShop(h.db, currentUser(c), c.PostForm("shop_id")) - if err != nil { - if service.IsValidationError(err) { - redirectSybShops(c, "", err.Error()) - return - } - fail(c, http.StatusInternalServerError, "删除同步店铺失败,已有配置和历史货运单没有被改动。") - return - } - redirectSybShops(c, "已删除停用店铺;历史货运单和同步记录保持不变", "") -} - -func redirectSybShops(c *gin.Context, message, errorMessage string) { - values := url.Values{} - if message != "" { - values.Set("msg", message) - } - if errorMessage != "" { - values.Set("error", errorMessage) - } - target := "/syb/shops" - if encoded := values.Encode(); encoded != "" { - target += "?" + encoded - } - c.Redirect(http.StatusSeeOther, target) -} diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 7b38e72..99f4ebe 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -86,11 +86,16 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { pages.POST("/syb/match", h.SybMatch) pages.POST("/syb/create-task", h.SybCreateTask) pages.POST("/syb/delete", h.SybDelete) - sybShops := pages.Group("/syb/shops", AdminRequired()) - sybShops.GET("", h.SybAllowedShopList) - sybShops.POST("/create", h.SybAllowedShopCreate) - sybShops.POST("/status", h.SybAllowedShopStatus) - sybShops.POST("/delete", h.SybAllowedShopDelete) + pages.GET("/syb/shops", AdminRequired(), func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/shops") }) + + // 全局店铺管理:统一维护 SYB 与蝦皮渠道名称,仅管理员可写。 + shops := pages.Group("/shops", AdminRequired()) + shops.GET("", h.ShopList) + 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. 采集采购 pages.GET("/tasks", h.TaskList) diff --git a/admin/main_test.go b/admin/main_test.go index 0abdbed..4c10879 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -52,6 +52,7 @@ func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *te "data-module-root=\"/shopee\"", "data-module-root=\"/pdd\"", "data-module-root=\"/syb\"", + "data-module-root=\"/shops\"", "data-module-root=\"/tasks\"", "data-module-root=\"/clients\"", "data-module-root=\"/users\"", @@ -64,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\", \"deleted\", \"page\"]", + "\"/shopee\": [\"goods_id\", \"shop_name\", \"status\", \"shop\", \"image\", \"store\", \"deleted\", \"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", @@ -315,8 +316,8 @@ func TestSybPage_明确展示蝦皮商品Pdd关联来源(t *testing.T) { } } -func TestSybAllowedShops_只给停用项提供二次确认删除(t *testing.T) { - templateContent, err := os.ReadFile("templates/syb/shops.html") +func TestShops_独立模块区分全局和SYB状态且保护删除(t *testing.T) { + templateContent, err := os.ReadFile("templates/shop/list.html") if err != nil { t.Fatal(err) } @@ -326,16 +327,16 @@ func TestSybAllowedShops_只给停用项提供二次确认删除(t *testing.T) { } page := string(templateContent) for _, want := range []string{ - `{{if not .Enabled}}`, `action="/syb/shops/delete"`, - `data-confirm-submit="确定永久删除已停用店铺“{{.ShopName}}”吗?`, + `{{if not .Enabled}}`, `action="/shops/delete"`, `action="/shops/syb-status"`, + `data-confirm-submit="确定删除停用店铺“{{.DisplayName}}”吗?`, `class="danger">删除`, } { if !strings.Contains(page, want) { - t.Errorf("同步店铺管理页缺少删除保护 %q", want) + t.Errorf("店铺管理页缺少状态或删除保护 %q", want) } } - if !strings.Contains(string(routes), `sybShops.POST("/delete", h.SybAllowedShopDelete)`) { - t.Error("同步店铺删除没有挂到管理员路由组") + if !strings.Contains(string(routes), `shops.POST("/delete", h.ShopDelete)`) { + t.Error("店铺删除没有挂到管理员路由组") } } diff --git a/admin/model/model.go b/admin/model/model.go index 44e3e81..2abe503 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -165,6 +165,7 @@ func (p PddProduct) IsDeleted() bool { // 采集结果和采集状态**不在这里**——它们属于 PDD 商品,见 PddProduct。 type ShopeeProduct struct { GoodsID string + ShopID string // 关联全局业务店铺;原始渠道店铺名仍单独保留 Title string ShopeeStatus string MainSKUCode string @@ -219,6 +220,7 @@ type ShopeeSKU struct { type SybOrder struct { SybID string OrderNo string + ShopID string // 由 SYB 渠道别名精确解析,无法识别时为空 ShopName string // 顺运宝货运单级 shopName;同一货运单的商品明细相同 Title string ProductSpec string // 规格原文,顺运宝 productSpec,原样保留,对应 shopee_skus.spec_raw @@ -266,13 +268,30 @@ type SybSyncRun struct { FinishedAt string } -// SybAllowedShop 是顺运宝同步的全局店铺准入项。 -type SybAllowedShop struct { +// Shop 是跨 SYB、Shopee 等渠道共享的业务店铺身份。 +// 渠道返回的原始名称保存在各业务表和 ShopChannelAlias 中,不用显示名替代。 +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 把一个渠道中的精确名称映射到稳定业务店铺。 +type ShopChannelAlias struct { + AliasID string ShopID string - ShopName string - NormalizedName string + Channel string // syb / shopee + AliasName string + NormalizedAlias string Enabled bool - CreatedByUserID string CreatedAt string UpdatedAt string } diff --git a/admin/repository/catalog_import.go b/admin/repository/catalog_import.go index 4e011d7..0bc6146 100644 --- a/admin/repository/catalog_import.go +++ b/admin/repository/catalog_import.go @@ -169,11 +169,15 @@ type CatalogProductOutcome struct { // UpsertCatalogShopeeProduct 独立保护主图和店铺字段,绝不覆盖人工 PDD 关联。 func UpsertCatalogShopeeProduct(q Execer, in CatalogShopeeProductInput) (out CatalogProductOutcome, err error) { - var oldObserved, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved sql.NullString + var oldObserved, oldShopID, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved sql.NullString var imageManual, shopManual int - err = q.QueryRow(`SELECT source_observed_at,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual FROM shopee_products WHERE goods_id=?`, in.GoodsID).Scan(&oldObserved, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual) + shopID, err := FindShopIDByAlias(q, "shopee", in.ShopName) + if err != nil { + return out, err + } + err = q.QueryRow(`SELECT source_observed_at,shop_id,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual FROM shopee_products WHERE goods_id=?`, in.GoodsID).Scan(&oldObserved, &oldShopID, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual) if errors.Is(err, sql.ErrNoRows) { - _, err = q.Exec(`INSERT INTO shopee_products(goods_id,title,shopee_status,main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual,source,source_observed_at,created_at,updated_at) VALUES(?,?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,0,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,0,'api',?,?,?)`, in.GoodsID, in.Title, in.Status, in.MainSKU, in.ImageURL, in.ShopName, in.ImageURL, in.Source, in.ImageURL, in.ObservedAt, in.ShopName, in.Source, in.ShopName, in.ObservedAt, in.ObservedAt, in.Now, in.Now) + _, err = q.Exec(`INSERT INTO shopee_products(goods_id,shop_id,title,shopee_status,main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual,source,source_observed_at,created_at,updated_at) VALUES(?,NULLIF(?,''),?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,0,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,0,'api',?,?,?)`, in.GoodsID, shopID, in.Title, in.Status, in.MainSKU, in.ImageURL, in.ShopName, in.ImageURL, in.Source, in.ImageURL, in.ObservedAt, in.ShopName, in.Source, in.ShopName, in.ObservedAt, in.ObservedAt, in.Now, in.Now) out.Created = err == nil return out, err } @@ -225,10 +229,22 @@ func UpsertCatalogShopeeProduct(q Execer, in CatalogShopeeProductInput) (out Cat } imageChanged = apply(in.ImageURL, &newImage, imageSource, imageObserved, imageManual) shopChanged = apply(in.ShopName, &newShop, shopSource, shopObserved, shopManual) - if !baseUpdate && !imageChanged && !shopChanged { + resolvedShopID := oldShopID.String + shopAssociationChanged := false + if shopChanged { + resolvedShopID, err = FindShopIDByAlias(q, "shopee", newShop) + if err != nil { + return out, err + } + shopAssociationChanged = resolvedShopID != oldShopID.String + } else if resolvedShopID == "" && shopID != "" && strings.TrimSpace(newShop) == strings.TrimSpace(in.ShopName) { + resolvedShopID = shopID + shopAssociationChanged = true + } + if !baseUpdate && !imageChanged && !shopChanged && !shopAssociationChanged { return out, nil } - _, err = q.Exec(`UPDATE shopee_products SET title=CASE WHEN ? THEN ? ELSE title END,shopee_status=CASE WHEN ? THEN NULLIF(?,'') ELSE shopee_status END,main_sku_code=CASE WHEN ? THEN NULLIF(?,'') ELSE main_sku_code END,source=CASE WHEN ? THEN 'api' ELSE source END,source_observed_at=CASE WHEN ? THEN ? ELSE source_observed_at END,image_url=NULLIF(?,''),shopee_shop_name=NULLIF(?,''),image_source=CASE WHEN ? THEN ? ELSE image_source END,image_observed_at=CASE WHEN ? THEN ? ELSE image_observed_at END,shop_name_source=CASE WHEN ? THEN ? ELSE shop_name_source END,shop_name_observed_at=CASE WHEN ? THEN ? ELSE shop_name_observed_at END,updated_at=? WHERE goods_id=?`, baseUpdate, in.Title, baseUpdate, in.Status, baseUpdate, in.MainSKU, baseUpdate, baseUpdate, in.ObservedAt, newImage, newShop, imageChanged, in.Source, imageChanged, in.ObservedAt, shopChanged, in.Source, shopChanged, in.ObservedAt, in.Now, in.GoodsID) + _, err = q.Exec(`UPDATE shopee_products SET title=CASE WHEN ? THEN ? ELSE title END,shopee_status=CASE WHEN ? THEN NULLIF(?,'') ELSE shopee_status END,main_sku_code=CASE WHEN ? THEN NULLIF(?,'') ELSE main_sku_code END,source=CASE WHEN ? THEN 'api' ELSE source END,source_observed_at=CASE WHEN ? THEN ? ELSE source_observed_at END,shop_id=CASE WHEN ? THEN NULLIF(?,'') ELSE shop_id END,image_url=NULLIF(?,''),shopee_shop_name=NULLIF(?,''),image_source=CASE WHEN ? THEN ? ELSE image_source END,image_observed_at=CASE WHEN ? THEN ? ELSE image_observed_at END,shop_name_source=CASE WHEN ? THEN ? ELSE shop_name_source END,shop_name_observed_at=CASE WHEN ? THEN ? ELSE shop_name_observed_at END,updated_at=? WHERE goods_id=?`, baseUpdate, in.Title, baseUpdate, in.Status, baseUpdate, in.MainSKU, baseUpdate, baseUpdate, in.ObservedAt, shopAssociationChanged, resolvedShopID, newImage, newShop, imageChanged, in.Source, imageChanged, in.ObservedAt, shopChanged, in.Source, shopChanged, in.ObservedAt, in.Now, in.GoodsID) out.Updated = err == nil return out, err } diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 171a75f..4e565a7 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -20,7 +20,7 @@ import ( "cmautobuy/admin/spec" ) -const mysqlSchemaVersion = 17 +const mysqlSchemaVersion = 18 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { @@ -628,6 +628,18 @@ func MigrateMySQL(db *sql.DB) error { if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 17, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { return fmt.Errorf("记录 MySQL schema v17 失败: %w", err) } + current = 17 + } + if current < 18 { + if err := migrateMySQLV18(db); err != nil { + return fmt.Errorf("执行 MySQL schema v18 失败: %w", err) + } + if err := checkMySQLV18Shape(db); err != nil { + return fmt.Errorf("MySQL schema v18 自检失败,未记录版本: %w", err) + } + 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 CheckMySQLSchema(db) } @@ -675,6 +687,114 @@ func migrateMySQLV17(db *sql.DB) error { return nil } +// migrateMySQLV18 建立跨渠道店铺身份,保留旧 SYB 准入表作为回退依据。 +func migrateMySQLV18(db *sql.DB) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS shops ( + shop_id VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY, + display_name VARCHAR(500) NOT NULL, + normalized_name VARCHAR(500) COLLATE utf8mb4_bin NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + created_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + created_at VARCHAR(35) NOT NULL, + updated_at VARCHAR(35) NOT NULL, + UNIQUE KEY uq_shops_name (normalized_name), + KEY idx_shops_enabled (enabled,normalized_name), + CONSTRAINT fk_shops_user FOREIGN KEY (created_by_user_id) REFERENCES users(user_id), + CONSTRAINT chk_shops_enabled CHECK (enabled IN (0,1)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS shop_channel_aliases ( + alias_id VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY, + shop_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + channel VARCHAR(16) COLLATE utf8mb4_bin NOT NULL, + alias_name VARCHAR(500) NOT NULL, + normalized_alias VARCHAR(500) COLLATE utf8mb4_bin NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + created_at VARCHAR(35) NOT NULL, + updated_at VARCHAR(35) NOT NULL, + UNIQUE KEY uq_shop_channel_alias (channel,normalized_alias), + KEY idx_shop_alias_shop (shop_id,channel,enabled), + CONSTRAINT fk_shop_alias_shop FOREIGN KEY (shop_id) REFERENCES shops(shop_id) ON DELETE CASCADE, + CONSTRAINT chk_shop_alias_channel CHECK (channel IN ('syb','shopee')), + CONSTRAINT chk_shop_alias_enabled CHECK (enabled IN (0,1)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + } + for _, statement := range statements { + if _, err := db.Exec(statement); err != nil { + return err + } + } + for _, column := range []struct{ table, name, ddl string }{ + {"syb_orders", "shop_id", `ALTER TABLE syb_orders ADD COLUMN shop_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER order_no`}, + {"shopee_products", "shop_id", `ALTER TABLE shopee_products ADD COLUMN shop_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER goods_id`}, + } { + exists, err := mysqlColumnExists(db, column.table, column.name) + if err != nil { + return err + } + if !exists { + if _, err := db.Exec(column.ddl); err != nil { + return fmt.Errorf("增加 %s.%s 失败: %w", column.table, column.name, err) + } + } + } + for _, index := range []struct{ table, name, ddl string }{ + {"syb_orders", "idx_syb_orders_shop_id", `ALTER TABLE syb_orders ADD INDEX idx_syb_orders_shop_id (shop_id)`}, + {"shopee_products", "idx_shopee_products_shop_id", `ALTER TABLE shopee_products ADD INDEX idx_shopee_products_shop_id (shop_id)`}, + } { + exists, err := mysqlIndexExists(db, index.table, index.name) + if err != nil { + return err + } + if !exists { + if _, err := db.Exec(index.ddl); err != nil { + return err + } + } + } + for _, fk := range []struct{ table, name, ddl string }{ + {"syb_orders", "fk_syb_orders_shop", `ALTER TABLE syb_orders ADD CONSTRAINT fk_syb_orders_shop FOREIGN KEY (shop_id) REFERENCES shops(shop_id) ON DELETE SET NULL`}, + {"shopee_products", "fk_shopee_products_shop", `ALTER TABLE shopee_products ADD CONSTRAINT fk_shopee_products_shop FOREIGN KEY (shop_id) REFERENCES shops(shop_id) ON DELETE SET NULL`}, + } { + exists, err := mysqlConstraintExists(db, fk.table, fk.name) + if err != nil { + return err + } + if !exists { + if _, err := db.Exec(fk.ddl); err != nil { + return err + } + } + } + // 旧准入项一对一迁移成业务店铺;旧 enabled 只控制 SYB 渠道,不停用业务店铺。 + if _, err := db.Exec(`INSERT INTO shops(shop_id,display_name,normalized_name,enabled,created_by_user_id,created_at,updated_at) + SELECT shop_id,shop_name,normalized_name,1,created_by_user_id,created_at,updated_at FROM syb_allowed_shops + ON DUPLICATE KEY UPDATE display_name=VALUES(display_name),updated_at=VALUES(updated_at)`); err != nil { + return fmt.Errorf("迁移旧同步店铺失败: %w", err) + } + if _, err := db.Exec(`INSERT INTO shop_channel_aliases(alias_id,shop_id,channel,alias_name,normalized_alias,enabled,created_at,updated_at) + SELECT CONCAT('legacy-syb-',shop_id),shop_id,'syb',shop_name,normalized_name,enabled,created_at,updated_at FROM syb_allowed_shops + ON DUPLICATE KEY UPDATE shop_id=VALUES(shop_id),alias_name=VALUES(alias_name),enabled=VALUES(enabled),updated_at=VALUES(updated_at)`); err != nil { + return fmt.Errorf("迁移 SYB 店铺别名失败: %w", err) + } + if _, err := db.Exec(`INSERT INTO shop_channel_aliases(alias_id,shop_id,channel,alias_name,normalized_alias,enabled,created_at,updated_at) + SELECT CONCAT('legacy-shopee-',shop_id),shop_id,'shopee',shop_name,normalized_name,1,created_at,updated_at FROM syb_allowed_shops + ON DUPLICATE KEY UPDATE shop_id=VALUES(shop_id),alias_name=VALUES(alias_name),updated_at=VALUES(updated_at)`); err != nil { + return fmt.Errorf("迁移蝦皮店铺别名失败: %w", err) + } + if _, err := db.Exec(`UPDATE syb_orders so JOIN shop_channel_aliases a + ON a.channel='syb' AND a.normalized_alias=TRIM(so.shop_name) COLLATE utf8mb4_bin + SET so.shop_id=a.shop_id WHERE so.shop_id IS NULL`); err != nil { + return fmt.Errorf("回填顺运宝业务店铺失败: %w", err) + } + if _, err := db.Exec(`UPDATE shopee_products sp JOIN shop_channel_aliases a + ON a.channel='shopee' AND a.normalized_alias=TRIM(sp.shopee_shop_name) COLLATE utf8mb4_bin + SET sp.shop_id=a.shop_id WHERE sp.shop_id IS NULL`); err != nil { + return fmt.Errorf("回填蝦皮业务店铺失败: %w", err) + } + return nil +} + // migrateMySQLV14 增加顺运宝同步店铺准入表和审计统计。 func migrateMySQLV14(db *sql.DB) error { if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS syb_allowed_shops ( @@ -1667,7 +1787,10 @@ func CheckMySQLSchema(db *sql.DB) error { if err := checkMySQLV16Shape(db); err != nil { return err } - return checkMySQLV17Shape(db) + if err := checkMySQLV17Shape(db); err != nil { + return err + } + return checkMySQLV18Shape(db) } func checkMySQLV15Shape(db *sql.DB) error { @@ -1713,6 +1836,39 @@ func checkMySQLV17Shape(db *sql.DB) error { return nil } +func checkMySQLV18Shape(db *sql.DB) error { + if err := checkMySQLSchema(db, []string{"shops", "shop_channel_aliases"}); err != nil { + return err + } + for _, column := range []struct{ table, name string }{{"syb_orders", "shop_id"}, {"shopee_products", "shop_id"}} { + if err := checkMySQLVarcharColumn(db, column.table, column.name, 191, true, "utf8mb4_bin", ""); err != nil { + return err + } + } + for _, item := range []struct{ table, name string }{ + {"shops", "uq_shops_name"}, {"shops", "idx_shops_enabled"}, + {"shop_channel_aliases", "uq_shop_channel_alias"}, {"shop_channel_aliases", "idx_shop_alias_shop"}, + {"syb_orders", "idx_syb_orders_shop_id"}, {"shopee_products", "idx_shopee_products_shop_id"}, + } { + exists, err := mysqlIndexExists(db, item.table, item.name) + if err != nil || !exists { + return fmt.Errorf("店铺索引 %s.%s 缺失: %v", item.table, item.name, err) + } + } + for _, item := range []struct{ table, name string }{ + {"shops", "fk_shops_user"}, {"shops", "chk_shops_enabled"}, + {"shop_channel_aliases", "fk_shop_alias_shop"}, {"shop_channel_aliases", "chk_shop_alias_channel"}, + {"shop_channel_aliases", "chk_shop_alias_enabled"}, {"syb_orders", "fk_syb_orders_shop"}, + {"shopee_products", "fk_shopee_products_shop"}, + } { + exists, err := mysqlConstraintExists(db, item.table, item.name) + if err != nil || !exists { + return fmt.Errorf("店铺约束 %s.%s 缺失: %v", item.table, item.name, err) + } + } + 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 b4f9be8..2c37ad3 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -875,6 +875,57 @@ func TestMySQLMigrate_V14升级V17回填元数据规格并增加软删除(t *tes } } +func TestMySQLMigrate_V17升级V18迁移全局店铺并精确回填(t *testing.T) { + db := openMySQLMigrationTestDB(t) + defer db.Close() + cleanMySQLTestSchema(t, db) + defer cleanMySQLTestSchema(t, db) + if err := MigrateMySQL(db); err != nil { + t.Fatal(err) + } + + // 退回真实 v17 形状,再放入旧准入配置和两侧历史数据。 + mustExec(t, db, `ALTER TABLE shopee_products DROP FOREIGN KEY fk_shopee_products_shop`) + mustExec(t, db, `ALTER TABLE syb_orders DROP FOREIGN KEY fk_syb_orders_shop`) + mustExec(t, db, `ALTER TABLE shopee_products 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 shops`) + 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) + 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) + } + if err := MigrateMySQL(db); err != nil { + t.Fatalf("v18 重放失败: %v", err) + } + if err := checkMySQLV18Shape(db); err != nil { + t.Fatalf("v18 结构错误: %v", 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'`, + } { + var shopID string + if err := db.QueryRow(query).Scan(&shopID); err != nil || shopID != "V18-SHOP" { + t.Fatalf("历史精确回填错误: shop=%q err=%v", shopID, err) + } + } + var unlinked sql.NullString + if err := db.QueryRow(`SELECT shop_id FROM shopee_products WHERE goods_id='V18-UNLINKED'`).Scan(&unlinked); err != nil || unlinked.Valid { + t.Fatalf("无法确认的店铺不应猜测关联: %+v err=%v", unlinked, err) + } +} + func openMySQLMigrationTestDB(t *testing.T) *sql.DB { t.Helper() if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" { diff --git a/admin/repository/shop.go b/admin/repository/shop.go new file mode 100644 index 0000000..b4820e1 --- /dev/null +++ b/admin/repository/shop.go @@ -0,0 +1,253 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + "strings" + + "cmautobuy/admin/model" + "github.com/go-sql-driver/mysql" +) + +var ( + ErrShopNameExists = errors.New("店铺名称已经存在") + ErrShopAliasExists = errors.New("渠道店铺名称已经关联") + ErrShopHasReferences = errors.New("店铺仍有关联数据") +) + +// 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 { + return nil, fmt.Errorf("查询店铺列表失败: %w", err) + } + 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.CreatedByUserID, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + item.Enabled = enabled == 1 + result = append(result, item) + } + return result, rows.Err() +} + +func GetShop(q Execer, shopID string) (model.Shop, bool, error) { + var item model.Shop + var enabled int + err := q.QueryRow(`SELECT shop_id,display_name,normalized_name,enabled,created_by_user_id,created_at,updated_at + FROM shops WHERE shop_id=?`, shopID).Scan(&item.ShopID, &item.DisplayName, + &item.NormalizedName, &enabled, &item.CreatedByUserID, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return model.Shop{}, false, nil + } + if err != nil { + return model.Shop{}, false, fmt.Errorf("查询店铺失败: %w", err) + } + item.Enabled = enabled == 1 + 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”。 +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`) + if err != nil { + return nil, fmt.Errorf("查询启用的 SYB 店铺配置失败: %w", err) + } + defer rows.Close() + result := map[string]string{} + for rows.Next() { + var name, shopID string + if err := rows.Scan(&name, &shopID); err != nil { + return nil, err + } + result[name] = shopID + } + return result, rows.Err() +} + +func FindShopIDByAlias(q Execer, channel, 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) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("按 %s 店铺名称查业务店铺失败: %w", channel, err) + } + return shopID, nil +} + +func InsertShop(q Execer, item model.Shop) error { + _, err := q.Exec(`INSERT INTO shops(shop_id,display_name,normalized_name,enabled,created_by_user_id,created_at,updated_at) + VALUES(?,?,?,?,?,?,?)`, item.ShopID, item.DisplayName, item.NormalizedName, item.Enabled, + item.CreatedByUserID, item.CreatedAt, item.UpdatedAt) + if err != nil { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + return ErrShopNameExists + } + return fmt.Errorf("新增店铺失败: %w", err) + } + return nil +} + +func InsertShopAlias(q Execer, item model.ShopChannelAlias) error { + _, err := q.Exec(`INSERT INTO shop_channel_aliases(alias_id,shop_id,channel,alias_name,normalized_alias,enabled,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?)`, item.AliasID, item.ShopID, item.Channel, item.AliasName, + item.NormalizedAlias, item.Enabled, item.CreatedAt, item.UpdatedAt) + if err != nil { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + return ErrShopAliasExists + } + return fmt.Errorf("新增渠道店铺名称失败: %w", err) + } + return nil +} + +func UpdateShopName(q Execer, shopID, displayName, normalizedName, updatedAt string) (bool, error) { + result, err := q.Exec(`UPDATE shops SET display_name=?,normalized_name=?,updated_at=? WHERE shop_id=?`, + displayName, normalizedName, updatedAt, shopID) + if err != nil { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + return false, ErrShopNameExists + } + return false, fmt.Errorf("更新店铺名称失败: %w", err) + } + n, err := result.RowsAffected() + return n == 1, err +} + +func SetShopEnabled(q Execer, shopID string, enabled bool, updatedAt string) (bool, error) { + result, err := q.Exec(`UPDATE shops SET enabled=?,updated_at=? WHERE shop_id=?`, enabled, updatedAt, shopID) + if err != nil { + return false, fmt.Errorf("更新店铺状态失败: %w", err) + } + n, err := result.RowsAffected() + 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) + if err != nil { + return false, fmt.Errorf("更新 SYB 同步状态失败: %w", err) + } + n, err := result.RowsAffected() + return n > 0, err +} + +func ReplaceShopAlias(q Execer, aliasID, shopID, channel, aliasName, updatedAt string, enabled bool) error { + if _, err := q.Exec(`DELETE FROM shop_channel_aliases WHERE shop_id=? AND channel=?`, shopID, channel); err != nil { + return fmt.Errorf("移除旧渠道店铺名称失败: %w", err) + } + if strings.TrimSpace(aliasName) == "" { + return nil + } + return InsertShopAlias(q, model.ShopChannelAlias{AliasID: aliasID, ShopID: shopID, Channel: channel, + AliasName: aliasName, NormalizedAlias: strings.TrimSpace(aliasName), Enabled: enabled, + CreatedAt: updatedAt, UpdatedAt: updatedAt}) +} + +// 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 + } + 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 { + 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 { + return err + } + return nil +} + +func DeleteDisabledShop(q Execer, shopID string) (bool, error) { + var references int + if err := q.QueryRow(`SELECT (SELECT COUNT(*) FROM syb_orders WHERE shop_id=?)+ + (SELECT COUNT(*) FROM shopee_products WHERE shop_id=?)`, shopID, shopID).Scan(&references); err != nil { + return false, err + } + if references > 0 { + return false, ErrShopHasReferences + } + result, err := q.Exec(`DELETE FROM shops WHERE shop_id=? AND enabled=0`, shopID) + if err != nil { + return false, fmt.Errorf("删除停用店铺失败: %w", err) + } + n, err := result.RowsAffected() + return n == 1, err +} + +func CountUnlinkedShopeeShops(q Execer) (int, error) { + var count int + err := q.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE deleted_at IS NULL AND shop_id IS NULL`).Scan(&count) + return count, err +} diff --git a/admin/repository/shopee.go b/admin/repository/shopee.go index efb17cc..f4fa6da 100644 --- a/admin/repository/shopee.go +++ b/admin/repository/shopee.go @@ -111,12 +111,13 @@ func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice stri // NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。 type ShopeeProductRow struct { model.ShopeeProduct - ColorCount int - SizeCount int - SKUCount int - PendingCount int - CollectStatus sql.NullString - CollectMsg sql.NullString + BusinessShopName string + ColorCount int + SizeCount int + SKUCount int + PendingCount int + CollectStatus sql.NullString + CollectMsg sql.NullString } // ShopeeFilter 是蝦皮商品列表页支持的筛选条件,四项都可以为空。 @@ -136,6 +137,7 @@ type ShopeeFilter struct { Status string Shop string // has / missing / 空(全部) Image string // has / missing / 空(全部) + StoreID string // 业务店铺 ID / unlinked / 空(全部) Deleted bool // true 只看软删除数据;false 只看正常数据 } @@ -186,6 +188,14 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) { 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`) + default: + clauses = append(clauses, `sp.shop_id=?`) + args = append(args, strings.TrimSpace(filter.StoreID)) + } return " WHERE " + strings.Join(clauses, " AND "), args } @@ -200,7 +210,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.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,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, 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, @@ -208,6 +218,7 @@ 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` + @@ -223,10 +234,10 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho var list []ShopeeProductRow for rows.Next() { var r ShopeeProductRow - var 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 if err := rows.Scan( - &r.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source, + &r.GoodsID, &shopID, &r.BusinessShopName, &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, @@ -234,6 +245,7 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho return nil, fmt.Errorf("读取蝦皮商品列表失败: %w", err) } r.Title = title.String + r.ShopID = shopID.String r.ShopeeStatus = shopeeStatus.String r.MainSKUCode = mainSKUCode.String r.ImageURL = imageURL.String @@ -273,13 +285,13 @@ func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) { // 弹窗组装商品信息时用。查不到返回 (nil, nil)。 func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) { var p model.ShopeeProduct - var 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 err := q.QueryRow(` - SELECT goods_id, title, shopee_status, main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual, source, + SELECT goods_id,shop_id, title, shopee_status, main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual, source, pdd_goods_url, pdd_goods_id,deleted_at,deleted_by_user_id, created_at, updated_at FROM shopee_products WHERE goods_id = ? AND deleted_at IS NULL`, goodsID).Scan( - &p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source, + &p.GoodsID, &shopID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source, &pddGoodsURL, &pddGoodsID, &deletedAt, &deletedBy, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -288,6 +300,7 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, return nil, fmt.Errorf("查询蝦皮商品 %s 失败: %w", goodsID, err) } p.Title = title.String + p.ShopID = shopID.String p.ShopeeStatus = shopeeStatus.String p.MainSKUCode = mainSKUCode.String p.ImageURL = imageURL.String diff --git a/admin/repository/syb.go b/admin/repository/syb.go index 28da1ec..94297cc 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -12,100 +12,10 @@ import ( "fmt" "strings" - "github.com/go-sql-driver/mysql" - "cmautobuy/admin/model" "cmautobuy/admin/spec" ) -var ErrSybAllowedShopExists = errors.New("顺运宝允许店铺已经存在") - -// ListSybAllowedShops 返回全部准入项;管理页需要同时看到已停用项。 -func ListSybAllowedShops(q Execer) ([]model.SybAllowedShop, error) { - rows, err := q.Query(`SELECT shop_id,shop_name,normalized_name,enabled,created_by_user_id,created_at,updated_at - FROM syb_allowed_shops ORDER BY enabled DESC,normalized_name`) - if err != nil { - return nil, fmt.Errorf("查询顺运宝允许店铺失败: %w", err) - } - defer rows.Close() - var result []model.SybAllowedShop - for rows.Next() { - var item model.SybAllowedShop - var enabled int - if err := rows.Scan(&item.ShopID, &item.ShopName, &item.NormalizedName, &enabled, - &item.CreatedByUserID, &item.CreatedAt, &item.UpdatedAt); err != nil { - return nil, fmt.Errorf("读取顺运宝允许店铺失败: %w", err) - } - item.Enabled = enabled == 1 - result = append(result, item) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("读取顺运宝允许店铺失败: %w", err) - } - return result, nil -} - -func ListEnabledSybShopNames(q Execer) ([]string, error) { - rows, err := q.Query(`SELECT normalized_name FROM syb_allowed_shops WHERE enabled=1 ORDER BY normalized_name`) - if err != nil { - return nil, fmt.Errorf("查询启用的顺运宝店铺失败: %w", err) - } - defer rows.Close() - var names []string - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return nil, fmt.Errorf("读取启用的顺运宝店铺失败: %w", err) - } - names = append(names, name) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("读取启用的顺运宝店铺失败: %w", err) - } - return names, nil -} - -func InsertSybAllowedShop(q Execer, item model.SybAllowedShop) error { - _, err := q.Exec(`INSERT INTO syb_allowed_shops - (shop_id,shop_name,normalized_name,enabled,created_by_user_id,created_at,updated_at) - VALUES(?,?,?,?,?,?,?)`, item.ShopID, item.ShopName, item.NormalizedName, item.Enabled, - item.CreatedByUserID, item.CreatedAt, item.UpdatedAt) - if err != nil { - var mysqlErr *mysql.MySQLError - if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { - return ErrSybAllowedShopExists - } - return fmt.Errorf("新增顺运宝允许店铺失败: %w", err) - } - return nil -} - -func SetSybAllowedShopEnabled(q Execer, shopID string, enabled bool, updatedAt string) (bool, error) { - result, err := q.Exec(`UPDATE syb_allowed_shops SET enabled=?,updated_at=? WHERE shop_id=?`, enabled, updatedAt, shopID) - if err != nil { - return false, fmt.Errorf("更新顺运宝允许店铺失败: %w", err) - } - affected, err := result.RowsAffected() - if err != nil { - return false, fmt.Errorf("确认顺运宝允许店铺更新结果失败: %w", err) - } - return affected == 1, nil -} - -// DeleteDisabledSybAllowedShop 只删除提交瞬间仍处于停用状态的配置项。 -// enabled 条件是最终并发保护,不能只依赖管理页隐藏启用项的删除按钮。 -func DeleteDisabledSybAllowedShop(q Execer, shopID string) (bool, error) { - result, err := q.Exec(`DELETE FROM syb_allowed_shops WHERE shop_id=? AND enabled=0`, shopID) - if err != nil { - return false, fmt.Errorf("删除已停用的顺运宝店铺失败: %w", err) - } - affected, err := result.RowsAffected() - if err != nil { - return false, fmt.Errorf("确认顺运宝店铺删除结果失败: %w", err) - } - return affected == 1, nil -} - // ---------- 会话缓存 ---------- // SaveSybSession 写入或更新顺运宝登录会话缓存(按用户名 upsert)。 @@ -333,6 +243,12 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) { if o.SybID == "" { return false, fmt.Errorf("syb_id 不能为空") } + if strings.TrimSpace(o.ShopID) == "" && strings.TrimSpace(o.ShopName) != "" { + o.ShopID, err = FindShopIDByAlias(q, "syb", o.ShopName) + if err != nil { + return false, fmt.Errorf("解析顺运宝明细 %s 的业务店铺失败: %w", o.SybID, err) + } + } var specKey any if strings.TrimSpace(o.ProductSpec) != "" { key, keyErr := spec.SpecKey(o.ProductSpec) @@ -353,17 +269,18 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) { now := model.NowISO() if strings.TrimSpace(o.ShopeeGoodsID) != "" { - if err := upsertSybShopeeProduct(q, o.ShopeeGoodsID, o.Title, o.ShopName, o.ImageURL, now); err != nil { + if err := upsertSybShopeeProduct(q, o.ShopeeGoodsID, o.ShopID, o.Title, o.ShopName, o.ImageURL, now); err != nil { return false, fmt.Errorf("为顺运宝明细 %s 补建蝦皮商品骨架失败: %w", o.SybID, err) } } _, err = q.Exec(` INSERT INTO syb_orders - (syb_id, order_no, shop_name, title, product_spec, spec_key, shopee_goods_id, + (syb_id, order_no, shop_id, shop_name, title, product_spec, spec_key, shopee_goods_id, quantity, price_twd_cent, image_url, syb_data, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, NULLIF(?,''), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE order_no = VALUES(order_no), + shop_id = VALUES(shop_id), shop_name = VALUES(shop_name), title = VALUES(title), product_spec = VALUES(product_spec), @@ -374,7 +291,7 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) { image_url = VALUES(image_url), syb_data = VALUES(syb_data), updated_at = VALUES(updated_at)`, - o.SybID, o.OrderNo, nullableText(o.ShopName), o.Title, nullableText(o.ProductSpec), specKey, nullableText(o.ShopeeGoodsID), + o.SybID, o.OrderNo, strings.TrimSpace(o.ShopID), nullableText(o.ShopName), o.Title, nullableText(o.ProductSpec), specKey, nullableText(o.ShopeeGoodsID), o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL), o.SybData, now, now) if err != nil { @@ -387,20 +304,24 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) { // // 店铺和图片是字段级低优先级数据:只能补空值,或更新原本同样来自 syb 的值; // 人工字段和商品目录等权威来源永远不被顺运宝覆盖。空值也不能清除已有内容。 -func upsertSybShopeeProduct(q Execer, goodsID, title, shopName, imageURL, observedAt string) error { +func upsertSybShopeeProduct(q Execer, goodsID, shopID, title, shopName, imageURL, observedAt string) error { _, err := q.Exec(` INSERT INTO shopee_products - (goods_id,title,image_url,shopee_shop_name, + (goods_id,shop_id,title,image_url,shopee_shop_name, image_source,image_observed_at,image_is_manual, shop_name_source,shop_name_observed_at,shop_name_is_manual, source,source_observed_at,created_at,updated_at) - VALUES (?, ?, NULLIF(TRIM(?),''), NULLIF(TRIM(?),''), + VALUES (?, NULLIF(TRIM(?),''), ?, NULLIF(TRIM(?),''), NULLIF(TRIM(?),''), CASE WHEN TRIM(?)='' THEN NULL ELSE 'syb' END, CASE WHEN TRIM(?)='' THEN NULL ELSE ? END, 0, CASE WHEN TRIM(?)='' THEN NULL ELSE 'syb' END, CASE WHEN TRIM(?)='' THEN NULL ELSE ? END, 0, 'syb', ?, ?, ?) ON DUPLICATE KEY UPDATE + shop_id = CASE + WHEN VALUES(shop_id) IS NOT NULL AND (shop_id IS NULL OR shop_name_source='syb') THEN VALUES(shop_id) + ELSE shop_id + END, title = CASE WHEN source='syb' AND TRIM(VALUES(title))<>'' THEN VALUES(title) ELSE title @@ -445,7 +366,7 @@ func upsertSybShopeeProduct(q Execer, goodsID, title, shopName, imageURL, observ OR (shop_name_source='syb' AND TRIM(VALUES(shopee_shop_name))<>'') THEN VALUES(updated_at) ELSE updated_at END`, - goodsID, title, imageURL, shopName, + goodsID, shopID, title, imageURL, shopName, imageURL, imageURL, observedAt, shopName, shopName, observedAt, observedAt, observedAt, observedAt) @@ -490,7 +411,11 @@ func backfillSybProductMetadata(db *sql.DB) error { return fmt.Errorf("读取历史顺运宝图片失败: %w", err) } for goodsID, item := range items { - if err := upsertSybShopeeProduct(db, goodsID, "", item.shopName, item.imageURL, item.observedAt); err != nil { + shopID, err := FindShopIDByAlias(db, "syb", item.shopName) + if err != nil { + return fmt.Errorf("解析历史顺运宝店铺失败: %w", err) + } + if err := upsertSybShopeeProduct(db, goodsID, shopID, "", item.shopName, item.imageURL, item.observedAt); err != nil { return fmt.Errorf("回填蝦皮商品 %s 的顺运宝元数据失败: %w", goodsID, err) } } diff --git a/admin/service/catalog_import_test.go b/admin/service/catalog_import_test.go index 574017c..366d050 100644 --- a/admin/service/catalog_import_test.go +++ b/admin/service/catalog_import_test.go @@ -19,7 +19,9 @@ func newCatalogTestDB(t *testing.T) *sql.DB { } db.SetMaxOpenConns(1) statements := []string{ - `CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,deleted_at TEXT,deleted_by_user_id TEXT,created_at TEXT,updated_at TEXT)`, + `CREATE TABLE shops(shop_id TEXT PRIMARY KEY,display_name TEXT,normalized_name TEXT,enabled INTEGER,created_by_user_id TEXT,created_at TEXT,updated_at TEXT)`, + `CREATE TABLE shop_channel_aliases(alias_id TEXT PRIMARY KEY,shop_id TEXT,channel TEXT,alias_name TEXT,normalized_alias TEXT,enabled INTEGER,created_at TEXT,updated_at TEXT)`, + `CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,shop_id TEXT,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,deleted_at TEXT,deleted_by_user_id TEXT,created_at TEXT,updated_at TEXT)`, `CREATE TABLE shopee_skus(sku_id TEXT PRIMARY KEY,shopee_sku_id TEXT UNIQUE,goods_id TEXT NOT NULL,spec_raw TEXT,spec_key TEXT,color TEXT,size TEXT,advice TEXT,parse_ok INTEGER,sku_code TEXT,is_manual INTEGER,source TEXT,field_sources TEXT,field_observed_at TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT,UNIQUE(goods_id,spec_key))`, `CREATE TABLE pdd_products(id INTEGER PRIMARY KEY AUTOINCREMENT,goods_id TEXT UNIQUE,url TEXT,title TEXT,shop_name TEXT,skus_json TEXT,collect_status TEXT,collect_msg TEXT,artifact_ref TEXT,collected_at TEXT,deleted_at TEXT,source TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT)`, `CREATE TABLE catalog_import_runs(source TEXT,batch_id TEXT,request_hash TEXT,status TEXT,request_count INTEGER,conflict_count INTEGER,observed_at TEXT,update_policy TEXT DEFAULT 'fill_missing',last_request_at TEXT,last_conflict_at TEXT,shopee_created INTEGER DEFAULT 0,shopee_updated INTEGER DEFAULT 0,shopee_fields_filled INTEGER DEFAULT 0,shopee_fields_same_source_updated INTEGER DEFAULT 0,shopee_fields_manual_skipped INTEGER DEFAULT 0,shopee_fields_stale_skipped INTEGER DEFAULT 0,sku_created INTEGER DEFAULT 0,sku_updated INTEGER DEFAULT 0,sku_filled INTEGER DEFAULT 0,sku_same_source_updated INTEGER DEFAULT 0,sku_skipped INTEGER DEFAULT 0,sku_manual_skipped INTEGER DEFAULT 0,sku_stale_skipped INTEGER DEFAULT 0,pdd_created INTEGER DEFAULT 0,pdd_updated INTEGER DEFAULT 0,association_created INTEGER DEFAULT 0,association_unchanged INTEGER DEFAULT 0,failure_count INTEGER DEFAULT 0,error_summary TEXT,response_body TEXT,created_at TEXT,finished_at TEXT,PRIMARY KEY(source,batch_id))`, diff --git a/admin/service/shop.go b/admin/service/shop.go new file mode 100644 index 0000000..88cb2dd --- /dev/null +++ b/admin/service/shop.go @@ -0,0 +1,211 @@ +package service + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +const maxShopNameLength = 500 + +type ShopListResult struct { + Items []model.Shop + UnlinkedShopeeCount int +} + +func ListShops(db *sql.DB, actor *model.User) (ShopListResult, error) { + if actor == nil || !actor.IsAdmin() { + return ShopListResult{}, ErrAdminRequired + } + items, err := repository.ListShops(db) + if err != nil { + return ShopListResult{}, err + } + unlinked, err := repository.CountUnlinkedShopeeShops(db) + if err != nil { + return ShopListResult{}, err + } + return ShopListResult{Items: items, UnlinkedShopeeCount: unlinked}, nil +} + +func ShopOptions(db *sql.DB) ([]model.Shop, error) { + return repository.ListShopOptions(db) +} + +func CreateShop(db *sql.DB, actor *model.User, displayName, sybAlias, shopeeAlias string, now time.Time) error { + if actor == nil || !actor.IsAdmin() { + return ErrAdminRequired + } + displayName, sybAlias, shopeeAlias, err := validateShopInput(displayName, sybAlias, shopeeAlias) + if err != nil { + return err + } + shopID, err := randomID("SHOP-", 16) + if err != nil { + return fmt.Errorf("生成店铺编号失败: %w", err) + } + at := now.UTC().Format(model.TimeLayout) + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := repository.InsertShop(tx, model.Shop{ShopID: shopID, DisplayName: displayName, + NormalizedName: displayName, Enabled: true, CreatedByUserID: actor.UserID, CreatedAt: at, UpdatedAt: at}); err != nil { + return shopValidationError(err) + } + if err := insertShopAlias(tx, shopID, "syb", sybAlias, true, at); err != nil { + return shopValidationError(err) + } + if err := insertShopAlias(tx, shopID, "shopee", shopeeAlias, true, at); err != nil { + return shopValidationError(err) + } + if err := repository.RebuildShopAssociations(tx, shopID); err != nil { + return err + } + return tx.Commit() +} + +func UpdateShop(db *sql.DB, actor *model.User, shopID, displayName, sybAlias, shopeeAlias string, now time.Time) error { + if actor == nil || !actor.IsAdmin() { + return ErrAdminRequired + } + shopID = strings.TrimSpace(shopID) + if shopID == "" { + return &validationError{field: "shop_id", message: "店铺编号不能为空"} + } + displayName, sybAlias, shopeeAlias, err := validateShopInput(displayName, sybAlias, shopeeAlias) + if err != nil { + return err + } + at := now.UTC().Format(model.TimeLayout) + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, found, err := repository.GetShop(tx, shopID); err != nil { + return err + } else if !found { + return &validationError{field: "shop_id", message: "店铺不存在,请刷新页面后重试"} + } + sybEnabled, hasSybAlias, err := repository.GetShopAliasEnabled(tx, shopID, "syb") + if err != nil { + return err + } + if !hasSybAlias { + sybEnabled = true + } + if _, err := repository.UpdateShopName(tx, shopID, displayName, displayName, at); err != nil { + return shopValidationError(err) + } + if err := replaceShopAlias(tx, shopID, "syb", sybAlias, sybEnabled, at); err != nil { + return shopValidationError(err) + } + if err := replaceShopAlias(tx, shopID, "shopee", shopeeAlias, true, at); err != nil { + return shopValidationError(err) + } + if err := repository.RebuildShopAssociations(tx, shopID); err != nil { + return err + } + return tx.Commit() +} + +func SetShopEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool, now time.Time) error { + if actor == nil || !actor.IsAdmin() { + return ErrAdminRequired + } + found, err := repository.SetShopEnabled(db, strings.TrimSpace(shopID), enabled, now.UTC().Format(model.TimeLayout)) + if err != nil { + return err + } + if !found { + return &validationError{field: "shop_id", message: "店铺不存在,请刷新页面后重试"} + } + return nil +} + +func SetShopSybEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool, now time.Time) error { + if actor == nil || !actor.IsAdmin() { + return ErrAdminRequired + } + found, err := repository.SetShopSybEnabled(db, strings.TrimSpace(shopID), enabled, now.UTC().Format(model.TimeLayout)) + if err != nil { + return err + } + if !found { + return &validationError{field: "shop_id", message: "该店铺没有配置 SYB 店铺名称"} + } + return nil +} + +func DeleteShop(db *sql.DB, actor *model.User, shopID string) error { + if actor == nil || !actor.IsAdmin() { + return ErrAdminRequired + } + deleted, err := repository.DeleteDisabledShop(db, strings.TrimSpace(shopID)) + if errors.Is(err, repository.ErrShopHasReferences) { + return &validationError{field: "shop_id", message: "店铺仍有关联数据,只能保留为停用状态"} + } + if err != nil { + return err + } + if !deleted { + return &validationError{field: "shop_id", message: "店铺不存在或仍在启用,请先停用"} + } + return nil +} + +func validateShopInput(displayName, sybAlias, shopeeAlias string) (string, string, string, error) { + displayName = strings.TrimSpace(displayName) + sybAlias = strings.TrimSpace(sybAlias) + shopeeAlias = strings.TrimSpace(shopeeAlias) + if displayName == "" { + return "", "", "", &validationError{field: "display_name", message: "业务店铺名称不能为空"} + } + for field, value := range map[string]string{"display_name": displayName, "syb_alias": sybAlias, "shopee_alias": shopeeAlias} { + if len([]rune(value)) > maxShopNameLength { + return "", "", "", &validationError{field: field, message: "店铺名称最多 500 个字符"} + } + } + if sybAlias == "" && shopeeAlias == "" { + return "", "", "", &validationError{field: "syb_alias", message: "SYB 或蝦皮店铺名称至少填写一个"} + } + return displayName, sybAlias, shopeeAlias, nil +} + +func insertShopAlias(q repository.Execer, shopID, channel, alias string, enabled bool, at string) error { + if alias == "" { + return nil + } + aliasID, err := randomID("ALIAS-", 16) + if err != nil { + return err + } + return repository.InsertShopAlias(q, model.ShopChannelAlias{AliasID: aliasID, ShopID: shopID, + Channel: channel, AliasName: alias, NormalizedAlias: alias, Enabled: enabled, CreatedAt: at, UpdatedAt: at}) +} + +func replaceShopAlias(q repository.Execer, shopID, channel, alias string, enabled bool, at string) error { + aliasID, err := randomID("ALIAS-", 16) + if err != nil { + return err + } + return repository.ReplaceShopAlias(q, aliasID, shopID, channel, alias, at, enabled) +} + +func shopValidationError(err error) error { + switch { + case errors.Is(err, repository.ErrShopNameExists): + return &validationError{field: "display_name", message: "业务店铺名称已经存在"} + case errors.Is(err, repository.ErrShopAliasExists): + return &validationError{field: "syb_alias", message: "该渠道店铺名称已关联到其他业务店铺"} + default: + return err + } +} diff --git a/admin/service/shop_test.go b/admin/service/shop_test.go new file mode 100644 index 0000000..7ec4824 --- /dev/null +++ b/admin/service/shop_test.go @@ -0,0 +1,79 @@ +package service + +import ( + "errors" + "testing" + "time" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +func TestShop_管理员维护渠道别名并精确关联历史数据(t *testing.T) { + db := newTestDB(t) + now := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) + admin := &model.User{UserID: "SHOP-ADMIN", Username: "shop-admin", PasswordHash: "x", + Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(), + CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()} + if err := repository.CreateUser(db, *admin); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,shopee_shop_name,source,created_at,updated_at) + VALUES('S-HISTORY','历史商品','蝦皮原名','api',?,?)`, model.NowISO(), model.NowISO()); err != nil { + t.Fatal(err) + } + + if err := CreateShop(db, admin, "统一店铺", "SYB原名", "蝦皮原名", now); err != nil { + t.Fatal(err) + } + result, err := ListShops(db, admin) + if err != nil || len(result.Items) != 1 { + t.Fatalf("店铺列表错误: %+v %v", result, err) + } + shop := result.Items[0] + if !shop.Enabled || !shop.SybSyncEnabled || shop.ShopeeProductCount != 1 { + t.Fatalf("店铺状态或回填错误: %+v", shop) + } + product, err := repository.GetShopeeProductByGoodsID(db, "S-HISTORY") + if err != nil || product.ShopID != shop.ShopID { + t.Fatalf("蝦皮商品未精确关联: %+v %v", product, err) + } + if err := SetShopSybEnabled(db, admin, shop.ShopID, false, now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if count, err := CountEnabledSybAllowedShops(db); err != nil || count != 0 { + t.Fatalf("SYB 停用没有生效: %d %v", count, err) + } + if err := EnsureEnabledSybAllowedShops(db); !IsValidationError(err) { + t.Fatalf("没有启用 SYB 店铺应阻止同步: %v", err) + } + if err := SetShopEnabled(db, admin, shop.ShopID, false, now.Add(2*time.Minute)); err != nil { + t.Fatal(err) + } + if err := DeleteShop(db, admin, shop.ShopID); !IsValidationError(err) { + t.Fatalf("有关联商品的店铺不能删除: %v", err) + } +} + +func TestShop_渠道名称不可重复且采购员不能管理(t *testing.T) { + db := newTestDB(t) + now := time.Now() + admin := &model.User{UserID: "SHOP-ADMIN-2", Username: "shop-admin-2", PasswordHash: "x", Role: model.RoleAdmin, + Status: model.UserActive, PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()} + if err := repository.CreateUser(db, *admin); err != nil { + t.Fatal(err) + } + if err := CreateShop(db, admin, "店铺一", "同名", "蝦皮一", now); err != nil { + t.Fatal(err) + } + if err := CreateShop(db, admin, "店铺二", "同名", "蝦皮二", now); !IsValidationError(err) { + t.Fatalf("重复渠道名称必须拒绝: %v", err) + } + purchaser := &model.User{UserID: "BUYER", Role: model.RolePurchaser, Status: model.UserActive} + if err := CreateShop(nil, purchaser, "店铺", "SYB", "", now); !errors.Is(err, ErrAdminRequired) { + t.Fatalf("采购员新增应被拒绝: %v", err) + } + if _, err := ListShops(nil, purchaser); !errors.Is(err, ErrAdminRequired) { + t.Fatalf("采购员读取管理页应被拒绝: %v", err) + } +} diff --git a/admin/service/shopee_list.go b/admin/service/shopee_list.go index 7cfae15..87b6ed1 100644 --- a/admin/service/shopee_list.go +++ b/admin/service/shopee_list.go @@ -18,10 +18,12 @@ import ( // // 一行对应一个**商品**,不是一个 SKU——见工单 #41。 type ShopeeProductView struct { - GoodsID string - Title string - ImageURL string - ShopName string + GoodsID string + Title string + ImageURL string + ShopName string + BusinessShopName string + ShopUnlinked bool // ColorCount / SizeCount 只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41)。 ColorCount int @@ -101,24 +103,26 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (* Rows: make([]ShopeeProductView, 0, len(rows)), Total: total, HasAnyProducts: hasAny > 0, - IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "" || filter.Deleted, + IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "" || filter.StoreID != "" || filter.Deleted, Page: page, PageSize: PageSize, TotalPages: totalPages, } for _, r := range rows { v := ShopeeProductView{ - GoodsID: r.GoodsID, - Title: r.Title, - ImageURL: r.ImageURL, - ShopName: r.ShopeeShopName, - ColorCount: r.ColorCount, - SizeCount: r.SizeCount, - SKUCount: r.SKUCount, - PendingCount: r.PendingCount, - SourceText: shopeeSourceText(r.Source), - UpdatedAt: formatLocalTime(r.UpdatedAt), - Deleted: r.IsDeleted(), + GoodsID: r.GoodsID, + Title: r.Title, + ImageURL: r.ImageURL, + ShopName: r.ShopeeShopName, + BusinessShopName: r.BusinessShopName, + ShopUnlinked: r.ShopID == "", + ColorCount: r.ColorCount, + SizeCount: r.SizeCount, + SKUCount: r.SKUCount, + PendingCount: r.PendingCount, + SourceText: shopeeSourceText(r.Source), + UpdatedAt: formatLocalTime(r.UpdatedAt), + Deleted: r.IsDeleted(), } if r.PddGoodsID == "" { diff --git a/admin/service/syb.go b/admin/service/syb.go index 910a02d..96833e3 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "math" + "sort" "strconv" "strings" "sync" @@ -553,21 +554,22 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config. // 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。 func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time, options SybSyncOptions) SyncReport { report := SyncReport{StartedAt: now, Specified: options.IsSpecified()} - allowedNames, err := repository.ListEnabledSybShopNames(db) + allowedShops, err := repository.ListEnabledSybShopMappings(db) if err != nil { report.Err = fmt.Errorf("读取顺运宝允许店铺失败: %w", err) report.FinishedAt = time.Now().UTC() return report } - if len(allowedNames) == 0 { - report.Err = fmt.Errorf("没有启用的顺运宝同步店铺,请先由管理员在“同步店铺”中配置并启用至少一个店铺") + if len(allowedShops) == 0 { + report.Err = fmt.Errorf("没有启用的顺运宝同步店铺,请先由管理员在“店铺管理”中配置并启用至少一个 SYB 店铺") report.FinishedAt = time.Now().UTC() return report } - allowedShops := make(map[string]struct{}, len(allowedNames)) - for _, name := range allowedNames { - allowedShops[name] = struct{}{} + allowedNames := make([]string, 0, len(allowedShops)) + for name := range allowedShops { + allowedNames = append(allowedNames, name) } + sort.Strings(allowedNames) report.ShopFilterHash = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(allowedNames, "\x00")))) pageSize := cfg.PageSize @@ -754,7 +756,7 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, report.StockCount += len(rawIDs) orderedIDs := make([]int64, 0, len(rawIDs)) for _, id := range rawIDs { - if !sybShopAllowed(allowedShops, stockByID[id].Raw, nil) { + if _, ok := sybShopID(allowedShops, stockByID[id].Raw, nil); !ok { report.ShopSkipped++ continue } @@ -782,12 +784,13 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, } for _, d := range details { stockRow := stockByID[d.ID] - if !sybShopAllowed(allowedShops, stockRow.Raw, d.Raw) { + shopID, ok := sybShopID(allowedShops, stockRow.Raw, d.Raw) + if !ok { report.AcceptedCount-- report.ShopSkipped++ continue } - if err := writeStockDetail(db, cfg.BaseURL, stockRow, d, &report); err != nil { + if err := writeStockDetail(db, cfg.BaseURL, shopID, stockRow, d, &report); err != nil { report.Err = fmt.Errorf("写入货运单 %s(id=%d)失败(本次同步整体作废,"+ "已写入的数据保留): %w", d.Code, d.ID, err) report.FinishedAt = time.Now().UTC() @@ -820,10 +823,10 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, // sybShopAllowed 用明细字段覆盖列表字段后再核对,防止列表通过但明细在同步 // 期间已变成其他店铺。detail 为空时只检查列表快照。 -func sybShopAllowed(allowed map[string]struct{}, listRaw, detailRaw map[string]any) bool { +func sybShopID(allowed map[string]string, listRaw, detailRaw map[string]any) (string, bool) { name := trimmedStringField(mergeRaw(listRaw, detailRaw), "shopName") - _, ok := allowed[name] - return ok + shopID, ok := allowed[name] + return shopID, ok } // validateDetailBatch 确认批量明细响应与请求 ID 一一对应。任何缺失、重复、 @@ -856,7 +859,7 @@ func validateDetailBatch(requested []int64, details []syb.StockDetail) error { // writeStockDetail 把一张货运单的全部商品明细写进 syb_orders, // 一张货运单一个事务(工单 #46「按货运单为单位提交」)。 -func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail syb.StockDetail, report *SyncReport) error { +func writeStockDetail(db *sql.DB, baseURL, shopID string, stockRow syb.StockRow, detail syb.StockDetail, report *SyncReport) error { if len(detail.Details) == 0 { return nil } @@ -888,6 +891,7 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail order := model.SybOrder{ SybID: sybID, OrderNo: detail.Code, + ShopID: shopID, ShopName: trimmedStringField(stockRaw, "shopName"), Title: item.ProductTitle, ProductSpec: item.ProductSpec, diff --git a/admin/service/syb_allowed_shop.go b/admin/service/syb_allowed_shop.go index 9393471..7256de7 100644 --- a/admin/service/syb_allowed_shop.go +++ b/admin/service/syb_allowed_shop.go @@ -2,27 +2,13 @@ package service import ( "database/sql" - "errors" - "fmt" - "strings" - "time" - "cmautobuy/admin/model" "cmautobuy/admin/repository" ) -const maxSybShopNameLength = 500 - -func ListSybAllowedShops(db *sql.DB, actor *model.User) ([]model.SybAllowedShop, error) { - if actor == nil || !actor.IsAdmin() { - return nil, ErrAdminRequired - } - return repository.ListSybAllowedShops(db) -} - func CountEnabledSybAllowedShops(db *sql.DB) (int, error) { - names, err := repository.ListEnabledSybShopNames(db) - return len(names), err + mappings, err := repository.ListEnabledSybShopMappings(db) + return len(mappings), err } func EnsureEnabledSybAllowedShops(db *sql.DB) error { @@ -31,69 +17,7 @@ func EnsureEnabledSybAllowedShops(db *sql.DB) error { return err } if count == 0 { - return &validationError{field: "shop_name", message: "没有启用的顺运宝同步店铺,请先由管理员配置并启用至少一个店铺"} - } - return nil -} - -func CreateSybAllowedShop(db *sql.DB, actor *model.User, rawName string, now time.Time) error { - if actor == nil || !actor.IsAdmin() { - return ErrAdminRequired - } - name := strings.TrimSpace(rawName) - if name == "" { - return &validationError{field: "shop_name", message: "店铺名称不能为空"} - } - if len([]rune(name)) > maxSybShopNameLength { - return &validationError{field: "shop_name", message: "店铺名称最多 500 个字符"} - } - id, err := randomID("SHOP-", 16) - if err != nil { - return fmt.Errorf("生成店铺编号失败: %w", err) - } - at := now.UTC().Format(model.TimeLayout) - err = repository.InsertSybAllowedShop(db, model.SybAllowedShop{ - ShopID: id, ShopName: name, NormalizedName: name, Enabled: true, - CreatedByUserID: actor.UserID, CreatedAt: at, UpdatedAt: at, - }) - if errors.Is(err, repository.ErrSybAllowedShopExists) { - return &validationError{field: "shop_name", message: "该店铺已经在允许列表中,可直接重新启用"} - } - return err -} - -func SetSybAllowedShopEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool, now time.Time) error { - if actor == nil || !actor.IsAdmin() { - return ErrAdminRequired - } - if strings.TrimSpace(shopID) == "" { - return &validationError{field: "shop_id", message: "店铺编号不能为空"} - } - found, err := repository.SetSybAllowedShopEnabled(db, shopID, enabled, now.UTC().Format(model.TimeLayout)) - if err != nil { - return err - } - if !found { - return &validationError{field: "shop_id", message: "店铺不存在,请刷新页面后重试"} - } - return nil -} - -// DeleteDisabledSybAllowedShop 永久删除一条已停用配置,不影响历史货运单和同步记录。 -func DeleteDisabledSybAllowedShop(db *sql.DB, actor *model.User, shopID string) error { - if actor == nil || !actor.IsAdmin() { - return ErrAdminRequired - } - shopID = strings.TrimSpace(shopID) - if shopID == "" { - return &validationError{field: "shop_id", message: "店铺编号不能为空"} - } - deleted, err := repository.DeleteDisabledSybAllowedShop(db, shopID) - if err != nil { - return err - } - if !deleted { - return &validationError{field: "shop_id", message: "店铺不存在或仍在启用,请刷新页面并先停用后再删除"} + return &validationError{field: "shop_name", message: "没有启用的顺运宝同步店铺,请先由管理员在店铺管理中配置并启用至少一个 SYB 店铺"} } return nil } diff --git a/admin/service/syb_allowed_shop_test.go b/admin/service/syb_allowed_shop_test.go deleted file mode 100644 index 3f086a0..0000000 --- a/admin/service/syb_allowed_shop_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package service - -import ( - "errors" - "testing" - "time" - - "cmautobuy/admin/model" - "cmautobuy/admin/repository" -) - -func TestSybAllowedShop_管理员维护与精确去重(t *testing.T) { - db := newTestDB(t) - now := time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC) - admin := &model.User{UserID: "SHOP-ADMIN", Username: "shop-admin", PasswordHash: "x", - Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(), - CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()} - if err := repository.CreateUser(db, *admin); err != nil { - t.Fatal(err) - } - - if err := CreateSybAllowedShop(db, admin, " qwg8fkb044 ", now); err != nil { - t.Fatal(err) - } - if _, err := repository.UpsertSybOrder(db, model.SybOrder{ - SybID: "SHOP-HISTORY", OrderNo: "ORDER-HISTORY", ShopName: "qwg8fkb044", - Quantity: 1, SybData: `{}`, CreatedAt: model.NowISO(), UpdatedAt: model.NowISO(), - }); err != nil { - t.Fatal(err) - } - if err := repository.CreateSybSyncRun(db, model.SybSyncRun{ - RunID: "SHOP-RUN-HISTORY", UserID: admin.UserID, DateFrom: "2026-08-12", - DateTo: "2026-08-12", Status: model.SybSyncRunning, StartedAt: model.NowISO(), - }); err != nil { - t.Fatal(err) - } - rows, err := ListSybAllowedShops(db, admin) - if err != nil || len(rows) != 1 || rows[0].ShopName != "qwg8fkb044" || !rows[0].Enabled { - t.Fatalf("新增结果错误: rows=%+v err=%v", rows, err) - } - if err := CreateSybAllowedShop(db, admin, "qwg8fkb044", now); !IsValidationError(err) { - t.Fatalf("去除首尾空白后的重名应是表单错误,实际 %v", err) - } - if err := SetSybAllowedShopEnabled(db, admin, rows[0].ShopID, false, now.Add(time.Minute)); err != nil { - t.Fatal(err) - } - if count, err := CountEnabledSybAllowedShops(db); err != nil || count != 0 { - t.Fatalf("停用后启用数错误: count=%d err=%v", count, err) - } - if err := EnsureEnabledSybAllowedShops(db); !IsValidationError(err) { - t.Fatalf("空白名单应阻止同步: %v", err) - } - if err := DeleteDisabledSybAllowedShop(db, admin, rows[0].ShopID); err != nil { - t.Fatalf("删除已停用店铺失败: %v", err) - } - if list, err := ListSybAllowedShops(db, admin); err != nil || len(list) != 0 { - t.Fatalf("删除后仍存在: rows=%+v err=%v", list, err) - } - if count, err := repository.CountSybOrdersTotal(db); err != nil || count != 1 { - t.Fatalf("删除配置不应影响历史货运单: count=%d err=%v", count, err) - } - if count, err := repository.CountSybSyncRuns(db); err != nil || count != 1 { - t.Fatalf("删除配置不应影响同步记录: count=%d err=%v", count, err) - } - if err := CreateSybAllowedShop(db, admin, "qwg8fkb044", now.Add(2*time.Minute)); err != nil { - t.Fatalf("删除后应允许重新新增同名店铺: %v", err) - } - newRows, _ := ListSybAllowedShops(db, admin) - if err := DeleteDisabledSybAllowedShop(db, admin, newRows[0].ShopID); !IsValidationError(err) { - t.Fatalf("启用店铺必须拒绝删除: %v", err) - } -} - -func TestSybAllowedShop_采购员不能管理(t *testing.T) { - purchaser := &model.User{UserID: "BUYER", Role: model.RolePurchaser, Status: model.UserActive} - if err := CreateSybAllowedShop(nil, purchaser, "店铺", time.Now()); !errors.Is(err, ErrAdminRequired) { - t.Fatalf("采购员新增应被拒绝: %v", err) - } - if _, err := ListSybAllowedShops(nil, purchaser); !errors.Is(err, ErrAdminRequired) { - t.Fatalf("采购员读取管理列表应被拒绝: %v", err) - } - if err := DeleteDisabledSybAllowedShop(nil, purchaser, "SHOP-1"); !errors.Is(err, ErrAdminRequired) { - t.Fatalf("采购员删除应被拒绝: %v", err) - } -} diff --git a/admin/service/syb_test.go b/admin/service/syb_test.go index d51ff25..ca7639f 100644 --- a/admin/service/syb_test.go +++ b/admin/service/syb_test.go @@ -373,18 +373,23 @@ func newSyncTestDB(t *testing.T) *sql.DB { }); err != nil { t.Fatalf("准备同步测试管理员失败: %v", err) } - if err := repository.InsertSybAllowedShop(db, model.SybAllowedShop{ - ShopID: "SYB-TEST-SHOP", ShopName: "测试店铺", NormalizedName: "测试店铺", Enabled: true, + if err := repository.InsertShop(db, model.Shop{ + ShopID: "SYB-TEST-SHOP", DisplayName: "测试店铺", NormalizedName: "测试店铺", Enabled: true, CreatedByUserID: "SYB-TEST-ADMIN", CreatedAt: now, UpdatedAt: now, }); err != nil { - t.Fatalf("准备同步测试允许店铺失败: %v", err) + t.Fatalf("准备同步测试业务店铺失败: %v", err) + } + if err := repository.InsertShopAlias(db, model.ShopChannelAlias{AliasID: "SYB-TEST-ALIAS", + ShopID: "SYB-TEST-SHOP", Channel: "syb", AliasName: "测试店铺", NormalizedAlias: "测试店铺", + Enabled: true, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("准备同步测试渠道名称失败: %v", err) } return db } func TestRunSybSync_没有启用店铺时不请求顺运宝(t *testing.T) { db := newSyncTestDB(t) - if _, err := db.Exec(`UPDATE syb_allowed_shops SET enabled=0`); err != nil { + if _, err := db.Exec(`UPDATE shop_channel_aliases SET enabled=0 WHERE channel='syb'`); err != nil { t.Fatal(err) } requests := 0 @@ -409,14 +414,14 @@ func TestRunSybSync_没有启用店铺时不请求顺运宝(t *testing.T) { } func TestSybShopAllowed_明细店铺覆盖列表后重新拦截(t *testing.T) { - allowed := map[string]struct{}{"测试店铺": {}} - if !sybShopAllowed(allowed, map[string]any{"shopName": " 测试店铺 "}, nil) { + allowed := map[string]string{"测试店铺": "SHOP-1"} + if shopID, ok := sybShopID(allowed, map[string]any{"shopName": " 测试店铺 "}, nil); !ok || shopID != "SHOP-1" { t.Fatal("应忽略允许店铺名称首尾空白") } - if sybShopAllowed(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": "其他店铺"}) { + if _, ok := sybShopID(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": "其他店铺"}); ok { t.Fatal("明细店铺变化后必须重新拦截") } - if sybShopAllowed(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": " "}) { + if _, ok := sybShopID(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": " "}); ok { t.Fatal("明细店铺变为空值时必须拦截") } } diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 9c63434..4e1d2df 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -17,9 +17,10 @@ 都不进入 sessionStorage。链接原始 href 保持模块根地址,因此脚本失效时 仍可正常导航。 */ var MODULE_QUERY_KEYS = { - "/shopee": ["goods_id", "shop_name", "status", "shop", "image", "deleted", "page"], + "/shopee": ["goods_id", "shop_name", "status", "shop", "image", "store", "deleted", "page"], "/pdd": ["q", "status", "page"], "/syb": ["order_no", "shop", "stage", "page", "date_from", "date_to"], + "/shops": [], "/tasks": ["type", "status", "creator", "q", "page"], "/clients": ["name", "page"], "/users": ["q", "status", "page"] diff --git a/admin/templates/partials/header.html b/admin/templates/partials/header.html index 96b1a45..d9a9a46 100644 --- a/admin/templates/partials/header.html +++ b/admin/templates/partials/header.html @@ -16,6 +16,7 @@ 采集采购 客户端列表 {{if and .CurrentUser .CurrentUser.IsAdmin}} + 店铺管理 用户管理 {{end}} {{if .CurrentUser}} diff --git a/admin/templates/shop/list.html b/admin/templates/shop/list.html new file mode 100644 index 0000000..ae7315a --- /dev/null +++ b/admin/templates/shop/list.html @@ -0,0 +1,64 @@ +{{define "shop/list"}} +{{template "header" .}} + +
+
+ + + + + + + + +
+
+ +{{if .Message}}

{{.Message}}

{{end}} +{{if .Error}}{{end}} +

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

+ +
+ + + + {{range .Rows}} + + + + + + + + + + + {{else}} + + {{end}} + +
业务店铺全局状态SYB 名称SYB 同步蝦皮名称蝦皮商品更新时间操作
{{if .Enabled}}启用{{else}}停用{{end}}{{if .SybAlias}}{{if and .Enabled .SybSyncEnabled}}同步{{else}}不进入同步{{end}}{{else}}—{{end}}{{.ShopeeProductCount}}{{.UpdatedAt}} +
+ + +
+
+ + +
+ {{if .SybAlias}} +
+ + +
+ {{end}} + {{if not .Enabled}} +
+ +
+ {{end}} +
还没有业务店铺。至少填写一个渠道名称后即可新增。
+
+
共 {{len .Rows}} 个业务店铺
+{{template "footer" .}} +{{end}} diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html index f2984d7..f860f0e 100644 --- a/admin/templates/shopee/list.html +++ b/admin/templates/shopee/list.html @@ -11,6 +11,7 @@ + {{if not .DeletedFilter}} @@ -39,6 +40,14 @@ {{end}} + + {{if .CurrentUser.IsAdmin}} @@ -67,7 +67,7 @@ {{if .RangeError}}{{end}} {{if .RangeWarning}}

{{.RangeWarning}}

{{end}}

- 按顺运宝货运单创建日期(UTC+8)同步,开始日和结束日都包含,每次最多 31 天;当前启用 {{.EnabledSyncShopCount}} 个同步店铺。 + 按顺运宝货运单创建日期(UTC+8)同步,开始日和结束日都包含,每次最多 31 天;当前在店铺管理中启用 {{.EnabledSyncShopCount}} 个 SYB 同步店铺。