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