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
+69 -16
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"`
@@ -72,19 +75,23 @@ type CatalogBatchRequest struct {
Associations []CatalogAssociation `json:"associations"`
}
type CatalogCounts struct {
ShopeeCreated int `json:"shopee_created"`
ShopeeUpdated int `json:"shopee_updated"`
SKUCreated int `json:"sku_created"`
SKUUpdated int `json:"sku_updated"`
SKUFilled int `json:"sku_filled"`
SKUSameSourceUpdated int `json:"sku_same_source_updated"`
SKUSkipped int `json:"sku_skipped"`
SKUManualSkipped int `json:"sku_manual_skipped"`
SKUStaleSkipped int `json:"sku_stale_skipped"`
PddCreated int `json:"pdd_created"`
PddUpdated int `json:"pdd_updated"`
AssociationCreated int `json:"association_created"`
AssociationUnchanged int `json:"association_unchanged"`
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"`
SKUSameSourceUpdated int `json:"sku_same_source_updated"`
SKUSkipped int `json:"sku_skipped"`
SKUManualSkipped int `json:"sku_manual_skipped"`
SKUStaleSkipped int `json:"sku_stale_skipped"`
PddCreated int `json:"pdd_created"`
PddUpdated int `json:"pdd_updated"`
AssociationCreated int `json:"association_created"`
AssociationUnchanged int `json:"association_unchanged"`
}
type CatalogBatchResponse struct {
BatchID string `json:"batch_id"`
@@ -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)
}
}
+35 -11
View File
@@ -18,8 +18,10 @@ import (
//
// 一行对应一个**商品**,不是一个 SKU——见工单 #41。
type ShopeeProductView struct {
GoodsID string
Title string
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,
@@ -204,10 +208,14 @@ type ShopeeSpecView struct {
// ShopeeProductDetail 是双击弹窗要显示的全部内容。
type ShopeeProductDetail struct {
GoodsID string
Title string
ShopeeStatus string
MainSKUCode string
GoodsID string
Title string
ShopeeStatus string
MainSKUCode string
ImageURL string
ShopName string
ImageSourceText, ImageObservedAt string
ShopNameSourceText, ShopNameObservedAt string
PddURL string
PddGoodsID string
@@ -238,11 +246,17 @@ func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, e
}
d := &ShopeeProductDetail{
GoodsID: p.GoodsID,
Title: p.Title,
ShopeeStatus: p.ShopeeStatus,
MainSKUCode: p.MainSKUCode,
PddGoodsID: p.PddGoodsID,
GoodsID: p.GoodsID,
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 == "" {
d.Title = placeholder
@@ -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
}