@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("明细店铺变为空值时必须拦截")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}>
|
||||
|
||||
@@ -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}}
|
||||
@@ -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` 顺运宝规格映射
|
||||
|
||||
|
||||
@@ -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` 对应的统一
|
||||
名称。两列不能互相替代,未精确关联时必须显示文字“待关联”。
|
||||
|
||||
Reference in New Issue
Block a user