@@ -18,7 +18,7 @@ import (
|
||||
// **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus
|
||||
// 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。
|
||||
func (h *Handler) ShopeeList(c *gin.Context) {
|
||||
h.renderShopeeList(c, c.Query("goods_id"), c.Query("shop_name"), c.Query("status"), c.Query("shop"), c.Query("image"), c.Query("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 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
|
||||
@@ -57,7 +57,7 @@ func (h *Handler) ShopeeDetail(c *gin.Context) {
|
||||
//
|
||||
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留关键词和三个下拉筛选,
|
||||
// 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #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{
|
||||
Keyword: keyword,
|
||||
ShopName: strings.TrimSpace(shopName),
|
||||
@@ -65,6 +65,9 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
|
||||
Shop: service.ParseShopeePresence(shopRaw),
|
||||
Image: service.ParseShopeePresence(imageRaw),
|
||||
}
|
||||
if currentUser(c).IsAdmin() && deletedRaw == "1" {
|
||||
filter.Deleted = true
|
||||
}
|
||||
// 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数,
|
||||
// 局部变量会把它遮住导致编译失败。
|
||||
pageNum := service.ParsePage(pageRaw)
|
||||
@@ -103,6 +106,9 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
|
||||
if 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{
|
||||
"Keyword": keyword,
|
||||
@@ -113,6 +119,7 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
|
||||
"ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"),
|
||||
"ImageFilter": filter.Image,
|
||||
"ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"),
|
||||
"DeletedFilter": filter.Deleted,
|
||||
"Rows": result.Rows,
|
||||
"Status": status,
|
||||
"HasAnyProducts": result.HasAnyProducts,
|
||||
@@ -160,11 +167,31 @@ func (h *Handler) ShopeeSave(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ShopeeDelete 批量删除勾选的行。
|
||||
//
|
||||
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
|
||||
func (h *Handler) ShopeeDelete(c *gin.Context) {
|
||||
// TODO(骨架): 取 ids,二次确认已在前端做过,这里直接删
|
||||
fail(c, http.StatusNotImplemented, "删除功能尚未实现。")
|
||||
count, err := service.DeleteShopeeProducts(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))
|
||||
}
|
||||
|
||||
// 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 发起采集任务。
|
||||
@@ -226,6 +253,9 @@ func (h *Handler) shopeeRedirect(c *gin.Context, msg string) {
|
||||
if value := service.ParseShopeePresence(c.PostForm("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 != "" {
|
||||
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/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容
|
||||
pages.POST("/shopee/save", h.ShopeeSave)
|
||||
pages.POST("/shopee/delete", h.ShopeeDelete)
|
||||
pages.POST("/shopee/collect", h.ShopeeCollect)
|
||||
pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch)
|
||||
shopeeAdmin := pages.Group("/shopee", AdminRequired())
|
||||
shopeeAdmin.POST("/delete", h.ShopeeDelete)
|
||||
shopeeAdmin.POST("/restore", h.ShopeeRestore)
|
||||
|
||||
// 2. PDD 商品
|
||||
pages.GET("/pdd", h.PddList)
|
||||
|
||||
+2
-2
@@ -64,7 +64,7 @@ func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *te
|
||||
jsSource := string(js)
|
||||
for _, want := range []string{
|
||||
"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\"]",
|
||||
"var MODULE_STATE_PREFIX = \"cmautobuy:module-state:\"",
|
||||
"parsed.origin !== window.location.origin || parsed.pathname !== moduleRoot",
|
||||
@@ -275,7 +275,7 @@ func TestShopeePage_批量创建Pdd采集任务入口(t *testing.T) {
|
||||
page := string(content)
|
||||
for _, want := range []string{
|
||||
`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>`,
|
||||
`不指定(任意客户端可领取)`, `.AssignableClients`, `data-collect-submit`,
|
||||
} {
|
||||
|
||||
@@ -177,10 +177,15 @@ type ShopeeProduct struct {
|
||||
Source string // report=蝦皮报表完整行;syb=顺运宝同步补建的商品骨架
|
||||
PddGoodsURL string
|
||||
PddGoodsID string // 指向 PddProduct.GoodsID,为空表示还没填链接
|
||||
DeletedAt string
|
||||
DeletedByUserID string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// IsDeleted 判断蝦皮商品是否在回收状态。
|
||||
func (p ShopeeProduct) IsDeleted() bool { return p.DeletedAt != "" }
|
||||
|
||||
// ShopeeSKU 是蝦皮的一个规格(SKU 级)。
|
||||
//
|
||||
// SpecRaw 是报表里的规格原文,例如「黑色,M【建議40-50公斤】」,
|
||||
|
||||
@@ -191,6 +191,16 @@ func UpsertCatalogShopeeProduct(q Execer, in CatalogShopeeProductInput) (out Cat
|
||||
out.FieldsManualSkipped++
|
||||
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 *current == "" {
|
||||
*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
|
||||
sources, times := catalogFieldProvenance(current)
|
||||
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
|
||||
sources["color"], times["color"] = in.Source, in.ObservedAt
|
||||
changed = true
|
||||
}
|
||||
if size == "" && strings.TrimSpace(in.Size) != "" {
|
||||
if canFill("size", size, in.Size) {
|
||||
size = in.Size
|
||||
sources["size"], times["size"] = in.Source, in.ObservedAt
|
||||
changed = true
|
||||
}
|
||||
if advice == "" && strings.TrimSpace(in.Advice) != "" {
|
||||
if canFill("advice", advice, in.Advice) {
|
||||
advice = in.Advice
|
||||
sources["advice"], times["advice"] = in.Source, in.ObservedAt
|
||||
changed = true
|
||||
}
|
||||
if skuCode == "" && strings.TrimSpace(in.SKUCode) != "" {
|
||||
if canFill("sku_code", skuCode, in.SKUCode) {
|
||||
skuCode = in.SKUCode
|
||||
sources["sku_code"], times["sku_code"] = in.Source, in.ObservedAt
|
||||
changed = true
|
||||
@@ -349,7 +362,7 @@ func UpsertCatalogShopeeSKU(q Execer, in CatalogShopeeSKUInput) (CatalogSKUOutco
|
||||
}
|
||||
sourcesJSON, _ := json.Marshal(sources)
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
@@ -448,7 +461,7 @@ func UpsertCatalogPddProduct(q Execer, goodsID, url, title, shopName, skusJSON,
|
||||
// ApplyCatalogAssociation 只允许空关联建立或相同关联重放。
|
||||
func ApplyCatalogAssociation(q Execer, shopeeGoodsID, pddGoodsID, now string) (created, unchanged bool, err error) {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
_, 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
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const mysqlSchemaVersion = 14
|
||||
const mysqlSchemaVersion = 17
|
||||
|
||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 增加顺运宝同步店铺准入表和审计统计。
|
||||
func migrateMySQLV14(db *sql.DB) error {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -560,7 +560,7 @@ func TestMySQLMigrate_V11回填顺运宝店铺且可重放(t *testing.T) {
|
||||
if err := MigrateMySQL(db); err != nil {
|
||||
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`)
|
||||
now := model.NowISO()
|
||||
mustExec(t, db, `INSERT INTO syb_orders
|
||||
@@ -595,7 +595,7 @@ func TestMySQLMigrate_V11升级V12并修复孤儿采集中状态(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟已在 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`)
|
||||
now := model.NowISO()
|
||||
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 {
|
||||
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, `CREATE TABLE task_syb_sources (
|
||||
task_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||
@@ -660,7 +660,7 @@ func TestMySQLMigrate_V12升级V13迁移任务主键和关联(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟生产 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, `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`)
|
||||
@@ -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 accepted_stock_count`)
|
||||
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)
|
||||
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)
|
||||
@@ -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 {
|
||||
t.Helper()
|
||||
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
|
||||
|
||||
+79
-15
@@ -136,6 +136,7 @@ type ShopeeFilter struct {
|
||||
Status string
|
||||
Shop string // has / missing / 空(全部)
|
||||
Image string // has / missing / 空(全部)
|
||||
Deleted bool // true 只看软删除数据;false 只看正常数据
|
||||
}
|
||||
|
||||
// shopeeFilterClause 把关键字、状态、店铺和图片筛选拼成 WHERE 子句,供 ListShopeeProducts
|
||||
@@ -146,7 +147,10 @@ type ShopeeFilter struct {
|
||||
// SKU 时 JOIN 会出重复行,DISTINCT 又会让外层 LIMIT/OFFSET 的行为难推理
|
||||
// (见工单 #43)。
|
||||
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
|
||||
|
||||
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) = '')`)
|
||||
}
|
||||
|
||||
if len(clauses) == 0 {
|
||||
return "", 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)
|
||||
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,
|
||||
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.size END) AS size_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
|
||||
for rows.Next() {
|
||||
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
|
||||
if err := rows.Scan(
|
||||
&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.CollectStatus, &r.CollectMsg,
|
||||
); err != nil {
|
||||
@@ -246,6 +247,8 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
|
||||
r.Source = source.String
|
||||
r.PddGoodsURL = pddGoodsURL.String
|
||||
r.PddGoodsID = pddGoodsID.String
|
||||
r.DeletedAt = deletedAt.String
|
||||
r.DeletedByUserID = deletedBy.String
|
||||
list = append(list, r)
|
||||
}
|
||||
return list, rows.Err()
|
||||
@@ -270,14 +273,14 @@ func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) {
|
||||
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
|
||||
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
|
||||
var p model.ShopeeProduct
|
||||
var title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID sql.NullString
|
||||
var title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
|
||||
var imageManual, shopManual int
|
||||
err := q.QueryRow(`
|
||||
SELECT goods_id, title, shopee_status, main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual, source,
|
||||
pdd_goods_url, pdd_goods_id, created_at, updated_at
|
||||
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
|
||||
pdd_goods_url, pdd_goods_id,deleted_at,deleted_by_user_id, created_at, updated_at
|
||||
FROM shopee_products WHERE goods_id = ? AND deleted_at IS NULL`, goodsID).Scan(
|
||||
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
||||
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
|
||||
&pddGoodsURL, &pddGoodsID, &deletedAt, &deletedBy, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -298,6 +301,8 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct,
|
||||
p.Source = source.String
|
||||
p.PddGoodsURL = pddGoodsURL.String
|
||||
p.PddGoodsID = pddGoodsID.String
|
||||
p.DeletedAt = deletedAt.String
|
||||
p.DeletedByUserID = deletedBy.String
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
@@ -314,7 +319,7 @@ func ListShopeePddLinksByGoodsIDs(q Execer, goodsIDs []string) (map[string]strin
|
||||
args[i] = goodsID
|
||||
}
|
||||
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 {
|
||||
return nil, fmt.Errorf("批量查询蝦皮商品 PDD 关联失败: %w", err)
|
||||
}
|
||||
@@ -338,7 +343,7 @@ func UpdateShopeePddLink(q Execer, shopeeGoodsID, pddGoodsID, pddURL string) err
|
||||
result, err := q.Exec(`
|
||||
UPDATE shopee_products
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新蝦皮商品 %s 的 PDD 关联失败: %w", shopeeGoodsID, err)
|
||||
@@ -418,7 +423,8 @@ func GetShopeeSKUByID(q Execer, skuID string) (*model.ShopeeSKU, error) {
|
||||
err := q.QueryRow(`
|
||||
SELECT sku_id, COALESCE(shopee_sku_id,''), goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
|
||||
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,
|
||||
&parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt,
|
||||
)
|
||||
@@ -440,8 +446,66 @@ func GetShopeeSKUByID(q Execer, skuID string) (*model.ShopeeSKU, error) {
|
||||
// CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
|
||||
func CountShopeeProducts(q Execer) (int, error) {
|
||||
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 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
|
||||
FROM shopee_products sp
|
||||
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))
|
||||
if err != nil {
|
||||
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
|
||||
FROM shopee_skus sk
|
||||
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))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取店铺正式规格失败: %w", err)
|
||||
|
||||
+171
-14
@@ -5,7 +5,9 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -351,19 +353,7 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
|
||||
|
||||
now := model.NowISO()
|
||||
if strings.TrimSpace(o.ShopeeGoodsID) != "" {
|
||||
if _, err := q.Exec(`
|
||||
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 {
|
||||
if err := upsertSybShopeeProduct(q, o.ShopeeGoodsID, o.Title, o.ShopName, o.ImageURL, now); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 是顺运宝对某个蝦皮商品规格的历史观测汇总。
|
||||
// 它不是正式蝦皮 SKU,不得写回 shopee_skus。
|
||||
type SybSpecObservation struct {
|
||||
@@ -482,7 +639,7 @@ func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
|
||||
|
||||
const sybOrderContextFrom = `
|
||||
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 spec_mappings sm
|
||||
ON sm.shopee_goods_id = so.shopee_goods_id
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
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) {
|
||||
db := newSybTestDB(t)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func newCatalogTestDB(t *testing.T) *sql.DB {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
statements := []string{
|
||||
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,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 pdd_products(id INTEGER PRIMARY KEY AUTOINCREMENT,goods_id TEXT UNIQUE,url TEXT,title TEXT,shop_name TEXT,skus_json TEXT,collect_status TEXT,collect_msg TEXT,artifact_ref TEXT,collected_at TEXT,deleted_at TEXT,source TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT)`,
|
||||
`CREATE TABLE catalog_import_runs(source TEXT,batch_id TEXT,request_hash TEXT,status TEXT,request_count INTEGER,conflict_count INTEGER,observed_at TEXT,update_policy TEXT DEFAULT 'fill_missing',last_request_at TEXT,last_conflict_at TEXT,shopee_created INTEGER DEFAULT 0,shopee_updated INTEGER DEFAULT 0,shopee_fields_filled INTEGER DEFAULT 0,shopee_fields_same_source_updated INTEGER DEFAULT 0,shopee_fields_manual_skipped INTEGER DEFAULT 0,shopee_fields_stale_skipped INTEGER DEFAULT 0,sku_created INTEGER DEFAULT 0,sku_updated INTEGER DEFAULT 0,sku_filled INTEGER DEFAULT 0,sku_same_source_updated INTEGER DEFAULT 0,sku_skipped INTEGER DEFAULT 0,sku_manual_skipped INTEGER DEFAULT 0,sku_stale_skipped INTEGER DEFAULT 0,pdd_created INTEGER DEFAULT 0,pdd_updated INTEGER DEFAULT 0,association_created INTEGER DEFAULT 0,association_unchanged INTEGER DEFAULT 0,failure_count INTEGER DEFAULT 0,error_summary TEXT,response_body TEXT,created_at TEXT,finished_at TEXT,PRIMARY KEY(source,batch_id))`,
|
||||
|
||||
@@ -0,0 +1,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
|
||||
UpdatedAt string
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
// ShopeeListResult 是列表页要的全部数据。
|
||||
@@ -100,7 +101,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
|
||||
Rows: make([]ShopeeProductView, 0, len(rows)),
|
||||
Total: total,
|
||||
HasAnyProducts: hasAny > 0,
|
||||
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "",
|
||||
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "" || filter.Deleted,
|
||||
Page: page,
|
||||
PageSize: PageSize,
|
||||
TotalPages: totalPages,
|
||||
@@ -117,6 +118,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
|
||||
PendingCount: r.PendingCount,
|
||||
SourceText: shopeeSourceText(r.Source),
|
||||
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||||
Deleted: r.IsDeleted(),
|
||||
}
|
||||
|
||||
if r.PddGoodsID == "" {
|
||||
|
||||
@@ -902,6 +902,9 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := repository.UpsertSybShopeeSKU(tx, order.ShopeeGoodsID, order.ProductSpec, model.NowISO()); err != nil {
|
||||
return fmt.Errorf("写入顺运宝规格观测失败: %w", err)
|
||||
}
|
||||
if created {
|
||||
report.Created++
|
||||
} 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 保持模块根地址,因此脚本失效时
|
||||
仍可正常导航。 */
|
||||
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"],
|
||||
"/syb": ["order_no", "shop", "stage", "page", "date_from", "date_to"],
|
||||
"/tasks": ["type", "status", "creator", "q", "page"],
|
||||
|
||||
@@ -4,16 +4,19 @@
|
||||
{{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
|
||||
<div class="toolbar">
|
||||
{{if .CurrentUser.IsAdmin}}<a class="button" href="/integrations/catalog">导入记录</a>{{end}}
|
||||
<form id="shopee-collect-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="list_goods_id" value="{{.Keyword}}" form="shopee-collect-form">
|
||||
<input type="hidden" name="list_shop_name" value="{{.ShopNameKeyword}}" form="shopee-collect-form">
|
||||
<input type="hidden" name="status" value="{{.StatusFilter}}" form="shopee-collect-form">
|
||||
<input type="hidden" name="shop" value="{{.ShopFilter}}" form="shopee-collect-form">
|
||||
<input type="hidden" name="image" value="{{.ImageFilter}}" form="shopee-collect-form">
|
||||
<input type="hidden" name="page" value="{{.CurrentPage}}" form="shopee-collect-form">
|
||||
<form id="shopee-bulk-form" method="post" action="/shopee/collect-batch" hidden></form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="shopee-bulk-form">
|
||||
<input type="hidden" name="list_goods_id" value="{{.Keyword}}" form="shopee-bulk-form">
|
||||
<input type="hidden" name="list_shop_name" value="{{.ShopNameKeyword}}" form="shopee-bulk-form">
|
||||
<input type="hidden" name="status" value="{{.StatusFilter}}" form="shopee-bulk-form">
|
||||
<input type="hidden" name="shop" value="{{.ShopFilter}}" form="shopee-bulk-form">
|
||||
<input type="hidden" name="image" value="{{.ImageFilter}}" form="shopee-bulk-form">
|
||||
<input type="hidden" name="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
|
||||
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">
|
||||
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花,
|
||||
见 docs/admin/05-ui-specification.md §4.1、工单 #43。
|
||||
@@ -36,6 +39,13 @@
|
||||
<option value="{{.Value}}" {{if eq .Value $.ImageFilter}}selected{{end}}>{{.Text}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
<label for="deleted">数据范围</label>
|
||||
<select id="deleted" name="deleted">
|
||||
<option value="" {{if not .DeletedFilter}}selected{{end}}>正常商品</option>
|
||||
<option value="1" {{if .DeletedFilter}}selected{{end}}>已删除</option>
|
||||
</select>
|
||||
{{end}}
|
||||
<label for="q">商品 ID / 名称</label>
|
||||
<input id="q" class="shopee-search-field" type="text" name="goods_id" value="{{.Keyword}}" placeholder="商品 ID / 名称">
|
||||
<label for="shop-name">店铺名称</label>
|
||||
@@ -43,17 +53,14 @@
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
|
||||
<form class="inline" method="post" action="/shopee/delete"
|
||||
data-confirm-delete>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="list_goods_id" value="{{.Keyword}}">
|
||||
<input type="hidden" name="list_shop_name" value="{{.ShopNameKeyword}}">
|
||||
<input type="hidden" name="status" value="{{.StatusFilter}}">
|
||||
<input type="hidden" name="shop" value="{{.ShopFilter}}">
|
||||
<input type="hidden" name="image" value="{{.ImageFilter}}">
|
||||
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||
<button type="submit" class="danger" data-need-checked>删除</button>
|
||||
</form>
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
{{if .DeletedFilter}}
|
||||
<button type="submit" form="shopee-bulk-form" formaction="/shopee/restore" data-need-checked>恢复</button>
|
||||
{{else}}
|
||||
<button type="submit" class="danger" form="shopee-bulk-form" formaction="/shopee/delete"
|
||||
data-need-checked data-confirm-delete="将把 {n} 个蝦皮商品移入已删除,规格和 PDD 关联会保留,可以恢复。">删除</button>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* ── 第二段:带勾选的表格 ──────────────────────────── */}}
|
||||
@@ -86,8 +93,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr data-detail-id="{{.GoodsID}}"{{if gt .PendingCount 0}} class="row-warn"{{end}}>
|
||||
<td class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}" form="shopee-collect-form"></td>
|
||||
<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-bulk-form"></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>
|
||||
{{/* 商品名称列收窄,用独立的 .col-title 类设 max-width(具体数值见 app.css),
|
||||
@@ -131,7 +138,7 @@
|
||||
<p>将按当前 PDD 关联处理已选择的 <strong data-collect-count>0</strong> 个蝦皮商品。</p>
|
||||
<div class="field">
|
||||
<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>
|
||||
{{range .AssignableClients}}
|
||||
<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>
|
||||
@@ -142,7 +149,7 @@
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<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>
|
||||
@@ -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>
|
||||
|
||||
<p class="hint">
|
||||
双击任意一行可以查看这个商品的完整规格表。
|
||||
{{if .DeletedFilter}}已删除商品保留规格和 PDD 关联;恢复后可继续处理。{{else}}双击任意一行可以查看这个商品的完整规格表。{{end}}
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user