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:
chengma
2026-08-07 11:27:06 +08:00
co-authored by Claude Opus 5
parent fab20cfd2f
commit eb8d357f5a
16 changed files with 2333 additions and 72 deletions
+539
View File
@@ -0,0 +1,539 @@
// PDD 商品模块的业务逻辑。
//
// 这一层负责三件事,界面层和数据层都不做:
// - 从链接里解析 goods_id(解析不出就不许写库);
// - 把数据库里的原始字段翻成界面直接能显示的文字(状态、价格、时间);
// - 创建采集任务时的去重和跳过规则。
//
// 改动前必读 admin/AGENTS.md 的分层约定:本层**不碰 HTTP,也不拼 SQL**。
package service
import (
"database/sql"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// ErrBadPddURL 链接里解析不出 goods_id。
//
// 这个错误必须让操作员看见,**不能容错兜底**:goods_id 上有 UNIQUE 约束,
// 防重全靠它。拿不到就没法查重,同一个商品会存成好几行,
// 采好几遍,规格映射还说不清指向哪一行。
var ErrBadPddURL = errors.New("PDD 链接无效")
// ErrPddGoodsIDChanged 编辑链接时换成了另一个商品。
var ErrPddGoodsIDChanged = errors.New("新链接指向的是另一个商品")
// ---------- 链接解析 ----------
// pddHosts 是认识的拼多多域名(含子域名)。
//
// 卡域名是为了拦住"粘错了链接"这种常见失误——粘了淘宝链接却存进 PDD 表,
// 要等客户端跑到手机上才发现。
var pddHosts = []string{"yangkeduo.com", "pinduoduo.com"}
// goodsIDParams 是可能装着 goods_id 的查询参数名,按优先级排。
var goodsIDParams = []string{"goods_id", "_x_goods_id"}
// ParsePddGoodsID 从 PDD 商品链接里取出 goods_id。
//
// 认这几种写法:
//
// https://mobile.yangkeduo.com/goods.html?goods_id=737116531267
// https://mobile.yangkeduo.com/goods2.html?goods_id=737116531267&_x_xx=1
// https://yangkeduo.com/duo_coupon_landing.html?_x_goods_id=737116531267
//
// **短链(如 p.pinduoduo.com/xxxx)一律拒绝**,因为里面没有 goods_id。
// 让操作员回 App 里复制完整链接,比在这里猜要安全得多。
func ParsePddGoodsID(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("%w: 链接不能为空", ErrBadPddURL)
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("%w: 这不是一个链接(%s)", ErrBadPddURL, raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("%w: 链接要以 http:// 或 https:// 开头", ErrBadPddURL)
}
if !isPddHost(u.Hostname()) {
return "", fmt.Errorf(
"%w: %s 不是拼多多的网址,认得的是 yangkeduo.com 和 pinduoduo.com",
ErrBadPddURL, u.Hostname())
}
query := u.Query()
for _, name := range goodsIDParams {
if id := strings.TrimSpace(query.Get(name)); isGoodsID(id) {
return id, nil
}
}
return "", fmt.Errorf(
"%w: 链接里没有 goods_id。请在拼多多 App 的商品页点「分享」→「复制链接」,"+
"拿到带 goods_id= 的完整链接,短链接不行", ErrBadPddURL)
}
func isPddHost(host string) bool {
host = strings.ToLower(host)
for _, h := range pddHosts {
if host == h || strings.HasSuffix(host, "."+h) {
return true
}
}
return false
}
// isGoodsID 判断是不是一串合理长度的纯数字。
// 卡长度是为了拦住 goods_id=0 或者被截断的半截 ID。
func isGoodsID(s string) bool {
if len(s) < 6 || len(s) > 24 {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// ---------- 界面文字 ----------
// collectStatusTexts 是采集状态的中文说法。
//
// `[必须]` 界面上必须有文字,不能只用颜色区分——
// 见 docs/admin/05-ui-specification.md §2。
var collectStatusTexts = map[model.CollectStatus]string{
model.CollectPending: "未采集",
model.CollectCollecting: "采集中",
model.CollectCollected: "已采集",
model.CollectFailed: "采集失败",
}
// CollectStatusOption 是筛选下拉框的一项。
type CollectStatusOption struct {
Value string // 空串表示"全部"
Text string
}
// CollectStatusOptions 返回筛选下拉框的全部选项。
func CollectStatusOptions() []CollectStatusOption {
return []CollectStatusOption{
{"", "全部"},
{string(model.CollectPending), collectStatusTexts[model.CollectPending]},
{string(model.CollectCollecting), collectStatusTexts[model.CollectCollecting]},
{string(model.CollectCollected), collectStatusTexts[model.CollectCollected]},
{string(model.CollectFailed), collectStatusTexts[model.CollectFailed]},
}
}
// ParseCollectStatus 校验筛选参数。认不出的一律当"全部",
// 不报错——地址栏里的参数是用户可以随便改的,不值得为它弹错误页。
func ParseCollectStatus(s string) model.CollectStatus {
status := model.CollectStatus(strings.TrimSpace(s))
if _, ok := collectStatusTexts[status]; ok {
return status
}
return ""
}
func collectStatusText(s model.CollectStatus) string {
if t, ok := collectStatusTexts[s]; ok {
return t
}
return string(s)
}
// formatLocalTime 把库里的 ISO 8601 转成本地时区的可读写法。
// 空值或坏值都显示占位符,不显示 0001-01-01。
func formatLocalTime(iso string) string {
if strings.TrimSpace(iso) == "" {
return placeholder
}
t, ok := model.ParseISO(iso)
if !ok {
return iso // 原样显示,好让人看出数据有问题
}
return t.Local().Format("2006-01-02 15:04")
}
// placeholder 是"这里没有值"的统一写法。空单元格看着像页面坏了。
const placeholder = "—"
// formatPriceCent 把整数分显示成 ¥12.56。
//
// `[必须]` price_cent 为 null 时显示"未采到",**不能显示 ¥0.00**。
// 0 元和采不到价格是两回事,显示成 0 元会让操作员以为捡到便宜,
// 而这个数是要参与价格保护比对的(会花钱)。
func formatPriceCent(cent *int64) string {
if cent == nil {
return "未采到"
}
v := *cent
sign := ""
if v < 0 {
sign, v = "-", -v
}
return fmt.Sprintf("%s¥%d.%02d", sign, v/100, v%100)
}
// ---------- 列表 ----------
// PddProductView 是列表页一行要显示的全部内容,全部已经是字符串。
// 模板里不做判断和格式化,该在这里算完。
type PddProductView struct {
ID int64
GoodsID string
URL string
Title string // 未采集时是占位符
StatusText string
SkuCountText string // 未采集时是占位符
CollectedAt string
UpdatedAt string
CollectMsg string
IsFailed bool // 失败的行标黄,方便一眼找出来重采
}
// PddListResult 是列表页要的全部数据。
type PddListResult struct {
Rows []PddProductView
Total int // 全部未删除记录数,不受筛选影响
Counts map[model.CollectStatus]int
IsFiltered bool
}
// ListPddProducts 查列表并把每一行翻成界面文字。
func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus) (*PddListResult, error) {
rows, err := repository.ListPddProducts(db, keyword, status)
if err != nil {
return nil, err
}
counts, err := repository.CountPddProductsByStatus(db)
if err != nil {
return nil, err
}
result := &PddListResult{
Rows: make([]PddProductView, 0, len(rows)),
Counts: counts,
IsFiltered: strings.TrimSpace(keyword) != "" || status != "",
}
for _, n := range counts {
result.Total += n
}
for _, r := range rows {
v := PddProductView{
ID: r.ID,
GoodsID: r.GoodsID,
URL: r.URL,
Title: r.Title,
StatusText: collectStatusText(r.CollectStatus),
CollectedAt: formatLocalTime(r.CollectedAt),
UpdatedAt: formatLocalTime(r.UpdatedAt),
CollectMsg: r.CollectMsg,
IsFailed: r.CollectStatus == model.CollectFailed,
}
if v.Title == "" {
v.Title = "(未采集,采集后自动回填)"
}
// -1 是"还没采过"。注意 0 要如实显示 0:
// 采到 0 个规格说明采集出了问题,显示成 — 就看不出来了。
if r.SkuCount < 0 {
v.SkuCountText = placeholder
} else {
v.SkuCountText = fmt.Sprintf("%d", r.SkuCount)
}
result.Rows = append(result.Rows, v)
}
return result, nil
}
// StatusLine 拼底部状态条,形如:
//
// 共 24 条 · 已采集 18 · 未采集 4 · 采集中 1 · 采集失败 1
//
// 有筛选时前面加一句"筛选出 N 条",免得操作员把筛选后的行数
// 当成全部行数,以为记录被删了。
func (r *PddListResult) StatusLine() string {
order := []model.CollectStatus{
model.CollectCollected, model.CollectPending,
model.CollectCollecting, model.CollectFailed,
}
parts := []string{fmt.Sprintf("共 %d 条", r.Total)}
for _, s := range order {
parts = append(parts, fmt.Sprintf("%s %d", collectStatusText(s), r.Counts[s]))
}
line := strings.Join(parts, " · ")
if r.IsFiltered {
line = fmt.Sprintf("筛选出 %d 条 / %s", len(r.Rows), line)
}
return line
}
// ---------- 弹窗 ----------
// PddSkuView 是弹窗规格表的一行。
type PddSkuView struct {
Options []string // 按 DimensionNames 的顺序排好,模板直接 range
PriceText string
Available string
}
// PddProductDetail 是双击弹窗要显示的全部内容。
type PddProductDetail struct {
ID int64
GoodsID string
URL string
Title string
StatusText string
CollectMsg string
CollectedAt string
ArtifactRef string
// Collected 为 false 时模板显示"尚未采集",而不是一张空表。
Collected bool
DimensionNames []string
SKUs []PddSkuView
// SkusError 非空表示 skus_json 存的东西解析不了。
// 这种情况要如实说出来,不能装作"没有规格"——
// 前者是数据坏了要重采,后者是商品本身没规格。
SkusError string
}
// GetPddProductDetail 读一条 PDD 商品,并把 skus_json 拆成规格表。
// 商品不存在或已删除返回 (nil, nil)。
func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) {
p, err := repository.GetPddProductByID(db, id)
if err != nil || p == nil {
return nil, err
}
d := &PddProductDetail{
ID: p.ID,
GoodsID: p.GoodsID,
URL: p.URL,
Title: p.Title,
StatusText: collectStatusText(p.CollectStatus),
CollectMsg: p.CollectMsg,
CollectedAt: formatLocalTime(p.CollectedAt),
ArtifactRef: p.ArtifactRef,
}
if d.Title == "" {
d.Title = placeholder
}
if d.CollectMsg == "" {
d.CollectMsg = placeholder
}
if strings.TrimSpace(p.SkusJSON) == "" {
return d, nil
}
collected, err := parseCollected(p.SkusJSON)
if err != nil {
d.SkusError = "采集结果解析不了,需要重新采集:" + err.Error()
return d, nil
}
d.Collected = true
keys, names := dimensionOrder(collected)
d.DimensionNames = names
for _, sku := range collected.SKUs {
row := PddSkuView{
Options: make([]string, 0, len(keys)),
PriceText: formatPriceCent(sku.PriceCent),
Available: "否",
}
if sku.Available {
row.Available = "是"
}
for _, k := range keys {
value := sku.Options[k]
if value == "" {
value = placeholder
}
row.Options = append(row.Options, value)
}
d.SKUs = append(d.SKUs, row)
}
return d, nil
}
// dimensionOrder 定下规格各维度的显示顺序,返回 (取值用的 key, 表头文字)。
//
// 优先用采集结果里的 dimensions;它缺失时退回"把所有 SKU 出现过的 key 排序"。
// 退回方案必须排序:Go 的 map 遍历顺序是随机的,不排的话
// 同一个商品每次刷新页面列的顺序都不一样,看着像数据在跳。
func dimensionOrder(c *collectedData) (keys, names []string) {
if len(c.Dimensions) > 0 {
for _, d := range c.Dimensions {
name := d.Name
if name == "" {
name = d.Key
}
keys = append(keys, d.Key)
names = append(names, name)
}
return keys, names
}
seen := map[string]bool{}
for _, sku := range c.SKUs {
for k := range sku.Options {
seen[k] = true
}
}
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, keys
}
// ---------- 创建与编辑 ----------
// CreatePddProduct 按链接建一条 PDD 商品,返回它的 goods_id。
//
// 只需要链接,其余字段全靠采集回填。
// 重复创建同一个商品不会产生第二行,软删除过的会被复活
// (三个分支都在 repository.EnsurePddProduct 里,见 #16)。
func CreatePddProduct(db *sql.DB, rawURL string) (string, error) {
goodsID, err := ParsePddGoodsID(rawURL)
if err != nil {
return "", err
}
p, err := repository.EnsurePddProduct(db, goodsID, strings.TrimSpace(rawURL))
if err != nil {
return "", err
}
return p.GoodsID, nil
}
// UpdatePddProductURL 保存弹窗里改过的链接。
//
// `[必须]` 新链接必须还是同一个商品。换成别的商品要新建一条,不能就地改:
// 这一行上挂着采集结果和 sku_mappings,goods_id 一换,
// 那些数据就全指到错的商品上了,之后按它下单就是买错东西。
func UpdatePddProductURL(db *sql.DB, id int64, rawURL string) error {
goodsID, err := ParsePddGoodsID(rawURL)
if err != nil {
return err
}
p, err := repository.GetPddProductByID(db, id)
if err != nil {
return err
}
if p == nil {
return fmt.Errorf("PDD 商品 #%d 不存在或已被删除", id)
}
if p.GoodsID != goodsID {
return fmt.Errorf(
"%w:这条记录是商品 %s,新链接是 %s。"+
"要维护另一个商品请用「创建」新建一条",
ErrPddGoodsIDChanged, p.GoodsID, goodsID)
}
return repository.UpdatePddProductURL(db, id, strings.TrimSpace(rawURL))
}
// DeletePddProducts 批量软删除,返回实际删掉的条数。
func DeletePddProducts(db *sql.DB, goodsIDs []string) (int64, error) {
return repository.SoftDeletePddProducts(db, dedupe(goodsIDs))
}
// ---------- 创建采集任务 ----------
// CreatePddCollectTasks 为勾选的 PDD 商品创建采集任务。
//
// `[必须]` **不指定客户端**(assigned_client 为 NULL,status 为 pending),
// 谁领到就在领取时标记谁,见 #17。采集是纯读取操作,哪台机器跑都一样,
// 指定了反而会在那台机器关着的时候干等。
//
// 规则:
// - 按 goods_id 去重,勾了重复的只建一个;
// - collect_status 已经是 collecting 的**跳过**——有任务在跑了,
// 再建一个就是让两台机器采同一个商品,白费一趟;
// - 建成功后把状态置为 collecting。
//
// 返回的 skipped 必须显示给操作员。静默跳过的话,
// 操作员会以为任务建好了,等半天没动静也不知道为什么。
func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (created, skipped int, err error) {
goodsIDs = dedupe(goodsIDs)
if len(goodsIDs) == 0 {
return 0, 0, nil
}
tx, err := db.Begin()
if err != nil {
return 0, 0, fmt.Errorf("开始事务失败: %w", err)
}
defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全
for _, goodsID := range goodsIDs {
p, err := repository.GetPddProductByGoodsID(tx, goodsID)
if err != nil {
return 0, 0, err
}
if p == nil || p.IsDeleted() {
skipped++
continue
}
// 先占状态再建任务:MarkCollecting 只在 pending/failed 时成功,
// 它同时起到"这个商品有没有人已经在采"的判断作用。
ok, err := repository.MarkCollecting(tx, goodsID)
if err != nil {
return 0, 0, err
}
if !ok {
skipped++
continue
}
if err := repository.InsertCollectTask(
tx, newCollectTaskID(), p.GoodsID, p.URL); err != nil {
return 0, 0, err
}
created++
}
if err := tx.Commit(); err != nil {
return 0, 0, fmt.Errorf("提交事务失败: %w", err)
}
return created, skipped, nil
}
// newCollectTaskID 生成采集任务编号。
// 带 COL- 前缀是为了在日志和数据库里一眼认出这是采集任务而不是采购任务。
func newCollectTaskID() string {
id := newID()
if len(id) > 16 {
id = id[:16]
}
return "COL-" + id
}
// dedupe 去掉重复项并保持原有顺序,顺带丢掉空串。
func dedupe(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" || seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
return out
}