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