feat: 完善 SYB 蝦皮主数据与软删除 (#203 #204 #205)

This commit is contained in:
chengma
2026-08-13 10:39:21 +08:00
parent 3af9bf245a
commit ad1ccc2c60
23 changed files with 864 additions and 90 deletions
+36 -6
View File
@@ -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)
}
+3 -1
View File
@@ -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
View File
@@ -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`,
} {
+5
View File
@@ -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公斤】」,
+20 -7
View File
@@ -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(&current); err != nil {
if err = q.QueryRow(`SELECT pdd_goods_id FROM shopee_products WHERE goods_id=? AND deleted_at IS NULL`, shopeeGoodsID).Scan(&current); 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
}
+133 -2
View File
@@ -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 {
+45 -5
View File
@@ -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
View File
@@ -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()
}
+2 -2
View File
@@ -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
View File
@@ -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
+62
View File
@@ -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)
+1 -1
View File
@@ -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))`,
+79
View File
@@ -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()
}
+69
View File
@@ -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)
}
}
+3 -1
View File
@@ -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 == "" {
+3
View File
@@ -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 {
+56
View File
@@ -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
}
}
+27
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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"],
+32 -25
View File
@@ -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>