feat: 展示蝦皮主图和店铺 (#142)

This commit is contained in:
chengma
2026-08-11 11:18:51 +08:00
parent a441dfef87
commit 38d6a0a04b
22 changed files with 555 additions and 101 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ func TestCatalogBatchAPI_按批次查询摘要(t *testing.T) {
t.Fatal(err)
}
defer db.Close()
_, err = db.Exec(`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,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))`)
_, err = db.Exec(`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))`)
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -37,7 +37,7 @@ func (h *Handler) GetBatch(c *gin.Context) {
integrationError(c, 500, "INTERNAL_ERROR", "查询批次失败", true, nil)
return
}
c.JSON(200, gin.H{"source": run.Source, "batch_id": run.BatchID, "update_policy": run.UpdatePolicy, "status": run.Status, "request_count": run.RequestCount, "conflict_count": run.ConflictCount, "observed_at": run.ObservedAt, "created_at": run.CreatedAt, "finished_at": run.FinishedAt, "counts": gin.H{"shopee_created": run.ShopeeCreated, "shopee_updated": run.ShopeeUpdated, "sku_created": run.SKUCreated, "sku_updated": run.SKUUpdated, "sku_filled": run.SKUFilled, "sku_same_source_updated": run.SKUSameSourceUpdated, "sku_skipped": run.SKUSkipped, "sku_manual_skipped": run.SKUManualSkipped, "sku_stale_skipped": run.SKUStaleSkipped, "pdd_created": run.PddCreated, "pdd_updated": run.PddUpdated, "association_created": run.AssociationCreated, "association_unchanged": run.AssociationUnchanged}, "failure_count": run.FailureCount, "error_summary": run.ErrorSummary})
c.JSON(200, gin.H{"source": run.Source, "batch_id": run.BatchID, "update_policy": run.UpdatePolicy, "status": run.Status, "request_count": run.RequestCount, "conflict_count": run.ConflictCount, "observed_at": run.ObservedAt, "created_at": run.CreatedAt, "finished_at": run.FinishedAt, "counts": gin.H{"shopee_created": run.ShopeeCreated, "shopee_updated": run.ShopeeUpdated, "shopee_fields_filled": run.ShopeeFieldsFilled, "shopee_fields_same_source_updated": run.ShopeeFieldsSameSourceUpdated, "shopee_fields_manual_skipped": run.ShopeeFieldsManualSkipped, "shopee_fields_stale_skipped": run.ShopeeFieldsStaleSkipped, "sku_created": run.SKUCreated, "sku_updated": run.SKUUpdated, "sku_filled": run.SKUFilled, "sku_same_source_updated": run.SKUSameSourceUpdated, "sku_skipped": run.SKUSkipped, "sku_manual_skipped": run.SKUManualSkipped, "sku_stale_skipped": run.SKUStaleSkipped, "pdd_created": run.PddCreated, "pdd_updated": run.PddUpdated, "association_created": run.AssociationCreated, "association_unchanged": run.AssociationUnchanged}, "failure_count": run.FailureCount, "error_summary": run.ErrorSummary})
}
func (h *Handler) CreateBatch(c *gin.Context) {
+25
View File
@@ -168,6 +168,31 @@ func TestCatalogHistory_展示更新策略和分级统计(t *testing.T) {
}
}
func TestShopeePage_展示主图店铺且保留标题宽度(t *testing.T) {
page, err := os.ReadFile("templates/shopee/list.html")
if err != nil {
t.Fatal(err)
}
detail, err := os.ReadFile("templates/shopee/detail_modal.html")
if err != nil {
t.Fatal(err)
}
css, err := os.ReadFile("static/css/app.css")
if err != nil {
t.Fatal(err)
}
js, err := os.ReadFile("static/js/app.js")
if err != nil {
t.Fatal(err)
}
content := string(page) + string(detail) + string(css) + string(js)
for _, want := range []string{"蝦皮店铺", "data-image-preview-url", "loading=\"lazy\"", "data-image-thumb-fallback", "ImageSourceText", "max-width: 18%", ".col-shop", "setupImageThumbnails"} {
if !strings.Contains(content, want) {
t.Errorf("蝦皮图片店铺界面缺少 %q", want)
}
}
}
func TestShopeeImportRoute_已移除(t *testing.T) {
router, err := newRouter(nil)
if err != nil {
+4
View File
@@ -22,6 +22,10 @@ type CatalogImportRun struct {
LastConflictAt string
ShopeeCreated int
ShopeeUpdated int
ShopeeFieldsFilled int
ShopeeFieldsSameSourceUpdated int
ShopeeFieldsManualSkipped int
ShopeeFieldsStaleSkipped int
SKUCreated int
SKUUpdated int
UpdatePolicy string
+6
View File
@@ -168,6 +168,12 @@ type ShopeeProduct struct {
Title string
ShopeeStatus string
MainSKUCode string
ImageURL string
ShopeeShopName string
ImageSource, ImageObservedAt string
ImageIsManual bool
ShopNameSource, ShopNameObservedAt string
ShopNameIsManual bool
Source string // report=蝦皮报表完整行;syb=顺运宝同步补建的商品骨架
PddGoodsURL string
PddGoodsID string // 指向 PddProduct.GoodsID,为空表示还没填链接
+59 -18
View File
@@ -43,13 +43,13 @@ func GetCatalogImportRun(q Execer, source, batchID string) (model.CatalogImportR
var run model.CatalogImportRun
var observedAt, lastConflictAt, errorSummary, responseBody, finishedAt sql.NullString
err := q.QueryRow(`SELECT source,batch_id,request_hash,status,request_count,conflict_count,
observed_at,update_policy,last_request_at,last_conflict_at,shopee_created,shopee_updated,sku_created,
observed_at,update_policy,last_request_at,last_conflict_at,shopee_created,shopee_updated,shopee_fields_filled,shopee_fields_same_source_updated,shopee_fields_manual_skipped,shopee_fields_stale_skipped,sku_created,
sku_updated,sku_filled,sku_same_source_updated,sku_skipped,sku_manual_skipped,sku_stale_skipped,
pdd_created,pdd_updated,association_created,association_unchanged,failure_count,
error_summary,response_body,created_at,finished_at
FROM catalog_import_runs WHERE source=? AND batch_id=?`, source, batchID).Scan(
&run.Source, &run.BatchID, &run.RequestHash, &run.Status, &run.RequestCount, &run.ConflictCount,
&observedAt, &run.UpdatePolicy, &run.LastRequestAt, &lastConflictAt, &run.ShopeeCreated, &run.ShopeeUpdated,
&observedAt, &run.UpdatePolicy, &run.LastRequestAt, &lastConflictAt, &run.ShopeeCreated, &run.ShopeeUpdated, &run.ShopeeFieldsFilled, &run.ShopeeFieldsSameSourceUpdated, &run.ShopeeFieldsManualSkipped, &run.ShopeeFieldsStaleSkipped,
&run.SKUCreated, &run.SKUUpdated, &run.SKUFilled, &run.SKUSameSourceUpdated, &run.SKUSkipped,
&run.SKUManualSkipped, &run.SKUStaleSkipped, &run.PddCreated, &run.PddUpdated, &run.AssociationCreated,
&run.AssociationUnchanged, &run.FailureCount, &errorSummary, &responseBody, &run.CreatedAt, &finishedAt)
@@ -149,37 +149,78 @@ func RecordCatalogImportConflict(q Execer, source, batchID, requestedAt string)
// CompleteCatalogImportRun 把处理结果摘要和安全的响应 JSON 固化,供幂等重放。
func CompleteCatalogImportRun(q Execer, run model.CatalogImportRun) error {
result, err := q.Exec(`UPDATE catalog_import_runs SET status=?,shopee_created=?,shopee_updated=?,
result, err := q.Exec(`UPDATE catalog_import_runs SET status=?,shopee_created=?,shopee_updated=?,shopee_fields_filled=?,shopee_fields_same_source_updated=?,shopee_fields_manual_skipped=?,shopee_fields_stale_skipped=?,
sku_created=?,sku_updated=?,sku_filled=?,sku_same_source_updated=?,sku_skipped=?,
sku_manual_skipped=?,sku_stale_skipped=?,pdd_created=?,pdd_updated=?,association_created=?,
association_unchanged=?,failure_count=?,error_summary=NULLIF(?,''),response_body=NULLIF(?,''),
finished_at=? WHERE source=? AND batch_id=?`, run.Status, run.ShopeeCreated, run.ShopeeUpdated,
run.SKUCreated, run.SKUUpdated, run.SKUFilled, run.SKUSameSourceUpdated, run.SKUSkipped,
run.ShopeeFieldsFilled, run.ShopeeFieldsSameSourceUpdated, run.ShopeeFieldsManualSkipped, run.ShopeeFieldsStaleSkipped, run.SKUCreated, run.SKUUpdated, run.SKUFilled, run.SKUSameSourceUpdated, run.SKUSkipped,
run.SKUManualSkipped, run.SKUStaleSkipped, run.PddCreated, run.PddUpdated, run.AssociationCreated,
run.AssociationUnchanged, run.FailureCount, run.ErrorSummary, run.ResponseBody,
run.FinishedAt, run.Source, run.BatchID)
return catalogRunUpdateResult(result, err, "完成商品目录批次")
}
// UpsertCatalogShopeeProduct 按上游观测时间更新来源字段,绝不覆盖人工 PDD 关联。
func UpsertCatalogShopeeProduct(q Execer, goodsID, title, status, mainSKU, observedAt, now string) (created, updated bool, err error) {
var oldObserved sql.NullString
err = q.QueryRow(`SELECT source_observed_at FROM shopee_products WHERE goods_id=?`, goodsID).Scan(&oldObserved)
type CatalogShopeeProductInput struct{ GoodsID, Title, Status, MainSKU, ImageURL, ShopName, Source, ObservedAt, Now, UpdatePolicy string }
type CatalogProductOutcome struct {
Created, Updated bool
FieldsFilled, FieldsSameSourceUpdated, FieldsManualSkipped, FieldsStaleSkipped int
}
// UpsertCatalogShopeeProduct 独立保护主图和店铺字段,绝不覆盖人工 PDD 关联。
func UpsertCatalogShopeeProduct(q Execer, in CatalogShopeeProductInput) (out CatalogProductOutcome, err error) {
var oldObserved, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved sql.NullString
var imageManual, shopManual int
err = q.QueryRow(`SELECT source_observed_at,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual FROM shopee_products WHERE goods_id=?`, in.GoodsID).Scan(&oldObserved, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual)
if errors.Is(err, sql.ErrNoRows) {
_, err = q.Exec(`INSERT INTO shopee_products(goods_id,title,shopee_status,main_sku_code,source,
source_observed_at,created_at,updated_at) VALUES(?,?,NULLIF(?,''),NULLIF(?,''),'api',?,?,?)`,
goodsID, title, status, mainSKU, observedAt, now, now)
return err == nil, false, err
_, err = q.Exec(`INSERT INTO shopee_products(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,source_observed_at,created_at,updated_at) VALUES(?,?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,0,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,CASE WHEN TRIM(?)='' THEN NULL ELSE ? END,0,'api',?,?,?)`, in.GoodsID, in.Title, in.Status, in.MainSKU, in.ImageURL, in.ShopName, in.ImageURL, in.Source, in.ImageURL, in.ObservedAt, in.ShopName, in.Source, in.ShopName, in.ObservedAt, in.ObservedAt, in.Now, in.Now)
out.Created = err == nil
return out, err
}
if err != nil {
return false, false, err
return out, err
}
if oldObserved.Valid && oldObserved.String > observedAt {
return false, false, nil
baseUpdate := !oldObserved.Valid || oldObserved.String <= in.ObservedAt
newImage, newShop := imageURL.String, shopName.String
imageChanged, shopChanged := false, false
apply := func(incoming string, current *string, source, observed sql.NullString, manual int) (changed bool) {
if strings.TrimSpace(incoming) == "" || in.UpdatePolicy == "insert_only" {
return false
}
_, err = q.Exec(`UPDATE shopee_products SET title=?,shopee_status=NULLIF(?,''),main_sku_code=NULLIF(?,''),
source='api',source_observed_at=?,updated_at=? WHERE goods_id=?`, title, status, mainSKU, observedAt, now, goodsID)
return false, err == nil, err
if manual != 0 {
out.FieldsManualSkipped++
return false
}
if in.UpdatePolicy == "fill_missing" {
if *current == "" {
*current = incoming
out.FieldsFilled++
return true
}
return false
}
if !source.Valid || source.String != in.Source {
return false
}
if observed.Valid && observed.String > in.ObservedAt {
out.FieldsStaleSkipped++
return false
}
if *current != incoming {
*current = incoming
out.FieldsSameSourceUpdated++
return true
}
return false
}
imageChanged = apply(in.ImageURL, &newImage, imageSource, imageObserved, imageManual)
shopChanged = apply(in.ShopName, &newShop, shopSource, shopObserved, shopManual)
if !baseUpdate && !imageChanged && !shopChanged {
return out, nil
}
_, err = q.Exec(`UPDATE shopee_products SET title=CASE WHEN ? THEN ? ELSE title END,shopee_status=CASE WHEN ? THEN NULLIF(?,'') ELSE shopee_status END,main_sku_code=CASE WHEN ? THEN NULLIF(?,'') ELSE main_sku_code END,source=CASE WHEN ? THEN 'api' ELSE source END,source_observed_at=CASE WHEN ? THEN ? ELSE source_observed_at END,image_url=NULLIF(?,''),shopee_shop_name=NULLIF(?,''),image_source=CASE WHEN ? THEN ? ELSE image_source END,image_observed_at=CASE WHEN ? THEN ? ELSE image_observed_at END,shop_name_source=CASE WHEN ? THEN ? ELSE shop_name_source END,shop_name_observed_at=CASE WHEN ? THEN ? ELSE shop_name_observed_at END,updated_at=? WHERE goods_id=?`, baseUpdate, in.Title, baseUpdate, in.Status, baseUpdate, in.MainSKU, baseUpdate, baseUpdate, in.ObservedAt, newImage, newShop, imageChanged, in.Source, imageChanged, in.ObservedAt, shopChanged, in.Source, shopChanged, in.ObservedAt, in.Now, in.GoodsID)
out.Updated = err == nil
return out, err
}
type CatalogSKUOutcome string
+1 -1
View File
@@ -20,7 +20,7 @@ func TestCatalogImportRun_登记查询重放和冲突(t *testing.T) {
if _, err := db.Exec(`CREATE TABLE catalog_import_runs (
source TEXT NOT NULL,batch_id TEXT NOT NULL,request_hash TEXT NOT NULL,status TEXT NOT NULL,
request_count INTEGER NOT NULL,conflict_count INTEGER NOT NULL,observed_at TEXT,update_policy TEXT NOT NULL DEFAULT 'fill_missing',last_request_at TEXT NOT NULL,
last_conflict_at TEXT,shopee_created INTEGER NOT NULL DEFAULT 0,shopee_updated INTEGER NOT NULL DEFAULT 0,
last_conflict_at TEXT,shopee_created INTEGER NOT NULL DEFAULT 0,shopee_updated INTEGER NOT NULL DEFAULT 0,shopee_fields_filled INTEGER NOT NULL DEFAULT 0,shopee_fields_same_source_updated INTEGER NOT NULL DEFAULT 0,shopee_fields_manual_skipped INTEGER NOT NULL DEFAULT 0,shopee_fields_stale_skipped INTEGER NOT NULL DEFAULT 0,
sku_created INTEGER NOT NULL DEFAULT 0,sku_updated INTEGER NOT NULL DEFAULT 0,sku_filled INTEGER NOT NULL DEFAULT 0,sku_same_source_updated INTEGER NOT NULL DEFAULT 0,sku_skipped INTEGER NOT NULL DEFAULT 0,sku_manual_skipped INTEGER NOT NULL DEFAULT 0,sku_stale_skipped INTEGER NOT NULL DEFAULT 0,pdd_created INTEGER NOT NULL DEFAULT 0,
pdd_updated INTEGER NOT NULL DEFAULT 0,association_created INTEGER NOT NULL DEFAULT 0,
association_unchanged INTEGER NOT NULL DEFAULT 0,failure_count INTEGER NOT NULL DEFAULT 0,
+98 -2
View File
@@ -19,7 +19,7 @@ import (
"cmautobuy/admin/spec"
)
const mysqlSchemaVersion = 8
const mysqlSchemaVersion = 9
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -519,10 +519,65 @@ func MigrateMySQL(db *sql.DB) error {
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 8, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v8 失败: %w", err)
}
current = 8
}
if current < 9 {
if err := migrateMySQLV9(db); err != nil {
return fmt.Errorf("执行 MySQL schema v9 失败: %w", err)
}
if err := checkMySQLV9Shape(db); err != nil {
return fmt.Errorf("MySQL schema v9 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 9, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v9 失败: %w", err)
}
}
return CheckMySQLSchema(db)
}
func migrateMySQLV9(db *sql.DB) error {
columns := []struct{ table, name, ddl string }{
{"shopee_products", "image_url", `ALTER TABLE shopee_products ADD COLUMN image_url VARCHAR(2048) NULL AFTER main_sku_code`},
{"shopee_products", "shopee_shop_name", `ALTER TABLE shopee_products ADD COLUMN shopee_shop_name VARCHAR(500) NULL AFTER image_url`},
{"shopee_products", "image_source", `ALTER TABLE shopee_products ADD COLUMN image_source VARCHAR(64) COLLATE utf8mb4_bin NULL AFTER shopee_shop_name`},
{"shopee_products", "image_observed_at", `ALTER TABLE shopee_products ADD COLUMN image_observed_at VARCHAR(35) NULL AFTER image_source`},
{"shopee_products", "image_is_manual", `ALTER TABLE shopee_products ADD COLUMN image_is_manual TINYINT NOT NULL DEFAULT 0 AFTER image_observed_at`},
{"shopee_products", "shop_name_source", `ALTER TABLE shopee_products ADD COLUMN shop_name_source VARCHAR(64) COLLATE utf8mb4_bin NULL AFTER image_is_manual`},
{"shopee_products", "shop_name_observed_at", `ALTER TABLE shopee_products ADD COLUMN shop_name_observed_at VARCHAR(35) NULL AFTER shop_name_source`},
{"shopee_products", "shop_name_is_manual", `ALTER TABLE shopee_products ADD COLUMN shop_name_is_manual TINYINT NOT NULL DEFAULT 0 AFTER shop_name_observed_at`},
{"catalog_import_runs", "shopee_fields_filled", `ALTER TABLE catalog_import_runs ADD COLUMN shopee_fields_filled INT UNSIGNED NOT NULL DEFAULT 0 AFTER shopee_updated`},
{"catalog_import_runs", "shopee_fields_same_source_updated", `ALTER TABLE catalog_import_runs ADD COLUMN shopee_fields_same_source_updated INT UNSIGNED NOT NULL DEFAULT 0 AFTER shopee_fields_filled`},
{"catalog_import_runs", "shopee_fields_manual_skipped", `ALTER TABLE catalog_import_runs ADD COLUMN shopee_fields_manual_skipped INT UNSIGNED NOT NULL DEFAULT 0 AFTER shopee_fields_same_source_updated`},
{"catalog_import_runs", "shopee_fields_stale_skipped", `ALTER TABLE catalog_import_runs ADD COLUMN shopee_fields_stale_skipped INT UNSIGNED NOT NULL DEFAULT 0 AFTER shopee_fields_manual_skipped`},
}
for _, column := range columns {
exists, err := mysqlColumnExists(db, column.table, column.name)
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(column.ddl); err != nil {
return fmt.Errorf("增加 %s.%s 失败: %w", column.table, column.name, err)
}
}
}
for _, constraint := range []struct{ name, ddl string }{
{"chk_shopee_products_image_manual", `ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_image_manual CHECK (image_is_manual IN (0,1))`},
{"chk_shopee_products_shop_manual", `ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_shop_manual CHECK (shop_name_is_manual IN (0,1))`},
} {
exists, err := mysqlConstraintExists(db, "shopee_products", constraint.name)
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(constraint.ddl); err != nil {
return fmt.Errorf("增加约束 %s 失败: %w", constraint.name, err)
}
}
}
return nil
}
// migrateMySQLV8 保留 sku_id 作为内部主键,并增加可空的真实蝦皮 SKU ID。
// 每个 DDL 都先检查形状,支持 MySQL 隐式提交后的中断重放。
func migrateMySQLV8(db *sql.DB) error {
@@ -1056,7 +1111,48 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV7Shape(db); err != nil {
return err
}
return checkMySQLV8Shape(db)
if err := checkMySQLV8Shape(db); err != nil {
return err
}
return checkMySQLV9Shape(db)
}
func checkMySQLV9Shape(db *sql.DB) error {
for _, column := range []struct {
name string
length int64
nullable bool
collation, def string
}{
{"image_url", 2048, true, "utf8mb4_0900_ai_ci", ""}, {"shopee_shop_name", 500, true, "utf8mb4_0900_ai_ci", ""},
{"image_source", 64, true, "utf8mb4_bin", ""}, {"image_observed_at", 35, true, "utf8mb4_0900_ai_ci", ""},
{"shop_name_source", 64, true, "utf8mb4_bin", ""}, {"shop_name_observed_at", 35, true, "utf8mb4_0900_ai_ci", ""},
} {
if err := checkMySQLVarcharColumn(db, "shopee_products", column.name, column.length, column.nullable, column.collation, column.def); err != nil {
return err
}
}
for _, name := range []string{"image_is_manual", "shop_name_is_manual"} {
var dataType, isNullable string
var defaultValue sql.NullString
err := db.QueryRow(`SELECT data_type,is_nullable,column_default FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='shopee_products' AND column_name=?`, name).Scan(&dataType, &isNullable, &defaultValue)
if err != nil || dataType != "tinyint" || isNullable != "NO" || !defaultValue.Valid || defaultValue.String != "0" {
return fmt.Errorf("蝦皮商品字段 %s 形状不正确", name)
}
}
for _, name := range []string{"chk_shopee_products_image_manual", "chk_shopee_products_shop_manual"} {
exists, err := mysqlConstraintExists(db, "shopee_products", name)
if err != nil || !exists {
return fmt.Errorf("蝦皮商品约束 %s 缺失", name)
}
}
for _, name := range []string{"shopee_fields_filled", "shopee_fields_same_source_updated", "shopee_fields_manual_skipped", "shopee_fields_stale_skipped"} {
exists, err := mysqlColumnExists(db, "catalog_import_runs", name)
if err != nil || !exists {
return fmt.Errorf("目录记录字段 %s 缺失", name)
}
}
return nil
}
func checkMySQLV8Shape(db *sql.DB) error {
@@ -429,6 +429,45 @@ func TestMySQLMigrate_V8重复规格停止且不记版本(t *testing.T) {
}
}
func TestMySQLMigrate_V8升级V9且断点重放(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
prepareMySQLV7(t, db)
if err := migrateMySQLV8(db); err != nil {
t.Fatal(err)
}
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES(8,'2026-08-11T00:00:00Z')`)
if err := migrateMySQLV9(db); err != nil {
t.Fatal(err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatal(err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v9 重放失败: %v", err)
}
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,source,image_url,shopee_shop_name,image_is_manual,shop_name_is_manual,created_at,updated_at) VALUES('S','商品','api','https://example.com/a.jpg','店铺',0,0,'2026-08-11T00:00:00Z','2026-08-11T00:00:00Z')`)
}
func TestMySQLMigrate_V9形状错误不记版本(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
prepareMySQLV8(t, db)
mustExec(t, db, `ALTER TABLE shopee_products ADD COLUMN image_url VARCHAR(10) NULL`)
if err := MigrateMySQL(db); err == nil {
t.Fatal("错误 image_url 形状必须阻止 v9")
}
var count int
db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=9`).Scan(&count)
if count != 0 {
t.Fatal("v9 自检失败不得记录版本")
}
}
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
t.Helper()
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
@@ -492,6 +531,15 @@ func prepareMySQLV7(t *testing.T, db *sql.DB) {
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (7,'2026-08-11T00:00:00Z')`)
}
func prepareMySQLV8(t *testing.T, db *sql.DB) {
t.Helper()
prepareMySQLV7(t, db)
if err := migrateMySQLV8(db); err != nil {
t.Fatal(err)
}
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES(8,'2026-08-11T00:00:00Z')`)
}
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
+24 -6
View File
@@ -178,7 +178,7 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) {
where, args := shopeeFilterClause(filter)
sqlText := `
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code, sp.source,
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code, sp.image_url,sp.shopee_shop_name,sp.image_source,sp.image_observed_at,sp.image_is_manual,sp.shop_name_source,sp.shop_name_observed_at,sp.shop_name_is_manual,sp.source,
sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at,
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,
@@ -201,9 +201,10 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
var list []ShopeeProductRow
for rows.Next() {
var r ShopeeProductRow
var title, shopeeStatus, mainSKUCode, source, pddGoodsURL, pddGoodsID sql.NullString
var title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID sql.NullString
var imageManual, shopManual int
if err := rows.Scan(
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode, &source,
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
&pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt,
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
&r.CollectStatus, &r.CollectMsg,
@@ -213,6 +214,14 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
r.Title = title.String
r.ShopeeStatus = shopeeStatus.String
r.MainSKUCode = mainSKUCode.String
r.ImageURL = imageURL.String
r.ShopeeShopName = shopName.String
r.ImageSource = imageSource.String
r.ImageObservedAt = imageObserved.String
r.ImageIsManual = imageManual != 0
r.ShopNameSource = shopSource.String
r.ShopNameObservedAt = shopObserved.String
r.ShopNameIsManual = shopManual != 0
r.Source = source.String
r.PddGoodsURL = pddGoodsURL.String
r.PddGoodsID = pddGoodsID.String
@@ -240,12 +249,13 @@ 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, source, pddGoodsURL, pddGoodsID sql.NullString
var title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID sql.NullString
var imageManual, shopManual int
err := q.QueryRow(`
SELECT goods_id, title, shopee_status, main_sku_code, source,
SELECT goods_id, title, shopee_status, main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual, source,
pdd_goods_url, pdd_goods_id, created_at, updated_at
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &source,
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -256,6 +266,14 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct,
p.Title = title.String
p.ShopeeStatus = shopeeStatus.String
p.MainSKUCode = mainSKUCode.String
p.ImageURL = imageURL.String
p.ShopeeShopName = shopName.String
p.ImageSource = imageSource.String
p.ImageObservedAt = imageObserved.String
p.ImageIsManual = imageManual != 0
p.ShopNameSource = shopSource.String
p.ShopNameObservedAt = shopObserved.String
p.ShopNameIsManual = shopManual != 0
p.Source = source.String
p.PddGoodsURL = pddGoodsURL.String
p.PddGoodsID = pddGoodsID.String
+56 -3
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
@@ -28,6 +29,8 @@ type CatalogShopeeProduct struct {
Title string `json:"title"`
Status string `json:"status"`
MainSKUCode string `json:"main_sku_code"`
ImageURL string `json:"image_url"`
ShopName string `json:"shop_name"`
}
type CatalogShopeeSKU struct {
SKUID string `json:"sku_id"`
@@ -74,6 +77,10 @@ type CatalogBatchRequest struct {
type CatalogCounts struct {
ShopeeCreated int `json:"shopee_created"`
ShopeeUpdated int `json:"shopee_updated"`
ShopeeFieldsFilled int `json:"shopee_fields_filled"`
ShopeeFieldsSameSourceUpdated int `json:"shopee_fields_same_source_updated"`
ShopeeFieldsManualSkipped int `json:"shopee_fields_manual_skipped"`
ShopeeFieldsStaleSkipped int `json:"shopee_fields_stale_skipped"`
SKUCreated int `json:"sku_created"`
SKUUpdated int `json:"sku_updated"`
SKUFilled int `json:"sku_filled"`
@@ -132,6 +139,12 @@ func ValidateCatalogBatch(req CatalogBatchRequest) error {
return catalogInvalid("INVALID_SHOPEE_PRODUCT", "蝦皮商品 ID/标题不能为空且批内不能重复", map[string]any{"index": i, "goods_id": p.GoodsID})
}
seenProducts[p.GoodsID] = true
if utf8.RuneCountInString(strings.TrimSpace(p.ShopName)) > 500 {
return catalogInvalid("INVALID_SHOPEE_SHOP_NAME", "蝦皮店铺名不能超过 500 个字符", map[string]any{"index": i})
}
if err := validateCatalogImageURL(p.ImageURL); err != nil {
return catalogInvalid("INVALID_SHOPEE_IMAGE_URL", err.Error(), map[string]any{"index": i})
}
}
for i, s := range req.ShopeeSKUs {
key, keyErr := spec.SpecKey(s.SpecRaw)
@@ -205,16 +218,20 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
return CatalogBatchResponse{}, importErr
}
for _, p := range req.ShopeeProducts {
c, u, e := repository.UpsertCatalogShopeeProduct(tx, p.GoodsID, p.Title, p.Status, p.MainSKUCode, req.ObservedAt, now)
outcome, e := repository.UpsertCatalogShopeeProduct(tx, repository.CatalogShopeeProductInput{GoodsID: p.GoodsID, Title: p.Title, Status: p.Status, MainSKU: p.MainSKUCode, ImageURL: strings.TrimSpace(p.ImageURL), ShopName: strings.TrimSpace(p.ShopName), Source: source, ObservedAt: req.ObservedAt, Now: now, UpdatePolicy: policy})
if e != nil {
return fail(e)
}
if c {
if outcome.Created {
counts.ShopeeCreated++
}
if u {
if outcome.Updated {
counts.ShopeeUpdated++
}
counts.ShopeeFieldsFilled += outcome.FieldsFilled
counts.ShopeeFieldsSameSourceUpdated += outcome.FieldsSameSourceUpdated
counts.ShopeeFieldsManualSkipped += outcome.FieldsManualSkipped
counts.ShopeeFieldsStaleSkipped += outcome.FieldsStaleSkipped
}
for _, p := range req.PddProducts {
var skus string
@@ -278,6 +295,10 @@ func ImportCatalogBatch(db *sql.DB, source string, req CatalogBatchRequest, raw
run.ResponseBody = string(body)
run.ShopeeCreated = counts.ShopeeCreated
run.ShopeeUpdated = counts.ShopeeUpdated
run.ShopeeFieldsFilled = counts.ShopeeFieldsFilled
run.ShopeeFieldsSameSourceUpdated = counts.ShopeeFieldsSameSourceUpdated
run.ShopeeFieldsManualSkipped = counts.ShopeeFieldsManualSkipped
run.ShopeeFieldsStaleSkipped = counts.ShopeeFieldsStaleSkipped
run.SKUCreated = counts.SKUCreated
run.SKUUpdated = counts.SKUUpdated
run.SKUFilled = counts.SKUFilled
@@ -314,6 +335,38 @@ func catalogSKURecordID(goodsID, specKey string) string {
return "catalog:" + hex.EncodeToString(sum[:])
}
func validateCatalogImageURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if len(raw) > 2048 {
return fmt.Errorf("蝦皮主图片 URL 不能超过 2048 字节")
}
u, err := url.ParseRequestURI(raw)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("蝦皮主图片 URL 必须是完整的 HTTP/HTTPS 地址")
}
if u.User != nil {
return fmt.Errorf("蝦皮主图片 URL 不能包含用户名或密码")
}
parameters := u.Query()
if fragmentParameters, fragmentErr := url.ParseQuery(u.Fragment); fragmentErr == nil {
for key, values := range fragmentParameters {
parameters[key] = append(parameters[key], values...)
}
}
for key := range parameters {
lower := strings.ToLower(key)
for _, risky := range []string{"token", "cookie", "password", "passwd", "authorization", "credential", "secret", "session"} {
if strings.Contains(lower, risky) {
return fmt.Errorf("蝦皮主图片 URL 不能包含凭据参数")
}
}
}
return nil
}
func replayCatalogBatch(db *sql.DB, source, batchID, hash, now string) (CatalogBatchResponse, error) {
run, err := repository.GetCatalogImportRun(db, source, batchID)
if err != nil {
+96 -2
View File
@@ -19,10 +19,10 @@ 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,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,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,sku_created INTEGER DEFAULT 0,sku_updated INTEGER DEFAULT 0,sku_filled INTEGER DEFAULT 0,sku_same_source_updated INTEGER DEFAULT 0,sku_skipped INTEGER DEFAULT 0,sku_manual_skipped INTEGER DEFAULT 0,sku_stale_skipped INTEGER DEFAULT 0,pdd_created INTEGER DEFAULT 0,pdd_updated INTEGER DEFAULT 0,association_created INTEGER DEFAULT 0,association_unchanged INTEGER DEFAULT 0,failure_count INTEGER DEFAULT 0,error_summary TEXT,response_body TEXT,created_at TEXT,finished_at TEXT,PRIMARY KEY(source,batch_id))`,
`CREATE TABLE catalog_import_runs(source TEXT,batch_id TEXT,request_hash TEXT,status TEXT,request_count INTEGER,conflict_count INTEGER,observed_at TEXT,update_policy TEXT DEFAULT 'fill_missing',last_request_at TEXT,last_conflict_at TEXT,shopee_created INTEGER DEFAULT 0,shopee_updated INTEGER DEFAULT 0,shopee_fields_filled INTEGER DEFAULT 0,shopee_fields_same_source_updated INTEGER DEFAULT 0,shopee_fields_manual_skipped INTEGER DEFAULT 0,shopee_fields_stale_skipped INTEGER DEFAULT 0,sku_created INTEGER DEFAULT 0,sku_updated INTEGER DEFAULT 0,sku_filled INTEGER DEFAULT 0,sku_same_source_updated INTEGER DEFAULT 0,sku_skipped INTEGER DEFAULT 0,sku_manual_skipped INTEGER DEFAULT 0,sku_stale_skipped INTEGER DEFAULT 0,pdd_created INTEGER DEFAULT 0,pdd_updated INTEGER DEFAULT 0,association_created INTEGER DEFAULT 0,association_unchanged INTEGER DEFAULT 0,failure_count INTEGER DEFAULT 0,error_summary TEXT,response_body TEXT,created_at TEXT,finished_at TEXT,PRIMARY KEY(source,batch_id))`,
}
for _, statement := range statements {
if _, err := db.Exec(statement); err != nil {
@@ -260,3 +260,97 @@ func TestImportCatalogBatch_同规格不同真实SKU冲突并回滚(t *testing.T
t.Fatalf("冲突批次未整体回滚:%q", title)
}
}
func TestImportCatalogBatch_蝦皮图片店铺分级更新和人工保护(t *testing.T) {
db := newCatalogTestDB(t)
base := validCatalogBatch()
raw, _ := json.Marshal(base)
if _, err := ImportCatalogBatch(db, "script-a", base, raw); err != nil {
t.Fatal(err)
}
fill := validCatalogBatch()
fill.BatchID = "product-fill"
fill.ShopeeProducts[0].ImageURL = "https://img.example.com/a.jpg"
fill.ShopeeProducts[0].ShopName = "蝦皮店铺A"
raw, _ = json.Marshal(fill)
got, err := ImportCatalogBatch(db, "script-a", fill, raw)
if err != nil || got.Counts.ShopeeFieldsFilled != 2 {
t.Fatalf("补空失败:%+v %v", got, err)
}
keep := validCatalogBatch()
keep.BatchID = "product-keep"
keep.ShopeeProducts[0].ImageURL = "https://img.example.com/b.jpg"
keep.ShopeeProducts[0].ShopName = "店铺B"
raw, _ = json.Marshal(keep)
got, err = ImportCatalogBatch(db, "script-b", keep, raw)
if err != nil || got.Counts.ShopeeFieldsFilled != 0 {
t.Fatalf("默认策略不应覆盖:%+v %v", got, err)
}
cross := keep
cross.BatchID = "product-cross"
cross.UpdatePolicy = "overwrite_same_source"
cross.ObservedAt = "2026-08-12T07:00:00+08:00"
raw, _ = json.Marshal(cross)
got, err = ImportCatalogBatch(db, "script-b", cross, raw)
if err != nil || got.Counts.ShopeeFieldsSameSourceUpdated != 0 {
t.Fatalf("跨来源不应覆盖:%+v %v", got, err)
}
insertOnly := keep
insertOnly.BatchID = "product-insert-only"
insertOnly.UpdatePolicy = "insert_only"
raw, _ = json.Marshal(insertOnly)
got, err = ImportCatalogBatch(db, "script-a", insertOnly, raw)
if err != nil || got.Counts.ShopeeFieldsFilled != 0 || got.Counts.ShopeeFieldsSameSourceUpdated != 0 {
t.Fatalf("insert_only 不应修改已有商品:%+v %v", got, err)
}
overwrite := keep
overwrite.BatchID = "product-overwrite"
overwrite.UpdatePolicy = "overwrite_same_source"
overwrite.ObservedAt = "2026-08-12T08:00:00+08:00"
raw, _ = json.Marshal(overwrite)
got, err = ImportCatalogBatch(db, "script-a", overwrite, raw)
if err != nil || got.Counts.ShopeeFieldsSameSourceUpdated != 2 {
t.Fatalf("同来源更新失败:%+v %v", got, err)
}
var image, shop string
_ = db.QueryRow(`SELECT image_url,shopee_shop_name FROM shopee_products WHERE goods_id='S-1'`).Scan(&image, &shop)
if image != "https://img.example.com/b.jpg" || shop != "店铺B" {
t.Fatalf("字段未更新:%q %q", image, shop)
}
stale := overwrite
stale.BatchID = "product-stale"
stale.ObservedAt = "2026-08-11T09:00:00+08:00"
stale.ShopeeProducts[0].ImageURL = "https://img.example.com/old.jpg"
stale.ShopeeProducts[0].ShopName = "旧店铺"
raw, _ = json.Marshal(stale)
got, err = ImportCatalogBatch(db, "script-a", stale, raw)
if err != nil || got.Counts.ShopeeFieldsStaleSkipped != 2 {
t.Fatalf("旧观测保护失败:%+v %v", got, err)
}
_, _ = db.Exec(`UPDATE shopee_products SET image_is_manual=1,shop_name_is_manual=1 WHERE goods_id='S-1'`)
manual := overwrite
manual.BatchID = "product-manual"
manual.ObservedAt = "2026-08-13T08:00:00+08:00"
manual.ShopeeProducts[0].ImageURL = "https://img.example.com/c.jpg"
manual.ShopeeProducts[0].ShopName = "店铺C"
raw, _ = json.Marshal(manual)
got, err = ImportCatalogBatch(db, "script-a", manual, raw)
if err != nil || got.Counts.ShopeeFieldsManualSkipped != 2 {
t.Fatalf("人工保护失败:%+v %v", got, err)
}
}
func TestValidateCatalogBatch_拒绝危险图片URL(t *testing.T) {
for _, rawURL := range []string{"file:///tmp/a.jpg", "data:image/png;base64,AA", "https://user:pass@example.com/a.jpg", "https://example.com/a.jpg?access_token=secret", "javascript:alert(1)"} {
req := validCatalogBatch()
req.ShopeeProducts[0].ImageURL = rawURL
if err := ValidateCatalogBatch(req); err == nil {
t.Errorf("应拒绝 %q", rawURL)
}
}
req := validCatalogBatch()
req.ShopeeProducts[0].ImageURL = "https://img.example.com/a.jpg?v=1"
if err := ValidateCatalogBatch(req); err != nil {
t.Fatalf("合法图片被拒绝:%v", err)
}
}
+24
View File
@@ -20,6 +20,8 @@ import (
type ShopeeProductView struct {
GoodsID string
Title string
ImageURL string
ShopName string
// ColorCount / SizeCount 只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41)。
ColorCount int
@@ -107,6 +109,8 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
v := ShopeeProductView{
GoodsID: r.GoodsID,
Title: r.Title,
ImageURL: r.ImageURL,
ShopName: r.ShopeeShopName,
ColorCount: r.ColorCount,
SizeCount: r.SizeCount,
SKUCount: r.SKUCount,
@@ -208,6 +212,10 @@ type ShopeeProductDetail struct {
Title string
ShopeeStatus string
MainSKUCode string
ImageURL string
ShopName string
ImageSourceText, ImageObservedAt string
ShopNameSourceText, ShopNameObservedAt string
PddURL string
PddGoodsID string
@@ -242,6 +250,12 @@ func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, e
Title: p.Title,
ShopeeStatus: p.ShopeeStatus,
MainSKUCode: p.MainSKUCode,
ImageURL: p.ImageURL,
ShopName: p.ShopeeShopName,
ImageSourceText: catalogFieldSourceText(p.ImageSource, p.ImageIsManual),
ImageObservedAt: formatLocalTime(p.ImageObservedAt),
ShopNameSourceText: catalogFieldSourceText(p.ShopNameSource, p.ShopNameIsManual),
ShopNameObservedAt: formatLocalTime(p.ShopNameObservedAt),
PddGoodsID: p.PddGoodsID,
}
if d.Title == "" {
@@ -321,3 +335,13 @@ func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, e
}
return d, nil
}
func catalogFieldSourceText(source string, manual bool) string {
if manual {
return "人工维护"
}
if strings.TrimSpace(source) == "" {
return "—"
}
return source
}
+3
View File
@@ -249,6 +249,9 @@ tr.empty small { color: #aaa; }
.col-title {
max-width: 18%;
}
.col-shop { max-width: 150px; }
.thumb-placeholder, .thumb-fallback { color: #6b7280; font-size: 12px; white-space: nowrap; }
.detail-product-image { width: 120px; max-height: 120px; object-fit: contain; border: 1px solid #ccd1d6; border-radius: 4px; }
/* ── 弹窗 ─────────────────────────────── */
/* 用 hidden 属性控制显示隐藏,JS 只改这一个属性,不拼样式 */
+14
View File
@@ -342,6 +342,7 @@
title.textContent = productTitle + " · 原图";
link.href = url;
image.alt = productTitle + " 原图";
image.referrerPolicy = "no-referrer";
image.hidden = true;
status.hidden = false;
status.classList.remove("missing");
@@ -363,6 +364,18 @@
});
}
function setupImageThumbnails() {
document.querySelectorAll("[data-image-thumb]").forEach(function (image) {
function showFallback() {
image.hidden = true;
var fallback = image.parentElement && image.parentElement.querySelector("[data-image-thumb-fallback]");
if (fallback) fallback.hidden = false;
}
image.addEventListener("error", showFallback);
if (image.complete && image.naturalWidth === 0) showFallback();
});
}
/* ── 顺运宝登录弹窗:验证码"换一张" ─────────────
图片本身、以及旁边的"换一张"按钮,点了都重新请求验证码接口。
`[必须]` 每次请求带一个新的时间戳查询参数,绕开浏览器缓存——
@@ -474,6 +487,7 @@
setupModals();
setupRowDetail();
setupImagePreview();
setupImageThumbnails();
setupCaptchaRefresh();
setupSybSyncFeedback();
setupSybHistoryRefresh();
+1 -1
View File
@@ -1 +1 @@
{{define "catalog/detail_modal"}}<div class="modal-body"><dl class="detail-grid"><dt>来源</dt><dd>{{.Run.Source}}</dd><dt>批次号</dt><dd>{{.Run.BatchID}}</dd><dt>更新策略</dt><dd>{{.Run.UpdatePolicy}}</dd><dt>状态</dt><dd>{{.Run.StatusText}}</dd><dt>上游观测时间</dt><dd>{{.Run.ObservedAt}}</dd><dt>SKU 新增/补空/同源更新</dt><dd>{{.Run.SKUCreated}} / {{.Run.SKUFilled}} / {{.Run.SKUSameSourceUpdated}}</dd><dt>SKU 跳过(人工/旧数据)</dt><dd>{{.Run.SKUSkipped}}({{.Run.SKUManualSkipped}} / {{.Run.SKUStaleSkipped}})</dd><dt>请求/冲突次数</dt><dd>{{.Run.RequestCount}} / {{.Run.ConflictCount}}</dd><dt>失败数</dt><dd>{{.Run.FailureCount}}</dd><dt>错误码</dt><dd>{{if .Run.ErrorCode}}{{.Run.ErrorCode}}{{else}}—{{end}}</dd><dt>可重试</dt><dd>{{if .Run.Retryable}}是{{else}}否{{end}}</dd><dt>错误摘要</dt><dd>{{if .Run.ErrorSummary}}{{.Run.ErrorSummary}}{{else}}—{{end}}</dd><dt>定位信息</dt><dd>{{if .Run.ErrorDetails}}{{.Run.ErrorDetails}}{{else}}—{{end}}</dd></dl><p class="hint">定位信息只包含数组位置和业务 ID;这里不保存完整请求 JSON 或任何凭据。</p></div>{{end}}
{{define "catalog/detail_modal"}}<div class="modal-body"><dl class="detail-grid"><dt>来源</dt><dd>{{.Run.Source}}</dd><dt>批次号</dt><dd>{{.Run.BatchID}}</dd><dt>更新策略</dt><dd>{{.Run.UpdatePolicy}}</dd><dt>状态</dt><dd>{{.Run.StatusText}}</dd><dt>上游观测时间</dt><dd>{{.Run.ObservedAt}}</dd><dt>商品字段 补空/同源更新</dt><dd>{{.Run.ShopeeFieldsFilled}} / {{.Run.ShopeeFieldsSameSourceUpdated}}</dd><dt>商品字段 人工/旧数据跳过</dt><dd>{{.Run.ShopeeFieldsManualSkipped}} / {{.Run.ShopeeFieldsStaleSkipped}}</dd><dt>SKU 新增/补空/同源更新</dt><dd>{{.Run.SKUCreated}} / {{.Run.SKUFilled}} / {{.Run.SKUSameSourceUpdated}}</dd><dt>SKU 跳过(人工/旧数据)</dt><dd>{{.Run.SKUSkipped}}({{.Run.SKUManualSkipped}} / {{.Run.SKUStaleSkipped}})</dd><dt>请求/冲突次数</dt><dd>{{.Run.RequestCount}} / {{.Run.ConflictCount}}</dd><dt>失败数</dt><dd>{{.Run.FailureCount}}</dd><dt>错误码</dt><dd>{{if .Run.ErrorCode}}{{.Run.ErrorCode}}{{else}}—{{end}}</dd><dt>可重试</dt><dd>{{if .Run.Retryable}}是{{else}}否{{end}}</dd><dt>错误摘要</dt><dd>{{if .Run.ErrorSummary}}{{.Run.ErrorSummary}}{{else}}—{{end}}</dd><dt>定位信息</dt><dd>{{if .Run.ErrorDetails}}{{.Run.ErrorDetails}}{{else}}—{{end}}</dd></dl><p class="hint">定位信息只包含数组位置和业务 ID;这里不保存完整请求 JSON 或任何凭据。</p></div>{{end}}
+2 -2
View File
@@ -7,8 +7,8 @@
<button type="submit">筛选</button>
</form>
</div>
<div class="table-wrap"><table><thead><tr><th>来源</th><th>批次号</th><th>策略</th><th>状态</th><th>请求</th><th>冲突</th><th>蝦皮 新/更</th><th>SKU 新/补/同源更/跳过</th><th>PDD 新/更</th><th>关联 新/同</th><th>首次接收</th><th>完成</th><th>耗时</th></tr></thead><tbody>
{{range .Rows}}<tr data-detail-id="{{.Source}}|{{.BatchID}}"><td>{{.Source}}</td><td>{{.BatchID}}</td><td>{{.UpdatePolicy}}</td><td>{{.StatusText}}</td><td>{{.RequestCount}}</td><td>{{.ConflictCount}}</td><td>{{.ShopeeCreated}} / {{.ShopeeUpdated}}</td><td>{{.SKUCreated}} / {{.SKUFilled}} / {{.SKUSameSourceUpdated}} / {{.SKUSkipped}}</td><td>{{.PddCreated}} / {{.PddUpdated}}</td><td>{{.AssociationCreated}} / {{.AssociationUnchanged}}</td><td>{{.CreatedText}}</td><td>{{.FinishedText}}</td><td>{{.DurationText}}</td></tr>{{else}}<tr class="empty"><td colspan="13">还没有符合条件的导入记录。</td></tr>{{end}}</tbody></table></div>
<div class="table-wrap"><table><thead><tr><th>来源</th><th>批次号</th><th>策略</th><th>状态</th><th>请求</th><th>冲突</th><th>蝦皮 新/更/字段补/同源更</th><th>SKU 新/补/同源更/跳过</th><th>PDD 新/更</th><th>关联 新/同</th><th>首次接收</th><th>完成</th><th>耗时</th></tr></thead><tbody>
{{range .Rows}}<tr data-detail-id="{{.Source}}|{{.BatchID}}"><td>{{.Source}}</td><td>{{.BatchID}}</td><td>{{.UpdatePolicy}}</td><td>{{.StatusText}}</td><td>{{.RequestCount}}</td><td>{{.ConflictCount}}</td><td>{{.ShopeeCreated}} / {{.ShopeeUpdated}} / {{.ShopeeFieldsFilled}} / {{.ShopeeFieldsSameSourceUpdated}}</td><td>{{.SKUCreated}} / {{.SKUFilled}} / {{.SKUSameSourceUpdated}} / {{.SKUSkipped}}</td><td>{{.PddCreated}} / {{.PddUpdated}}</td><td>{{.AssociationCreated}} / {{.AssociationUnchanged}}</td><td>{{.CreatedText}}</td><td>{{.FinishedText}}</td><td>{{.DurationText}}</td></tr>{{else}}<tr class="empty"><td colspan="13">还没有符合条件的导入记录。</td></tr>{{end}}</tbody></table></div>
<p class="hint">双击一行查看批次摘要;系统不会保存或展示 Token 和完整请求体。</p>
<div class="modal-backdrop" id="detail-modal" hidden><div class="modal" role="dialog" aria-modal="true"><div class="modal-head"><h2>导入批次详情</h2><button type="button" class="modal-x" data-modal-close>×</button></div><div id="detail-content"></div></div></div>
<script>document.querySelectorAll('tr[data-detail-id]').forEach(function(row){row.addEventListener('dblclick',function(){var p=row.dataset.detailId.split('|');fetch('/integrations/catalog/detail?source='+encodeURIComponent(p[0])+'&batch_id='+encodeURIComponent(p[1])).then(function(r){return r.text()}).then(function(html){document.getElementById('detail-content').innerHTML=html;document.getElementById('detail-modal').hidden=false})})})</script>
+4
View File
@@ -10,6 +10,10 @@
<dt>商品名称</dt><dd>{{.Title}}</dd>
<dt>蝦皮状态</dt><dd>{{.ShopeeStatus}}</dd>
<dt>主商品货号</dt><dd>{{.MainSKUCode}}</dd>
<dt>蝦皮店铺</dt><dd>{{if .ShopName}}{{.ShopName}}{{else}}—{{end}}</dd>
<dt>店铺来源 / 观测时间</dt><dd>{{.ShopNameSourceText}} / {{if .ShopNameObservedAt}}{{.ShopNameObservedAt}}{{else}}—{{end}}</dd>
<dt>蝦皮主图</dt><dd>{{if .ImageURL}}<a href="{{.ImageURL}}" target="_blank" rel="noopener noreferrer"><img src="{{.ImageURL}}" alt="{{.Title}} 主图" class="detail-product-image" loading="lazy" referrerpolicy="no-referrer"></a>{{else}}无图{{end}}</dd>
<dt>图片来源 / 观测时间</dt><dd>{{.ImageSourceText}} / {{if .ImageObservedAt}}{{.ImageObservedAt}}{{else}}—{{end}}</dd>
<dt>PDD 商品 ID</dt><dd{{if .PddMissing}} class="missing"{{end}}>{{if .PddGoodsID}}{{.PddGoodsID}}{{else}}未关联{{end}}</dd>
<dt>采集状态</dt><dd>{{.StatusText}}{{if .CollectMsg}}:{{.CollectMsg}}{{end}}</dd>
</dl>
+7 -1
View File
@@ -35,7 +35,9 @@
<tr>
<th class="col-check"><input type="checkbox" data-check-all></th>
<th>商品 ID</th>
<th>图片</th>
<th>商品名称</th>
<th>蝦皮店铺</th>
<th>颜色</th>
<th>尺码</th>
{{/* SKU 数是这个商品报表里实际出现过的规格条数。
@@ -57,10 +59,12 @@
<tr data-detail-id="{{.GoodsID}}"{{if gt .PendingCount 0}} class="row-warn"{{end}}>
<td class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}"></td>
<td>{{.GoodsID}}</td>
<td>{{if .ImageURL}}<button type="button" class="thumb-action" data-image-preview-url="{{.ImageURL}}" data-image-preview-title="{{.Title}}" title="查看蝦皮主图"><img src="{{.ImageURL}}" alt="{{.Title}} 缩略图" class="thumb" width="32" height="32" loading="lazy" referrerpolicy="no-referrer" data-image-thumb><span class="thumb-fallback" hidden data-image-thumb-fallback>加载失败</span></button>{{else}}<span class="thumb-placeholder">无图</span>{{end}}</td>
{{/* 商品名称列收窄,用独立的 .col-title 类设 max-width(具体数值见 app.css),
不用 flex——表格单元格和工具条的 .search-narrow 那套不一样,见 #41。
title 属性让悬停能看完整标题,收窄之后必须留着。 */}}
<td class="truncate col-title" title="{{.Title}}">{{.Title}}</td>
<td class="truncate col-shop" title="{{.ShopName}}">{{if .ShopName}}{{.ShopName}}{{else}}—{{end}}</td>
<td>{{.ColorCount}}</td>
<td>{{.SizeCount}}</td>
<td>{{.SKUCount}}</td>
@@ -72,7 +76,7 @@
</tr>
{{else}}
<tr class="empty">
<td colspan="11">
<td colspan="13">
{{if .IsFiltered}}
没有匹配的数据,换个商品 ID 或商品名称试试。
{{else}}
@@ -86,6 +90,8 @@
</table>
</div>
<div class="modal-backdrop" id="image-preview-modal" hidden><div class="modal image-preview-modal" role="dialog" aria-modal="true" aria-labelledby="image-preview-title"><div class="modal-head"><h2 id="image-preview-title" data-image-preview-title>蝦皮商品主图</h2><button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button></div><div class="modal-body image-preview-body"><p class="hint image-preview-status" data-image-preview-status role="status">正在加载原图…</p><img data-image-preview-image alt="" hidden></div><div class="modal-foot"><a class="button-link" data-image-preview-link href="#" target="_blank" rel="noopener noreferrer">新窗口打开原图</a><button type="button" data-modal-close>关闭</button></div></div></div>
<p class="hint">
双击任意一行可以查看这个商品的完整规格表。
</p>
+6
View File
@@ -438,6 +438,12 @@ MySQL schema v8 将 `shopee_skus.sku_id` 保留为系统内部记录主键(兼
来源表;`fill_missing` 补字段时分别登记,`overwrite_same_source` 只更新来源属于本次
调用方且观测时间不旧的字段,避免不同脚本各补一部分后互相覆盖。
MySQL schema v9 为 `shopee_products` 增加 `image_url` 和 `shopee_shop_name`。两者分别
使用 `image_source/image_observed_at/image_is_manual` 与
`shop_name_source/shop_name_observed_at/shop_name_is_manual` 记录来源边界。目录接口只
能补空或更新同来源的新观测,空值、跨来源、旧观测和人工字段均不覆盖。该图片是蝦皮
商品主图,与 `syb_orders.image_url` 的历史货运单观测图片是两个独立概念。
蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”:
订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。
该读模型不写入 `shopee_skus`。只有同一商品下恰好一条正式 SKU 的 `spec_raw`
+6
View File
@@ -819,3 +819,9 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
商品详情在正式 SKU 表之后显示“顺运宝观测规格”。该区域必须明确标注它不是正式
蝦皮 SKU,价格是最近货运单台币售价、不是 PDD 人民币采购价;展示规格原文、订单
数、累计数量、最近图片、最后出现时间及唯一正式 SKU 匹配结果,多条匹配交给人工。
蝦皮商品列表在商品 ID 后显示 32×32 懒加载主图,在标题后显示“蝦皮店铺”。主图可
打开通用原图弹窗和新窗口,加载失败显示文字占位;无图不打开空弹窗。店铺列最大约
150px并省略,完整值放在 `title` 和详情中。商品标题继续使用 `.col-title` 的 18%,
不得因新增列再次缩窄;1366×768 下通过表格横向滚动保留可读性。详情同时显示图片、
店铺、各自来源和观测时间,顺运宝观测图仍只出现在观测区域。
+8 -2
View File
@@ -21,7 +21,7 @@ Token 通过 `CMAUTOBUY_CATALOG_TOKEN` 或未提交的 `config.yaml` 配置。
"observed_at": "2026-08-11T10:00:00+08:00",
"update_policy": "fill_missing",
"shopee_products": [
{"goods_id": "S-1", "title": "蝦皮上衣", "status": "NORMAL", "main_sku_code": "A01"}
{"goods_id": "S-1", "title": "蝦皮上衣", "status": "NORMAL", "main_sku_code": "A01", "image_url": "https://img.example.com/S-1.jpg", "shop_name": "蝦皮示例店铺"}
],
"shopee_skus": [
{"sku_id": null, "goods_id": "S-1", "spec_raw": "黑色,M", "color": "黑色", "size": "M", "parse_ok": true, "sku_code": "A01-B-M"}
@@ -47,6 +47,10 @@ Token 通过 `CMAUTOBUY_CATALOG_TOKEN` 或未提交的 `config.yaml` 配置。
不得自行生成。系统用内部主键和 `goods_id + spec_key` 保持记录身份,后续取得真实 ID
会补到原记录,不会重复新增。
`image_url` 和 `shop_name` 可选。图片只接受不含用户信息和明显凭据参数的完整
HTTP/HTTPS URL,最长 2048 字节;店铺名最长 500 个字符。Admin 只保存地址并由浏览器
懒加载,不会在服务端下载或代理图片。
## 3. 写入与幂等规则
- 整个批次在一个事务中写入:实体、SKU、关联任一步失败就全部回滚。
@@ -57,6 +61,8 @@ Token 通过 `CMAUTOBUY_CATALOG_TOKEN` 或未提交的 `config.yaml` 配置。
- `overwrite_same_source`:只有来源相同且 `observed_at` 更新时才覆盖来源字段。
- 较旧的 `observed_at` 不覆盖较新的接口数据,任何策略都不能覆盖 `is_manual=1`。
- `spec_raw` 和由它生成的规格身份不被更新;同商品同规格出现不同真实 SKU ID 返回 409。
- 蝦皮主图和店铺名也遵循本批次策略,但各自独立记录来源、观测时间和人工保护;
空值永不清除现有值。顺运宝观测图片不参与蝦皮主图更新。
- 蝦皮 upsert 不覆盖采购员维护的 PDD 链接;SKU 的 `is_manual` 不被接口改写。
- 空关联可以建立、相同关联不重复写;已有不同 PDD 关联返回 409,绝不静默替换。
- 批次缺少某条记录不表示删除,接口没有“全量覆盖”语义。
@@ -83,7 +89,7 @@ Token 通过 `CMAUTOBUY_CATALOG_TOKEN` 或未提交的 `config.yaml` 配置。
## 5. schema v8 发布与回退
发布前先备份生产库,并在 MySQL 8.4、库名以 `_test` 结尾的测试库演练 v7→v8。
发布前先备份生产库,并在 MySQL 8.4、库名以 `_test` 结尾的测试库演练 v7→v8→v9。
迁移只增加列和唯一索引,既有 `sku_id` 继续作为内部主键,因此历史引用不变;若检测
到同商品重复 `spec_key`,迁移会停止且不记录 v8,必须人工确认,不能自动合并。