@@ -18,7 +18,7 @@ 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("page"), c.Query("msg"))
|
h.renderShopeeList(c, c.Query("goods_id"), c.Query("shop_name"), c.Query("status"), c.Query("shop"), c.Query("image"), c.Query("deleted"), c.Query("page"), c.Query("msg"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
|
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
|
||||||
@@ -57,7 +57,7 @@ func (h *Handler) ShopeeDetail(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留关键词和三个下拉筛选,
|
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留关键词和三个下拉筛选,
|
||||||
// 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#150)。
|
// 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#150)。
|
||||||
func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, shopRaw, imageRaw, pageRaw, msg string) {
|
func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, shopRaw, imageRaw, deletedRaw, pageRaw, msg string) {
|
||||||
filter := repository.ShopeeFilter{
|
filter := repository.ShopeeFilter{
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
ShopName: strings.TrimSpace(shopName),
|
ShopName: strings.TrimSpace(shopName),
|
||||||
@@ -65,6 +65,9 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
|
|||||||
Shop: service.ParseShopeePresence(shopRaw),
|
Shop: service.ParseShopeePresence(shopRaw),
|
||||||
Image: service.ParseShopeePresence(imageRaw),
|
Image: service.ParseShopeePresence(imageRaw),
|
||||||
}
|
}
|
||||||
|
if currentUser(c).IsAdmin() && deletedRaw == "1" {
|
||||||
|
filter.Deleted = true
|
||||||
|
}
|
||||||
// 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数,
|
// 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数,
|
||||||
// 局部变量会把它遮住导致编译失败。
|
// 局部变量会把它遮住导致编译失败。
|
||||||
pageNum := service.ParsePage(pageRaw)
|
pageNum := service.ParsePage(pageRaw)
|
||||||
@@ -103,6 +106,9 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
|
|||||||
if filter.Image != "" {
|
if filter.Image != "" {
|
||||||
values.Set("image", filter.Image)
|
values.Set("image", filter.Image)
|
||||||
}
|
}
|
||||||
|
if filter.Deleted {
|
||||||
|
values.Set("deleted", "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": keyword,
|
||||||
@@ -113,6 +119,7 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
|
|||||||
"ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"),
|
"ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"),
|
||||||
"ImageFilter": filter.Image,
|
"ImageFilter": filter.Image,
|
||||||
"ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"),
|
"ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"),
|
||||||
|
"DeletedFilter": filter.Deleted,
|
||||||
"Rows": result.Rows,
|
"Rows": result.Rows,
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"HasAnyProducts": result.HasAnyProducts,
|
"HasAnyProducts": result.HasAnyProducts,
|
||||||
@@ -160,11 +167,31 @@ func (h *Handler) ShopeeSave(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ShopeeDelete 批量删除勾选的行。
|
// ShopeeDelete 批量删除勾选的行。
|
||||||
//
|
|
||||||
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
|
|
||||||
func (h *Handler) ShopeeDelete(c *gin.Context) {
|
func (h *Handler) ShopeeDelete(c *gin.Context) {
|
||||||
// TODO(骨架): 取 ids,二次确认已在前端做过,这里直接删
|
count, err := service.DeleteShopeeProducts(h.db, currentUser(c), c.PostFormArray("ids"))
|
||||||
fail(c, http.StatusNotImplemented, "删除功能尚未实现。")
|
if err != nil {
|
||||||
|
if service.IsValidationError(err) {
|
||||||
|
h.shopeeRedirect(c, "删除失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusInternalServerError, "删除蝦皮商品失败,数据没有被改动。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.shopeeRedirect(c, fmt.Sprintf("已将 %d 个蝦皮商品移入已删除,可由管理员恢复", count))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShopeeRestore 恢复软删除商品及其原有 SKU、PDD 关联。
|
||||||
|
func (h *Handler) ShopeeRestore(c *gin.Context) {
|
||||||
|
count, err := service.RestoreShopeeProducts(h.db, currentUser(c), c.PostFormArray("ids"))
|
||||||
|
if err != nil {
|
||||||
|
if service.IsValidationError(err) {
|
||||||
|
h.shopeeRedirect(c, "恢复失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusInternalServerError, "恢复蝦皮商品失败,数据没有被改动。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.shopeeRedirect(c, fmt.Sprintf("已恢复 %d 个蝦皮商品", count))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShopeeCollect 发起采集任务。
|
// ShopeeCollect 发起采集任务。
|
||||||
@@ -226,6 +253,9 @@ func (h *Handler) shopeeRedirect(c *gin.Context, msg string) {
|
|||||||
if value := service.ParseShopeePresence(c.PostForm("image")); value != "" {
|
if value := service.ParseShopeePresence(c.PostForm("image")); value != "" {
|
||||||
params.Set("image", value)
|
params.Set("image", value)
|
||||||
}
|
}
|
||||||
|
if currentUser(c).IsAdmin() && c.PostForm("deleted") == "1" {
|
||||||
|
params.Set("deleted", "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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,11 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
|||||||
pages.GET("/shopee", h.ShopeeList)
|
pages.GET("/shopee", h.ShopeeList)
|
||||||
pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容
|
pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容
|
||||||
pages.POST("/shopee/save", h.ShopeeSave)
|
pages.POST("/shopee/save", h.ShopeeSave)
|
||||||
pages.POST("/shopee/delete", h.ShopeeDelete)
|
|
||||||
pages.POST("/shopee/collect", h.ShopeeCollect)
|
pages.POST("/shopee/collect", h.ShopeeCollect)
|
||||||
pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch)
|
pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch)
|
||||||
|
shopeeAdmin := pages.Group("/shopee", AdminRequired())
|
||||||
|
shopeeAdmin.POST("/delete", h.ShopeeDelete)
|
||||||
|
shopeeAdmin.POST("/restore", h.ShopeeRestore)
|
||||||
|
|
||||||
// 2. PDD 商品
|
// 2. PDD 商品
|
||||||
pages.GET("/pdd", h.PddList)
|
pages.GET("/pdd", h.PddList)
|
||||||
|
|||||||
+2
-2
@@ -64,7 +64,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\", \"page\"]",
|
"\"/shopee\": [\"goods_id\", \"shop_name\", \"status\", \"shop\", \"image\", \"deleted\", \"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",
|
||||||
@@ -275,7 +275,7 @@ func TestShopeePage_批量创建Pdd采集任务入口(t *testing.T) {
|
|||||||
page := string(content)
|
page := string(content)
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`action="/shopee/collect-batch"`, `data-modal-open="shopee-collect-modal"`,
|
`action="/shopee/collect-batch"`, `data-modal-open="shopee-collect-modal"`,
|
||||||
`data-collect-form="shopee-collect-form"`, `form="shopee-collect-form"`,
|
`data-collect-form="shopee-bulk-form"`, `form="shopee-bulk-form"`,
|
||||||
`<label for="shopee-collect-client">执行客户端</label>`,
|
`<label for="shopee-collect-client">执行客户端</label>`,
|
||||||
`不指定(任意客户端可领取)`, `.AssignableClients`, `data-collect-submit`,
|
`不指定(任意客户端可领取)`, `.AssignableClients`, `data-collect-submit`,
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -177,10 +177,15 @@ type ShopeeProduct struct {
|
|||||||
Source string // report=蝦皮报表完整行;syb=顺运宝同步补建的商品骨架
|
Source string // report=蝦皮报表完整行;syb=顺运宝同步补建的商品骨架
|
||||||
PddGoodsURL string
|
PddGoodsURL string
|
||||||
PddGoodsID string // 指向 PddProduct.GoodsID,为空表示还没填链接
|
PddGoodsID string // 指向 PddProduct.GoodsID,为空表示还没填链接
|
||||||
|
DeletedAt string
|
||||||
|
DeletedByUserID string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsDeleted 判断蝦皮商品是否在回收状态。
|
||||||
|
func (p ShopeeProduct) IsDeleted() bool { return p.DeletedAt != "" }
|
||||||
|
|
||||||
// ShopeeSKU 是蝦皮的一个规格(SKU 级)。
|
// ShopeeSKU 是蝦皮的一个规格(SKU 级)。
|
||||||
//
|
//
|
||||||
// SpecRaw 是报表里的规格原文,例如「黑色,M【建議40-50公斤】」,
|
// SpecRaw 是报表里的规格原文,例如「黑色,M【建議40-50公斤】」,
|
||||||
|
|||||||
@@ -191,6 +191,16 @@ func UpsertCatalogShopeeProduct(q Execer, in CatalogShopeeProductInput) (out Cat
|
|||||||
out.FieldsManualSkipped++
|
out.FieldsManualSkipped++
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// 商品目录是权威来源,可以替换顺运宝为了预览而填入的低优先级值。
|
||||||
|
// 这不是任意跨来源覆盖:只有明确标记为 syb 的值享受升级规则。
|
||||||
|
if source.Valid && source.String == "syb" {
|
||||||
|
if *current != incoming {
|
||||||
|
*current = incoming
|
||||||
|
out.FieldsFilled++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
if in.UpdatePolicy == "fill_missing" {
|
if in.UpdatePolicy == "fill_missing" {
|
||||||
if *current == "" {
|
if *current == "" {
|
||||||
*current = incoming
|
*current = incoming
|
||||||
@@ -324,22 +334,25 @@ func UpsertCatalogShopeeSKU(q Execer, in CatalogShopeeSKUInput) (CatalogSKUOutco
|
|||||||
color, size, advice, skuCode := current.Color.String, current.Size.String, current.Advice.String, current.SKUCode.String
|
color, size, advice, skuCode := current.Color.String, current.Size.String, current.Advice.String, current.SKUCode.String
|
||||||
sources, times := catalogFieldProvenance(current)
|
sources, times := catalogFieldProvenance(current)
|
||||||
changed := external != current.ShopeeSKUID.String
|
changed := external != current.ShopeeSKUID.String
|
||||||
if color == "" && strings.TrimSpace(in.Color) != "" {
|
canFill := func(field, current, incoming string) bool {
|
||||||
|
return strings.TrimSpace(incoming) != "" && (current == "" || (sources[field] == "syb" && in.Source != "syb"))
|
||||||
|
}
|
||||||
|
if canFill("color", color, in.Color) {
|
||||||
color = in.Color
|
color = in.Color
|
||||||
sources["color"], times["color"] = in.Source, in.ObservedAt
|
sources["color"], times["color"] = in.Source, in.ObservedAt
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if size == "" && strings.TrimSpace(in.Size) != "" {
|
if canFill("size", size, in.Size) {
|
||||||
size = in.Size
|
size = in.Size
|
||||||
sources["size"], times["size"] = in.Source, in.ObservedAt
|
sources["size"], times["size"] = in.Source, in.ObservedAt
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if advice == "" && strings.TrimSpace(in.Advice) != "" {
|
if canFill("advice", advice, in.Advice) {
|
||||||
advice = in.Advice
|
advice = in.Advice
|
||||||
sources["advice"], times["advice"] = in.Source, in.ObservedAt
|
sources["advice"], times["advice"] = in.Source, in.ObservedAt
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if skuCode == "" && strings.TrimSpace(in.SKUCode) != "" {
|
if canFill("sku_code", skuCode, in.SKUCode) {
|
||||||
skuCode = in.SKUCode
|
skuCode = in.SKUCode
|
||||||
sources["sku_code"], times["sku_code"] = in.Source, in.ObservedAt
|
sources["sku_code"], times["sku_code"] = in.Source, in.ObservedAt
|
||||||
changed = true
|
changed = true
|
||||||
@@ -349,7 +362,7 @@ func UpsertCatalogShopeeSKU(q Execer, in CatalogShopeeSKUInput) (CatalogSKUOutco
|
|||||||
}
|
}
|
||||||
sourcesJSON, _ := json.Marshal(sources)
|
sourcesJSON, _ := json.Marshal(sources)
|
||||||
timesJSON, _ := json.Marshal(times)
|
timesJSON, _ := json.Marshal(times)
|
||||||
_, err = q.Exec(`UPDATE shopee_skus SET shopee_sku_id=NULLIF(?,''),color=NULLIF(?,''),size=NULLIF(?,''),advice=NULLIF(?,''),sku_code=NULLIF(?,''),parse_ok=CASE WHEN parse_ok=1 THEN 1 ELSE ? END,field_sources=?,field_observed_at=?,updated_at=? WHERE sku_id=?`, external, color, size, advice, skuCode, parse, string(sourcesJSON), string(timesJSON), in.Now, current.RecordID)
|
_, err = q.Exec(`UPDATE shopee_skus SET shopee_sku_id=NULLIF(?,''),color=NULLIF(?,''),size=NULLIF(?,''),advice=NULLIF(?,''),sku_code=NULLIF(?,''),parse_ok=CASE WHEN parse_ok=1 THEN 1 ELSE ? END,field_sources=?,field_observed_at=?,source_observed_at=CASE WHEN source='syb' AND ?<>'syb' THEN ? ELSE source_observed_at END,source=CASE WHEN source='syb' AND ?<>'syb' THEN ? ELSE source END,updated_at=? WHERE sku_id=?`, external, color, size, advice, skuCode, parse, string(sourcesJSON), string(timesJSON), in.Source, in.ObservedAt, in.Source, in.Source, in.Now, current.RecordID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -448,7 +461,7 @@ func UpsertCatalogPddProduct(q Execer, goodsID, url, title, shopName, skusJSON,
|
|||||||
// ApplyCatalogAssociation 只允许空关联建立或相同关联重放。
|
// ApplyCatalogAssociation 只允许空关联建立或相同关联重放。
|
||||||
func ApplyCatalogAssociation(q Execer, shopeeGoodsID, pddGoodsID, now string) (created, unchanged bool, err error) {
|
func ApplyCatalogAssociation(q Execer, shopeeGoodsID, pddGoodsID, now string) (created, unchanged bool, err error) {
|
||||||
var current sql.NullString
|
var current sql.NullString
|
||||||
if err = q.QueryRow(`SELECT pdd_goods_id FROM shopee_products WHERE goods_id=?`, shopeeGoodsID).Scan(¤t); err != nil {
|
if err = q.QueryRow(`SELECT pdd_goods_id FROM shopee_products WHERE goods_id=? AND deleted_at IS NULL`, shopeeGoodsID).Scan(¤t); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var url string
|
var url string
|
||||||
@@ -461,7 +474,7 @@ func ApplyCatalogAssociation(q Execer, shopeeGoodsID, pddGoodsID, now string) (c
|
|||||||
}
|
}
|
||||||
return false, false, fmt.Errorf("蝦皮商品 %s 已关联 PDD 商品 %s", shopeeGoodsID, current.String)
|
return false, false, fmt.Errorf("蝦皮商品 %s 已关联 PDD 商品 %s", shopeeGoodsID, current.String)
|
||||||
}
|
}
|
||||||
_, err = q.Exec(`UPDATE shopee_products SET pdd_goods_id=?,pdd_goods_url=?,updated_at=? WHERE goods_id=?`, pddGoodsID, url, now, shopeeGoodsID)
|
_, err = q.Exec(`UPDATE shopee_products SET pdd_goods_id=?,pdd_goods_url=?,updated_at=? WHERE goods_id=? AND deleted_at IS NULL`, pddGoodsID, url, now, shopeeGoodsID)
|
||||||
return err == nil, false, err
|
return err == nil, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"cmautobuy/admin/spec"
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mysqlSchemaVersion = 14
|
const mysqlSchemaVersion = 17
|
||||||
|
|
||||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||||
@@ -592,10 +592,89 @@ func MigrateMySQL(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 14, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 14, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
return fmt.Errorf("记录 MySQL schema v14 失败: %w", err)
|
return fmt.Errorf("记录 MySQL schema v14 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
current = 14
|
||||||
|
}
|
||||||
|
if current < 15 {
|
||||||
|
if err := migrateMySQLV15(db); err != nil {
|
||||||
|
return fmt.Errorf("执行 MySQL schema v15 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := checkMySQLV15Shape(db); err != nil {
|
||||||
|
return fmt.Errorf("MySQL schema v15 自检失败,未记录版本: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 15, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
return fmt.Errorf("记录 MySQL schema v15 失败: %w", err)
|
||||||
|
}
|
||||||
|
current = 15
|
||||||
|
}
|
||||||
|
if current < 16 {
|
||||||
|
if err := migrateMySQLV16(db); err != nil {
|
||||||
|
return fmt.Errorf("执行 MySQL schema v16 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := checkMySQLV16Shape(db); err != nil {
|
||||||
|
return fmt.Errorf("MySQL schema v16 自检失败,未记录版本: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 16, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
return fmt.Errorf("记录 MySQL schema v16 失败: %w", err)
|
||||||
|
}
|
||||||
|
current = 16
|
||||||
|
}
|
||||||
|
if current < 17 {
|
||||||
|
if err := migrateMySQLV17(db); err != nil {
|
||||||
|
return fmt.Errorf("执行 MySQL schema v17 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := checkMySQLV17Shape(db); err != nil {
|
||||||
|
return fmt.Errorf("MySQL schema v17 自检失败,未记录版本: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 17, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
return fmt.Errorf("记录 MySQL schema v17 失败: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return CheckMySQLSchema(db)
|
return CheckMySQLSchema(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateMySQLV15 以低优先级补全历史 SYB 商品的店铺和图片。
|
||||||
|
func migrateMySQLV15(db *sql.DB) error {
|
||||||
|
return backfillSybProductMetadata(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateMySQLV16 为历史 SYB 数据补建格式明确的低优先级蝦皮 SKU。
|
||||||
|
func migrateMySQLV16(db *sql.DB) error {
|
||||||
|
return backfillSybShopeeSKUs(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateMySQLV17 为蝦皮商品增加可恢复的软删除标记。
|
||||||
|
func migrateMySQLV17(db *sql.DB) error {
|
||||||
|
for _, column := range []struct{ name, ddl string }{
|
||||||
|
{"deleted_at", `ALTER TABLE shopee_products ADD COLUMN deleted_at VARCHAR(35) NULL AFTER pdd_goods_id`},
|
||||||
|
{"deleted_by_user_id", `ALTER TABLE shopee_products ADD COLUMN deleted_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER deleted_at`},
|
||||||
|
} {
|
||||||
|
exists, err := mysqlColumnExists(db, "shopee_products", column.name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
if _, err := db.Exec(column.ddl); err != nil {
|
||||||
|
return fmt.Errorf("增加 shopee_products.%s 失败: %w", column.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exists, err := mysqlIndexExists(db, "shopee_products", "idx_shopee_products_deleted"); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !exists {
|
||||||
|
if _, err := db.Exec(`ALTER TABLE shopee_products ADD INDEX idx_shopee_products_deleted (deleted_at,updated_at,goods_id)`); err != nil {
|
||||||
|
return fmt.Errorf("增加蝦皮商品删除状态索引失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exists, err := mysqlConstraintExists(db, "shopee_products", "fk_shopee_products_deleted_by"); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !exists {
|
||||||
|
if _, err := db.Exec(`ALTER TABLE shopee_products ADD CONSTRAINT fk_shopee_products_deleted_by FOREIGN KEY (deleted_by_user_id) REFERENCES users(user_id) ON DELETE SET NULL`); err != nil {
|
||||||
|
return fmt.Errorf("增加蝦皮商品删除人外键失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 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 (
|
||||||
@@ -1579,7 +1658,59 @@ func CheckMySQLSchema(db *sql.DB) error {
|
|||||||
if err := checkMySQLV13Shape(db); err != nil {
|
if err := checkMySQLV13Shape(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return checkMySQLV14Shape(db)
|
if err := checkMySQLV14Shape(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := checkMySQLV15Shape(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := checkMySQLV16Shape(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkMySQLV17Shape(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMySQLV15Shape(db *sql.DB) error {
|
||||||
|
rows, err := db.Query(`SELECT image_source,image_observed_at,image_is_manual,
|
||||||
|
shop_name_source,shop_name_observed_at,shop_name_is_manual
|
||||||
|
FROM shopee_products LIMIT 0`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("蝦皮商品店铺与图片来源字段缺失: %w", err)
|
||||||
|
}
|
||||||
|
return rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMySQLV16Shape(db *sql.DB) error {
|
||||||
|
rows, err := db.Query(`SELECT spec_key,color,size,advice,parse_ok,source,field_sources,field_observed_at FROM shopee_skus LIMIT 0`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("蝦皮 SKU 解析与来源字段缺失: %w", err)
|
||||||
|
}
|
||||||
|
return rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMySQLV17Shape(db *sql.DB) error {
|
||||||
|
if err := checkMySQLVarcharColumn(db, "shopee_products", "deleted_at", 35, true, "utf8mb4_0900_ai_ci", ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := checkMySQLVarcharColumn(db, "shopee_products", "deleted_by_user_id", 191, true, "utf8mb4_bin", ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range []struct{ kind, name string }{{"index", "idx_shopee_products_deleted"}, {"constraint", "fk_shopee_products_deleted_by"}} {
|
||||||
|
var exists bool
|
||||||
|
var err error
|
||||||
|
if item.kind == "index" {
|
||||||
|
exists, err = mysqlIndexExists(db, "shopee_products", item.name)
|
||||||
|
} else {
|
||||||
|
exists, err = mysqlConstraintExists(db, "shopee_products", item.name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("蝦皮商品软删除%s %s 缺失", item.kind, item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkMySQLV14Shape(db *sql.DB) error {
|
func checkMySQLV14Shape(db *sql.DB) error {
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ func TestMySQLMigrate_V11回填顺运宝店铺且可重放(t *testing.T) {
|
|||||||
if err := MigrateMySQL(db); err != nil {
|
if err := MigrateMySQL(db); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=11`)
|
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=11`)
|
||||||
mustExec(t, db, `ALTER TABLE syb_orders DROP COLUMN shop_name`)
|
mustExec(t, db, `ALTER TABLE syb_orders DROP COLUMN shop_name`)
|
||||||
now := model.NowISO()
|
now := model.NowISO()
|
||||||
mustExec(t, db, `INSERT INTO syb_orders
|
mustExec(t, db, `INSERT INTO syb_orders
|
||||||
@@ -595,7 +595,7 @@ func TestMySQLMigrate_V11升级V12并修复孤儿采集中状态(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// 模拟已在 v11 的生产库:去掉 v12 版本及其附加表,保留全部 v1-v11 结构。
|
// 模拟已在 v11 的生产库:去掉 v12 版本及其附加表,保留全部 v1-v11 结构。
|
||||||
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=12`)
|
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=12`)
|
||||||
mustExec(t, db, `DROP TABLE task_syb_sources`)
|
mustExec(t, db, `DROP TABLE task_syb_sources`)
|
||||||
now := model.NowISO()
|
now := model.NowISO()
|
||||||
mustExec(t, db, `INSERT INTO pdd_products(goods_id,url,collect_status,created_at,updated_at) VALUES
|
mustExec(t, db, `INSERT INTO pdd_products(goods_id,url,collect_status,created_at,updated_at) VALUES
|
||||||
@@ -634,7 +634,7 @@ func TestMySQLMigrate_V12形状错误不记录版本(t *testing.T) {
|
|||||||
if err := MigrateMySQL(db); err != nil {
|
if err := MigrateMySQL(db); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=12`)
|
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=12`)
|
||||||
mustExec(t, db, `DROP TABLE task_syb_sources`)
|
mustExec(t, db, `DROP TABLE task_syb_sources`)
|
||||||
mustExec(t, db, `CREATE TABLE task_syb_sources (
|
mustExec(t, db, `CREATE TABLE task_syb_sources (
|
||||||
task_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
task_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
@@ -660,7 +660,7 @@ func TestMySQLMigrate_V12升级V13迁移任务主键和关联(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// 模拟生产 v12:移除 v13 表、版本和新增的级联更新外键。
|
// 模拟生产 v12:移除 v13 表、版本和新增的级联更新外键。
|
||||||
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=13`)
|
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=13`)
|
||||||
mustExec(t, db, `DROP TABLE task_sequences`)
|
mustExec(t, db, `DROP TABLE task_sequences`)
|
||||||
mustExec(t, db, `ALTER TABLE task_claims DROP FOREIGN KEY fk_task_claims_task`)
|
mustExec(t, db, `ALTER TABLE task_claims DROP FOREIGN KEY fk_task_claims_task`)
|
||||||
mustExec(t, db, `ALTER TABLE task_syb_sources DROP FOREIGN KEY fk_task_syb_sources_task`)
|
mustExec(t, db, `ALTER TABLE task_syb_sources DROP FOREIGN KEY fk_task_syb_sources_task`)
|
||||||
@@ -814,7 +814,7 @@ func TestMySQLMigrate_V13升级V14并可重放(t *testing.T) {
|
|||||||
mustExec(t, db, `ALTER TABLE syb_sync_runs DROP COLUMN shop_skipped_count`)
|
mustExec(t, db, `ALTER TABLE syb_sync_runs DROP COLUMN shop_skipped_count`)
|
||||||
mustExec(t, db, `ALTER TABLE syb_sync_runs DROP COLUMN accepted_stock_count`)
|
mustExec(t, db, `ALTER TABLE syb_sync_runs DROP COLUMN accepted_stock_count`)
|
||||||
mustExec(t, db, `DROP TABLE syb_allowed_shops`)
|
mustExec(t, db, `DROP TABLE syb_allowed_shops`)
|
||||||
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=14`)
|
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=14`)
|
||||||
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('V14-USER','v14-user','x','admin','active','2026-08-12T00:00:00Z','2026-08-12T00:00:00Z','2026-08-12T00:00:00Z')`)
|
VALUES('V14-USER','v14-user','x','admin','active','2026-08-12T00:00:00Z','2026-08-12T00:00:00Z','2026-08-12T00:00:00Z')`)
|
||||||
mustExec(t, db, `INSERT INTO syb_sync_runs(run_id,user_id,date_from,date_to,status,stock_count,started_at,finished_at)
|
mustExec(t, db, `INSERT INTO syb_sync_runs(run_id,user_id,date_from,date_to,status,stock_count,started_at,finished_at)
|
||||||
@@ -835,6 +835,46 @@ func TestMySQLMigrate_V13升级V14并可重放(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMySQLMigrate_V14升级V17回填元数据规格并增加软删除(t *testing.T) {
|
||||||
|
db := openMySQLMigrationTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
cleanMySQLTestSchema(t, db)
|
||||||
|
defer cleanMySQLTestSchema(t, db)
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟真实 v14:移除后三版新增结构和版本记录,保留历史业务数据。
|
||||||
|
mustExec(t, db, `ALTER TABLE shopee_products DROP FOREIGN KEY fk_shopee_products_deleted_by`)
|
||||||
|
mustExec(t, db, `ALTER TABLE shopee_products DROP INDEX idx_shopee_products_deleted`)
|
||||||
|
mustExec(t, db, `ALTER TABLE shopee_products DROP COLUMN deleted_by_user_id`)
|
||||||
|
mustExec(t, db, `ALTER TABLE shopee_products DROP COLUMN deleted_at`)
|
||||||
|
mustExec(t, db, `DELETE FROM schema_migrations WHERE version>=15`)
|
||||||
|
now := "2026-08-13T01:00:00Z"
|
||||||
|
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at)
|
||||||
|
VALUES('S-V15','历史商品','syb',?,?)`, now, now)
|
||||||
|
mustExec(t, db, `INSERT INTO syb_orders(syb_id,order_no,shop_name,title,product_spec,spec_key,shopee_goods_id,quantity,image_url,syb_data,created_at,updated_at)
|
||||||
|
VALUES('SO-V15','O-V15','历史店铺','历史商品','白色,L【建議50-60公斤】','白色,L【建議50-60公斤】','S-V15',1,'https://example.com/history.jpg','{}',?,?)`, now, now)
|
||||||
|
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatalf("v14 升级 v17 失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatalf("v17 重放失败: %v", err)
|
||||||
|
}
|
||||||
|
var shop, image string
|
||||||
|
if err := db.QueryRow(`SELECT shopee_shop_name,image_url FROM shopee_products WHERE goods_id='S-V15'`).Scan(&shop, &image); err != nil || shop != "历史店铺" || image != "https://example.com/history.jpg" {
|
||||||
|
t.Fatalf("v15 元数据回填错误: shop=%q image=%q err=%v", shop, image, err)
|
||||||
|
}
|
||||||
|
var color, size, advice, source string
|
||||||
|
if err := db.QueryRow(`SELECT color,size,advice,source FROM shopee_skus WHERE goods_id='S-V15'`).Scan(&color, &size, &advice, &source); err != nil || color != "白色" || size != "L" || advice != "50-60公斤" || source != "syb" {
|
||||||
|
t.Fatalf("v16 规格回填错误: color=%q size=%q advice=%q source=%q err=%v", color, size, advice, source, err)
|
||||||
|
}
|
||||||
|
if err := checkMySQLV17Shape(db); err != nil {
|
||||||
|
t.Fatalf("v17 软删除结构错误: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
|
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
||||||
|
|||||||
+79
-15
@@ -136,6 +136,7 @@ type ShopeeFilter struct {
|
|||||||
Status string
|
Status string
|
||||||
Shop string // has / missing / 空(全部)
|
Shop string // has / missing / 空(全部)
|
||||||
Image string // has / missing / 空(全部)
|
Image string // has / missing / 空(全部)
|
||||||
|
Deleted bool // true 只看软删除数据;false 只看正常数据
|
||||||
}
|
}
|
||||||
|
|
||||||
// shopeeFilterClause 把关键字、状态、店铺和图片筛选拼成 WHERE 子句,供 ListShopeeProducts
|
// shopeeFilterClause 把关键字、状态、店铺和图片筛选拼成 WHERE 子句,供 ListShopeeProducts
|
||||||
@@ -146,7 +147,10 @@ type ShopeeFilter struct {
|
|||||||
// SKU 时 JOIN 会出重复行,DISTINCT 又会让外层 LIMIT/OFFSET 的行为难推理
|
// SKU 时 JOIN 会出重复行,DISTINCT 又会让外层 LIMIT/OFFSET 的行为难推理
|
||||||
// (见工单 #43)。
|
// (见工单 #43)。
|
||||||
func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
|
func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
|
||||||
var clauses []string
|
clauses := []string{"sp.deleted_at IS NULL"}
|
||||||
|
if filter.Deleted {
|
||||||
|
clauses[0] = "sp.deleted_at IS NOT NULL"
|
||||||
|
}
|
||||||
var args []any
|
var args []any
|
||||||
|
|
||||||
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
|
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
|
||||||
@@ -183,9 +187,6 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
|
|||||||
clauses = append(clauses, `(sp.image_url IS NULL OR TRIM(sp.image_url) = '')`)
|
clauses = append(clauses, `(sp.image_url IS NULL OR TRIM(sp.image_url) = '')`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(clauses) == 0 {
|
|
||||||
return "", args
|
|
||||||
}
|
|
||||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +201,7 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
|||||||
where, args := shopeeFilterClause(filter)
|
where, args := shopeeFilterClause(filter)
|
||||||
sqlText := `
|
sqlText := `
|
||||||
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code, sp.image_url,sp.shopee_shop_name,sp.image_source,sp.image_observed_at,sp.image_is_manual,sp.shop_name_source,sp.shop_name_observed_at,sp.shop_name_is_manual,sp.source,
|
SELECT sp.goods_id, sp.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.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,
|
||||||
COUNT(sk.sku_id) AS sku_count,
|
COUNT(sk.sku_id) AS sku_count,
|
||||||
@@ -222,11 +223,11 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
|||||||
var list []ShopeeProductRow
|
var list []ShopeeProductRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ShopeeProductRow
|
var r ShopeeProductRow
|
||||||
var title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID sql.NullString
|
var 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, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
||||||
&pddGoodsURL, &pddGoodsID, &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,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -246,6 +247,8 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
|||||||
r.Source = source.String
|
r.Source = source.String
|
||||||
r.PddGoodsURL = pddGoodsURL.String
|
r.PddGoodsURL = pddGoodsURL.String
|
||||||
r.PddGoodsID = pddGoodsID.String
|
r.PddGoodsID = pddGoodsID.String
|
||||||
|
r.DeletedAt = deletedAt.String
|
||||||
|
r.DeletedByUserID = deletedBy.String
|
||||||
list = append(list, r)
|
list = append(list, r)
|
||||||
}
|
}
|
||||||
return list, rows.Err()
|
return list, rows.Err()
|
||||||
@@ -270,14 +273,14 @@ func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) {
|
|||||||
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
|
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
|
||||||
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
|
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
|
||||||
var p model.ShopeeProduct
|
var p model.ShopeeProduct
|
||||||
var title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID sql.NullString
|
var title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
|
||||||
var imageManual, shopManual int
|
var imageManual, shopManual int
|
||||||
err := q.QueryRow(`
|
err := q.QueryRow(`
|
||||||
SELECT goods_id, title, shopee_status, main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual, source,
|
SELECT goods_id, title, shopee_status, main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual, source,
|
||||||
pdd_goods_url, pdd_goods_id, created_at, updated_at
|
pdd_goods_url, pdd_goods_id,deleted_at,deleted_by_user_id, created_at, updated_at
|
||||||
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
|
FROM shopee_products WHERE goods_id = ? AND deleted_at IS NULL`, goodsID).Scan(
|
||||||
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
||||||
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
|
&pddGoodsURL, &pddGoodsID, &deletedAt, &deletedBy, &p.CreatedAt, &p.UpdatedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -298,6 +301,8 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct,
|
|||||||
p.Source = source.String
|
p.Source = source.String
|
||||||
p.PddGoodsURL = pddGoodsURL.String
|
p.PddGoodsURL = pddGoodsURL.String
|
||||||
p.PddGoodsID = pddGoodsID.String
|
p.PddGoodsID = pddGoodsID.String
|
||||||
|
p.DeletedAt = deletedAt.String
|
||||||
|
p.DeletedByUserID = deletedBy.String
|
||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +319,7 @@ func ListShopeePddLinksByGoodsIDs(q Execer, goodsIDs []string) (map[string]strin
|
|||||||
args[i] = goodsID
|
args[i] = goodsID
|
||||||
}
|
}
|
||||||
rows, err := q.Query(`SELECT goods_id, COALESCE(pdd_goods_id, '')
|
rows, err := q.Query(`SELECT goods_id, COALESCE(pdd_goods_id, '')
|
||||||
FROM shopee_products WHERE goods_id IN (`+placeholders+`)`, args...)
|
FROM shopee_products WHERE deleted_at IS NULL AND goods_id IN (`+placeholders+`)`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("批量查询蝦皮商品 PDD 关联失败: %w", err)
|
return nil, fmt.Errorf("批量查询蝦皮商品 PDD 关联失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -338,7 +343,7 @@ func UpdateShopeePddLink(q Execer, shopeeGoodsID, pddGoodsID, pddURL string) err
|
|||||||
result, err := q.Exec(`
|
result, err := q.Exec(`
|
||||||
UPDATE shopee_products
|
UPDATE shopee_products
|
||||||
SET pdd_goods_id = ?, pdd_goods_url = ?, updated_at = ?
|
SET pdd_goods_id = ?, pdd_goods_url = ?, updated_at = ?
|
||||||
WHERE goods_id = ?`,
|
WHERE goods_id = ? AND deleted_at IS NULL`,
|
||||||
pddGoodsID, pddURL, model.NowISO(), shopeeGoodsID)
|
pddGoodsID, pddURL, model.NowISO(), shopeeGoodsID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("更新蝦皮商品 %s 的 PDD 关联失败: %w", shopeeGoodsID, err)
|
return fmt.Errorf("更新蝦皮商品 %s 的 PDD 关联失败: %w", shopeeGoodsID, err)
|
||||||
@@ -418,7 +423,8 @@ func GetShopeeSKUByID(q Execer, skuID string) (*model.ShopeeSKU, error) {
|
|||||||
err := q.QueryRow(`
|
err := q.QueryRow(`
|
||||||
SELECT sku_id, COALESCE(shopee_sku_id,''), goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
|
SELECT sku_id, COALESCE(shopee_sku_id,''), goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
|
||||||
is_manual, created_at, updated_at
|
is_manual, created_at, updated_at
|
||||||
FROM shopee_skus WHERE sku_id = ?`, skuID).Scan(
|
FROM shopee_skus sk WHERE sku_id = ?
|
||||||
|
AND EXISTS (SELECT 1 FROM shopee_products sp WHERE sp.goods_id=sk.goods_id AND sp.deleted_at IS NULL)`, skuID).Scan(
|
||||||
&sk.RecordID, &sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice,
|
&sk.RecordID, &sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice,
|
||||||
&parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt,
|
&parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -440,8 +446,66 @@ func GetShopeeSKUByID(q Execer, skuID string) (*model.ShopeeSKU, error) {
|
|||||||
// CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
|
// CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
|
||||||
func CountShopeeProducts(q Execer) (int, error) {
|
func CountShopeeProducts(q Execer) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
if err := q.QueryRow(`SELECT COUNT(*) FROM shopee_products`).Scan(&n); err != nil {
|
if err := q.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE deleted_at IS NULL`).Scan(&n); err != nil {
|
||||||
return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err)
|
return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err)
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShopeeProductsWithActiveTasks 返回仍有采集或采购任务执行中的商品编号。
|
||||||
|
func ShopeeProductsWithActiveTasks(q Execer, goodsIDs []string) ([]string, error) {
|
||||||
|
if len(goodsIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(goodsIDs)), ",")
|
||||||
|
args := make([]any, len(goodsIDs))
|
||||||
|
for i, id := range goodsIDs {
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
rows, err := q.Query(`SELECT DISTINCT sp.goods_id
|
||||||
|
FROM shopee_products sp JOIN tasks t
|
||||||
|
ON (t.task_type='purchase' AND t.goods_id=sp.goods_id)
|
||||||
|
OR (t.task_type='collect' AND t.goods_id=sp.pdd_goods_id)
|
||||||
|
WHERE sp.goods_id IN (`+placeholders+`)
|
||||||
|
AND t.status IN ('pending','assigned','claimed') ORDER BY sp.goods_id`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("检查蝦皮商品进行中任务失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetShopeeProductsDeleted 批量设置或清除软删除标记。
|
||||||
|
func SetShopeeProductsDeleted(q Execer, goodsIDs []string, actorUserID, deletedAt string) (int64, error) {
|
||||||
|
if len(goodsIDs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(goodsIDs)), ",")
|
||||||
|
args := make([]any, 0, len(goodsIDs)+3)
|
||||||
|
var sqlText string
|
||||||
|
if deletedAt != "" {
|
||||||
|
sqlText = `UPDATE shopee_products SET deleted_at=?,deleted_by_user_id=?,updated_at=?
|
||||||
|
WHERE deleted_at IS NULL AND goods_id IN (` + placeholders + `)`
|
||||||
|
args = append(args, deletedAt, actorUserID, deletedAt)
|
||||||
|
} else {
|
||||||
|
sqlText = `UPDATE shopee_products SET deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
|
||||||
|
WHERE deleted_at IS NOT NULL AND goods_id IN (` + placeholders + `)`
|
||||||
|
args = append(args, model.NowISO())
|
||||||
|
}
|
||||||
|
for _, id := range goodsIDs {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
result, err := q.Exec(sqlText, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("更新蝦皮商品删除状态失败: %w", err)
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func ListShopSpecSources(q Execer, shopName string) ([]ShopSpecSource, error) {
|
|||||||
pp.goods_id, pp.deleted_at, pp.skus_json
|
pp.goods_id, pp.deleted_at, pp.skus_json
|
||||||
FROM shopee_products sp
|
FROM shopee_products sp
|
||||||
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id
|
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id
|
||||||
WHERE sp.shopee_shop_name = ?
|
WHERE sp.deleted_at IS NULL AND sp.shopee_shop_name = ?
|
||||||
ORDER BY sp.goods_id`, strings.TrimSpace(shopName))
|
ORDER BY sp.goods_id`, strings.TrimSpace(shopName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("读取店铺规格对比数据失败: %w", err)
|
return nil, fmt.Errorf("读取店铺规格对比数据失败: %w", err)
|
||||||
@@ -65,7 +65,7 @@ func ListShopSpecSources(q Execer, shopName string) ([]ShopSpecSource, error) {
|
|||||||
SELECT sk.goods_id, sk.color, sk.size, sk.parse_ok
|
SELECT sk.goods_id, sk.color, sk.size, sk.parse_ok
|
||||||
FROM shopee_skus sk
|
FROM shopee_skus sk
|
||||||
JOIN shopee_products sp ON sp.goods_id = sk.goods_id
|
JOIN shopee_products sp ON sp.goods_id = sk.goods_id
|
||||||
WHERE sp.shopee_shop_name = ?
|
WHERE sp.deleted_at IS NULL AND sp.shopee_shop_name = ?
|
||||||
ORDER BY sk.goods_id, sk.sku_id`, strings.TrimSpace(shopName))
|
ORDER BY sk.goods_id, sk.sku_id`, strings.TrimSpace(shopName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("读取店铺正式规格失败: %w", err)
|
return nil, fmt.Errorf("读取店铺正式规格失败: %w", err)
|
||||||
|
|||||||
+171
-14
@@ -5,7 +5,9 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -351,19 +353,7 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
|||||||
|
|
||||||
now := model.NowISO()
|
now := model.NowISO()
|
||||||
if strings.TrimSpace(o.ShopeeGoodsID) != "" {
|
if strings.TrimSpace(o.ShopeeGoodsID) != "" {
|
||||||
if _, err := q.Exec(`
|
if err := upsertSybShopeeProduct(q, o.ShopeeGoodsID, o.Title, o.ShopName, o.ImageURL, now); err != nil {
|
||||||
INSERT INTO shopee_products (goods_id, title, source, created_at, updated_at)
|
|
||||||
VALUES (?, ?, 'syb', ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
title = CASE
|
|
||||||
WHEN source = 'syb' AND TRIM(VALUES(title)) <> '' THEN VALUES(title)
|
|
||||||
ELSE title
|
|
||||||
END,
|
|
||||||
updated_at = CASE
|
|
||||||
WHEN source = 'syb' AND TRIM(VALUES(title)) <> '' THEN VALUES(updated_at)
|
|
||||||
ELSE updated_at
|
|
||||||
END`,
|
|
||||||
o.ShopeeGoodsID, o.Title, now, now); err != nil {
|
|
||||||
return false, fmt.Errorf("为顺运宝明细 %s 补建蝦皮商品骨架失败: %w", o.SybID, err)
|
return false, fmt.Errorf("为顺运宝明细 %s 补建蝦皮商品骨架失败: %w", o.SybID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,6 +383,173 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
|||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upsertSybShopeeProduct 用顺运宝观测补建蝦皮商品骨架。
|
||||||
|
//
|
||||||
|
// 店铺和图片是字段级低优先级数据:只能补空值,或更新原本同样来自 syb 的值;
|
||||||
|
// 人工字段和商品目录等权威来源永远不被顺运宝覆盖。空值也不能清除已有内容。
|
||||||
|
func upsertSybShopeeProduct(q Execer, goodsID, title, shopName, imageURL, observedAt string) error {
|
||||||
|
_, err := q.Exec(`
|
||||||
|
INSERT INTO shopee_products
|
||||||
|
(goods_id,title,image_url,shopee_shop_name,
|
||||||
|
image_source,image_observed_at,image_is_manual,
|
||||||
|
shop_name_source,shop_name_observed_at,shop_name_is_manual,
|
||||||
|
source,source_observed_at,created_at,updated_at)
|
||||||
|
VALUES (?, ?, NULLIF(TRIM(?),''), NULLIF(TRIM(?),''),
|
||||||
|
CASE WHEN TRIM(?)='' THEN NULL ELSE 'syb' END,
|
||||||
|
CASE WHEN TRIM(?)='' THEN NULL ELSE ? END, 0,
|
||||||
|
CASE WHEN TRIM(?)='' THEN NULL ELSE 'syb' END,
|
||||||
|
CASE WHEN TRIM(?)='' THEN NULL ELSE ? END, 0,
|
||||||
|
'syb', ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
title = CASE
|
||||||
|
WHEN source='syb' AND TRIM(VALUES(title))<>'' THEN VALUES(title)
|
||||||
|
ELSE title
|
||||||
|
END,
|
||||||
|
source_observed_at = CASE
|
||||||
|
WHEN source='syb' AND TRIM(VALUES(title))<>'' THEN VALUES(source_observed_at)
|
||||||
|
ELSE source_observed_at
|
||||||
|
END,
|
||||||
|
image_url = CASE
|
||||||
|
WHEN image_is_manual=0 AND TRIM(VALUES(image_url))<>''
|
||||||
|
AND (image_url IS NULL OR TRIM(image_url)='' OR image_source='syb') THEN VALUES(image_url)
|
||||||
|
ELSE image_url
|
||||||
|
END,
|
||||||
|
image_observed_at = CASE
|
||||||
|
WHEN image_is_manual=0 AND TRIM(VALUES(image_url))<>''
|
||||||
|
AND (image_url IS NULL OR TRIM(image_url)='' OR image_source='syb') THEN VALUES(image_observed_at)
|
||||||
|
ELSE image_observed_at
|
||||||
|
END,
|
||||||
|
image_source = CASE
|
||||||
|
WHEN image_is_manual=0 AND TRIM(VALUES(image_url))<>''
|
||||||
|
AND (image_url IS NULL OR TRIM(image_url)='' OR image_source='syb') THEN 'syb'
|
||||||
|
ELSE image_source
|
||||||
|
END,
|
||||||
|
shopee_shop_name = CASE
|
||||||
|
WHEN shop_name_is_manual=0 AND TRIM(VALUES(shopee_shop_name))<>''
|
||||||
|
AND (shopee_shop_name IS NULL OR TRIM(shopee_shop_name)='' OR shop_name_source='syb') THEN VALUES(shopee_shop_name)
|
||||||
|
ELSE shopee_shop_name
|
||||||
|
END,
|
||||||
|
shop_name_observed_at = CASE
|
||||||
|
WHEN shop_name_is_manual=0 AND TRIM(VALUES(shopee_shop_name))<>''
|
||||||
|
AND (shopee_shop_name IS NULL OR TRIM(shopee_shop_name)='' OR shop_name_source='syb') THEN VALUES(shop_name_observed_at)
|
||||||
|
ELSE shop_name_observed_at
|
||||||
|
END,
|
||||||
|
shop_name_source = CASE
|
||||||
|
WHEN shop_name_is_manual=0 AND TRIM(VALUES(shopee_shop_name))<>''
|
||||||
|
AND (shopee_shop_name IS NULL OR TRIM(shopee_shop_name)='' OR shop_name_source='syb') THEN 'syb'
|
||||||
|
ELSE shop_name_source
|
||||||
|
END,
|
||||||
|
updated_at = CASE
|
||||||
|
WHEN source='syb'
|
||||||
|
OR (image_source='syb' AND TRIM(VALUES(image_url))<>'')
|
||||||
|
OR (shop_name_source='syb' AND TRIM(VALUES(shopee_shop_name))<>'') THEN VALUES(updated_at)
|
||||||
|
ELSE updated_at
|
||||||
|
END`,
|
||||||
|
goodsID, title, imageURL, shopName,
|
||||||
|
imageURL, imageURL, observedAt,
|
||||||
|
shopName, shopName, observedAt,
|
||||||
|
observedAt, observedAt, observedAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfillSybProductMetadata 把历史货运单中每个商品最新的非空店铺、图片补入商品主表。
|
||||||
|
// 重放是安全的:实际覆盖规则仍由 upsertSybShopeeProduct 统一执行。
|
||||||
|
func backfillSybProductMetadata(db *sql.DB) error {
|
||||||
|
type metadata struct{ shopName, imageURL, observedAt string }
|
||||||
|
items := map[string]metadata{}
|
||||||
|
readLatest := func(column string, assign func(*metadata, string)) error {
|
||||||
|
query := fmt.Sprintf(`SELECT shopee_goods_id, value_text, updated_at FROM (
|
||||||
|
SELECT shopee_goods_id, %s AS value_text, updated_at,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY shopee_goods_id ORDER BY updated_at DESC, syb_id DESC) AS row_no
|
||||||
|
FROM syb_orders
|
||||||
|
WHERE TRIM(COALESCE(shopee_goods_id,''))<>'' AND TRIM(COALESCE(%s,''))<>''
|
||||||
|
) ranked WHERE row_no=1`, column, column)
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var goodsID, value, observedAt string
|
||||||
|
if err := rows.Scan(&goodsID, &value, &observedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
item := items[goodsID]
|
||||||
|
assign(&item, value)
|
||||||
|
if observedAt > item.observedAt {
|
||||||
|
item.observedAt = observedAt
|
||||||
|
}
|
||||||
|
items[goodsID] = item
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
if err := readLatest("shop_name", func(item *metadata, value string) { item.shopName = value }); err != nil {
|
||||||
|
return fmt.Errorf("读取历史顺运宝店铺失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := readLatest("image_url", func(item *metadata, value string) { item.imageURL = value }); err != nil {
|
||||||
|
return fmt.Errorf("读取历史顺运宝图片失败: %w", err)
|
||||||
|
}
|
||||||
|
for goodsID, item := range items {
|
||||||
|
if err := upsertSybShopeeProduct(db, goodsID, "", item.shopName, item.imageURL, item.observedAt); err != nil {
|
||||||
|
return fmt.Errorf("回填蝦皮商品 %s 的顺运宝元数据失败: %w", goodsID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertSybShopeeSKU 把格式明确的 SYB 规格作为低优先级蝦皮 SKU 观测写入。
|
||||||
|
// 返回 parsed=false 时不写库,避免把猜测结果污染商品主数据。
|
||||||
|
func UpsertSybShopeeSKU(q Execer, goodsID, specRaw, observedAt string) (parsed bool, err error) {
|
||||||
|
parsedSpec, ok := spec.ParseShopeeSpec(specRaw)
|
||||||
|
if !ok || strings.TrimSpace(goodsID) == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
specKey, err := spec.SpecKey(specRaw)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(goodsID + "\x00" + specKey))
|
||||||
|
recordID := "syb:" + hex.EncodeToString(sum[:])
|
||||||
|
now := model.NowISO()
|
||||||
|
_, err = UpsertCatalogShopeeSKU(q, CatalogShopeeSKUInput{
|
||||||
|
RecordID: recordID, GoodsID: goodsID, SpecRaw: specRaw, SpecKey: specKey,
|
||||||
|
Color: parsedSpec.Color, Size: parsedSpec.Size, Advice: parsedSpec.Advice,
|
||||||
|
ParseOK: true, Source: "syb", ObservedAt: observedAt, Now: now, UpdatePolicy: "fill_missing",
|
||||||
|
})
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func backfillSybShopeeSKUs(db *sql.DB) error {
|
||||||
|
rows, err := db.Query(`SELECT shopee_goods_id,product_spec,updated_at FROM (
|
||||||
|
SELECT shopee_goods_id,product_spec,updated_at,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY shopee_goods_id,product_spec ORDER BY updated_at DESC,syb_id DESC) row_no
|
||||||
|
FROM syb_orders
|
||||||
|
WHERE TRIM(COALESCE(shopee_goods_id,''))<>'' AND TRIM(COALESCE(product_spec,''))<>''
|
||||||
|
) ranked WHERE row_no=1`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取历史顺运宝规格失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type observation struct{ goodsID, raw, observedAt string }
|
||||||
|
var observations []observation
|
||||||
|
for rows.Next() {
|
||||||
|
var item observation
|
||||||
|
if err := rows.Scan(&item.goodsID, &item.raw, &item.observedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
observations = append(observations, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range observations {
|
||||||
|
if _, err := UpsertSybShopeeSKU(db, item.goodsID, item.raw, item.observedAt); err != nil {
|
||||||
|
return fmt.Errorf("回填蝦皮商品 %s 的顺运宝规格失败: %w", item.goodsID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SybSpecObservation 是顺运宝对某个蝦皮商品规格的历史观测汇总。
|
// SybSpecObservation 是顺运宝对某个蝦皮商品规格的历史观测汇总。
|
||||||
// 它不是正式蝦皮 SKU,不得写回 shopee_skus。
|
// 它不是正式蝦皮 SKU,不得写回 shopee_skus。
|
||||||
type SybSpecObservation struct {
|
type SybSpecObservation struct {
|
||||||
@@ -482,7 +639,7 @@ func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
|
|||||||
|
|
||||||
const sybOrderContextFrom = `
|
const sybOrderContextFrom = `
|
||||||
FROM syb_orders so
|
FROM syb_orders so
|
||||||
LEFT JOIN shopee_products sp ON sp.goods_id = so.shopee_goods_id
|
LEFT JOIN shopee_products sp ON sp.goods_id = so.shopee_goods_id AND sp.deleted_at IS NULL
|
||||||
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL
|
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL
|
||||||
LEFT JOIN spec_mappings sm
|
LEFT JOIN spec_mappings sm
|
||||||
ON sm.shopee_goods_id = so.shopee_goods_id
|
ON sm.shopee_goods_id = so.shopee_goods_id
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"cmautobuy/admin/config"
|
"cmautobuy/admin/config"
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newSybTestDB(t *testing.T) *sql.DB {
|
func newSybTestDB(t *testing.T) *sql.DB {
|
||||||
@@ -140,6 +141,67 @@ func TestUpsertSybOrder_只更新Syb骨架标题(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpsertSybOrder_店铺图片按字段来源保护(t *testing.T) {
|
||||||
|
db := newSybTestDB(t)
|
||||||
|
order := model.SybOrder{SybID: "META-1", OrderNo: "O-META", Title: "商品", ShopeeGoodsID: "SP-META", ProductSpec: "黑色,M", ShopName: "店铺一", ImageURL: "https://example.com/1.jpg", Quantity: 1, SybData: "{}"}
|
||||||
|
if _, err := UpsertSybOrder(db, order); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var shop, image, shopSource, imageSource string
|
||||||
|
if err := db.QueryRow(`SELECT shopee_shop_name,image_url,shop_name_source,image_source FROM shopee_products WHERE goods_id='SP-META'`).Scan(&shop, &image, &shopSource, &imageSource); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if shop != "店铺一" || image != "https://example.com/1.jpg" || shopSource != "syb" || imageSource != "syb" {
|
||||||
|
t.Fatalf("首次补全不正确:shop=%q image=%q sources=%q/%q", shop, image, shopSource, imageSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
order.ShopName, order.ImageURL = "店铺二", "https://example.com/2.jpg"
|
||||||
|
if _, err := UpsertSybOrder(db, order); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`UPDATE shopee_products SET image_url='https://example.com/api.jpg',image_source='api',shop_name_is_manual=1 WHERE goods_id='SP-META'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
order.ShopName, order.ImageURL = "不应覆盖的店铺", "https://example.com/3.jpg"
|
||||||
|
if _, err := UpsertSybOrder(db, order); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT shopee_shop_name,image_url FROM shopee_products WHERE goods_id='SP-META'`).Scan(&shop, &image); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if shop != "店铺二" || image != "https://example.com/api.jpg" {
|
||||||
|
t.Fatalf("字段保护失败:shop=%q image=%q", shop, image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertSybShopeeSKU_确定性解析并允许权威来源升级(t *testing.T) {
|
||||||
|
db := newSybTestDB(t)
|
||||||
|
now := model.NowISO()
|
||||||
|
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('SP-SKU','商品','syb',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parsed, err := UpsertSybShopeeSKU(db, "SP-SKU", "白色,L【建議50-60公斤】", now)
|
||||||
|
if err != nil || !parsed {
|
||||||
|
t.Fatalf("SYB 规格应解析成功:parsed=%v err=%v", parsed, err)
|
||||||
|
}
|
||||||
|
if parsed, err := UpsertSybShopeeSKU(db, "SP-SKU", "不明确规格", now); err != nil || parsed {
|
||||||
|
t.Fatalf("不明确规格不应写入:parsed=%v err=%v", parsed, err)
|
||||||
|
}
|
||||||
|
key, _ := spec.SpecKey("白色,L【建議50-60公斤】")
|
||||||
|
outcome, err := UpsertCatalogShopeeSKU(db, CatalogShopeeSKUInput{RecordID: "API-NEW", ShopeeSKUID: "REAL-1", GoodsID: "SP-SKU", SpecRaw: "白色,L【建議50-60公斤】", SpecKey: key, Color: "象牙白", Size: "L", Advice: "建议50-60公斤", ParseOK: true, Source: "partner", ObservedAt: now, Now: now, UpdatePolicy: "fill_missing"})
|
||||||
|
if err != nil || outcome != CatalogSKUFilled {
|
||||||
|
t.Fatalf("权威来源升级失败:outcome=%s err=%v", outcome, err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
var external, color, sourceJSON string
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*),MAX(COALESCE(shopee_sku_id,'')),MAX(COALESCE(color,'')),MAX(COALESCE(field_sources,'')) FROM shopee_skus WHERE goods_id='SP-SKU'`).Scan(&count, &external, &color, &sourceJSON); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 || external != "REAL-1" || color != "象牙白" || !strings.Contains(sourceJSON, `"color":"partner"`) {
|
||||||
|
t.Fatalf("升级后数据不正确:count=%d external=%q color=%q sources=%s", count, external, color, sourceJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpsertSybOrder_ShopeeGoodsID会被同步更新(t *testing.T) {
|
func TestUpsertSybOrder_ShopeeGoodsID会被同步更新(t *testing.T) {
|
||||||
db := newSybTestDB(t)
|
db := newSybTestDB(t)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func newCatalogTestDB(t *testing.T) *sql.DB {
|
|||||||
}
|
}
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
statements := []string{
|
statements := []string{
|
||||||
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,created_at TEXT,updated_at TEXT)`,
|
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,deleted_at TEXT,deleted_by_user_id TEXT,created_at TEXT,updated_at TEXT)`,
|
||||||
`CREATE TABLE shopee_skus(sku_id TEXT PRIMARY KEY,shopee_sku_id TEXT UNIQUE,goods_id TEXT NOT NULL,spec_raw TEXT,spec_key TEXT,color TEXT,size TEXT,advice TEXT,parse_ok INTEGER,sku_code TEXT,is_manual INTEGER,source TEXT,field_sources TEXT,field_observed_at TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT,UNIQUE(goods_id,spec_key))`,
|
`CREATE TABLE shopee_skus(sku_id TEXT PRIMARY KEY,shopee_sku_id TEXT UNIQUE,goods_id TEXT NOT NULL,spec_raw TEXT,spec_key TEXT,color TEXT,size TEXT,advice TEXT,parse_ok INTEGER,sku_code TEXT,is_manual INTEGER,source TEXT,field_sources TEXT,field_observed_at TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT,UNIQUE(goods_id,spec_key))`,
|
||||||
`CREATE TABLE pdd_products(id INTEGER PRIMARY KEY AUTOINCREMENT,goods_id TEXT UNIQUE,url TEXT,title TEXT,shop_name TEXT,skus_json TEXT,collect_status TEXT,collect_msg TEXT,artifact_ref TEXT,collected_at TEXT,deleted_at TEXT,source TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT)`,
|
`CREATE TABLE pdd_products(id INTEGER PRIMARY KEY AUTOINCREMENT,goods_id TEXT UNIQUE,url TEXT,title TEXT,shop_name TEXT,skus_json TEXT,collect_status TEXT,collect_msg TEXT,artifact_ref TEXT,collected_at TEXT,deleted_at TEXT,source TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT)`,
|
||||||
`CREATE TABLE catalog_import_runs(source TEXT,batch_id TEXT,request_hash TEXT,status TEXT,request_count INTEGER,conflict_count INTEGER,observed_at TEXT,update_policy TEXT DEFAULT 'fill_missing',last_request_at TEXT,last_conflict_at TEXT,shopee_created INTEGER DEFAULT 0,shopee_updated INTEGER DEFAULT 0,shopee_fields_filled INTEGER DEFAULT 0,shopee_fields_same_source_updated INTEGER DEFAULT 0,shopee_fields_manual_skipped INTEGER DEFAULT 0,shopee_fields_stale_skipped INTEGER DEFAULT 0,sku_created INTEGER DEFAULT 0,sku_updated INTEGER DEFAULT 0,sku_filled INTEGER DEFAULT 0,sku_same_source_updated INTEGER DEFAULT 0,sku_skipped INTEGER DEFAULT 0,sku_manual_skipped INTEGER DEFAULT 0,sku_stale_skipped INTEGER DEFAULT 0,pdd_created INTEGER DEFAULT 0,pdd_updated INTEGER DEFAULT 0,association_created INTEGER DEFAULT 0,association_unchanged INTEGER DEFAULT 0,failure_count INTEGER DEFAULT 0,error_summary TEXT,response_body TEXT,created_at TEXT,finished_at TEXT,PRIMARY KEY(source,batch_id))`,
|
`CREATE TABLE catalog_import_runs(source TEXT,batch_id TEXT,request_hash TEXT,status TEXT,request_count INTEGER,conflict_count INTEGER,observed_at TEXT,update_policy TEXT DEFAULT 'fill_missing',last_request_at TEXT,last_conflict_at TEXT,shopee_created INTEGER DEFAULT 0,shopee_updated INTEGER DEFAULT 0,shopee_fields_filled INTEGER DEFAULT 0,shopee_fields_same_source_updated INTEGER DEFAULT 0,shopee_fields_manual_skipped INTEGER DEFAULT 0,shopee_fields_stale_skipped INTEGER DEFAULT 0,sku_created INTEGER DEFAULT 0,sku_updated INTEGER DEFAULT 0,sku_filled INTEGER DEFAULT 0,sku_same_source_updated INTEGER DEFAULT 0,sku_skipped INTEGER DEFAULT 0,sku_manual_skipped INTEGER DEFAULT 0,sku_stale_skipped INTEGER DEFAULT 0,pdd_created INTEGER DEFAULT 0,pdd_updated INTEGER DEFAULT 0,association_created INTEGER DEFAULT 0,association_unchanged INTEGER DEFAULT 0,failure_count INTEGER DEFAULT 0,error_summary TEXT,response_body TEXT,created_at TEXT,finished_at TEXT,PRIMARY KEY(source,batch_id))`,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeShopeeIDs(ids []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
result := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
result = append(result, id)
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteShopeeProducts 由管理员把商品移入可恢复的删除状态。
|
||||||
|
func DeleteShopeeProducts(db *sql.DB, actor *model.User, ids []string) (int64, error) {
|
||||||
|
if actor == nil || !actor.IsAdmin() {
|
||||||
|
return 0, ErrAdminRequired
|
||||||
|
}
|
||||||
|
ids = normalizeShopeeIDs(ids)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, invalidInput("没有勾选任何蝦皮商品")
|
||||||
|
}
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
busy, err := repository.ShopeeProductsWithActiveTasks(tx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(busy) > 0 {
|
||||||
|
return 0, invalidInput(fmt.Sprintf("商品 %s 仍有进行中的采集或采购任务,不能删除", strings.Join(busy, "、")))
|
||||||
|
}
|
||||||
|
count, err := repository.SetShopeeProductsDeleted(tx, ids, actor.UserID, time.Now().UTC().Format(model.TimeLayout))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreShopeeProducts 由管理员恢复软删除商品,原 SKU 和 PDD 关联保持不变。
|
||||||
|
func RestoreShopeeProducts(db *sql.DB, actor *model.User, ids []string) (int64, error) {
|
||||||
|
if actor == nil || !actor.IsAdmin() {
|
||||||
|
return 0, ErrAdminRequired
|
||||||
|
}
|
||||||
|
ids = normalizeShopeeIDs(ids)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, invalidInput("没有勾选任何蝦皮商品")
|
||||||
|
}
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
count, err := repository.SetShopeeProductsDeleted(tx, ids, actor.UserID, "")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, tx.Commit()
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShopeeSoftDelete_保留关联且可恢复(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
now := model.NowISO()
|
||||||
|
admin := &model.User{UserID: "ADMIN-DELETE", Username: "delete-admin", PasswordHash: "test", Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: now, CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := repository.CreateUser(db, *admin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO pdd_products(goods_id,url,collect_status,created_at,updated_at) VALUES('P-DEL','https://example.com/p','collected',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,pdd_goods_id,pdd_goods_url,source,created_at,updated_at) VALUES('S-DEL','商品','P-DEL','https://example.com/p','report',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO shopee_skus(sku_id,goods_id,spec_raw,spec_key,parse_ok,is_manual,source,created_at,updated_at) VALUES('SKU-DEL','S-DEL','黑色,M','黑色,M',1,0,'report',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
count, err := DeleteShopeeProducts(db, admin, []string{"S-DEL", "S-DEL"})
|
||||||
|
if err != nil || count != 1 {
|
||||||
|
t.Fatalf("软删除失败:count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
if product, _ := repository.GetShopeeProductByGoodsID(db, "S-DEL"); product != nil {
|
||||||
|
t.Fatal("默认查询不应返回已删除商品")
|
||||||
|
}
|
||||||
|
var skuCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM shopee_skus WHERE goods_id='S-DEL'`).Scan(&skuCount); err != nil || skuCount != 1 {
|
||||||
|
t.Fatalf("软删除不应删除 SKU:count=%d err=%v", skuCount, err)
|
||||||
|
}
|
||||||
|
count, err = RestoreShopeeProducts(db, admin, []string{"S-DEL"})
|
||||||
|
if err != nil || count != 1 {
|
||||||
|
t.Fatalf("恢复失败:count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
product, err := repository.GetShopeeProductByGoodsID(db, "S-DEL")
|
||||||
|
if err != nil || product == nil || product.PddGoodsID != "P-DEL" {
|
||||||
|
t.Fatalf("恢复后关联丢失:product=%+v err=%v", product, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopeeSoftDelete_进行中任务阻止整批删除(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
now := model.NowISO()
|
||||||
|
admin := &model.User{UserID: "ADMIN-BUSY", Username: "busy-admin", PasswordHash: "test", Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: now, CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := repository.CreateUser(db, *admin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, id := range []string{"S-BUSY", "S-FREE"} {
|
||||||
|
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES(?,?,'report',?,?)`, id, id, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO tasks(task_id,task_type,status,goods_id,pdd_goods_url,created_at,updated_at) VALUES('cg999','purchase','pending','S-BUSY','https://example.com/p',?,?)`, now, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := DeleteShopeeProducts(db, admin, []string{"S-BUSY", "S-FREE"}); err == nil || !IsValidationError(err) {
|
||||||
|
t.Fatalf("有进行中任务时应阻止整批删除,实际 err=%v", err)
|
||||||
|
}
|
||||||
|
var deleted int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE deleted_at IS NOT NULL`).Scan(&deleted); err != nil || deleted != 0 {
|
||||||
|
t.Fatalf("整批删除应回滚:deleted=%d err=%v", deleted, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ type ShopeeProductView struct {
|
|||||||
|
|
||||||
StatusText string
|
StatusText string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
|
Deleted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShopeeListResult 是列表页要的全部数据。
|
// ShopeeListResult 是列表页要的全部数据。
|
||||||
@@ -100,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 != "",
|
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "" || filter.Deleted,
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: PageSize,
|
PageSize: PageSize,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
@@ -117,6 +118,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
|
|||||||
PendingCount: r.PendingCount,
|
PendingCount: r.PendingCount,
|
||||||
SourceText: shopeeSourceText(r.Source),
|
SourceText: shopeeSourceText(r.Source),
|
||||||
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||||||
|
Deleted: r.IsDeleted(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.PddGoodsID == "" {
|
if r.PddGoodsID == "" {
|
||||||
|
|||||||
@@ -902,6 +902,9 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := repository.UpsertSybShopeeSKU(tx, order.ShopeeGoodsID, order.ProductSpec, model.NowISO()); err != nil {
|
||||||
|
return fmt.Errorf("写入顺运宝规格观测失败: %w", err)
|
||||||
|
}
|
||||||
if created {
|
if created {
|
||||||
report.Created++
|
report.Created++
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package spec
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ParsedShopeeSpec 是从顺运宝规格原文中确定性得到的蝦皮规格字段。
|
||||||
|
// 解析器只接受“颜色,尺码”这种明确的二维格式,不做语义猜测。
|
||||||
|
type ParsedShopeeSpec struct {
|
||||||
|
Color string
|
||||||
|
Size string
|
||||||
|
Advice string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseShopeeSpec 解析顺运宝常见的“颜色,尺码【建议范围】”格式。
|
||||||
|
// 返回 false 表示格式不明确,调用方应保留原文且不得写入结构化字段。
|
||||||
|
func ParseShopeeSpec(raw string) (ParsedShopeeSpec, bool) {
|
||||||
|
normalized := strings.ReplaceAll(strings.TrimSpace(raw), ",", ",")
|
||||||
|
if strings.Count(normalized, ",") != 1 {
|
||||||
|
return ParsedShopeeSpec{}, false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(normalized, ",", 2)
|
||||||
|
color, sizePart := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||||
|
if color == "" || sizePart == "" {
|
||||||
|
return ParsedShopeeSpec{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
size, advice := sizePart, ""
|
||||||
|
if bracket := strings.Index(sizePart, "【"); bracket >= 0 {
|
||||||
|
if !strings.HasSuffix(sizePart, "】") || strings.Count(sizePart, "【") != 1 || strings.Count(sizePart, "】") != 1 {
|
||||||
|
return ParsedShopeeSpec{}, false
|
||||||
|
}
|
||||||
|
size = strings.TrimSpace(sizePart[:bracket])
|
||||||
|
advice = strings.TrimSpace(strings.TrimSuffix(sizePart[bracket+len("【"):], "】"))
|
||||||
|
for _, prefix := range []string{"建议", "建議", "推荐", "推薦"} {
|
||||||
|
advice = strings.TrimSpace(strings.TrimPrefix(advice, prefix))
|
||||||
|
}
|
||||||
|
if advice == "" {
|
||||||
|
return ParsedShopeeSpec{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size = strings.ToUpper(strings.TrimSpace(size))
|
||||||
|
if !isRecognizedSize(size) {
|
||||||
|
return ParsedShopeeSpec{}, false
|
||||||
|
}
|
||||||
|
return ParsedShopeeSpec{Color: color, Size: size, Advice: advice}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecognizedSize(size string) bool {
|
||||||
|
switch size {
|
||||||
|
case "XXXS", "XXS", "XS", "S", "M", "L", "XL", "XXL", "XXXL",
|
||||||
|
"2XL", "3XL", "4XL", "5XL", "6XL", "7XL", "8XL", "9XL",
|
||||||
|
"F", "FREE", "均码", "均碼":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package spec
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseShopeeSpec(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, raw, color, size, advice string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"基础格式", "黑色,M", "黑色", "M", "", true},
|
||||||
|
{"全角逗号", "白色,L【建議50-60公斤】", "白色", "L", "50-60公斤", true},
|
||||||
|
{"复合颜色描述", "黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】", "黑色+白色【純棉兩件裝】 簡約親膚", "L", "52.5-60公斤", true},
|
||||||
|
{"数字尺码前缀", "蓝色,2xl", "蓝色", "2XL", "", true},
|
||||||
|
{"额外维度", "黑色,M,两件", "", "", "", false},
|
||||||
|
{"未知尺码", "黑色,中码", "", "", "", false},
|
||||||
|
{"空建议", "黑色,M【】", "", "", "", false},
|
||||||
|
{"缺少颜色", ",M", "", "", "", false},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, ok := ParseShopeeSpec(test.raw)
|
||||||
|
if ok != test.ok || got.Color != test.color || got.Size != test.size || got.Advice != test.advice {
|
||||||
|
t.Fatalf("ParseShopeeSpec(%q)=(%+v,%v),期望 (%q,%q,%q,%v)", test.raw, got, ok, test.color, test.size, test.advice, test.ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
都不进入 sessionStorage。链接原始 href 保持模块根地址,因此脚本失效时
|
都不进入 sessionStorage。链接原始 href 保持模块根地址,因此脚本失效时
|
||||||
仍可正常导航。 */
|
仍可正常导航。 */
|
||||||
var MODULE_QUERY_KEYS = {
|
var MODULE_QUERY_KEYS = {
|
||||||
"/shopee": ["goods_id", "shop_name", "status", "shop", "image", "page"],
|
"/shopee": ["goods_id", "shop_name", "status", "shop", "image", "deleted", "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"],
|
||||||
"/tasks": ["type", "status", "creator", "q", "page"],
|
"/tasks": ["type", "status", "creator", "q", "page"],
|
||||||
|
|||||||
@@ -4,16 +4,19 @@
|
|||||||
{{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
|
{{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
|
||||||
<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}}
|
||||||
<form id="shopee-collect-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-collect-form">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="shopee-bulk-form">
|
||||||
<input type="hidden" name="list_goods_id" value="{{.Keyword}}" form="shopee-collect-form">
|
<input type="hidden" name="list_goods_id" value="{{.Keyword}}" form="shopee-bulk-form">
|
||||||
<input type="hidden" name="list_shop_name" value="{{.ShopNameKeyword}}" form="shopee-collect-form">
|
<input type="hidden" name="list_shop_name" value="{{.ShopNameKeyword}}" form="shopee-bulk-form">
|
||||||
<input type="hidden" name="status" value="{{.StatusFilter}}" form="shopee-collect-form">
|
<input type="hidden" name="status" value="{{.StatusFilter}}" form="shopee-bulk-form">
|
||||||
<input type="hidden" name="shop" value="{{.ShopFilter}}" form="shopee-collect-form">
|
<input type="hidden" name="shop" value="{{.ShopFilter}}" form="shopee-bulk-form">
|
||||||
<input type="hidden" name="image" value="{{.ImageFilter}}" form="shopee-collect-form">
|
<input type="hidden" name="image" value="{{.ImageFilter}}" form="shopee-bulk-form">
|
||||||
<input type="hidden" name="page" value="{{.CurrentPage}}" form="shopee-collect-form">
|
<input type="hidden" name="deleted" value="{{if .DeletedFilter}}1{{end}}" form="shopee-bulk-form">
|
||||||
|
<input type="hidden" name="page" value="{{.CurrentPage}}" form="shopee-bulk-form">
|
||||||
|
{{if not .DeletedFilter}}
|
||||||
<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-collect-form" data-need-checked>创建 PDD 采集任务</button>
|
data-collect-form="shopee-bulk-form" data-need-checked>创建 PDD 采集任务</button>
|
||||||
|
{{end}}
|
||||||
<form class="inline grow" method="get" action="/shopee">
|
<form class="inline grow" method="get" action="/shopee">
|
||||||
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花,
|
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花,
|
||||||
见 docs/admin/05-ui-specification.md §4.1、工单 #43。
|
见 docs/admin/05-ui-specification.md §4.1、工单 #43。
|
||||||
@@ -36,6 +39,13 @@
|
|||||||
<option value="{{.Value}}" {{if eq .Value $.ImageFilter}}selected{{end}}>{{.Text}}</option>
|
<option value="{{.Value}}" {{if eq .Value $.ImageFilter}}selected{{end}}>{{.Text}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</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>
|
<label for="q">商品 ID / 名称</label>
|
||||||
<input id="q" class="shopee-search-field" type="text" name="goods_id" value="{{.Keyword}}" placeholder="商品 ID / 名称">
|
<input id="q" class="shopee-search-field" type="text" name="goods_id" value="{{.Keyword}}" placeholder="商品 ID / 名称">
|
||||||
<label for="shop-name">店铺名称</label>
|
<label for="shop-name">店铺名称</label>
|
||||||
@@ -43,17 +53,14 @@
|
|||||||
<button type="submit">搜索</button>
|
<button type="submit">搜索</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form class="inline" method="post" action="/shopee/delete"
|
{{if .CurrentUser.IsAdmin}}
|
||||||
data-confirm-delete>
|
{{if .DeletedFilter}}
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<button type="submit" form="shopee-bulk-form" formaction="/shopee/restore" data-need-checked>恢复</button>
|
||||||
<input type="hidden" name="list_goods_id" value="{{.Keyword}}">
|
{{else}}
|
||||||
<input type="hidden" name="list_shop_name" value="{{.ShopNameKeyword}}">
|
<button type="submit" class="danger" form="shopee-bulk-form" formaction="/shopee/delete"
|
||||||
<input type="hidden" name="status" value="{{.StatusFilter}}">
|
data-need-checked data-confirm-delete="将把 {n} 个蝦皮商品移入已删除,规格和 PDD 关联会保留,可以恢复。">删除</button>
|
||||||
<input type="hidden" name="shop" value="{{.ShopFilter}}">
|
{{end}}
|
||||||
<input type="hidden" name="image" value="{{.ImageFilter}}">
|
{{end}}
|
||||||
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
|
||||||
<button type="submit" class="danger" data-need-checked>删除</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* ── 第二段:带勾选的表格 ──────────────────────────── */}}
|
{{/* ── 第二段:带勾选的表格 ──────────────────────────── */}}
|
||||||
@@ -86,8 +93,8 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Rows}}
|
{{range .Rows}}
|
||||||
<tr data-detail-id="{{.GoodsID}}"{{if gt .PendingCount 0}} class="row-warn"{{end}}>
|
<tr{{if not $.DeletedFilter}} data-detail-id="{{.GoodsID}}"{{end}}{{if gt .PendingCount 0}} class="row-warn"{{end}}>
|
||||||
<td class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}" form="shopee-collect-form"></td>
|
<td class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}" form="shopee-bulk-form"></td>
|
||||||
<td>{{.GoodsID}}</td>
|
<td>{{.GoodsID}}</td>
|
||||||
<td>{{if .ImageURL}}<button type="button" class="thumb-action" data-image-preview-url="{{.ImageURL}}" data-image-preview-title="{{.Title}}" title="查看蝦皮主图"><img src="{{.ImageURL}}" alt="{{.Title}} 缩略图" class="thumb" width="32" height="32" loading="lazy" referrerpolicy="no-referrer" data-image-thumb><span class="thumb-fallback" hidden data-image-thumb-fallback>加载失败</span></button>{{else}}<span class="thumb-placeholder">无图</span>{{end}}</td>
|
<td>{{if .ImageURL}}<button type="button" class="thumb-action" data-image-preview-url="{{.ImageURL}}" data-image-preview-title="{{.Title}}" title="查看蝦皮主图"><img src="{{.ImageURL}}" alt="{{.Title}} 缩略图" class="thumb" width="32" height="32" loading="lazy" referrerpolicy="no-referrer" data-image-thumb><span class="thumb-fallback" hidden data-image-thumb-fallback>加载失败</span></button>{{else}}<span class="thumb-placeholder">无图</span>{{end}}</td>
|
||||||
{{/* 商品名称列收窄,用独立的 .col-title 类设 max-width(具体数值见 app.css),
|
{{/* 商品名称列收窄,用独立的 .col-title 类设 max-width(具体数值见 app.css),
|
||||||
@@ -131,7 +138,7 @@
|
|||||||
<p>将按当前 PDD 关联处理已选择的 <strong data-collect-count>0</strong> 个蝦皮商品。</p>
|
<p>将按当前 PDD 关联处理已选择的 <strong data-collect-count>0</strong> 个蝦皮商品。</p>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="shopee-collect-client">执行客户端</label>
|
<label for="shopee-collect-client">执行客户端</label>
|
||||||
<select id="shopee-collect-client" name="client_id" form="shopee-collect-form" autofocus>
|
<select id="shopee-collect-client" name="client_id" form="shopee-bulk-form" autofocus>
|
||||||
<option value="">不指定(任意客户端可领取)</option>
|
<option value="">不指定(任意客户端可领取)</option>
|
||||||
{{range .AssignableClients}}
|
{{range .AssignableClients}}
|
||||||
<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>
|
<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>
|
||||||
@@ -142,7 +149,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<button type="button" data-modal-close>取消</button>
|
<button type="button" data-modal-close>取消</button>
|
||||||
<button type="submit" form="shopee-collect-form" class="primary" data-collect-submit>确认创建</button>
|
<button type="submit" form="shopee-bulk-form" class="primary" data-collect-submit>确认创建</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +157,7 @@
|
|||||||
<div class="modal-backdrop" id="image-preview-modal" hidden><div class="modal image-preview-modal" role="dialog" aria-modal="true" aria-labelledby="image-preview-title"><div class="modal-head"><h2 id="image-preview-title" data-image-preview-title>蝦皮商品主图</h2><button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button></div><div class="modal-body image-preview-body"><p class="hint image-preview-status" data-image-preview-status role="status">正在加载原图…</p><img data-image-preview-image alt="" hidden></div><div class="modal-foot"><a class="button-link" data-image-preview-link href="#" target="_blank" rel="noopener noreferrer">新窗口打开原图</a><button type="button" data-modal-close>关闭</button></div></div></div>
|
<div class="modal-backdrop" id="image-preview-modal" hidden><div class="modal image-preview-modal" role="dialog" aria-modal="true" aria-labelledby="image-preview-title"><div class="modal-head"><h2 id="image-preview-title" data-image-preview-title>蝦皮商品主图</h2><button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button></div><div class="modal-body image-preview-body"><p class="hint image-preview-status" data-image-preview-status role="status">正在加载原图…</p><img data-image-preview-image alt="" hidden></div><div class="modal-foot"><a class="button-link" data-image-preview-link href="#" target="_blank" rel="noopener noreferrer">新窗口打开原图</a><button type="button" data-modal-close>关闭</button></div></div></div>
|
||||||
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
双击任意一行可以查看这个商品的完整规格表。
|
{{if .DeletedFilter}}已删除商品保留规格和 PDD 关联;恢复后可继续处理。{{else}}双击任意一行可以查看这个商品的完整规格表。{{end}}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
|
|
||||||
### 4.1 蝦皮数据模块
|
### 4.1 蝦皮数据模块
|
||||||
|
|
||||||
**顶部工具条:** 导入按钮、批量创建 PDD 采集任务按钮、状态筛选
|
**顶部工具条:** 导入记录按钮、批量创建 PDD 采集任务按钮、状态筛选
|
||||||
(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、商品 ID 搜索框、搜索按钮、删除按钮。
|
(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、店铺与图片筛选、商品 ID 和店铺名称
|
||||||
|
搜索、搜索按钮;管理员另有正常/已删除范围筛选以及删除/恢复按钮。
|
||||||
|
|
||||||
`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护
|
`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护
|
||||||
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话,
|
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话,
|
||||||
@@ -120,6 +121,9 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
有效任务的商品可以恢复创建。该入口不修改 PDD 关联,不替代商品详情里的单商品入口;
|
有效任务的商品可以恢复创建。该入口不修改 PDD 关联,不替代商品详情里的单商品入口;
|
||||||
- `[必须]` 采集操作**只出现在顶部批量入口和商品详情弹窗里**,不要放在每个表格行——
|
- `[必须]` 采集操作**只出现在顶部批量入口和商品详情弹窗里**,不要放在每个表格行——
|
||||||
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
|
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
|
||||||
|
- `[必须]` 蝦皮商品删除为管理员软删除。正常业务查询默认排除;SKU、PDD 关联和历史
|
||||||
|
数据保留。存在待分配、待领取或已领取任务时整批拒绝;恢复只能从管理员“已删除”
|
||||||
|
列表显式执行,顺运宝同步和商品目录导入不得自动恢复。
|
||||||
|
|
||||||
`[注意]` PDD 商品模块(§4.2)继续作为商品档案、采集状态和故障诊断页;
|
`[注意]` PDD 商品模块(§4.2)继续作为商品档案、采集状态和故障诊断页;
|
||||||
蝦皮详情和顺运宝处理详情是带业务上下文的关联入口,两者调用同一个 Service。
|
蝦皮详情和顺运宝处理详情是带业务上下文的关联入口,两者调用同一个 Service。
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ v13(#172)新增 `task_sequences`,把存量 `tasks.task_id` 按类型和创
|
|||||||
v14(#196)新增 `syb_allowed_shops` 全局店铺准入表;`syb_sync_runs` 增加
|
v14(#196)新增 `syb_allowed_shops` 全局店铺准入表;`syb_sync_runs` 增加
|
||||||
`accepted_stock_count`、`shop_skipped_count` 和 `shop_filter_hash`。旧同步记录回填为
|
`accepted_stock_count`、`shop_skipped_count` 和 `shop_filter_hash`。旧同步记录回填为
|
||||||
“接受数=原始货运单数、店铺跳过=0”;历史顺运宝货运单不删除。
|
“接受数=原始货运单数、店铺跳过=0”;历史顺运宝货运单不删除。
|
||||||
|
v15(#203)把历史 `syb_orders` 中每个蝦皮商品最新的非空店铺和图片,以
|
||||||
|
`source='syb'` 的字段级低优先级来源补入 `shopee_products`。v16(#204)确定性解析
|
||||||
|
“颜色,尺码【建议】”格式,并为历史数据补建无真实外部 ID 的低优先级蝦皮 SKU;
|
||||||
|
不明确格式只保留原文。v17(#205)为蝦皮商品增加 `deleted_at`、
|
||||||
|
`deleted_by_user_id` 和删除状态索引,删除改为可恢复的软删除。
|
||||||
|
|
||||||
**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 语句
|
||||||
@@ -175,6 +180,8 @@ CREATE TABLE shopee_products (
|
|||||||
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
|
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
|
||||||
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
|
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
|
||||||
pdd_goods_id TEXT, -- 从 url 解析,指向 pdd_products.goods_id
|
pdd_goods_id TEXT, -- 从 url 解析,指向 pdd_products.goods_id
|
||||||
|
deleted_at VARCHAR(35), -- 软删除时间;非空时默认业务查询不可见
|
||||||
|
deleted_by_user_id VARCHAR(191), -- 执行删除的管理员
|
||||||
|
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
@@ -184,6 +191,7 @@ ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_source
|
|||||||
CHECK (source IN ('report', 'syb', 'api'));
|
CHECK (source IN ('report', 'syb', 'api'));
|
||||||
|
|
||||||
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
|
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
|
||||||
|
CREATE INDEX idx_shopee_products_deleted ON shopee_products(deleted_at, updated_at, goods_id);
|
||||||
```
|
```
|
||||||
|
|
||||||
`[必须]` **采集结果和采集状态不在这张表里**,它们属于 PDD 商品,见 §4。
|
`[必须]` **采集结果和采集状态不在这张表里**,它们属于 PDD 商品,见 §4。
|
||||||
@@ -193,6 +201,11 @@ CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
|
|||||||
后续 Excel 导入同一 `goods_id` 时必须补全商品信息并把来源提升为 `report`;
|
后续 Excel 导入同一 `goods_id` 时必须补全商品信息并把来源提升为 `report`;
|
||||||
导入不得覆盖人工维护的 PDD 关联。
|
导入不得覆盖人工维护的 PDD 关联。
|
||||||
|
|
||||||
|
顺运宝同步只把非空店铺和图片补到空字段,或更新同样来自 `syb` 的字段;人工值和
|
||||||
|
目录接口值不覆盖。后续目录接口可把 `syb` 低优先级值提升为权威来源。软删除商品
|
||||||
|
不会因顺运宝同步或目录导入自动恢复,SKU、PDD 关联和历史记录全部保留;只能由
|
||||||
|
管理员显式恢复。存在 `pending/assigned/claimed` 采集或采购任务时禁止软删除。
|
||||||
|
|
||||||
`pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。
|
`pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。
|
||||||
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
|
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
|
||||||
|
|
||||||
@@ -225,7 +238,8 @@ CREATE INDEX idx_shopee_skus_parse ON shopee_skus(parse_ok);
|
|||||||
再按 [商品目录接口契约](10-商品目录接入接口.md) 提交结构化商品、SKU、PDD 和关联。
|
再按 [商品目录接口契约](10-商品目录接入接口.md) 提交结构化商品、SKU、PDD 和关联。
|
||||||
|
|
||||||
- `[必须]` 整批只做 upsert,不清空表,也不删除批次中缺席的数据。
|
- `[必须]` 整批只做 upsert,不清空表,也不删除批次中缺席的数据。
|
||||||
- `[必须]` `spec_raw` 原样保留;解析失败由上游明确提交 `parse_ok=false`,Admin 不猜。
|
- `[必须]` `spec_raw` 原样保留。商品目录解析结果由上游提交;SYB 只对明确的
|
||||||
|
“颜色,尺码【建议】”二维格式做确定性解析,未知尺码、额外维度和不完整括号均不猜。
|
||||||
- `[必须]` 商品更新不覆盖 `pdd_goods_url` / `pdd_goods_id`,SKU 更新不覆盖 `is_manual`。
|
- `[必须]` 商品更新不覆盖 `pdd_goods_url` / `pdd_goods_id`,SKU 更新不覆盖 `is_manual`。
|
||||||
- `[必须]` 较旧 `observed_at` 不覆盖较新接口数据。
|
- `[必须]` 较旧 `observed_at` 不覆盖较新接口数据。
|
||||||
- 空 PDD 关联可以建立;相同关联幂等;不同关联返回冲突,不静默换品。
|
- 空 PDD 关联可以建立;相同关联幂等;不同关联返回冲突,不静默换品。
|
||||||
@@ -466,13 +480,16 @@ v10 幂等重放 v8、v9 的逐项建列、空值回填、约束和唯一索引
|
|||||||
|
|
||||||
MySQL schema v11(工单 #151)为 `syb_orders` 增加可空 `VARCHAR(500)` 的
|
MySQL schema v11(工单 #151)为 `syb_orders` 增加可空 `VARCHAR(500)` 的
|
||||||
`shop_name`,并从合法原始 JSON 的 `$.stock.shopName` 回填历史数据。迁移可重放,
|
`shop_name`,并从合法原始 JSON 的 `$.stock.shopName` 回填历史数据。迁移可重放,
|
||||||
只填仍为空的列;新同步直接更新该列,不把它写入或覆盖蝦皮商品主数据。
|
只填仍为空的列;新同步直接更新该列。v15(#203)在此基础上把非空店铺和图片作为
|
||||||
|
`syb` 低优先级字段补入蝦皮商品主数据,但仍不得覆盖人工或目录接口字段。
|
||||||
|
|
||||||
蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”:
|
蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”:
|
||||||
订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。
|
订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。
|
||||||
该读模型不写入 `shopee_skus`。只有同一商品下恰好一条正式 SKU 的 `spec_raw`
|
从 v16 起,同步会把格式明确的规格以 `source='syb'` 写入 `shopee_skus`:内部主键
|
||||||
经 `SpecKey()` 后相等时才展示关联;零条或多条都不猜测、不写回。重复同步同一个
|
由商品 ID 和 `spec_key` 确定性生成,真实 `shopee_sku_id` 保持 `NULL`。目录接口后到时
|
||||||
`syb_id` 只更新原行,因此不会重复累计。
|
按同一规格身份补上真实 ID,并可替换字段来源为权威来源;人工行永不覆盖。不明确格式
|
||||||
|
仍只存在于观测读模型中,不创建 SKU。重复同步同一个 `syb_id` 只更新原行,因此不会
|
||||||
|
重复累计。
|
||||||
|
|
||||||
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
|
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
|
||||||
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
|
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
|
||||||
|
|||||||
@@ -136,7 +136,7 @@
|
|||||||
### 4.1 工具条
|
### 4.1 工具条
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[导入记录] [创建 PDD 采集任务] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除]
|
[导入记录] [创建 PDD 采集任务] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 数据范围[正常商品▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除]
|
||||||
```
|
```
|
||||||
|
|
||||||
- **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。
|
- **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。
|
||||||
@@ -166,9 +166,15 @@
|
|||||||
- `[必须]` 店铺、图片使用独立三态下拉框,不使用含义不清的复选框;两个条件可互相
|
- `[必须]` 店铺、图片使用独立三态下拉框,不使用含义不清的复选框;两个条件可互相
|
||||||
组合,也可与状态和商品 ID/标题搜索叠加。未知参数按“全部”处理,翻页、详情弹窗
|
组合,也可与状态和商品 ID/标题搜索叠加。未知参数按“全部”处理,翻页、详情弹窗
|
||||||
和写操作返回时保留全部条件。工具条在窄窗口沿用公共 `flex-wrap` 自然换行。
|
和写操作返回时保留全部条件。工具条在窄窗口沿用公共 `flex-wrap` 自然换行。
|
||||||
|
- **数据范围**(工单 #205):只向管理员显示“正常商品 / 已删除”。正常列表不返回
|
||||||
|
软删除商品;已删除列表只用于查看和恢复,不允许创建采集任务或打开处理详情。
|
||||||
- **创建 PDD 采集任务**(工单 #185):勾选蝦皮商品后打开批量确认弹窗,显示选择
|
- **创建 PDD 采集任务**(工单 #185):勾选蝦皮商品后打开批量确认弹窗,显示选择
|
||||||
数量及带可见标签的“执行客户端”,默认不指定。服务端按当前 PDD 关联和 PDD 商品 ID
|
数量及带可见标签的“执行客户端”,默认不指定。服务端按当前 PDD 关联和 PDD 商品 ID
|
||||||
去重;未关联、已删除、已采集、正常采集中分别统计并跳过。商品详情的单商品入口保留。
|
去重;未关联、已删除、已采集、正常采集中分别统计并跳过。商品详情的单商品入口保留。
|
||||||
|
- **删除 / 恢复**(工单 #205):只允许管理员操作。删除前确认文案明确显示选中数量、
|
||||||
|
可恢复且规格和 PDD 关联会保留;有进行中采集或采购任务时整批拒绝。已删除列表显示
|
||||||
|
“恢复”按钮,恢复不重建数据、不改变原关联。批量采集、删除和恢复共用同一个表单,
|
||||||
|
通过提交按钮的 `formaction` 选择接口,避免勾选值落到错误表单。
|
||||||
|
|
||||||
### 4.2 表格列
|
### 4.2 表格列
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user