feat: PDD 商品页面 (#18)
打通 Client↔Admin 闭环的一环:录入 PDD 链接 → 创建采集任务 → 客户端领走去采 → 规格和价格显示在页面上。#16 写的 MarkCollecting 和 SoftDeletePddProduct 至此才有生产调用方。 链接解析严格、不做容错兜底:goods_id 上有 UNIQUE 约束,防重全靠它。 猜一个的话同一商品会存成好几行、采好几遍,规格映射还说不清指向哪一行。 短链一律拒绝,卡域名是为了拦"粘了淘宝链接"这种失误。 创建采集任务先占状态再建任务:MarkCollecting 只在 pending/failed 时成功, 同时充当"有没有人已经在采"的判断,与建任务在同一事务里, 所以并发点多次只会建出一个任务(实测 4 并发 → 1 条)。 submit.go 的 collectedData 增加 Dimensions —— 没有它就只能按 Go 的 map 遍历,而 map 无序,同一商品每次刷新"颜色/尺码"的先后都可能变。 顺带修 #16 一处缺陷:EnsurePddProduct 复活分支清空了 skus_json 却漏了 title,导致复活后状态显示"未采集"但标题还留着旧值。 审查打回一次:清理"PDD 链接唯一的录入口"这一过期说法(全库 5 处), 以及 shopee.go 里"空 → no_link"的过期 TODO —— no_link 已在 #16 从 CHECK 约束删除,照写会直接撞约束。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,661 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
// ── 链接解析 ───────────────────────────────────────────
|
||||
|
||||
func TestParsePddGoodsID_认得的写法(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"标准商品页", "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267", "737116531267"},
|
||||
{"goods2 带一堆参数", "https://mobile.yangkeduo.com/goods2.html?_x_org=2&goods_id=737116531267&refer_page=1", "737116531267"},
|
||||
{"优惠券落地页用的是 _x_goods_id", "https://yangkeduo.com/duo_coupon_landing.html?_x_goods_id=737116531267", "737116531267"},
|
||||
{"pinduoduo.com 域名", "https://mobile.pinduoduo.com/goods.html?goods_id=737116531267", "737116531267"},
|
||||
{"http 也认", "http://mobile.yangkeduo.com/goods.html?goods_id=737116531267", "737116531267"},
|
||||
{"前后有空格", " https://mobile.yangkeduo.com/goods.html?goods_id=737116531267 ", "737116531267"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := ParsePddGoodsID(tc.url)
|
||||
if err != nil {
|
||||
t.Fatalf("应该解析成功: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("goods_id = %q,期望 %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 解析不出来必须报错。容错兜底会让同一个商品存成好几行,
|
||||
// 采好几遍,规格映射还说不清指向哪一行。
|
||||
func TestParsePddGoodsID_认不出的一律报错(t *testing.T) {
|
||||
cases := []struct{ name, url string }{
|
||||
{"空串", ""},
|
||||
{"只有空格", " "},
|
||||
{"短链没有 goods_id", "https://p.pinduoduo.com/AbCdEfGh"},
|
||||
{"不是拼多多", "https://item.taobao.com/item.htm?id=737116531267"},
|
||||
{"不带协议", "mobile.yangkeduo.com/goods.html?goods_id=737116531267"},
|
||||
{"goods_id 不是数字", "https://mobile.yangkeduo.com/goods.html?goods_id=abc123456"},
|
||||
{"goods_id 太短像是被截断", "https://mobile.yangkeduo.com/goods.html?goods_id=123"},
|
||||
{"goods_id 为空", "https://mobile.yangkeduo.com/goods.html?goods_id="},
|
||||
{"域名只是长得像", "https://yangkeduo.com.evil.example/goods.html?goods_id=737116531267"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := ParsePddGoodsID(tc.url)
|
||||
if !errors.Is(err, ErrBadPddURL) {
|
||||
t.Fatalf("期望 ErrBadPddURL,实际 err=%v got=%q", err, got)
|
||||
}
|
||||
// 报错要说清下一步该怎么办,光说"无效"操作员不知道改什么
|
||||
if len(err.Error()) < len("PDD 链接无效")+4 {
|
||||
t.Errorf("错误信息太笼统: %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 价格显示 ───────────────────────────────────────────
|
||||
|
||||
// price_cent 为 null 显示"未采到",**不能显示 ¥0.00**。
|
||||
// 0 元和采不到价格是两回事,而这个数是要参与价格保护比对的。
|
||||
func TestFormatPriceCent(t *testing.T) {
|
||||
cent := func(v int64) *int64 { return &v }
|
||||
cases := []struct {
|
||||
in *int64
|
||||
want string
|
||||
}{
|
||||
{nil, "未采到"},
|
||||
{cent(1256), "¥12.56"},
|
||||
{cent(0), "¥0.00"},
|
||||
{cent(5), "¥0.05"},
|
||||
{cent(100), "¥1.00"},
|
||||
{cent(123456), "¥1234.56"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := formatPriceCent(tc.in); got != tc.want {
|
||||
t.Errorf("formatPriceCent(%v) = %q,期望 %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 创建 ───────────────────────────────────────────────
|
||||
|
||||
func TestCreatePddProduct_只填链接即可(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
goodsID, err := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267")
|
||||
if err != nil {
|
||||
t.Fatalf("创建失败: %v", err)
|
||||
}
|
||||
if goodsID != "737116531267" {
|
||||
t.Errorf("goods_id = %q", goodsID)
|
||||
}
|
||||
|
||||
p, _ := repository.GetPddProductByGoodsID(db, goodsID)
|
||||
if p == nil {
|
||||
t.Fatal("应该建出一行")
|
||||
}
|
||||
if p.CollectStatus != model.CollectPending {
|
||||
t.Errorf("新建的状态应为 pending,实际 %s", p.CollectStatus)
|
||||
}
|
||||
if p.Title != "" || p.SkusJSON != "" {
|
||||
t.Error("标题和规格应该留空,等采集回填")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePddProduct_链接解析不出就不写库(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
if _, err := CreatePddProduct(db, "https://p.pinduoduo.com/AbCdEfGh"); !errors.Is(err, ErrBadPddURL) {
|
||||
t.Fatalf("期望 ErrBadPddURL,实际 %v", err)
|
||||
}
|
||||
|
||||
var n int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM pdd_products`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Errorf("解析失败时不该写库,实际有 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePddProduct_重复创建不产生第二行(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
url := "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267"
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := CreatePddProduct(db, url); err != nil {
|
||||
t.Fatalf("第 %d 次创建失败: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
var n int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM pdd_products`).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("同一个商品应该只有 1 行,实际 %d 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 软删除后重新创建同一链接要复活原行,并且**旧采集结果被清空**。
|
||||
// 记录被删过一次,旧数据不该再当有效的用。
|
||||
func TestCreatePddProduct_删除后重新创建复活且清空采集结果(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
url := "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267"
|
||||
|
||||
goodsID, _ := CreatePddProduct(db, url)
|
||||
if err := repository.SetCollectResult(db, goodsID, "旧标题", sampleSkusJSON); err != nil {
|
||||
t.Fatalf("写采集结果失败: %v", err)
|
||||
}
|
||||
before, _ := repository.GetPddProductByGoodsID(db, goodsID)
|
||||
|
||||
if _, err := DeletePddProducts(db, []string{goodsID}); err != nil {
|
||||
t.Fatalf("删除失败: %v", err)
|
||||
}
|
||||
if _, err := CreatePddProduct(db, url); err != nil {
|
||||
t.Fatalf("重新创建失败: %v", err)
|
||||
}
|
||||
|
||||
after, _ := repository.GetPddProductByGoodsID(db, goodsID)
|
||||
if after.ID != before.ID {
|
||||
t.Errorf("应该复活原行(id %d),实际是新行 id %d", before.ID, after.ID)
|
||||
}
|
||||
if after.IsDeleted() {
|
||||
t.Error("复活后 deleted_at 应该清掉")
|
||||
}
|
||||
if after.SkusJSON != "" || after.Title != "" {
|
||||
t.Errorf("复活后采集结果应清空,实际 title=%q skus=%q", after.Title, after.SkusJSON)
|
||||
}
|
||||
if after.CollectStatus != model.CollectPending {
|
||||
t.Errorf("复活后状态应回到 pending,实际 %s", after.CollectStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 编辑链接 ───────────────────────────────────────────
|
||||
|
||||
func TestUpdatePddProductURL_同一个商品可以改写法(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
goodsID, _ := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267")
|
||||
p, _ := repository.GetPddProductByGoodsID(db, goodsID)
|
||||
|
||||
newURL := "https://mobile.yangkeduo.com/goods2.html?goods_id=737116531267&refer_page=1"
|
||||
if err := UpdatePddProductURL(db, p.ID, newURL); err != nil {
|
||||
t.Fatalf("保存失败: %v", err)
|
||||
}
|
||||
|
||||
after, _ := repository.GetPddProductByID(db, p.ID)
|
||||
if after.URL != newURL {
|
||||
t.Errorf("链接没保存上: %q", after.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// 换成另一个商品必须拒绝:这一行上挂着采集结果和 sku_mappings,
|
||||
// goods_id 一换那些数据就全指到错的商品上,之后按它下单就是买错东西。
|
||||
func TestUpdatePddProductURL_换成别的商品要拒绝(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
goodsID, _ := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267")
|
||||
p, _ := repository.GetPddProductByGoodsID(db, goodsID)
|
||||
|
||||
err := UpdatePddProductURL(db, p.ID,
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=999888777666")
|
||||
if !errors.Is(err, ErrPddGoodsIDChanged) {
|
||||
t.Fatalf("期望 ErrPddGoodsIDChanged,实际 %v", err)
|
||||
}
|
||||
|
||||
after, _ := repository.GetPddProductByID(db, p.ID)
|
||||
if after.GoodsID != goodsID {
|
||||
t.Error("拒绝之后 goods_id 不该变")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "创建") {
|
||||
t.Errorf("错误信息要告诉操作员改用「创建」,实际 %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ── 列表 ───────────────────────────────────────────────
|
||||
|
||||
// sampleSkusJSON 是一份采集结果样本,两个维度三个规格,其中一个采不到价格。
|
||||
const sampleSkusJSON = `{
|
||||
"schema_version": 1,
|
||||
"goods_id": "737116531267",
|
||||
"title": "西装外套三件套",
|
||||
"dimensions": [
|
||||
{"key": "color", "name": "颜色分类"},
|
||||
{"key": "size", "name": "尺码"}
|
||||
],
|
||||
"skus": [
|
||||
{"options": {"color": "黑色", "size": "M"}, "price_cent": 1256, "available": true, "raw_price": "¥12.56"},
|
||||
{"options": {"color": "白色", "size": "M"}, "price_cent": 1256, "available": false, "raw_price": "¥12.56"},
|
||||
{"options": {"color": "红色", "size": "L"}, "price_cent": null, "available": true, "raw_price": ""}
|
||||
]
|
||||
}`
|
||||
|
||||
func createProduct(t *testing.T, db *sql.DB, goodsID string) {
|
||||
t.Helper()
|
||||
if _, err := CreatePddProduct(db,
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id="+goodsID); err != nil {
|
||||
t.Fatalf("创建商品 %s 失败: %v", goodsID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 规格数:未采集显示 —,采到几个就显示几个。
|
||||
// **采到 0 个要如实显示 0**,那说明采集出了问题,显示成 — 就看不出来了。
|
||||
func TestListPddProducts_规格数(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "100000000001") // 没采过
|
||||
createProduct(t, db, "100000000002") // 采到 3 个
|
||||
createProduct(t, db, "100000000003") // 采到 0 个
|
||||
|
||||
repository.SetCollectResult(db, "100000000002", "有规格的", sampleSkusJSON)
|
||||
repository.SetCollectResult(db, "100000000003", "没规格的", `{"skus": []}`)
|
||||
|
||||
result, err := ListPddProducts(db, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("查列表失败: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"100000000001": placeholder,
|
||||
"100000000002": "3",
|
||||
"100000000003": "0",
|
||||
}
|
||||
for _, row := range result.Rows {
|
||||
if got := row.SkuCountText; got != want[row.GoodsID] {
|
||||
t.Errorf("商品 %s 规格数 = %q,期望 %q", row.GoodsID, got, want[row.GoodsID])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// skus_json 存进了坏数据时,列表页必须还能打开。
|
||||
// json_array_length 碰到非法 JSON 会让整条查询报错,那样页面直接白屏。
|
||||
func TestListPddProducts_坏掉的采集结果不影响列表打开(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "100000000001")
|
||||
if _, err := db.Exec(
|
||||
`UPDATE pdd_products SET skus_json = '这不是JSON' WHERE goods_id = ?`,
|
||||
"100000000001"); err != nil {
|
||||
t.Fatalf("造坏数据失败: %v", err)
|
||||
}
|
||||
|
||||
result, err := ListPddProducts(db, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("列表页应该照样打得开: %v", err)
|
||||
}
|
||||
if len(result.Rows) != 1 || result.Rows[0].SkuCountText != placeholder {
|
||||
t.Errorf("坏数据的规格数应显示占位符,实际 %+v", result.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPddProducts_按采集状态筛选(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "100000000001")
|
||||
createProduct(t, db, "100000000002")
|
||||
repository.SetCollectResult(db, "100000000002", "已采的", sampleSkusJSON)
|
||||
|
||||
collected, err := ListPddProducts(db, "", model.CollectCollected)
|
||||
if err != nil {
|
||||
t.Fatalf("筛选失败: %v", err)
|
||||
}
|
||||
if len(collected.Rows) != 1 || collected.Rows[0].GoodsID != "100000000002" {
|
||||
t.Errorf("按「已采集」筛选应只剩 1 条,实际 %d 条", len(collected.Rows))
|
||||
}
|
||||
if !collected.IsFiltered {
|
||||
t.Error("IsFiltered 应为 true,空状态文案要靠它分情况")
|
||||
}
|
||||
|
||||
// 统计不受筛选影响:状态条是全局概览
|
||||
if collected.Total != 2 {
|
||||
t.Errorf("Total 应是全部 2 条,不该跟着筛选变,实际 %d", collected.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPddProducts_按商品ID和链接搜索(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
createProduct(t, db, "999888777666")
|
||||
|
||||
byID, _ := ListPddProducts(db, "7371165", "")
|
||||
if len(byID.Rows) != 1 || byID.Rows[0].GoodsID != "737116531267" {
|
||||
t.Errorf("按 ID 片段搜索失败,命中 %d 条", len(byID.Rows))
|
||||
}
|
||||
|
||||
byURL, _ := ListPddProducts(db, "yangkeduo", "")
|
||||
if len(byURL.Rows) != 2 {
|
||||
t.Errorf("按链接搜索应命中 2 条,实际 %d 条", len(byURL.Rows))
|
||||
}
|
||||
}
|
||||
|
||||
// LIKE 的通配符要转义,否则搜 "7_7" 会把 "737…" 也捞出来,看着像搜索坏了。
|
||||
//
|
||||
// 注意别拿单个 "_" 当测试词:链接里的 goods_id= 本来就带下划线,
|
||||
// 那种情况命中是对的,测不出问题。
|
||||
func TestListPddProducts_搜索词里的通配符不当通配符用(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
|
||||
for _, kw := range []string{"7_7", "%"} {
|
||||
result, err := ListPddProducts(db, kw, "")
|
||||
if err != nil {
|
||||
t.Fatalf("搜 %q 出错: %v", kw, err)
|
||||
}
|
||||
if len(result.Rows) != 0 {
|
||||
t.Errorf("搜 %q 应该一条都搜不到,实际 %d 条", kw, len(result.Rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPddProducts_删除的查不到(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
DeletePddProducts(db, []string{"737116531267"})
|
||||
|
||||
result, _ := ListPddProducts(db, "", "")
|
||||
if len(result.Rows) != 0 {
|
||||
t.Errorf("软删除的不该出现在列表里,实际 %d 条", len(result.Rows))
|
||||
}
|
||||
if result.Total != 0 {
|
||||
t.Errorf("统计也不该算上删掉的,实际 %d", result.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusLine_四个状态都列出来(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "100000000001")
|
||||
createProduct(t, db, "100000000002")
|
||||
repository.SetCollectResult(db, "100000000002", "已采的", sampleSkusJSON)
|
||||
|
||||
result, _ := ListPddProducts(db, "", "")
|
||||
line := result.StatusLine()
|
||||
for _, want := range []string{"共 2 条", "已采集 1", "未采集 1", "采集中 0", "采集失败 0"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Errorf("状态条缺少 %q:%s", want, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 弹窗详情 ───────────────────────────────────────────
|
||||
|
||||
func TestGetPddProductDetail_规格表按dimensions排列(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
repository.SetCollectResult(db, "737116531267", "西装外套三件套", sampleSkusJSON)
|
||||
p, _ := repository.GetPddProductByGoodsID(db, "737116531267")
|
||||
|
||||
d, err := GetPddProductDetail(db, p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("读详情失败: %v", err)
|
||||
}
|
||||
if !d.Collected {
|
||||
t.Fatal("已采集的应标为 Collected")
|
||||
}
|
||||
|
||||
// 表头按 dimensions 里的顺序,用的是 name 不是 key
|
||||
if len(d.DimensionNames) != 2 ||
|
||||
d.DimensionNames[0] != "颜色分类" || d.DimensionNames[1] != "尺码" {
|
||||
t.Errorf("维度顺序不对: %v", d.DimensionNames)
|
||||
}
|
||||
if len(d.SKUs) != 3 {
|
||||
t.Fatalf("应有 3 个规格,实际 %d", len(d.SKUs))
|
||||
}
|
||||
|
||||
first := d.SKUs[0]
|
||||
if len(first.Options) != 2 || first.Options[0] != "黑色" || first.Options[1] != "M" {
|
||||
t.Errorf("第一行规格值顺序不对: %v", first.Options)
|
||||
}
|
||||
if first.PriceText != "¥12.56" || first.Available != "是" {
|
||||
t.Errorf("第一行价格/有货不对: %q %q", first.PriceText, first.Available)
|
||||
}
|
||||
if d.SKUs[1].Available != "否" {
|
||||
t.Errorf("缺货的应显示「否」,实际 %q", d.SKUs[1].Available)
|
||||
}
|
||||
// price_cent 为 null 的那一行
|
||||
if d.SKUs[2].PriceText != "未采到" {
|
||||
t.Errorf("采不到价格应显示「未采到」而不是 ¥0.00,实际 %q", d.SKUs[2].PriceText)
|
||||
}
|
||||
}
|
||||
|
||||
// dimensions 缺失时退回按 key 排序。不排的话 Go 的 map 是随机顺序,
|
||||
// 同一个商品每次刷新页面列的顺序都不一样。
|
||||
func TestGetPddProductDetail_没有dimensions时按key排序且稳定(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
repository.SetCollectResult(db, "737116531267", "无维度信息", `{
|
||||
"skus": [{"options": {"size": "M", "color": "黑色", "style": "A"},
|
||||
"price_cent": 100, "available": true}]
|
||||
}`)
|
||||
p, _ := repository.GetPddProductByGoodsID(db, "737116531267")
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
d, err := GetPddProductDetail(db, p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("读详情失败: %v", err)
|
||||
}
|
||||
want := []string{"color", "size", "style"}
|
||||
for j, name := range want {
|
||||
if d.DimensionNames[j] != name {
|
||||
t.Fatalf("第 %d 次读,维度顺序应稳定为 %v,实际 %v", i+1, want, d.DimensionNames)
|
||||
}
|
||||
}
|
||||
if d.SKUs[0].Options[0] != "黑色" {
|
||||
t.Fatalf("规格值应跟着表头一起排,实际 %v", d.SKUs[0].Options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPddProductDetail_未采集时不算已采集(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
p, _ := repository.GetPddProductByGoodsID(db, "737116531267")
|
||||
|
||||
d, err := GetPddProductDetail(db, p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("读详情失败: %v", err)
|
||||
}
|
||||
if d.Collected {
|
||||
t.Error("没采过的不该标为已采集——界面要显示「尚未采集」而不是空表")
|
||||
}
|
||||
if d.StatusText != "未采集" {
|
||||
t.Errorf("状态文字 = %q", d.StatusText)
|
||||
}
|
||||
if d.SkusError != "" {
|
||||
t.Errorf("没采过不是解析出错,SkusError 应为空,实际 %q", d.SkusError)
|
||||
}
|
||||
}
|
||||
|
||||
// 采集结果坏掉要如实说出来,不能装作"没有规格"——
|
||||
// 前者是数据坏了要重采,后者是商品本身没规格,处理方式不一样。
|
||||
func TestGetPddProductDetail_采集结果坏掉时说清楚(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
db.Exec(`UPDATE pdd_products SET skus_json = '坏数据' WHERE goods_id = ?`, "737116531267")
|
||||
p, _ := repository.GetPddProductByGoodsID(db, "737116531267")
|
||||
|
||||
d, err := GetPddProductDetail(db, p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("不该整个失败,要能打开弹窗: %v", err)
|
||||
}
|
||||
if d.SkusError == "" {
|
||||
t.Error("应该给出解析失败的说明")
|
||||
}
|
||||
if d.Collected {
|
||||
t.Error("解析不了就不能当成已采集")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPddProductDetail_删除的读不到(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
p, _ := repository.GetPddProductByGoodsID(db, "737116531267")
|
||||
DeletePddProducts(db, []string{"737116531267"})
|
||||
|
||||
d, err := GetPddProductDetail(db, p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
if d != nil {
|
||||
t.Error("已删除的应返回 nil,让界面提示「已被删除,请刷新」")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 创建采集任务 ───────────────────────────────────────
|
||||
|
||||
// collectTaskOf 读出某个商品对应的采集任务。
|
||||
func collectTaskOf(t *testing.T, db *sql.DB, goodsID string) (status, assigned, url string, ok bool) {
|
||||
t.Helper()
|
||||
var a sql.NullString
|
||||
err := db.QueryRow(`
|
||||
SELECT status, assigned_client, pdd_goods_url
|
||||
FROM tasks WHERE task_type = 'collect' AND pdd_goods_id = ?`, goodsID,
|
||||
).Scan(&status, &a, &url)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", "", "", false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("查采集任务失败: %v", err)
|
||||
}
|
||||
return status, a.String, url, true
|
||||
}
|
||||
|
||||
// `[必须]` 采集任务不指定客户端。采集是纯读取,哪台机器跑都一样,
|
||||
// 指定了反而会在那台机器关着的时候干等。
|
||||
func TestCreatePddCollectTasks_不指定客户端(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
|
||||
created, skipped, err := CreatePddCollectTasks(db, []string{"737116531267"})
|
||||
if err != nil {
|
||||
t.Fatalf("建任务失败: %v", err)
|
||||
}
|
||||
if created != 1 || skipped != 0 {
|
||||
t.Fatalf("created=%d skipped=%d,期望 1/0", created, skipped)
|
||||
}
|
||||
|
||||
status, assigned, url, ok := collectTaskOf(t, db, "737116531267")
|
||||
if !ok {
|
||||
t.Fatal("应该建出一条采集任务")
|
||||
}
|
||||
if assigned != "" {
|
||||
t.Errorf("assigned_client 必须为空,实际 %q", assigned)
|
||||
}
|
||||
if status != "pending" {
|
||||
t.Errorf("状态应为 pending(无主待领),实际 %q", status)
|
||||
}
|
||||
// Client 契约里 pdd_goods_url 必填,没有它客户端拿到任务也不知道去哪采
|
||||
if url == "" {
|
||||
t.Error("pdd_goods_url 必须有值")
|
||||
}
|
||||
|
||||
// 建完任务商品状态要变成采集中
|
||||
p, _ := repository.GetPddProductByGoodsID(db, "737116531267")
|
||||
if p.CollectStatus != model.CollectCollecting {
|
||||
t.Errorf("商品状态应为 collecting,实际 %s", p.CollectStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// 建出来的任务必须真的能被领走,否则页面上永远卡在"采集中"。
|
||||
func TestCreatePddCollectTasks_建出的任务能被任意客户端领走(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
CreatePddCollectTasks(db, []string{"737116531267"})
|
||||
|
||||
task, err := ClaimNextTask(db, "client-随便哪台", []string{"collect"})
|
||||
if err != nil {
|
||||
t.Fatalf("领取失败: %v", err)
|
||||
}
|
||||
if task == nil {
|
||||
t.Fatal("采集任务应该能被任意客户端领到")
|
||||
}
|
||||
if task.PddGoodsID != "737116531267" {
|
||||
t.Errorf("领到的任务商品不对: %q", task.PddGoodsID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePddCollectTasks_按商品去重(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
|
||||
created, _, err := CreatePddCollectTasks(db,
|
||||
[]string{"737116531267", "737116531267", " 737116531267 ", ""})
|
||||
if err != nil {
|
||||
t.Fatalf("建任务失败: %v", err)
|
||||
}
|
||||
if created != 1 {
|
||||
t.Errorf("重复勾选只该建 1 个任务,实际 %d", created)
|
||||
}
|
||||
|
||||
var n int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type = 'collect'`).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("库里应只有 1 条采集任务,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 已经在采的跳过:再建一个就是让两台机器采同一个商品,白费一趟。
|
||||
// 而且跳过了几个必须报出来,静默跳过会让操作员等半天不知道为什么没动静。
|
||||
func TestCreatePddCollectTasks_采集中的跳过并报数(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "100000000001")
|
||||
createProduct(t, db, "100000000002")
|
||||
|
||||
// 第一个先建一次,让它进入 collecting
|
||||
if _, _, err := CreatePddCollectTasks(db, []string{"100000000001"}); err != nil {
|
||||
t.Fatalf("第一次建任务失败: %v", err)
|
||||
}
|
||||
|
||||
created, skipped, err := CreatePddCollectTasks(db,
|
||||
[]string{"100000000001", "100000000002"})
|
||||
if err != nil {
|
||||
t.Fatalf("第二次建任务失败: %v", err)
|
||||
}
|
||||
if created != 1 {
|
||||
t.Errorf("只该给没在采的那个建任务,实际建了 %d 个", created)
|
||||
}
|
||||
if skipped != 1 {
|
||||
t.Errorf("采集中的那个应计入 skipped,实际 %d", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// 采集失败的可以重新采集,不能一直卡在失败状态。
|
||||
func TestCreatePddCollectTasks_失败的可以重新采集(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
repository.SetCollectFailed(db, "737116531267", "页面打不开", "")
|
||||
|
||||
created, skipped, err := CreatePddCollectTasks(db, []string{"737116531267"})
|
||||
if err != nil {
|
||||
t.Fatalf("建任务失败: %v", err)
|
||||
}
|
||||
if created != 1 || skipped != 0 {
|
||||
t.Errorf("失败的应该能重采,created=%d skipped=%d", created, skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePddCollectTasks_已删除的跳过(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
createProduct(t, db, "737116531267")
|
||||
DeletePddProducts(db, []string{"737116531267"})
|
||||
|
||||
created, skipped, err := CreatePddCollectTasks(db, []string{"737116531267"})
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
if created != 0 || skipped != 1 {
|
||||
t.Errorf("已删除的应被跳过,created=%d skipped=%d", created, skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePddCollectTasks_什么都没勾时不建任务(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
created, skipped, err := CreatePddCollectTasks(db, nil)
|
||||
if err != nil || created != 0 || skipped != 0 {
|
||||
t.Errorf("空输入应安静返回,得到 %d/%d/%v", created, skipped, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user