From ad1ccc2c60b8c1627ea7222de82245904ecc2291 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 13 Aug 2026 10:39:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20SYB=20=E8=9D=A6?= =?UTF-8?q?=E7=9A=AE=E4=B8=BB=E6=95=B0=E6=8D=AE=E4=B8=8E=E8=BD=AF=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20(#203=20#204=20#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/shopee.go | 42 +++- admin/handler/web/web.go | 4 +- admin/main_test.go | 4 +- admin/model/model.go | 5 + admin/repository/catalog_import.go | 27 ++- admin/repository/mysql_db.go | 135 ++++++++++++- admin/repository/mysql_db_integration_test.go | 50 ++++- admin/repository/shopee.go | 94 +++++++-- admin/repository/spec_compare.go | 4 +- admin/repository/syb.go | 185 ++++++++++++++++-- admin/repository/syb_test.go | 62 ++++++ admin/service/catalog_import_test.go | 2 +- admin/service/shopee_delete.go | 79 ++++++++ admin/service/shopee_delete_test.go | 69 +++++++ admin/service/shopee_list.go | 4 +- admin/service/syb.go | 3 + admin/spec/shopee_spec.go | 56 ++++++ admin/spec/shopee_spec_test.go | 27 +++ admin/static/js/app.js | 2 +- admin/templates/shopee/list.html | 57 +++--- docs/admin/01-requirements.md | 8 +- docs/admin/03-data-model.md | 27 ++- docs/admin/05-ui-specification.md | 8 +- 23 files changed, 864 insertions(+), 90 deletions(-) create mode 100644 admin/service/shopee_delete.go create mode 100644 admin/service/shopee_delete_test.go create mode 100644 admin/spec/shopee_spec.go create mode 100644 admin/spec/shopee_spec_test.go diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index dc09cf8..9173e2f 100644 --- a/admin/handler/web/shopee.go +++ b/admin/handler/web/shopee.go @@ -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) { // // `[必须]` 分页控件用 纯 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) } diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 594d2ca..7b38e72 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -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) diff --git a/admin/main_test.go b/admin/main_test.go index d51116d..0abdbed 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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"`, ``, `不指定(任意客户端可领取)`, `.AssignableClients`, `data-collect-submit`, } { diff --git a/admin/model/model.go b/admin/model/model.go index 1994c30..44e3e81 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -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公斤】」, diff --git a/admin/repository/catalog_import.go b/admin/repository/catalog_import.go index 9c553a6..4e011d7 100644 --- a/admin/repository/catalog_import.go +++ b/admin/repository/catalog_import.go @@ -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 } diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 91c5c08..171a75f 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -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 { diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index 49b1fa8..b4f9be8 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -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" { diff --git a/admin/repository/shopee.go b/admin/repository/shopee.go index 3302854..efb17cc 100644 --- a/admin/repository/shopee.go +++ b/admin/repository/shopee.go @@ -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() +} diff --git a/admin/repository/spec_compare.go b/admin/repository/spec_compare.go index 35086df..dbc6788 100644 --- a/admin/repository/spec_compare.go +++ b/admin/repository/spec_compare.go @@ -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) diff --git a/admin/repository/syb.go b/admin/repository/syb.go index 69cd2c2..28da1ec 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -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 diff --git a/admin/repository/syb_test.go b/admin/repository/syb_test.go index 60669dd..c77b731 100644 --- a/admin/repository/syb_test.go +++ b/admin/repository/syb_test.go @@ -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) diff --git a/admin/service/catalog_import_test.go b/admin/service/catalog_import_test.go index 5ce13b4..574017c 100644 --- a/admin/service/catalog_import_test.go +++ b/admin/service/catalog_import_test.go @@ -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))`, diff --git a/admin/service/shopee_delete.go b/admin/service/shopee_delete.go new file mode 100644 index 0000000..d9604ef --- /dev/null +++ b/admin/service/shopee_delete.go @@ -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() +} diff --git a/admin/service/shopee_delete_test.go b/admin/service/shopee_delete_test.go new file mode 100644 index 0000000..6beafa7 --- /dev/null +++ b/admin/service/shopee_delete_test.go @@ -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) + } +} diff --git a/admin/service/shopee_list.go b/admin/service/shopee_list.go index aa1a610..7cfae15 100644 --- a/admin/service/shopee_list.go +++ b/admin/service/shopee_list.go @@ -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 == "" { diff --git a/admin/service/syb.go b/admin/service/syb.go index daed4b8..910a02d 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -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 { diff --git a/admin/spec/shopee_spec.go b/admin/spec/shopee_spec.go new file mode 100644 index 0000000..dfe40d6 --- /dev/null +++ b/admin/spec/shopee_spec.go @@ -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 + } +} diff --git a/admin/spec/shopee_spec_test.go b/admin/spec/shopee_spec_test.go new file mode 100644 index 0000000..761e04d --- /dev/null +++ b/admin/spec/shopee_spec_test.go @@ -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) + } + }) + } +} diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 1ec7623..9c63434 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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"], diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html index f30a139..f2984d7 100644 --- a/admin/templates/shopee/list.html +++ b/admin/templates/shopee/list.html @@ -4,16 +4,19 @@ {{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
{{if .CurrentUser.IsAdmin}}导入记录{{end}} - - - - - - - - + + + + + + + + + + {{if not .DeletedFilter}} + data-collect-form="shopee-bulk-form" data-need-checked>创建 PDD 采集任务 + {{end}}
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花, 见 docs/admin/05-ui-specification.md §4.1、工单 #43。 @@ -36,6 +39,13 @@ {{end}} + {{if .CurrentUser.IsAdmin}} + + + {{end}} @@ -43,17 +53,14 @@
-
- - - - - - - - -
+ {{if .CurrentUser.IsAdmin}} + {{if .DeletedFilter}} + + {{else}} + + {{end}} + {{end}}
{{/* ── 第二段:带勾选的表格 ──────────────────────────── */}} @@ -86,8 +93,8 @@ {{range .Rows}} - - + + {{.GoodsID}} {{if .ImageURL}}{{else}}无图{{end}} {{/* 商品名称列收窄,用独立的 .col-title 类设 max-width(具体数值见 app.css), @@ -131,7 +138,7 @@

将按当前 PDD 关联处理已选择的 0 个蝦皮商品。

- {{range .AssignableClients}} @@ -142,7 +149,7 @@
@@ -150,7 +157,7 @@

- 双击任意一行可以查看这个商品的完整规格表。 + {{if .DeletedFilter}}已删除商品保留规格和 PDD 关联;恢复后可继续处理。{{else}}双击任意一行可以查看这个商品的完整规格表。{{end}}

diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index 9e22dad..d1a7d7d 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -87,8 +87,9 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 ### 4.1 蝦皮数据模块 -**顶部工具条:** 导入按钮、批量创建 PDD 采集任务按钮、状态筛选 -(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、商品 ID 搜索框、搜索按钮、删除按钮。 +**顶部工具条:** 导入记录按钮、批量创建 PDD 采集任务按钮、状态筛选 +(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、店铺与图片筛选、商品 ID 和店铺名称 +搜索、搜索按钮;管理员另有正常/已删除范围筛选以及删除/恢复按钮。 `[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护 (找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话, @@ -120,6 +121,9 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 有效任务的商品可以恢复创建。该入口不修改 PDD 关联,不替代商品详情里的单商品入口; - `[必须]` 采集操作**只出现在顶部批量入口和商品详情弹窗里**,不要放在每个表格行—— 一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。 +- `[必须]` 蝦皮商品删除为管理员软删除。正常业务查询默认排除;SKU、PDD 关联和历史 + 数据保留。存在待分配、待领取或已领取任务时整批拒绝;恢复只能从管理员“已删除” + 列表显式执行,顺运宝同步和商品目录导入不得自动恢复。 `[注意]` PDD 商品模块(§4.2)继续作为商品档案、采集状态和故障诊断页; 蝦皮详情和顺运宝处理详情是带业务上下文的关联入口,两者调用同一个 Service。 diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 77340ff..05becb2 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -116,6 +116,11 @@ v13(#172)新增 `task_sequences`,把存量 `tasks.task_id` 按类型和创 v14(#196)新增 `syb_allowed_shops` 全局店铺准入表;`syb_sync_runs` 增加 `accepted_stock_count`、`shop_skipped_count` 和 `shop_filter_hash`。旧同步记录回填为 “接受数=原始货运单数、店铺跳过=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`, 这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句 @@ -175,6 +180,8 @@ CREATE TABLE shopee_products ( -- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖 pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文 pdd_goods_id TEXT, -- 从 url 解析,指向 pdd_products.goods_id + deleted_at VARCHAR(35), -- 软删除时间;非空时默认业务查询不可见 + deleted_by_user_id VARCHAR(191), -- 执行删除的管理员 created_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')); 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。 @@ -193,6 +201,11 @@ CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id); 后续 Excel 导入同一 `goods_id` 时必须补全商品信息并把来源提升为 `report`; 导入不得覆盖人工维护的 PDD 关联。 +顺运宝同步只把非空店铺和图片补到空字段,或更新同样来自 `syb` 的字段;人工值和 +目录接口值不覆盖。后续目录接口可把 `syb` 低优先级值提升为权威来源。软删除商品 +不会因顺运宝同步或目录导入自动恢复,SKU、PDD 关联和历史记录全部保留;只能由 +管理员显式恢复。存在 `pending/assigned/claimed` 采集或采购任务时禁止软删除。 + `pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。 PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。 @@ -225,7 +238,8 @@ CREATE INDEX idx_shopee_skus_parse ON shopee_skus(parse_ok); 再按 [商品目录接口契约](10-商品目录接入接口.md) 提交结构化商品、SKU、PDD 和关联。 - `[必须]` 整批只做 upsert,不清空表,也不删除批次中缺席的数据。 -- `[必须]` `spec_raw` 原样保留;解析失败由上游明确提交 `parse_ok=false`,Admin 不猜。 +- `[必须]` `spec_raw` 原样保留。商品目录解析结果由上游提交;SYB 只对明确的 + “颜色,尺码【建议】”二维格式做确定性解析,未知尺码、额外维度和不完整括号均不猜。 - `[必须]` 商品更新不覆盖 `pdd_goods_url` / `pdd_goods_id`,SKU 更新不覆盖 `is_manual`。 - `[必须]` 较旧 `observed_at` 不覆盖较新接口数据。 - 空 PDD 关联可以建立;相同关联幂等;不同关联返回冲突,不静默换品。 @@ -466,13 +480,16 @@ v10 幂等重放 v8、v9 的逐项建列、空值回填、约束和唯一索引 MySQL schema v11(工单 #151)为 `syb_orders` 增加可空 `VARCHAR(500)` 的 `shop_name`,并从合法原始 JSON 的 `$.stock.shopName` 回填历史数据。迁移可重放, -只填仍为空的列;新同步直接更新该列,不把它写入或覆盖蝦皮商品主数据。 +只填仍为空的列;新同步直接更新该列。v15(#203)在此基础上把非空店铺和图片作为 +`syb` 低优先级字段补入蝦皮商品主数据,但仍不得覆盖人工或目录接口字段。 蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”: 订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。 -该读模型不写入 `shopee_skus`。只有同一商品下恰好一条正式 SKU 的 `spec_raw` -经 `SpecKey()` 后相等时才展示关联;零条或多条都不猜测、不写回。重复同步同一个 -`syb_id` 只更新原行,因此不会重复累计。 +从 v16 起,同步会把格式明确的规格以 `source='syb'` 写入 `shopee_skus`:内部主键 +由商品 ID 和 `spec_key` 确定性生成,真实 `shopee_sku_id` 保持 `NULL`。目录接口后到时 +按同一规格身份补上真实 ID,并可替换字段来源为权威来源;人工行永不覆盖。不明确格式 +仍只存在于观测读模型中,不创建 SKU。重复同步同一个 `syb_id` 只更新原行,因此不会 +重复累计。 `[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项), 不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 8ed4289..defc3fc 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -136,7 +136,7 @@ ### 4.1 工具条 ```text -[导入记录] [创建 PDD 采集任务] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除] +[导入记录] [创建 PDD 采集任务] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 数据范围[正常商品▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除] ``` - **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。 @@ -166,9 +166,15 @@ - `[必须]` 店铺、图片使用独立三态下拉框,不使用含义不清的复选框;两个条件可互相 组合,也可与状态和商品 ID/标题搜索叠加。未知参数按“全部”处理,翻页、详情弹窗 和写操作返回时保留全部条件。工具条在窄窗口沿用公共 `flex-wrap` 自然换行。 +- **数据范围**(工单 #205):只向管理员显示“正常商品 / 已删除”。正常列表不返回 + 软删除商品;已删除列表只用于查看和恢复,不允许创建采集任务或打开处理详情。 - **创建 PDD 采集任务**(工单 #185):勾选蝦皮商品后打开批量确认弹窗,显示选择 数量及带可见标签的“执行客户端”,默认不指定。服务端按当前 PDD 关联和 PDD 商品 ID 去重;未关联、已删除、已采集、正常采集中分别统计并跳过。商品详情的单商品入口保留。 +- **删除 / 恢复**(工单 #205):只允许管理员操作。删除前确认文案明确显示选中数量、 + 可恢复且规格和 PDD 关联会保留;有进行中采集或采购任务时整批拒绝。已删除列表显示 + “恢复”按钮,恢复不重建数据、不改变原关联。批量采集、删除和恢复共用同一个表单, + 通过提交按钮的 `formaction` 选择接口,避免勾选值落到错误表单。 ### 4.2 表格列