feat: 保存并展示 PDD 店铺与价格采样信息 (#31)

This commit is contained in:
chengma
2026-08-07 17:13:25 +08:00
parent a2fc1501c4
commit 93beef8b36
17 changed files with 492 additions and 73 deletions
+38 -4
View File
@@ -227,6 +227,7 @@ type PddProductView struct {
GoodsID string
URL string
Title string // 未采集时是占位符
ShopName string // 未采到时是占位符
StatusText string
SkuCountText string // 未采集时是占位符
CollectedAt string
@@ -269,6 +270,7 @@ func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus) (*P
GoodsID: r.GoodsID,
URL: r.URL,
Title: r.Title,
ShopName: r.ShopName,
StatusText: statusTextFor(r.PddProduct),
CollectedAt: formatLocalTime(r.CollectedAt),
UpdatedAt: formatLocalTime(r.UpdatedAt),
@@ -278,6 +280,9 @@ func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus) (*P
if v.Title == "" {
v.Title = "(未采集,采集后自动回填)"
}
if v.ShopName == "" {
v.ShopName = placeholder
}
// -1 是"还没采过"。注意 0 要如实显示 0:
// 采到 0 个规格说明采集出了问题,显示成 — 就看不出来了。
if r.SkuCount < 0 {
@@ -318,7 +323,9 @@ func (r *PddListResult) StatusLine() string {
type PddSkuView struct {
Options []string // 按 DimensionNames 的顺序排好,模板直接 range
PriceText string
Available string
// PriceSource 在按颜色采样时显示“✓ 实测”或“推断”,不能只靠颜色区分。
PriceSource string
Available string
}
// PddProductDetail 是双击弹窗要显示的全部内容。
@@ -327,15 +334,17 @@ type PddProductDetail struct {
GoodsID string
URL string
Title string
ShopName string
StatusText string
CollectMsg string
CollectedAt string
ArtifactRef string
// Collected 为 false 时模板显示"尚未采集",而不是一张空表。
Collected bool
DimensionNames []string
SKUs []PddSkuView
Collected bool
ShowPriceSource bool
DimensionNames []string
SKUs []PddSkuView
// SkusError 非空表示 skus_json 存的东西解析不了。
// 这种情况要如实说出来,不能装作"没有规格"——
@@ -356,6 +365,7 @@ func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) {
GoodsID: p.GoodsID,
URL: p.URL,
Title: p.Title,
ShopName: p.ShopName,
StatusText: statusTextFor(*p),
CollectMsg: p.CollectMsg,
CollectedAt: formatLocalTime(p.CollectedAt),
@@ -364,6 +374,9 @@ func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) {
if d.Title == "" {
d.Title = placeholder
}
if d.ShopName == "" {
d.ShopName = placeholder
}
if d.CollectMsg == "" {
d.CollectMsg = placeholder
}
@@ -379,6 +392,7 @@ func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) {
}
d.Collected = true
d.ShowPriceSource = collected.PriceGranularity == "color"
keys, names := dimensionOrder(collected)
d.DimensionNames = names
@@ -391,6 +405,12 @@ func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) {
if sku.Available {
row.Available = "是"
}
if d.ShowPriceSource {
row.PriceSource = "推断"
if sameOptions(sku.Options, sku.PriceObservedAt) {
row.PriceSource = "✓ 实测"
}
}
for _, k := range keys {
value := sku.Options[k]
if value == "" {
@@ -403,6 +423,20 @@ func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) {
return d, nil
}
// sameOptions 判断价格读取时实际选中的组合是否就是当前这一行。
// 两个 map 必须键和值都完全一致,避免只比较颜色后把多个尺码都标成实测。
func sameOptions(options, observed map[string]string) bool {
if len(options) == 0 || len(options) != len(observed) {
return false
}
for key, value := range options {
if observed[key] != value {
return false
}
}
return true
}
// dimensionOrder 定下规格各维度的显示顺序,返回 (取值用的 key, 表头文字)。
//
// 优先用采集结果里的 dimensions;它缺失时退回"把所有 SKU 出现过的 key 排序"。
+51 -8
View File
@@ -155,7 +155,7 @@ func TestCreatePddProduct_删除后重新创建复活且清空采集结果(t *te
url := "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267"
goodsID, _ := CreatePddProduct(db, url)
if err := repository.SetCollectResult(db, goodsID, "旧标题", sampleSkusJSON); err != nil {
if err := repository.SetCollectResult(db, goodsID, "旧标题", "", sampleSkusJSON); err != nil {
t.Fatalf("写采集结果失败: %v", err)
}
before, _ := repository.GetPddProductByGoodsID(db, goodsID)
@@ -256,8 +256,8 @@ func TestListPddProducts_规格数(t *testing.T) {
createProduct(t, db, "100000000002") // 采到 3 个
createProduct(t, db, "100000000003") // 采到 0 个
repository.SetCollectResult(db, "100000000002", "有规格的", sampleSkusJSON)
repository.SetCollectResult(db, "100000000003", "没规格的", `{"skus": []}`)
repository.SetCollectResult(db, "100000000002", "有规格的", "", sampleSkusJSON)
repository.SetCollectResult(db, "100000000003", "没规格的", "", `{"skus": []}`)
result, err := ListPddProducts(db, "", "")
if err != nil {
@@ -300,7 +300,7 @@ func TestListPddProducts_按采集状态筛选(t *testing.T) {
db := newTestDB(t)
createProduct(t, db, "100000000001")
createProduct(t, db, "100000000002")
repository.SetCollectResult(db, "100000000002", "已采的", sampleSkusJSON)
repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON)
collected, err := ListPddProducts(db, "", model.CollectCollected)
if err != nil {
@@ -372,7 +372,7 @@ func TestStatusLine_四个状态都列出来(t *testing.T) {
db := newTestDB(t)
createProduct(t, db, "100000000001")
createProduct(t, db, "100000000002")
repository.SetCollectResult(db, "100000000002", "已采的", sampleSkusJSON)
repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON)
result, _ := ListPddProducts(db, "", "")
line := result.StatusLine()
@@ -388,7 +388,7 @@ func TestStatusLine_四个状态都列出来(t *testing.T) {
func TestGetPddProductDetail_规格表按dimensions排列(t *testing.T) {
db := newTestDB(t)
createProduct(t, db, "737116531267")
repository.SetCollectResult(db, "737116531267", "西装外套三件套", sampleSkusJSON)
repository.SetCollectResult(db, "737116531267", "西装外套三件套", "", sampleSkusJSON)
p, _ := repository.GetPddProductByGoodsID(db, "737116531267")
d, err := GetPddProductDetail(db, p.ID)
@@ -424,12 +424,55 @@ func TestGetPddProductDetail_规格表按dimensions排列(t *testing.T) {
}
}
func TestPdd页面_显示店铺与按颜色采样标记(t *testing.T) {
db := newTestDB(t)
createProduct(t, db, "737116531267")
resultJSON := `{
"goods_id":"737116531267",
"price_granularity":"color",
"dimensions":[
{"key":"color","name":"颜色分类"},
{"key":"size","name":"尺码"}
],
"skus":[
{"options":{"color":"黑色","size":"M"},"price_cent":470,
"price_observed_at":{"color":"黑色","size":"M"},"available":true},
{"options":{"color":"黑色","size":"2XL"},"price_cent":470,
"price_observed_at":{"color":"黑色","size":"M"},"available":true}
]
}`
repository.SetCollectResult(
db, "737116531267", "测试商品", "测试旗舰店", resultJSON)
list, err := ListPddProducts(db, "", "")
if err != nil {
t.Fatalf("读取列表失败: %v", err)
}
if len(list.Rows) != 1 || list.Rows[0].ShopName != "测试旗舰店" {
t.Fatalf("列表应显示店铺名,实际 %+v", list.Rows)
}
p, _ := repository.GetPddProductByGoodsID(db, "737116531267")
detail, err := GetPddProductDetail(db, p.ID)
if err != nil {
t.Fatalf("读取详情失败: %v", err)
}
if detail.ShopName != "测试旗舰店" || !detail.ShowPriceSource {
t.Errorf("详情应显示店铺和价格粒度提示,实际 shop=%q show=%v",
detail.ShopName, detail.ShowPriceSource)
}
if len(detail.SKUs) != 2 || detail.SKUs[0].PriceSource != "✓ 实测" ||
detail.SKUs[1].PriceSource != "推断" {
t.Errorf("实测/推断标记不正确: %+v", detail.SKUs)
}
}
// dimensions 缺失时退回按 key 排序。不排的话 Go 的 map 是随机顺序,
// 同一个商品每次刷新页面列的顺序都不一样。
func TestGetPddProductDetail_没有dimensions时按key排序且稳定(t *testing.T) {
db := newTestDB(t)
createProduct(t, db, "737116531267")
repository.SetCollectResult(db, "737116531267", "无维度信息", `{
repository.SetCollectResult(db, "737116531267", "无维度信息", "", `{
"skus": [{"options": {"size": "M", "color": "黑色", "style": "A"},
"price_cent": 100, "available": true}]
}`)
@@ -661,7 +704,7 @@ func TestCreatePddCollectTasks_已删除的跳过(t *testing.T) {
func TestCreatePddCollectTasks_已采集的跳过并单独计数(t *testing.T) {
db := newTestDB(t)
createProduct(t, db, "737116531267")
repository.SetCollectResult(db, "737116531267", "已采完的商品", sampleSkusJSON)
repository.SetCollectResult(db, "737116531267", "已采完的商品", "", sampleSkusJSON)
result, err := CreatePddCollectTasks(db, []string{"737116531267"})
if err != nil {
+2 -2
View File
@@ -104,7 +104,7 @@ func TestEnsurePddProduct_重复保存不产生重复行(t *testing.T) {
func TestEnsurePddProduct_不覆盖已有采集结果(t *testing.T) {
db := newTestDB(t)
repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
if err := repository.SetCollectResult(db, "PDD-1", "商品甲", `{"skus":[1]}`); err != nil {
if err := repository.SetCollectResult(db, "PDD-1", "商品甲", "", `{"skus":[1]}`); err != nil {
t.Fatal(err)
}
@@ -130,7 +130,7 @@ func TestEnsurePddProduct_不覆盖已有采集结果(t *testing.T) {
func TestEnsurePddProduct_软删除后可复活(t *testing.T) {
db := newTestDB(t)
repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
repository.SetCollectResult(db, "PDD-1", "商品甲", `{"skus":[1]}`)
repository.SetCollectResult(db, "PDD-1", "商品甲", "", `{"skus":[1]}`)
if err := repository.SoftDeletePddProduct(db, "PDD-1"); err != nil {
t.Fatal(err)
+18 -7
View File
@@ -29,8 +29,11 @@ var (
// 客户端提交的完整内容会原样存进 tasks.result_data,这里只挑业务要用的解析出来。
// 用不到的字段不写进结构体,多余的 JSON 字段会被忽略,不影响向前兼容。
type collectedData struct {
GoodsID string `json:"goods_id"`
Title string `json:"title"`
GoodsID string `json:"goods_id"`
Title string `json:"title"`
ShopName string `json:"shop_name"`
// PriceGranularity 说明价格是逐个 SKU 实测,还是只按颜色采样。
PriceGranularity string `json:"price_granularity"`
// Dimensions 决定规格各维度的显示顺序。
// 少了它就只能按 Go 的 map 遍历,而 map 是无序的——
// 同一个商品每次刷新页面「颜色/尺码」的先后都可能变。
@@ -39,10 +42,12 @@ type collectedData struct {
Name string `json:"name"`
} `json:"dimensions"`
SKUs []struct {
Options map[string]string `json:"options"`
PriceCent *int64 `json:"price_cent"` // 指针:采不到价格时是 null,不是 0
Available bool `json:"available"`
RawPrice string `json:"raw_price"`
Options map[string]string `json:"options"`
PriceCent *int64 `json:"price_cent"` // 指针:采不到价格时是 null,不是 0
ListPriceCent *int64 `json:"list_price_cent"`
PriceObservedAt map[string]string `json:"price_observed_at"`
Available bool `json:"available"`
RawPrice string `json:"raw_price"`
} `json:"skus"`
}
@@ -53,6 +58,12 @@ func parseCollected(raw string) (*collectedData, error) {
if err := json.Unmarshal([]byte(raw), &c); err != nil {
return nil, fmt.Errorf("采集结果不是合法结构: %w", err)
}
if c.PriceGranularity != "" && c.PriceGranularity != "color" &&
c.PriceGranularity != "sku" {
return nil, fmt.Errorf(
"price_granularity 只能是 color 或 sku,收到 %q",
c.PriceGranularity)
}
return &c, nil
}
@@ -153,7 +164,7 @@ func SubmitResult(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte)
return nil, err
}
} else if err := repository.SetCollectResult(
tx, info.PddGoodsID, collected.Title, pddData); err != nil {
tx, info.PddGoodsID, collected.Title, collected.ShopName, pddData); err != nil {
return nil, err
}
}
+59
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
@@ -283,8 +284,10 @@ func pddProductState(t *testing.T, db *sql.DB, goodsID string) (status, title, s
const collectBody = `{"task_version":1,"attempt_id":"a-1","result_type":"collect",
"pdd_data":{"schema_version":1,"goods_id":"PDD-1","title":"测试商品",
"shop_name":"测试旗舰店","price_granularity":"color",
"dimensions":[{"key":"color","name":"颜色分类"}],
"skus":[{"options":{"color":"黑色","size":"M"},"price_cent":1256,
"price_observed_at":{"color":"黑色","size":"M"},
"available":true,"raw_price":"¥12.56"}]}}`
func TestSubmitResult_采集结果写入PDD商品(t *testing.T) {
@@ -307,6 +310,62 @@ func TestSubmitResult_采集结果写入PDD商品(t *testing.T) {
if skus == "" {
t.Error("skus_json 应被写入,实际为空")
}
var shop sql.NullString
if err := db.QueryRow(
`SELECT shop_name FROM pdd_products WHERE goods_id = ?`, "PDD-1",
).Scan(&shop); err != nil {
t.Fatalf("读取店铺名失败: %v", err)
}
if !shop.Valid || shop.String != "测试旗舰店" {
t.Errorf("店铺名应从采集结果落库,实际 %+v", shop)
}
if !strings.Contains(skus, `"price_granularity":"color"`) ||
!strings.Contains(skus, `"price_observed_at"`) {
t.Errorf("价格粒度与实测组合应保留在 skus_json,实际 %s", skus)
}
}
func TestSubmitResult_没有店铺名时不覆盖已有值(t *testing.T) {
db := newTestDB(t)
insertPddProduct(t, db, "PDD-1")
if _, err := db.Exec(
`UPDATE pdd_products SET shop_name = '旧店铺' WHERE goods_id = 'PDD-1'`,
); err != nil {
t.Fatalf("准备已有店铺名失败: %v", err)
}
insertCollectTask(t, db, "TASK-C", "client-001", "SHOPEE-1", "PDD-1")
claimTask(t, db, "TASK-C", "client-001")
body := `{"attempt_id":"a-1","result_type":"collect",
"pdd_data":{"goods_id":"PDD-1","title":"测试商品",
"skus":[{"options":{"color":"黑色"},"price_cent":100,"available":true}]}}`
if _, err := SubmitResult(db, "TASK-C", "client-001", "key-no-shop", []byte(body)); err != nil {
t.Fatalf("老版本报文不应失败: %v", err)
}
var shop string
if err := db.QueryRow(
`SELECT shop_name FROM pdd_products WHERE goods_id = 'PDD-1'`,
).Scan(&shop); err != nil {
t.Fatalf("读取店铺名失败: %v", err)
}
if shop != "旧店铺" {
t.Errorf("缺少 shop_name 时不应覆盖已有值,实际 %q", shop)
}
}
func TestSubmitResult_拒绝未知价格采样粒度(t *testing.T) {
db := newTestDB(t)
insertPddProduct(t, db, "PDD-1")
insertCollectTask(t, db, "TASK-C", "client-001", "SHOPEE-1", "PDD-1")
claimTask(t, db, "TASK-C", "client-001")
body := `{"attempt_id":"a-1","result_type":"collect",
"pdd_data":{"goods_id":"PDD-1","price_granularity":"unknown",
"skus":[{"options":{"color":"黑色"},"price_cent":100,"available":true}]}}`
if _, err := SubmitResult(db, "TASK-C", "client-001", "key-bad-granularity", []byte(body)); err == nil {
t.Fatal("未知 price_granularity 应被拒绝")
}
}
// 采到 0 个规格必须算失败——数据对业务毫无用处,