feat: 展示蝦皮主图和店铺 (#142)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user