feat: 建立全局店铺跨渠道管理 (#208 #209 #210 #211)

This commit is contained in:
chengma
2026-08-13 11:15:54 +08:00
parent 901b37a8cb
commit 8f707722ba
28 changed files with 1160 additions and 500 deletions
+112
View File
@@ -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)
}
+17 -2
View File
@@ -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) {
//
// `[必须]` 分页控件用 <a href> 纯 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")
}
-89
View File
@@ -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)
}
+10 -5
View File
@@ -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)
+9 -8
View File
@@ -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("店铺删除没有挂到管理员路由组")
}
}
+24 -5
View File
@@ -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
}
+21 -5
View File
@@ -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
}
+158 -2
View File
@@ -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
@@ -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" {
+253
View File
@@ -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
}
+25 -12
View File
@@ -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
+24 -99
View File
@@ -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)
}
}
+3 -1
View File
@@ -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))`,
+211
View File
@@ -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
}
}
+79
View File
@@ -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)
}
}
+20 -16
View File
@@ -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 == "" {
+17 -13
View File
@@ -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,
+3 -79
View File
@@ -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
}
-85
View File
@@ -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)
}
}
+13 -8
View File
@@ -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("明细店铺变为空值时必须拦截")
}
}
+2 -1
View File
@@ -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"]
+1
View File
@@ -16,6 +16,7 @@
<a href="/tasks" data-module-root="/tasks" class="{{if eq .Active "tasks"}}active{{end}}">采集采购</a>
<a href="/clients" data-module-root="/clients" class="{{if eq .Active "clients"}}active{{end}}">客户端列表</a>
{{if and .CurrentUser .CurrentUser.IsAdmin}}
<a href="/shops" data-module-root="/shops" class="{{if eq .Active "shops"}}active{{end}}">店铺管理</a>
<a href="/users" data-module-root="/users" class="{{if eq .Active "users"}}active{{end}}">用户管理</a>
{{end}}
{{if .CurrentUser}}
+64
View File
@@ -0,0 +1,64 @@
{{define "shop/list"}}
{{template "header" .}}
<div class="toolbar">
<form class="inline grow" method="post" action="/shops/create">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="display-name">业务店铺</label>
<input id="display-name" type="text" name="display_name" maxlength="500" required placeholder="页面统一显示名称">
<label for="syb-alias">SYB 名称</label>
<input id="syb-alias" type="text" name="syb_alias" maxlength="500" placeholder="与 SYB shopName 完全一致">
<label for="shopee-alias">蝦皮名称</label>
<input id="shopee-alias" type="text" name="shopee_alias" maxlength="500" placeholder="与导入店铺名完全一致">
<button type="submit" class="primary">新增并启用</button>
</form>
</div>
{{if .Message}}<p class="hint" role="status">{{.Message}}</p>{{end}}
{{if .Error}}<p class="missing" role="alert">{{.Error}}</p>{{end}}
<p class="hint">业务店铺是统一名称;SYB 和蝦皮名称是各渠道原始名称。系统只做忽略首尾空白后的精确匹配,不会模糊猜测。当前有 {{.UnlinkedShopeeCount}} 个蝦皮商品待关联。</p>
<div class="table-wrap">
<table>
<thead><tr><th>业务店铺</th><th>全局状态</th><th>SYB 名称</th><th>SYB 同步</th><th>蝦皮名称</th><th>蝦皮商品</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody>
{{range .Rows}}
<tr>
<td><input name="display_name" value="{{.DisplayName}}" maxlength="500" required form="shop-edit-{{.ShopID}}"></td>
<td>{{if .Enabled}}启用{{else}}停用{{end}}</td>
<td><input name="syb_alias" value="{{.SybAlias}}" maxlength="500" placeholder="未配置" form="shop-edit-{{.ShopID}}"></td>
<td>{{if .SybAlias}}{{if and .Enabled .SybSyncEnabled}}同步{{else}}不进入同步{{end}}{{else}}—{{end}}</td>
<td><input name="shopee_alias" value="{{.ShopeeAlias}}" maxlength="500" placeholder="未配置" form="shop-edit-{{.ShopID}}"></td>
<td>{{.ShopeeProductCount}}</td>
<td>{{.UpdatedAt}}</td>
<td>
<form id="shop-edit-{{.ShopID}}" class="inline" method="post" action="/shops/update">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="shop_id" value="{{.ShopID}}">
<button type="submit">保存</button>
</form>
<form class="inline" method="post" action="/shops/status">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="shop_id" value="{{.ShopID}}"><input type="hidden" name="enabled" value="{{if .Enabled}}0{{else}}1{{end}}">
<button type="submit">{{if .Enabled}}停用店铺{{else}}启用店铺{{end}}</button>
</form>
{{if .SybAlias}}
<form class="inline" method="post" action="/shops/syb-status">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="shop_id" value="{{.ShopID}}"><input type="hidden" name="enabled" value="{{if .SybSyncEnabled}}0{{else}}1{{end}}">
<button type="submit">{{if .SybSyncEnabled}}停用 SYB{{else}}启用 SYB{{end}}</button>
</form>
{{end}}
{{if not .Enabled}}
<form class="inline" method="post" action="/shops/delete" data-confirm-submit="确定删除停用店铺“{{.DisplayName}}”吗?有关联商品或货运单时系统会拒绝删除。">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="shop_id" value="{{.ShopID}}"><button type="submit" class="danger">删除</button>
</form>
{{end}}
</td>
</tr>
{{else}}
<tr class="empty"><td colspan="8">还没有业务店铺。至少填写一个渠道名称后即可新增。</td></tr>
{{end}}
</tbody>
</table>
</div>
<div class="statusbar"><span class="grow"></span><span>共 {{len .Rows}} 个业务店铺</span></div>
{{template "footer" .}}
{{end}}
+12 -1
View File
@@ -11,6 +11,7 @@
<input type="hidden" name="status" value="{{.StatusFilter}}" form="shopee-bulk-form">
<input type="hidden" name="shop" value="{{.ShopFilter}}" form="shopee-bulk-form">
<input type="hidden" name="image" value="{{.ImageFilter}}" form="shopee-bulk-form">
<input type="hidden" name="store" value="{{.StoreFilter}}" form="shopee-bulk-form">
<input type="hidden" name="deleted" value="{{if .DeletedFilter}}1{{end}}" form="shopee-bulk-form">
<input type="hidden" name="page" value="{{.CurrentPage}}" form="shopee-bulk-form">
{{if not .DeletedFilter}}
@@ -39,6 +40,14 @@
<option value="{{.Value}}" {{if eq .Value $.ImageFilter}}selected{{end}}>{{.Text}}</option>
{{end}}
</select>
<label for="store">业务店铺</label>
<select id="store" name="store">
<option value="" {{if eq .StoreFilter ""}}selected{{end}}>全部业务店铺</option>
<option value="unlinked" {{if eq .StoreFilter "unlinked"}}selected{{end}}>待关联</option>
{{range .StoreOptions}}
<option value="{{.ShopID}}" {{if eq .ShopID $.StoreFilter}}selected{{end}}>{{.DisplayName}}{{if not .Enabled}}(已停用){{end}}</option>
{{end}}
</select>
{{if .CurrentUser.IsAdmin}}
<label for="deleted">数据范围</label>
<select id="deleted" name="deleted">
@@ -75,6 +84,7 @@
<th>图片</th>
<th>商品名称</th>
<th>蝦皮店铺</th>
<th>业务店铺</th>
<th>颜色</th>
<th>尺码</th>
{{/* SKU 数是这个商品报表里实际出现过的规格条数。
@@ -102,6 +112,7 @@
title 属性让悬停能看完整标题,收窄之后必须留着。 */}}
<td class="truncate col-title" title="{{.Title}}">{{.Title}}</td>
<td class="truncate col-shop" title="{{.ShopName}}">{{if .ShopName}}{{.ShopName}}{{else}}—{{end}}</td>
<td class="truncate col-shop" title="{{.BusinessShopName}}">{{if .ShopUnlinked}}<span class="missing">待关联</span>{{else}}{{.BusinessShopName}}{{end}}</td>
<td>{{.ColorCount}}</td>
<td>{{.SizeCount}}</td>
<td>{{.SKUCount}}</td>
@@ -113,7 +124,7 @@
</tr>
{{else}}
<tr class="empty">
<td colspan="13">
<td colspan="14">
{{if .IsFiltered}}
当前筛选条件下没有商品。请调整状态、店铺、图片或商品 ID。<br>
<small><a href="/shopee">清除筛选条件</a></small>
+2 -2
View File
@@ -21,7 +21,7 @@
</form>
<button type="button" data-modal-open="sync-history-modal">同步记录</button>
{{if .CurrentUser.IsAdmin}}<a class="button-link" href="/syb/shops">同步店铺({{.EnabledSyncShopCount}})</a>{{end}}
{{if .CurrentUser.IsAdmin}}<a class="button-link" href="/shops">店铺管理(同步 {{.EnabledSyncShopCount}})</a>{{end}}
<form id="syb-collect-form" method="post" action="/syb/collect-pdd-batch" hidden></form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="syb-collect-form">
@@ -67,7 +67,7 @@
{{if .RangeError}}<p class="missing sync-range-message" role="alert">{{.RangeError}}</p>{{end}}
{{if .RangeWarning}}<p class="hint sync-range-message">{{.RangeWarning}}</p>{{end}}
<p class="hint sync-range-message">
按顺运宝货运单创建日期(UTC+8)同步,开始日和结束日都包含,每次最多 31 天;当前启用 {{.EnabledSyncShopCount}} 个同步店铺。
按顺运宝货运单创建日期(UTC+8)同步,开始日和结束日都包含,每次最多 31 天;当前在店铺管理中启用 {{.EnabledSyncShopCount}} 个 SYB 同步店铺。
</p>
<div class="modal-backdrop" id="sync-history-modal" {{if not .NeedSyncHistory}}hidden{{end}}>
-54
View File
@@ -1,54 +0,0 @@
{{define "syb/shops"}}
{{template "header" .}}
<div class="toolbar">
<form class="inline grow" method="post" action="/syb/shops/create">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="shop-name">店铺名称</label>
<input id="shop-name" type="text" name="shop_name" maxlength="500" required
placeholder="与顺运宝 shopName 完全一致">
<button type="submit" class="primary">新增并启用</button>
</form>
<a class="button-link" href="/syb">返回顺运宝数据</a>
</div>
{{if .Message}}<p class="hint" role="status">{{.Message}}</p>{{end}}
{{if .Error}}<p class="missing" role="alert">{{.Error}}</p>{{end}}
<p class="hint">同步只接受启用店铺;匹配时忽略名称首尾空白,但不做模糊匹配。没有启用店铺时同步会安全停止。</p>
<div class="table-wrap">
<table>
<thead><tr><th>店铺名称</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody>
{{range .Rows}}
<tr>
<td title="{{.ShopName}}">{{.ShopName}}</td>
<td>{{if .Enabled}}启用{{else}}停用{{end}}</td>
<td>{{.UpdatedAt}}</td>
<td>
<form class="inline" method="post" action="/syb/shops/status">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="shop_id" value="{{.ShopID}}">
<input type="hidden" name="enabled" value="{{if .Enabled}}0{{else}}1{{end}}">
<button type="submit">{{if .Enabled}}停用{{else}}重新启用{{end}}</button>
</form>
{{if not .Enabled}}
<form class="inline" method="post" action="/syb/shops/delete"
data-confirm-submit="确定永久删除已停用店铺“{{.ShopName}}”吗?历史货运单和同步记录不会删除;如需恢复,必须重新新增该店铺。">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="shop_id" value="{{.ShopID}}">
<button type="submit" class="danger">删除</button>
</form>
{{end}}
</td>
</tr>
{{else}}
<tr class="empty"><td colspan="4">还没有同步店铺。请先新增并启用至少一个店铺,才能开始同步。</td></tr>
{{end}}
</tbody>
</table>
</div>
<div class="statusbar"><span class="grow"></span><span>共 {{len .Rows}} 个店铺,启用 {{.EnabledCount}} 个</span></div>
{{template "footer" .}}
{{end}}
+20 -8
View File
@@ -121,6 +121,10 @@ v15(#203)把历史 `syb_orders` 中每个蝦皮商品最新的非空店铺
“颜色,尺码【建议】”格式,并为历史数据补建无真实外部 ID 的低优先级蝦皮 SKU;
不明确格式只保留原文。v17(#205)为蝦皮商品增加 `deleted_at`、
`deleted_by_user_id` 和删除状态索引,删除改为可恢复的软删除。
v18(#208)新增 `shops` 业务店铺和 `shop_channel_aliases` 渠道名称表,并为
`syb_orders`、`shopee_products` 增加可空 `shop_id`。迁移把 v14 旧准入项一对一
转成业务店铺,同时保留 `syb_allowed_shops` 作为回退依据;历史数据只按渠道原始
名称精确回填,无法确认的记录保持未关联。
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
@@ -543,16 +547,24 @@ v14 起,`stock_count` 继续表示通过完整性校验的**原始货运单数
`shop_filter_hash` 保存同步开始时启用店铺排序后计算的 SHA-256,只用于判断两次同步
是否使用同一份快照,不保存 Cookie、密码或原始响应。
### 5.3 `syb_allowed_shops` 同步店铺准入(MySQL v14)
### 5.3 全局店铺与渠道名称(MySQL v18)
每个店铺只保留一条全局记录。`normalized_name` 是去除首尾空白后的名称并使用
`utf8mb4_bin` 唯一约束;匹配不做模糊、正则或大小写折叠。日常退出同步使用停用,
只有已停用项允许管理员永久删除;条件删除必须再次带 `enabled=0`,避免并发重新启用
后被误删。删除配置不级联历史货运单或同步记录。同步开始时一次读取所有
`enabled=1` 的名称作为不可变快照。
`shops` 是 Admin 内部统一展示的业务店铺;`shop_channel_aliases` 保存同一业务店铺
在 `syb`、`shopee` 渠道中的原始名称。首版每个业务店铺每个渠道维护一个主名称,
渠道和去除首尾空白后的原始名称组成唯一约束。匹配使用 `utf8mb4_bin` 精确比较,
不做模糊、正则、大小写折叠或 AI 猜测。
`[必须]` 没有任何启用店铺时同步关闭失败,不请求顺运宝、不推进游标。该表只控制
后续同步是否入库,不自动删除历史 `syb_orders`。
全局 `shops.enabled` 和 SYB 渠道名称的 `enabled` 是两个不同开关:前者停用整个业务
店铺,后者只停止该店铺进入后续 SYB 同步。蝦皮导入和 SYB 写入都保留上游原始店铺
名称,并把精确解析出的 `shop_id` 作为派生关联;未解析成功时 `shop_id` 保持 `NULL`,
由“待关联”筛选暴露给管理员处理。
`[必须]` 没有任何“业务店铺启用且 SYB 渠道启用”的名称时,同步关闭失败,不请求
顺运宝、不推进游标。编辑渠道名称会在一个事务里重建该业务店铺的历史精确关联。
永久删除只允许全局停用且没有任何货运单或蝦皮商品引用的店铺。
v14 的 `syb_allowed_shops` 在 v18 后不再作为运行时配置源,但为迁移回退保留,不能
在 v18 中直接改名或删除。
## 6. `spec_mappings` 顺运宝规格映射
+9 -5
View File
@@ -472,16 +472,16 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
### 6.1 工具条
```text
开始日期 [2026-08-08] 结束日期 [2026-08-09] [同步] [同步记录] [同步店铺(N)·管理员]
开始日期 [2026-08-08] 结束日期 [2026-08-09] [同步] [同步记录] [店铺管理(同步 N)·管理员]
[创建 PDD 采集任务] [创建采购任务] 处理阶段 [全部] 店铺 [___] 订单号 [___] [搜索] [删除]
```
日期始终显示在主工具条,按 UTC+8 解释且两端都包含。首次打开页面固定默认昨天
到今天,不因覆盖游标位置自动扩大范围;需要补历史缺口时由采购员明确选择日期。
所有账号都能看到当前启用同步店铺数量;只有管理员显示“同步店铺”管理入口。
管理页使用可见的“店铺名称”标签,支持新增、停用、重新启用,以及永久删除已停用
店铺。启用店铺不显示删除入口,服务端仍必须按停用状态条件删除;确认文案显示店铺
名称,并说明历史货运单和同步记录不会删除。
所有账号都能看到当前启用 SYB 同步店铺数量;只有管理员显示独立“店铺管理”入口。
管理页一行一个业务店铺,同时展示和编辑 SYB、蝦皮原始名称;全局启停与 SYB 同步
启停使用不同按钮和明确文字。启用店铺不显示删除入口,服务端还要拒绝删除有关联
货运单或蝦皮商品的停用店铺。
空状态必须明确提示先新增并启用至少一个店铺,否则同步会安全停止。
`[必须]` 「同步」是唯一同步入口:
@@ -913,3 +913,7 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
150px并省略,完整值放在 `title` 和详情中。商品标题继续使用 `.col-title` 的 18%,
不得因新增列再次缩窄;1366×768 下通过表格横向滚动保留可读性。详情同时显示图片、
店铺、各自来源和观测时间,顺运宝观测图仍只出现在观测区域。
蝦皮列表同时显示“业务店铺”列,并提供具体业务店铺和“待关联”筛选。蝦皮店铺列
始终显示导入或同步得到的渠道原始值,用于追溯;业务店铺列显示 `shop_id` 对应的统一
名称。两列不能互相替代,未精确关联时必须显示文字“待关联”。