feat: 保存并展示 PDD 店铺与价格采样信息 (#31)
This commit is contained in:
+31
-18
@@ -8,8 +8,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -60,29 +62,40 @@ func main() {
|
||||
log.Printf("数据库已就绪")
|
||||
|
||||
// 3. Web 引擎
|
||||
r := gin.Default()
|
||||
|
||||
// 模板:每个文件用 {{define "目录/名字"}} 声明自己的名字,
|
||||
// 所以一次全部解析进来不会冲突。
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
|
||||
r, err := newRouter(db)
|
||||
if err != nil {
|
||||
log.Fatalf("解析模板失败: %v", err)
|
||||
log.Fatalf("准备 Web 页面失败: %v", err)
|
||||
}
|
||||
r.SetHTMLTemplate(tmpl)
|
||||
|
||||
// 静态文件
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
log.Fatalf("准备静态文件失败: %v", err)
|
||||
}
|
||||
r.StaticFS("/static", http.FS(staticSub))
|
||||
|
||||
// 4. 路由
|
||||
web.Register(r, db, config.OnlineThreshold) // 给浏览器的页面
|
||||
api.Register(r, db) // 给 Client 的接口
|
||||
|
||||
log.Printf("Admin 已启动: http://%s", *addr)
|
||||
if err := r.Run(*addr); err != nil {
|
||||
log.Fatalf("启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newRouter 组装 Admin 的模板、静态文件和路由。
|
||||
// 单独提成函数是为了让页面冒烟测试无需真的占用 TCP 端口。
|
||||
func newRouter(db *sql.DB) (*gin.Engine, error) {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
// 模板:每个文件用 {{define "目录/名字"}} 声明自己的名字,
|
||||
// 所以一次全部解析进来不会冲突。
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析模板失败: %w", err)
|
||||
}
|
||||
r.SetHTMLTemplate(tmpl)
|
||||
|
||||
// 静态文件
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("准备静态文件失败: %w", err)
|
||||
}
|
||||
r.StaticFS("/static", http.FS(staticSub))
|
||||
|
||||
// 4. 路由
|
||||
web.Register(r, db, config.OnlineThreshold) // 给浏览器的页面
|
||||
api.Register(r, db) // 给 Client 的接口
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
// 五个主页面都走一次真实路由和模板渲染。
|
||||
// 这样模板字段写错或新增列漏接时,测试阶段就会失败,不必等人工点页面。
|
||||
func TestMainPagesReturnOK(t *testing.T) {
|
||||
db, err := repository.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := repository.Migrate(db); err != nil {
|
||||
t.Fatalf("迁移测试数据库失败: %v", err)
|
||||
}
|
||||
|
||||
router, err := newRouter(db)
|
||||
if err != nil {
|
||||
t.Fatalf("组装路由失败: %v", err)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d,期望 200,响应:%s",
|
||||
path, response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pdd, err := repository.EnsurePddProduct(
|
||||
db, "737116531267",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=737116531267")
|
||||
if err != nil {
|
||||
t.Fatalf("准备 PDD 商品失败: %v", err)
|
||||
}
|
||||
resultJSON := `{
|
||||
"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":"L"},"price_cent":470,
|
||||
"price_observed_at":{"color":"黑色","size":"M"},"available":true}
|
||||
]
|
||||
}`
|
||||
if err := repository.SetCollectResult(
|
||||
db, pdd.GoodsID, "测试商品", "测试旗舰店", resultJSON); err != nil {
|
||||
t.Fatalf("准备采集结果失败: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/pdd/detail?id=1", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("GET /pdd/detail = %d,响应:%s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, want := range []string{
|
||||
"测试旗舰店", "价格按颜色采样", "✓ 实测", "推断",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("PDD 详情缺少 %q,响应:%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ type PddProduct struct {
|
||||
GoodsID string // 从 URL 解析,UNIQUE,防重靠它
|
||||
URL string // 操作员填的链接原文
|
||||
Title string // 采集回来,人工核对用
|
||||
ShopName string // 采集回来的店铺名,可能为空
|
||||
SkusJSON string // schema_version + dimensions + skus
|
||||
|
||||
CollectStatus CollectStatus
|
||||
|
||||
+72
-11
@@ -264,7 +264,16 @@ var migrations = [][]string{
|
||||
// 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器
|
||||
// (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个
|
||||
// 和代码对不上的库静默启动。
|
||||
const schemaVersion = 3
|
||||
const schemaVersion = 4
|
||||
|
||||
// migrationV4 给 PDD 商品增加店铺名。
|
||||
//
|
||||
// v3 是特殊的 Go 迁移,不能塞进上面的纯 SQL migrations。v4 必须等 v3
|
||||
// 建好 pdd_products 后再执行,所以单独放在这里。已经发布的 v1/v2 原文
|
||||
// 保持不动,老库才能可靠地逐版升级。
|
||||
var migrationV4 = []string{
|
||||
`ALTER TABLE pdd_products ADD COLUMN shop_name TEXT;`,
|
||||
}
|
||||
|
||||
// Migrate 把数据库升到最新版本。
|
||||
// 已经是最新的就什么都不做,可以重复调用。
|
||||
@@ -309,15 +318,46 @@ func Migrate(db *sql.DB) error {
|
||||
// sku_mappings 结构),再由这一步收敛成最终结构——
|
||||
// 这样"全新库"和"老库升级"最终跑的是完全相同的 v3 代码,
|
||||
// 不需要分别维护两条路径。
|
||||
if reached < schemaVersion {
|
||||
if reached < 3 {
|
||||
if err := migrateV3(db); err != nil {
|
||||
return err
|
||||
}
|
||||
reached = 3
|
||||
}
|
||||
|
||||
// v4 是普通的追加列迁移,但必须排在特殊 v3 后面执行。
|
||||
if reached < 4 {
|
||||
if err := runSQLMigration(db, 4, migrationV4); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSQLMigration 在一个事务里执行指定版本的 SQL,并最后更新 user_version。
|
||||
// 它只接收本文件中写死的版本号和 SQL,不接收外部输入。
|
||||
func runSQLMigration(db *sql.DB, version int, statements []string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始迁移 v%d 失败: %w", version, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for i, stmt := range statements {
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("执行迁移 v%d 第 %d 条语句失败: %w", version, i+1, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil {
|
||||
return fmt.Errorf("更新 user_version 到 %d 失败: %w", version, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交迁移 v%d 失败: %w", version, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV3 把库收敛成当前结构,对应工单 #20。
|
||||
//
|
||||
// # 起点不止一种,不能用 user_version 推断结构
|
||||
@@ -752,6 +792,13 @@ var requiredTables = []string{
|
||||
"idempotency_keys", "task_claims",
|
||||
}
|
||||
|
||||
// requiredColumns 只列出不能靠“表存在”发现的关键追加列。
|
||||
// shop_name 是 v4 新增列;缺少它时查询 PDD 页面会直接失败,因此启动时
|
||||
// 就应给出明确错误,而不是等操作员点到页面才暴露。
|
||||
var requiredColumns = map[string][]string{
|
||||
"pdd_products": {"shop_name"},
|
||||
}
|
||||
|
||||
// CheckSchema 在 Migrate 成功后调用,确认代码依赖的表都在。
|
||||
//
|
||||
// [必须] 缺表就返回错误,调用方要**拒绝启动**,不是打个警告继续跑。
|
||||
@@ -759,8 +806,8 @@ var requiredTables = []string{
|
||||
// 错误要等操作员点到那个页面才暴露——如果那是个写操作页面,
|
||||
// 暴露出来的就不是报错而是写坏数据。
|
||||
//
|
||||
// [建议] 只查表名,不逐列校验:够抓住"迁移没跑到、表没建出来"这一类问题,
|
||||
// 代价也低。真出了列级别的不一致,业务 SQL 跑起来自然会报错。
|
||||
// 表名全部检查;关键的追加列也检查,防止 user_version 已更新但迁移未完整
|
||||
// 落地时,程序拿着缺列的库继续启动。
|
||||
func CheckSchema(db *sql.DB) error {
|
||||
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
||||
if err != nil {
|
||||
@@ -786,13 +833,27 @@ func CheckSchema(db *sql.DB) error {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf(
|
||||
"数据库结构与本程序不匹配:缺少表 %s。\n"+
|
||||
"这通常是数据库比程序旧、而迁移没有覆盖到。\n"+
|
||||
"请备份 data/admin.db 后删除它让程序重建,或联系维护者。",
|
||||
strings.Join(missing, "、"))
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"数据库结构与本程序不匹配:缺少表 %s。\n"+
|
||||
"这通常是数据库比程序旧、而迁移没有覆盖到。\n"+
|
||||
"请备份 data/admin.db 后删除它让程序重建,或联系维护者。",
|
||||
strings.Join(missing, "、"))
|
||||
for table, columns := range requiredColumns {
|
||||
existingColumns, err := tableColumnSet(db, table)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查数据表 %s 的列失败: %w", table, err)
|
||||
}
|
||||
for _, column := range columns {
|
||||
if !existingColumns[column] {
|
||||
return fmt.Errorf(
|
||||
"数据库结构与本程序不匹配:数据表 %s 缺少列 %s。\n"+
|
||||
"这通常是数据库迁移没有完整执行,请备份 data/admin.db 后联系维护者。",
|
||||
table, column)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -452,6 +452,37 @@ func TestMigrate_已经是最新版本再次调用不报错(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_v4增加可空店铺名且老数据保持NULL(t *testing.T) {
|
||||
db := newV2NewStructureDB(t)
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at)
|
||||
VALUES ('737116531267', 'https://example.invalid', 'pending', '2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z')`); err != nil {
|
||||
t.Fatalf("插入 v2 老数据失败: %v", err)
|
||||
}
|
||||
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatalf("迁移到 v4 失败: %v", err)
|
||||
}
|
||||
|
||||
columns, err := tableColumnSet(db, "pdd_products")
|
||||
if err != nil {
|
||||
t.Fatalf("读取 pdd_products 列失败: %v", err)
|
||||
}
|
||||
if !columns["shop_name"] {
|
||||
t.Fatal("v4 必须新增 shop_name 列")
|
||||
}
|
||||
|
||||
var shop sql.NullString
|
||||
if err := db.QueryRow(
|
||||
`SELECT shop_name FROM pdd_products WHERE goods_id = '737116531267'`,
|
||||
).Scan(&shop); err != nil {
|
||||
t.Fatalf("读取老数据店铺名失败: %v", err)
|
||||
}
|
||||
if shop.Valid {
|
||||
t.Errorf("老数据的 shop_name 应为 NULL,实际 %q", shop.String)
|
||||
}
|
||||
}
|
||||
|
||||
// v2 新结构库(#16 改写后的 v1)第一次 Migrate 时,migrateV3 检测到结构
|
||||
// 已经是最终形态,只更新版本号、不改任何表——第二次调用(对应用户重启
|
||||
// 服务)应该是彻底的空操作:user_version 已经是 3,Migrate 最外层的
|
||||
@@ -848,6 +879,18 @@ func TestCheckSchema_v2老库迁移后通过(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) {
|
||||
db := newV2NewStructureDB(t)
|
||||
if err := migrateV3(db); err != nil {
|
||||
t.Fatalf("准备 v3 数据库失败: %v", err)
|
||||
}
|
||||
|
||||
err := CheckSchema(db)
|
||||
if err == nil || !strings.Contains(err.Error(), "shop_name") {
|
||||
t.Fatalf("缺少 shop_name 时应拒绝启动并指出列名,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchema_缺表时拒绝(t *testing.T) {
|
||||
db := newFreshDB(t)
|
||||
if _, err := db.Exec(`DROP TABLE pdd_products`); err != nil {
|
||||
|
||||
+13
-9
@@ -12,7 +12,7 @@ import (
|
||||
// pddColumns 是所有查询共用的列清单。
|
||||
// 写成常量是为了让下面几个查询的列顺序和 scanPddProduct 永远对得上——
|
||||
// 改列的时候只改这一处,改漏了会 Scan 到错的字段上,而且不报错。
|
||||
const pddColumns = `id, goods_id, url, title, skus_json,
|
||||
const pddColumns = `id, goods_id, url, title, shop_name, skus_json,
|
||||
collect_status, collect_msg, artifact_ref, collected_at,
|
||||
deleted_at, created_at, updated_at`
|
||||
|
||||
@@ -24,10 +24,10 @@ type rowScanner interface {
|
||||
// scanPddProduct 按 pddColumns 的顺序读一行。
|
||||
func scanPddProduct(s rowScanner, extra ...any) (*model.PddProduct, error) {
|
||||
var p model.PddProduct
|
||||
var title, skus, msg, artifact, collectedAt, deletedAt sql.NullString
|
||||
var title, shopName, skus, msg, artifact, collectedAt, deletedAt sql.NullString
|
||||
|
||||
dest := []any{
|
||||
&p.ID, &p.GoodsID, &p.URL, &title, &skus,
|
||||
&p.ID, &p.GoodsID, &p.URL, &title, &shopName, &skus,
|
||||
&p.CollectStatus, &msg, &artifact, &collectedAt,
|
||||
&deletedAt, &p.CreatedAt, &p.UpdatedAt,
|
||||
}
|
||||
@@ -38,6 +38,7 @@ func scanPddProduct(s rowScanner, extra ...any) (*model.PddProduct, error) {
|
||||
}
|
||||
|
||||
p.Title = title.String
|
||||
p.ShopName = shopName.String
|
||||
p.SkusJSON = skus.String
|
||||
p.CollectMsg = msg.String
|
||||
p.ArtifactRef = artifact.String
|
||||
@@ -91,13 +92,13 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error)
|
||||
// 复活:清掉删除标记,状态回到待采集。
|
||||
// 采集结果一并清空——记录被删过一次,旧数据不能再当成有效的用。
|
||||
//
|
||||
// title 也要清:它是**采集回来的**(SetCollectResult 写的),
|
||||
// title 和 shop_name 也要清:它们是**采集回来的**(SetCollectResult 写的),
|
||||
// 属于采集结果的一部分。留着的话状态显示"未采集"、标题却有值,
|
||||
// 操作员会以为已经采过了。
|
||||
_, err := q.Exec(`
|
||||
UPDATE pdd_products
|
||||
SET deleted_at = NULL, url = ?, collect_status = 'pending',
|
||||
title = NULL, skus_json = NULL, collect_msg = NULL,
|
||||
title = NULL, shop_name = NULL, skus_json = NULL, collect_msg = NULL,
|
||||
artifact_ref = NULL, collected_at = NULL, updated_at = ?
|
||||
WHERE goods_id = ?`,
|
||||
url, now, goodsID)
|
||||
@@ -304,18 +305,21 @@ func SoftDeletePddProducts(q Execer, goodsIDs []string) (int64, error) {
|
||||
//
|
||||
// `[必须]` 按 **PDD 的 goods_id** 定位,不是蝦皮的。采集的对象是 PDD 商品。
|
||||
//
|
||||
// title 由调用方从采集结果里取出来传进来,用于人工核对"采的是不是要的那个商品"。
|
||||
func SetCollectResult(q Execer, pddGoodsID, title, skusJSON string) error {
|
||||
// title 和 shopName 由调用方从采集结果里取出来,用于人工核对商品来源。
|
||||
// shopName 为空时保留数据库已有值:这次没采到不代表上次采到的店铺名失效。
|
||||
func SetCollectResult(q Execer, pddGoodsID, title, shopName, skusJSON string) error {
|
||||
if pddGoodsID == "" {
|
||||
return fmt.Errorf("pdd goods_id 不能为空")
|
||||
}
|
||||
now := model.NowISO()
|
||||
res, err := q.Exec(`
|
||||
UPDATE pdd_products
|
||||
SET skus_json = ?, title = ?, collect_status = 'collected',
|
||||
SET skus_json = ?, title = ?,
|
||||
shop_name = CASE WHEN TRIM(?) = '' THEN shop_name ELSE ? END,
|
||||
collect_status = 'collected',
|
||||
collect_msg = NULL, collected_at = ?, updated_at = ?
|
||||
WHERE goods_id = ? AND deleted_at IS NULL`,
|
||||
skusJSON, title, now, now, pddGoodsID)
|
||||
skusJSON, title, shopName, shopName, now, now, pddGoodsID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存 PDD 商品 %s 的采集结果失败: %w", pddGoodsID, err)
|
||||
}
|
||||
|
||||
+38
-4
@@ -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 排序"。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 个规格必须算失败——数据对业务毫无用处,
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
autocomplete="off" class="wide"></dd>
|
||||
|
||||
<dt>标题</dt><dd>{{.Title}}<small>(只读,采集后自动回填)</small></dd>
|
||||
<dt>店铺</dt><dd>{{.ShopName}}<small>(只读,采集后自动回填)</small></dd>
|
||||
<dt>采集状态</dt><dd>{{.StatusText}}</dd>
|
||||
<dt>失败原因</dt><dd>{{.CollectMsg}}</dd>
|
||||
<dt>采集时间</dt><dd>{{.CollectedAt}}</dd>
|
||||
@@ -55,6 +56,12 @@
|
||||
建议重新采集一次。
|
||||
</p>
|
||||
{{else}}
|
||||
{{if .ShowPriceSource}}
|
||||
<p class="hint">
|
||||
价格按颜色采样,同一颜色下各尺码显示同一价格。<br>
|
||||
标 ✓ 的是实测价,其余为推断值。
|
||||
</p>
|
||||
{{end}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -62,6 +69,7 @@
|
||||
{{/* 列顺序按采集结果里的 dimensions 定,不是 Go map 的随机顺序 */}}
|
||||
{{range .DimensionNames}}<th>{{.}}</th>{{end}}
|
||||
<th>价格</th>
|
||||
{{if $.D.ShowPriceSource}}<th>价格来源</th>{{end}}
|
||||
<th>有货</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -72,6 +80,7 @@
|
||||
{{/* 价格底层存的是整数分。采不到价格显示「未采到」,
|
||||
绝不显示 ¥0.00——那会让人以为是白菜价 */}}
|
||||
<td>{{.PriceText}}</td>
|
||||
{{if $.D.ShowPriceSource}}<td>{{.PriceSource}}</td>{{end}}
|
||||
<td>{{.Available}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<th class="col-check"><input type="checkbox" data-check-all></th>
|
||||
<th>商品 ID</th>
|
||||
<th>标题</th>
|
||||
<th>店铺</th>
|
||||
<th>PDD 链接</th>
|
||||
<th>采集状态</th>
|
||||
{{/* 采到 1 个和采到 20 个差别很大:只采到 1 个通常是没点开规格面板,
|
||||
@@ -71,6 +72,7 @@
|
||||
</td>
|
||||
<td>{{.GoodsID}}</td>
|
||||
<td class="truncate" title="{{.Title}}">{{.Title}}</td>
|
||||
<td class="truncate" title="{{.ShopName}}">{{.ShopName}}</td>
|
||||
<td class="truncate">
|
||||
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer" title="{{.URL}}">{{.URL}}</a>
|
||||
</td>
|
||||
@@ -83,7 +85,7 @@
|
||||
{{else}}
|
||||
{{/* 空状态要分情况:从没建过 和 筛选没结果,下一步动作完全不同 */}}
|
||||
<tr class="empty">
|
||||
<td colspan="8">
|
||||
<td colspan="9">
|
||||
{{if .IsFiltered}}
|
||||
当前筛选条件下没有商品。<br>
|
||||
<small>换个采集状态或清空搜索词再试。<a href="/pdd">查看全部</a></small>
|
||||
|
||||
Reference in New Issue
Block a user