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
+171
View File
@@ -0,0 +1,171 @@
package web
import (
"fmt"
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"cmautobuy/admin/service"
)
// PDD 商品页的处理器。
//
// 本文件只做三件事:取参数 → 调 service → 渲染或跳转。
// 不写业务判断,不拼 SQL——那些在 service/pdd.go 和 repository/pdd.go 里。
// PddList 渲染 PDD 商品列表页。
func (h *Handler) PddList(c *gin.Context) {
keyword := c.Query("q")
status := service.ParseCollectStatus(c.Query("status"))
result, err := service.ListPddProducts(h.db, keyword, status)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
// 底部状态条平时显示统计,刚做完操作时先显示操作结果。
// 操作结果是通过跳转带过来的 msg 参数传的(见 pddRedirect)。
statusLine := result.StatusLine()
if msg := c.Query("msg"); msg != "" {
statusLine = msg + " · " + statusLine
}
c.HTML(http.StatusOK, "pdd/list", page(c, "pdd", "PDD 商品", gin.H{
"Rows": result.Rows,
"Keyword": keyword,
"Status": statusLine,
"StatusFilter": string(status),
"StatusOptions": service.CollectStatusOptions(),
"HasAnyProducts": result.Total > 0,
"IsFiltered": result.IsFiltered,
}))
}
// PddDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
//
// 弹窗内容由服务端渲染,前端只负责把它放进弹窗壳子里显示出来。
// 不要改成"前端拿 JSON 自己拼表格"——规格表的维度顺序、
// 价格格式、null 的处理都是业务规则,散到前端就没人维护得住了。
func (h *Handler) PddDetail(c *gin.Context) {
id, err := strconv.ParseInt(c.Query("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "商品编号不对,请刷新页面后重试。")
return
}
detail, err := service.GetPddProductDetail(h.db, id)
if err != nil {
fail(c, http.StatusInternalServerError, "读取商品详情失败,数据没有被改动。")
return
}
if detail == nil {
fail(c, http.StatusNotFound, "这个商品不存在或已被删除,请刷新页面。")
return
}
c.HTML(http.StatusOK, "pdd/edit_modal", gin.H{
"D": detail,
"CSRFToken": csrfToken(c),
})
}
// PddCreate 按链接创建一条 PDD 商品。
//
// 只填链接,其余字段全靠采集回填。
// 链接解析不出 goods_id 时**报错并且不写库**,见 service.ParsePddGoodsID。
func (h *Handler) PddCreate(c *gin.Context) {
goodsID, err := service.CreatePddProduct(h.db, c.PostForm("url"))
if err != nil {
fail(c, http.StatusBadRequest, err.Error()+"。没有创建任何记录。")
return
}
h.pddRedirect(c, fmt.Sprintf("已保存商品 %s,现在可以勾选它创建采集任务", goodsID))
}
// PddSave 保存弹窗里改过的链接。
func (h *Handler) PddSave(c *gin.Context) {
id, err := strconv.ParseInt(c.PostForm("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "商品编号不对,请刷新页面后重试。没有改动任何数据。")
return
}
if err := service.UpdatePddProductURL(h.db, id, c.PostForm("url")); err != nil {
fail(c, http.StatusBadRequest, err.Error()+"。没有改动任何数据。")
return
}
h.pddRedirect(c, "链接已保存")
}
// PddCollect 为勾选的商品创建采集任务。
//
// 按钮文案是「创建采集任务」而不是「采集」:它做的是建一个任务,
// 不是立刻去采。真正的采集要等客户端来领、去手机上跑,
// 可能几秒也可能几分钟。
func (h *Handler) PddCollect(c *gin.Context) {
ids := c.PostFormArray("ids")
if len(ids) == 0 {
h.pddRedirect(c, "没有勾选任何商品,没有创建任务")
return
}
created, skipped, err := service.CreatePddCollectTasks(h.db, ids)
if err != nil {
fail(c, http.StatusInternalServerError,
"创建采集任务失败:"+err.Error()+"。这一批任务整体没有创建,可以直接重试。")
return
}
msg := fmt.Sprintf("已创建 %d 个采集任务,等待客户端领取", created)
if skipped > 0 {
// 跳过了几个必须说出来,否则操作员会以为都建上了,等半天没动静
msg += fmt.Sprintf("(跳过 %d 个采集中或已删除的)", skipped)
}
h.pddRedirect(c, msg)
}
// PddDelete 批量软删除。
func (h *Handler) PddDelete(c *gin.Context) {
ids := c.PostFormArray("ids")
if len(ids) == 0 {
h.pddRedirect(c, "没有勾选任何商品,没有删除")
return
}
n, err := service.DeletePddProducts(h.db, ids)
if err != nil {
fail(c, http.StatusInternalServerError,
"删除失败:"+err.Error()+"。没有删除任何记录。")
return
}
h.pddRedirect(c, fmt.Sprintf("已删除 %d 条", n))
}
// pddRedirect 处理完写操作后跳回列表页。
//
// 用 303 跳转而不是直接渲染,是为了让浏览器地址栏变成 GET /pdd——
// 否则用户按 F5 会重复提交刚才那个 POST(重复建任务、重复删除)。
//
// 筛选条件一起带回去,免得操作员每做一次操作就要重新筛一遍。
func (h *Handler) pddRedirect(c *gin.Context, msg string) {
params := url.Values{}
if s := c.PostForm("status"); s != "" {
params.Set("status", s)
}
if q := c.PostForm("q"); q != "" {
params.Set("q", q)
}
if msg != "" {
params.Set("msg", msg)
}
target := "/pdd"
if len(params) > 0 {
target += "?" + params.Encode()
}
c.Redirect(http.StatusSeeOther, target)
}
+10 -3
View File
@@ -57,10 +57,17 @@ func (h *Handler) ShopeeImport(c *gin.Context) {
// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。
//
// 这是 PDD 链接唯一的录入口,见 docs/admin/05-ui-specification.md §4.3。
// 这是**从蝦皮商品出发**录入 PDD 链接的入口,
// 见 docs/admin/05-ui-specification.md §4.3。PDD 商品页(§5)也能录入,
// 两处并存;本页这个目前还是骨架,合并与否由「蝦皮↔PDD 关联入口」工单决定。
func (h *Handler) ShopeeSave(c *gin.Context) {
// TODO(骨架): 保存并根据 PDD 链接是否为空更新 collect_status
// (空 -> no_link,非空且未采集 -> pending)
// TODO(骨架): 保存 PDD 链接时调 repository.EnsurePddProduct,
// 由它去建 / 复用 / 复活 pdd_products 那一行。
//
// **采集状态不在蝦皮这边**:它属于 PDD 商品(pdd_products.collect_status)。
// "还没填链接"由 shopee_products.pdd_goods_id IS NULL 表达——
// 没填链接时 pdd_products 里根本不会有那一行。
// 不要写 no_link,那个值在 #16 已从 CHECK 约束里删掉了,写了会直接撞约束。
fail(c, http.StatusNotImplemented, "保存功能尚未实现。")
}
+13 -5
View File
@@ -26,9 +26,9 @@ type Handler struct {
onlineThreshold time.Duration
}
// Register 把四个模块的页面路由挂上去。
// Register 把五个模块的页面路由挂上去。
//
// 四个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条),
// 五个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条),
// 这是有意的,见 docs/admin/05-ui-specification.md §2。
func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
h := &Handler{db: db, onlineThreshold: onlineThreshold}
@@ -49,18 +49,26 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
pages.POST("/shopee/delete", h.ShopeeDelete)
pages.POST("/shopee/collect", h.ShopeeCollect)
// 2. 顺运宝数据
// 2. PDD 商品
pages.GET("/pdd", h.PddList)
pages.GET("/pdd/detail", h.PddDetail) // 双击行时前端来取弹窗内容
pages.POST("/pdd/create", h.PddCreate)
pages.POST("/pdd/save", h.PddSave)
pages.POST("/pdd/collect", h.PddCollect)
pages.POST("/pdd/delete", h.PddDelete)
// 3. 顺运宝数据
pages.GET("/syb", h.SybList)
pages.POST("/syb/sync", h.SybSync)
pages.POST("/syb/match", h.SybMatch)
pages.POST("/syb/create-task", h.SybCreateTask)
pages.POST("/syb/delete", h.SybDelete)
// 3. 采购任务
// 4. 采购任务
pages.GET("/tasks", h.TaskList)
pages.POST("/tasks/delete", h.TaskDelete)
// 4. 客户端列表
// 5. 客户端列表
pages.GET("/clients", h.ClientList)
pages.POST("/clients/delete", h.ClientDelete)
}
+210 -20
View File
@@ -3,10 +3,48 @@ package repository
import (
"database/sql"
"fmt"
"strings"
"cmautobuy/admin/model"
)
// pddColumns 是所有查询共用的列清单。
// 写成常量是为了让下面几个查询的列顺序和 scanPddProduct 永远对得上——
// 改列的时候只改这一处,改漏了会 Scan 到错的字段上,而且不报错。
const pddColumns = `id, goods_id, url, title, skus_json,
collect_status, collect_msg, artifact_ref, collected_at,
deleted_at, created_at, updated_at`
// rowScanner 让 *sql.Row 和 *sql.Rows 都能喂给 scanPddProduct。
type rowScanner interface {
Scan(dest ...any) error
}
// scanPddProduct 按 pddColumns 的顺序读一行。
func scanPddProduct(s rowScanner, extra ...any) (*model.PddProduct, error) {
var p model.PddProduct
var title, skus, msg, artifact, collectedAt, deletedAt sql.NullString
dest := []any{
&p.ID, &p.GoodsID, &p.URL, &title, &skus,
&p.CollectStatus, &msg, &artifact, &collectedAt,
&deletedAt, &p.CreatedAt, &p.UpdatedAt,
}
dest = append(dest, extra...)
if err := s.Scan(dest...); err != nil {
return nil, err
}
p.Title = title.String
p.SkusJSON = skus.String
p.CollectMsg = msg.String
p.ArtifactRef = artifact.String
p.CollectedAt = collectedAt.String
p.DeletedAt = deletedAt.String
return &p, nil
}
// EnsurePddProduct 保证 goods_id 对应的 PDD 商品存在,返回它。
//
// 操作员在货运单或蝦皮商品上填 PDD 链接、点保存时调它。三种情况:
@@ -51,11 +89,15 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error)
if existing.IsDeleted() {
// 复活:清掉删除标记,状态回到待采集。
// 采集结果一并清空——记录被删过一次,旧数据不能再当成有效的用。
//
// title 也要清:它是**采集回来的**(SetCollectResult 写的),
// 属于采集结果的一部分。留着的话状态显示"未采集"、标题却有值,
// 操作员会以为已经采过了。
_, err := q.Exec(`
UPDATE pdd_products
SET deleted_at = NULL, url = ?, collect_status = 'pending',
skus_json = NULL, collect_msg = NULL, artifact_ref = NULL,
collected_at = NULL, updated_at = ?
title = NULL, skus_json = NULL, collect_msg = NULL,
artifact_ref = NULL, collected_at = NULL, updated_at = ?
WHERE goods_id = ?`,
url, now, goodsID)
if err != nil {
@@ -82,31 +124,179 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error)
// 之所以连删除的也查出来,是因为 EnsurePddProduct 要靠它判断该不该复活。
// 给界面用的查询请用 ListPddProducts,那个会过滤掉已删除的。
func GetPddProductByGoodsID(q Execer, goodsID string) (*model.PddProduct, error) {
var p model.PddProduct
var title, skus, msg, artifact, collectedAt, deletedAt sql.NullString
err := q.QueryRow(`
SELECT id, goods_id, url, title, skus_json,
collect_status, collect_msg, artifact_ref, collected_at,
deleted_at, created_at, updated_at
FROM pdd_products WHERE goods_id = ?`, goodsID).Scan(
&p.ID, &p.GoodsID, &p.URL, &title, &skus,
&p.CollectStatus, &msg, &artifact, &collectedAt,
&deletedAt, &p.CreatedAt, &p.UpdatedAt)
p, err := scanPddProduct(q.QueryRow(
`SELECT `+pddColumns+` FROM pdd_products WHERE goods_id = ?`, goodsID))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询 PDD 商品 %s 失败: %w", goodsID, err)
}
return p, nil
}
p.Title = title.String
p.SkusJSON = skus.String
p.CollectMsg = msg.String
p.ArtifactRef = artifact.String
p.CollectedAt = collectedAt.String
p.DeletedAt = deletedAt.String
return &p, nil
// GetPddProductByID 按主键查一条,**已软删除的查不到**。
//
// 弹窗用的是这个,不是 GetPddProductByGoodsID —— 后者连删掉的也返回,
// 那是给 EnsurePddProduct 判断复活用的,不能拿来渲染界面。
func GetPddProductByID(q Execer, id int64) (*model.PddProduct, error) {
p, err := scanPddProduct(q.QueryRow(
`SELECT `+pddColumns+`
FROM pdd_products WHERE id = ? AND deleted_at IS NULL`, id))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询 PDD 商品 #%d 失败: %w", id, err)
}
return p, nil
}
// PddProductRow 是列表页要的一行:商品本身,外加数据库算好的规格数。
type PddProductRow struct {
model.PddProduct
// SkuCount 是 skus_json 里的规格条数。
// **-1 表示"还没有采集结果"**,和"采到 0 个规格"是两回事:
// 前者显示 —,后者是采集出了问题,要显示 0 让操作员看见。
SkuCount int
}
// 规格数交给 SQLite 算,不要把整个 skus_json 读进 Go 再数——
// 列表页几十上百行,每行都反序列化一遍纯属浪费。
//
// json_valid 那层判断不能省:skus_json 万一存进了坏数据,
// json_array_length 会让**整条查询报错**,页面直接打不开。
const pddSkuCountExpr = `
CASE WHEN skus_json IS NULL OR skus_json = '' OR NOT json_valid(skus_json)
THEN -1
ELSE COALESCE(json_array_length(skus_json, '$.skus'), -1)
END`
// ListPddProducts 查列表。keyword 匹配 goods_id 或链接,status 为空表示不筛选。
//
// 只返回未删除的。软删除的记录还在库里(sku_mappings 指着它),
// 但界面上不该看见。
func ListPddProducts(q Execer, keyword string, status model.CollectStatus) ([]PddProductRow, error) {
sqlText := `SELECT ` + pddColumns + `,` + pddSkuCountExpr + `
FROM pdd_products
WHERE deleted_at IS NULL`
var args []any
if keyword = strings.TrimSpace(keyword); keyword != "" {
// ESCAPE 不能省:操作员搜 "_" 时,LIKE 会把它当"任意一个字符",
// 结果是全部行都命中,看着像搜索坏了。
pattern := "%" + escapeLike(keyword) + "%"
sqlText += ` AND (goods_id LIKE ? ESCAPE '\' OR url LIKE ? ESCAPE '\')`
args = append(args, pattern, pattern)
}
if status != "" {
sqlText += ` AND collect_status = ?`
args = append(args, string(status))
}
// 刚动过的排前面,操作员点完"创建采集任务"一眼能看到结果
sqlText += ` ORDER BY updated_at DESC, id DESC`
rows, err := q.Query(sqlText, args...)
if err != nil {
return nil, fmt.Errorf("查询 PDD 商品列表失败: %w", err)
}
defer rows.Close()
list := make([]PddProductRow, 0, 16)
for rows.Next() {
var r PddProductRow
p, err := scanPddProduct(rows, &r.SkuCount)
if err != nil {
return nil, fmt.Errorf("读取 PDD 商品列表失败: %w", err)
}
r.PddProduct = *p
list = append(list, r)
}
return list, rows.Err()
}
// escapeLike 把 LIKE 的三个特殊字符转义掉,配合 ESCAPE '\' 使用。
// 反斜杠必须第一个换,否则会把后面刚加的反斜杠又转义一遍。
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
// CountPddProductsByStatus 统计各采集状态各有多少条,供底部状态条显示。
//
// 统计的是**全部未删除记录**,不受当前筛选影响——
// 状态条是全局概览,"总共还有几个没采"这个数不该跟着筛选变。
func CountPddProductsByStatus(q Execer) (map[model.CollectStatus]int, error) {
rows, err := q.Query(`
SELECT collect_status, COUNT(*)
FROM pdd_products
WHERE deleted_at IS NULL
GROUP BY collect_status`)
if err != nil {
return nil, fmt.Errorf("统计 PDD 商品状态失败: %w", err)
}
defer rows.Close()
counts := map[model.CollectStatus]int{}
for rows.Next() {
var status string
var n int
if err := rows.Scan(&status, &n); err != nil {
return nil, err
}
counts[model.CollectStatus(status)] = n
}
return counts, rows.Err()
}
// UpdatePddProductURL 只改链接原文,不动采集结果和状态。
//
// 调用方必须先确认新链接解析出来的 goods_id 和这条记录的一致,
// 见 service.UpdatePddProductURL 的说明。
func UpdatePddProductURL(q Execer, id int64, url string) error {
if url == "" {
return fmt.Errorf("url 不能为空")
}
res, err := q.Exec(`
UPDATE pdd_products SET url = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL`,
url, model.NowISO(), id)
if err != nil {
return fmt.Errorf("更新 PDD 商品 #%d 链接失败: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("PDD 商品 #%d 不存在或已被删除", id)
}
return nil
}
// SoftDeletePddProducts 批量软删除,返回实际删掉的条数。
//
// 已经删过的不会重复计数(WHERE 里带了 deleted_at IS NULL),
// 所以返回值就是"这次真正消失的行数",可以直接报给操作员。
func SoftDeletePddProducts(q Execer, goodsIDs []string) (int64, error) {
if len(goodsIDs) == 0 {
return 0, nil
}
// 占位符按数量生成,值仍然是参数化传入,不存在注入
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(goodsIDs)), ",")
now := model.NowISO()
args := make([]any, 0, len(goodsIDs)+2)
args = append(args, now, now)
for _, id := range goodsIDs {
args = append(args, id)
}
res, err := q.Exec(`
UPDATE pdd_products SET deleted_at = ?, updated_at = ?
WHERE goods_id IN (`+placeholders+`) AND deleted_at IS NULL`, args...)
if err != nil {
return 0, fmt.Errorf("批量删除 PDD 商品失败: %w", err)
}
return res.RowsAffected()
}
// SetCollectResult 保存采集回来的 PDD 商品数据。
+24
View File
@@ -262,3 +262,27 @@ func MarkTaskFailure(q Execer, taskID string, newStatus model.TaskStatus, errCod
}
return nil
}
// InsertCollectTask 建一条采集任务。
//
// `[必须]` 采集任务**不指定客户端**:assigned_client 为 NULL、status 为 pending,
// 谁领到就在领取时标记谁(见 #17 和 ClaimNextTask)。采集是纯读取操作,
// 哪台机器跑都一样,指定了反而会在那台机器关着的时候干等。
//
// goodsURL 必填 —— Client 契约里 pdd_goods_url 是 NOT NULL,
// 没有它客户端拿到任务也不知道去哪采。
func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error {
if goodsID == "" || goodsURL == "" {
return fmt.Errorf("采集任务的商品 ID 和链接都不能为空")
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO tasks (task_id, task_type, status, assigned_client,
pdd_goods_url, pdd_goods_id, created_at, updated_at)
VALUES (?, 'collect', 'pending', NULL, ?, ?, ?, ?)`,
taskID, goodsURL, goodsID, now, now)
if err != nil {
return fmt.Errorf("创建商品 %s 的采集任务失败: %w", goodsID, err)
}
return nil
}
+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
}
+661
View File
@@ -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)
}
}
+8 -1
View File
@@ -31,7 +31,14 @@ var (
type collectedData struct {
GoodsID string `json:"goods_id"`
Title string `json:"title"`
SKUs []struct {
// Dimensions 决定规格各维度的显示顺序。
// 少了它就只能按 Go 的 map 遍历,而 map 是无序的——
// 同一个商品每次刷新页面「颜色/尺码」的先后都可能变。
Dimensions []struct {
Key string `json:"key"`
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"`
+93
View File
@@ -135,6 +135,99 @@ tr.empty small { color: #aaa; }
font-size: 13px;
}
/* 链接和标题这类长内容截断显示,鼠标悬停看全文(title 属性)。
不截断的话一列能把表格撑到屏幕外面去。 */
.truncate {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 弹窗 ─────────────────────────────── */
/* 用 hidden 属性控制显示隐藏,JS 只改这一个属性,不拼样式 */
.modal-backdrop[hidden] { display: none; }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(20, 24, 30, .45);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
overflow-y: auto;
z-index: 10;
}
.modal {
background: #fff;
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, .25);
width: 100%;
max-width: 720px;
}
.modal-head {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e1e4e8;
}
.modal-head h2 { margin: 0; font-size: 16px; flex: 1; }
.modal-x {
border: none;
background: none;
font-size: 20px;
line-height: 1;
padding: 0 6px;
color: #666;
}
.modal-body { padding: 16px; }
.modal-body h3 { font-size: 14px; margin: 20px 0 8px; }
.modal-foot {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
padding: 12px 16px;
border-top: 1px solid #e1e4e8;
background: #fafbfc;
}
/* 把左边的按钮和右边的按钮推开 */
.modal-foot .grow { flex: 1; }
button.primary {
background: #1f6feb;
border-color: #1f6feb;
color: #fff;
}
button.primary:hover { background: #1a5fd0; }
/* 弹窗里的字段表:左边标签,右边值 */
.detail {
display: grid;
grid-template-columns: 88px 1fr;
gap: 8px 12px;
margin: 0;
align-items: baseline;
}
.detail dt { color: #555; }
.detail dd { margin: 0; word-break: break-all; }
.detail dd small { color: #999; margin-left: 6px; }
/* 创建弹窗里的单个字段,标签在输入框上面 */
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { color: #555; }
input[type="text"] {
padding: 5px 8px;
border: 1px solid #ccd1d6;
border-radius: 3px;
}
input.wide { width: 100%; }
select {
padding: 5px 8px;
border: 1px solid #ccd1d6;
border-radius: 3px;
background: #fff;
}
/* ── 错误页 ───────────────────────────── */
.error-box {
background: #fff;
+102 -9
View File
@@ -56,32 +56,125 @@
}
/* ── 删除前二次确认 ────────────────────────
标了 data-confirm-delete 的表单,提交前必须确认,
并明确告诉用户会删掉几条、不可恢复。 */
标了 data-confirm-delete 的元素,提交前必须确认,
并明确告诉用户会删掉几条、能不能恢复。
标在表单上就拦 submit,标在按钮上就拦 click——
一个表单配多个提交按钮时(PDD 页就是),只有删除那个要确认。
属性值可以写自定义文案,里面的 {n} 会替换成勾选条数;
不写就用默认那句。 */
var DEFAULT_CONFIRM = "将删除 {n} 条记录,不可恢复。确定继续?";
function setupConfirmDelete() {
document.querySelectorAll("[data-confirm-delete]").forEach(function (form) {
form.addEventListener("submit", function (e) {
document.querySelectorAll("[data-confirm-delete]").forEach(function (el) {
var isForm = el.tagName === "FORM";
el.addEventListener(isForm ? "submit" : "click", function (e) {
var n = checkedCount();
if (n === 0) {
e.preventDefault();
return;
}
if (!window.confirm("将删除 " + n + " 条记录,不可恢复。确定继续?")) {
var text = el.getAttribute("data-confirm-delete") || DEFAULT_CONFIRM;
if (!window.confirm(text.replace("{n}", n) + "\n\n确定继续?")) {
e.preventDefault();
}
});
});
}
/* ── 弹窗 ──────────────────────────────────
弹窗**内容由服务端渲染**,这里只负责显示、隐藏和把内容取回来。
不要在这里拼业务数据——价格格式、规格顺序都是业务规则,
散到前端就没人维护得住了。 */
function openModal(modal) {
modal.hidden = false;
var focusable = modal.querySelector("input, button");
if (focusable) focusable.focus();
}
function closeModal(modal) {
modal.hidden = true;
}
function setupModals() {
/* 打开:按钮上写 data-modal-open="弹窗的 id" */
document.querySelectorAll("[data-modal-open]").forEach(function (btn) {
btn.addEventListener("click", function () {
var modal = document.getElementById(btn.getAttribute("data-modal-open"));
if (modal) openModal(modal);
});
});
document.querySelectorAll(".modal-backdrop").forEach(function (modal) {
/* 关闭:叉、取消按钮,以及点弹窗外面的灰底 */
modal.addEventListener("click", function (e) {
if (e.target === modal || e.target.closest("[data-modal-close]")) {
closeModal(modal);
}
});
});
/* Esc 关掉最上面那个打开着的弹窗 */
document.addEventListener("keydown", function (e) {
if (e.key !== "Escape") return;
var open = document.querySelectorAll(".modal-backdrop:not([hidden])");
if (open.length > 0) closeModal(open[open.length - 1]);
});
}
/* ── 双击行打开详情弹窗 ────────────────────
行上写 data-detail-id,双击时去 /<模块>/detail?id=… 取回
已经渲染好的 HTML 片段,塞进壳子里显示。 */
function setupRowDetail() {
var modal = document.getElementById("detail-modal");
if (!modal) return;
var slot = modal.querySelector("[data-detail-slot]");
var base = modal.getAttribute("data-detail-url") || "";
document.querySelectorAll("tr[data-detail-id]").forEach(function (row) {
row.addEventListener("dblclick", function (e) {
/* 双击到勾选框或链接上时不要弹窗,那是另一个动作 */
if (e.target.closest("input, a, button")) return;
slot.innerHTML = "<div class='modal-body'>正在加载…</div>";
openModal(modal);
fetch(base + "?id=" + encodeURIComponent(row.getAttribute("data-detail-id")), {
headers: { "X-Requested-With": "fetch" }
})
.then(function (resp) {
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.text();
})
.then(function (html) {
slot.innerHTML = html;
})
.catch(function (err) {
/* 出错也要说清楚,不能让弹窗一直停在"正在加载…" */
slot.innerHTML =
"<div class='modal-body'><p class='missing'>打不开详情:" +
err.message +
"。刷新页面后重试。</p></div>" +
"<div class='modal-foot'><button type='button' data-modal-close>关闭</button></div>";
});
});
});
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("table").forEach(setupCheckAll);
setupConfirmDelete();
setupModals();
setupRowDetail();
syncButtons();
});
/* TODO(骨架): 双击行打开弹窗。
蝦皮数据页 -> 编辑弹窗(填 PDD 链接 + 采集按钮)
/* TODO(骨架): 另外两个页面的弹窗,做法照 PDD 商品页抄:
蝦皮数据页 -> 编辑弹窗(填 PDD 链接)
顺运宝页 -> 规格匹配弹窗
实现时注意:弹窗内容由服务端渲染,这里只负责显示/隐藏,
不要在前端拼业务数据。 */
页面里放一个 id="detail-modal" 的壳子、行上写 data-detail-id,
服务端出一个返回片段的 /<模块>/detail,这里就不用再加代码了。 */
})();
+1
View File
@@ -11,6 +11,7 @@
<nav class="nav">
<span class="nav-brand">采集采购管理端</span>
<a href="/shopee" class="{{if eq .Active "shopee"}}active{{end}}">蝦皮数据</a>
<a href="/pdd" class="{{if eq .Active "pdd"}}active{{end}}">PDD 商品</a>
<a href="/syb" class="{{if eq .Active "syb"}}active{{end}}">顺运宝数据</a>
<a href="/tasks" class="{{if eq .Active "tasks"}}active{{end}}">采购任务</a>
<a href="/clients" class="{{if eq .Active "clients"}}active{{end}}">客户端列表</a>
+92
View File
@@ -0,0 +1,92 @@
{{define "pdd/edit_modal"}}
{{/* 双击一行时弹出的内容。
这是一个**片段**,不是整页——外面的弹窗壳子在 pdd/list.html 里。
规格表由服务端渲染是有意的:维度顺序、价格格式、price_cent 为 null
的处理都是业务规则,放到前端去拼就没人维护得住了。 */}}
{{with .D}}
<div class="modal-head">
<h2>PDD 商品 {{.GoodsID}}</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
{{/* 「重新采集」自己一个表单,只提交这一个商品。
HTML 不允许表单套表单,所以它写在保存表单外面,
按钮靠 form="pdd-recollect-form" 挂过来。 */}}
<form id="pdd-recollect-form" method="post" action="/pdd/collect" hidden>
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="ids" value="{{.GoodsID}}">
</form>
<form method="post" action="/pdd/save">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="id" value="{{.ID}}">
<div class="modal-body">
<dl class="detail">
<dt>商品 ID</dt><dd>{{.GoodsID}}<small>(只读,由链接解析得出,不能改)</small></dd>
<dt><label for="detail-url">PDD 链接</label></dt>
<dd><input id="detail-url" type="text" name="url" value="{{.URL}}"
autocomplete="off" class="wide"></dd>
<dt>标题</dt><dd>{{.Title}}<small>(只读,采集后自动回填)</small></dd>
<dt>采集状态</dt><dd>{{.StatusText}}</dd>
<dt>失败原因</dt><dd>{{.CollectMsg}}</dd>
<dt>采集时间</dt><dd>{{.CollectedAt}}</dd>
{{if .ArtifactRef}}
{{/* 诊断产物只报位置、不上传文件:告诉操作员去哪台机器的哪个目录捞截图 */}}
<dt>诊断产物</dt><dd><code>{{.ArtifactRef}}</code></dd>
{{end}}
</dl>
<h3>规格{{if .Collected}}({{len .SKUs}} 个){{end}}</h3>
{{if .SkusError}}
<p class="missing">{{.SkusError}}</p>
{{else if not .Collected}}
<p class="hint">
尚未采集。关掉这个弹窗,勾选这一行再点「创建采集任务」,
等客户端领走执行完,规格和价格就会出现在这里。
</p>
{{else if not .SKUs}}
<p class="missing">
采集完成但一个规格都没有。这通常说明商品已下架,或者采集时没点开规格面板——
建议重新采集一次。
</p>
{{else}}
<div class="table-wrap">
<table>
<thead>
<tr>
{{/* 列顺序按采集结果里的 dimensions 定,不是 Go map 的随机顺序 */}}
{{range .DimensionNames}}<th>{{.}}</th>{{end}}
<th>价格</th>
<th>有货</th>
</tr>
</thead>
<tbody>
{{range .SKUs}}
<tr>
{{range .Options}}<td>{{.}}</td>{{end}}
{{/* 价格底层存的是整数分。采不到价格显示「未采到」,
绝不显示 ¥0.00——那会让人以为是白菜价 */}}
<td>{{.PriceText}}</td>
<td>{{.Available}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
<div class="modal-foot">
<button type="submit" form="pdd-recollect-form">重新采集</button>
<span class="grow"></span>
<button type="button" data-modal-close>取消</button>
<button type="submit" class="primary">保存</button>
</div>
</form>
{{end}}
{{end}}
+142
View File
@@ -0,0 +1,142 @@
{{define "pdd/list"}}
{{template "header" .}}
{{/* 三段式布局的第一段:顶部工具条。
和另外四个页面保持一致,见 docs/admin/05-ui-specification.md §2。 */}}
<div class="toolbar">
<button type="button" data-modal-open="create-modal">创建</button>
{{/* 「创建采集任务」和「删除」用的是**同一个表单**,靠按钮上的
formaction 决定提交到哪个地址。这样表格里的勾选框只需要挂一份,
不用为每个操作各复制一套(复制出来的两套迟早会不同步)。 */}}
<form id="pdd-form" method="post" action="/pdd/collect" hidden></form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="pdd-form">
{{/* 把当前筛选一起带过去,操作完还停在原来的筛选上 */}}
<input type="hidden" name="status" value="{{.StatusFilter}}" form="pdd-form">
<input type="hidden" name="q" value="{{.Keyword}}" form="pdd-form">
{{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队,
真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}}
<button type="submit" form="pdd-form" formaction="/pdd/collect"
data-need-checked>创建采集任务</button>
<form class="inline grow" method="get" action="/pdd">
<label for="status">采集状态</label>
<select id="status" name="status">
{{range .StatusOptions}}
<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>
{{end}}
</select>
<label for="q">商品 ID/链接</label>
<input id="q" type="text" name="q" value="{{.Keyword}}" placeholder="商品 ID 或链接的一部分">
<button type="submit">搜索</button>
</form>
<button type="submit" form="pdd-form" formaction="/pdd/delete" class="danger"
data-need-checked
data-confirm-delete="将删除 {n} 条记录。删除后重新创建同一链接可恢复,但采集结果会清空。"
>删除</button>
</div>
{{/* 第二段:表格。双击一行打开弹窗,见 static/js/app.js */}}
<div class="table-wrap" id="pddProductPage">
<table>
<thead>
<tr>
<th class="col-check"><input type="checkbox" data-check-all></th>
<th>商品 ID</th>
<th>标题</th>
<th>PDD 链接</th>
<th>采集状态</th>
{{/* 采到 1 个和采到 20 个差别很大:只采到 1 个通常是没点开规格面板,
说明这次采集有问题。这一列就是为了让它一眼能看出来 */}}
<th>规格数</th>
<th>采集时间</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr data-detail-id="{{.ID}}" {{if .IsFailed}}class="row-warn"{{end}}>
<td class="col-check">
{{/* 勾选框的值是 goods_id:删除和建采集任务都按它定位。
form 属性把它挂到工具条里那个表单上 */}}
<input type="checkbox" value="{{.GoodsID}}" name="ids" form="pdd-form"
aria-label="选择商品 {{.GoodsID}}">
</td>
<td>{{.GoodsID}}</td>
<td class="truncate" title="{{.Title}}">{{.Title}}</td>
<td class="truncate">
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer" title="{{.URL}}">{{.URL}}</a>
</td>
{{/* 状态是中文文字,不能只靠颜色区分 */}}
<td>{{.StatusText}}{{if .CollectMsg}}<br><small class="missing">{{.CollectMsg}}</small>{{end}}</td>
<td>{{.SkuCountText}}</td>
<td>{{.CollectedAt}}</td>
<td>{{.UpdatedAt}}</td>
</tr>
{{else}}
{{/* 空状态要分情况:从没建过 和 筛选没结果,下一步动作完全不同 */}}
<tr class="empty">
<td colspan="8">
{{if .IsFiltered}}
当前筛选条件下没有商品。<br>
<small>换个采集状态或清空搜索词再试。<a href="/pdd">查看全部</a></small>
{{else}}
还没有 PDD 商品。<br>
<small>点左上角「创建」,粘贴一个拼多多商品链接就行,其余信息采集后自动回填。</small>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="hint">
双击任意一行可以查看和编辑这个商品,包括采回来的规格和价格。
</p>
{{/* ── 创建弹窗 ─────────────────────────────────
内容是固定的(只有一个输入框),所以直接写在页面里藏着,
不像编辑弹窗那样要去服务端取。 */}}
<div class="modal-backdrop" id="create-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="create-modal-title">
<div class="modal-head">
<h2 id="create-modal-title">创建 PDD 商品</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<form method="post" action="/pdd/create">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="status" value="{{.StatusFilter}}">
<input type="hidden" name="q" value="{{.Keyword}}">
<div class="modal-body">
<div class="field">
<label for="create-url">PDD 链接</label>
<input id="create-url" type="text" name="url" required autocomplete="off"
placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=737116531267">
</div>
<p class="hint">
只需要链接,标题和规格由客户端采集后自动回填。<br>
链接里必须带 <code>goods_id=</code>,短链接不行——请在拼多多 App
的商品页点「分享」→「复制链接」。
</p>
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" class="primary">保存</button>
</div>
</form>
</div>
</div>
{{/* ── 编辑弹窗的壳子 ───────────────────────────
里面的内容双击行时由 /pdd/detail 返回,前端只负责放进来和显示。 */}}
<div class="modal-backdrop" id="detail-modal" data-detail-url="/pdd/detail" hidden>
<div class="modal" role="dialog" aria-modal="true" data-detail-slot>
<div class="modal-body">正在加载…</div>
</div>
</div>
{{template "footer" .}}
{{end}}
+8 -3
View File
@@ -60,9 +60,14 @@
</div>
{{/* TODO(骨架): 编辑弹窗 templates/shopee/edit_modal.html
这是 PDD 链接唯一的录入口,采集按钮也只出现在这里——
不要放到表格每一行,一个商品有 N 个 SKU 行,
放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。 */}}
这是从蝦皮商品出发录入 PDD 链接、发起采集的入口。
采集按钮**在本页只出现在这个弹窗里**——不要放到表格每一行,
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,
还会建出 N 个重复任务。
PDD 商品页(templates/pdd/)也能录入链接和发起采集,两处并存;
本页这个还是骨架(点了返回 501)。合并与否由
「蝦皮↔PDD 关联入口」工单决定,不要在本页单独改。 */}}
{{template "footer" .}}
{{end}}
+85 -16
View File
@@ -30,19 +30,22 @@ Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行
## 3. 完整业务链路
这条链路是理解全部四个模块的关键,先看懂它:
这条链路是理解全部五个模块的关键,先看懂它:
```text
蝦皮报表 ──导入──→ 商品表 + SKU 表
│
编辑弹窗:人工填 PDD 链接
│ 点【采集】
│
↓
创建采集任务 → 分配 Client → 被领取
PDD 商品页 ──────→ PDD 商品表
│ 点【创建采集任务】
↓
创建采集任务(不指定 Client)→ 谁领到算谁的
↓
Client 采回 PDD 的颜色尺码和价格
↓
存入 商品表.pdd_data
存入 PDD 商品表.skus_json
│
顺运宝货运单 ─同步─→ 货运单表
│ 双击行
@@ -62,18 +65,24 @@ Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行
## 4. 产品范围
顶级导航固定四个模块,顺序不变:
顶级导航固定五个模块,顺序不变:
| # | 模块 | 职责 |
|---|---|---|
| 1 | 蝦皮数据 | 商品档案、PDD 链接、发起采集 |
| 2 | 顺运宝数据 | 货运单、规格匹配、生成采购任务 |
| 3 | 采购任务 | 执行进度跟踪 |
| 4 | 客户端列表 | 客户端注册与状态 |
| 2 | PDD 商品 | PDD 商品档案、发起采集、查看采回来的规格价格 |
| 3 | 顺运宝数据 | 货运单、规格匹配、生成采购任务 |
| 4 | 采购任务 | 执行进度跟踪 |
| 5 | 客户端列表 | 客户端注册与状态 |
四个模块**统一使用三段式页面布局**:顶部工具条 / 中间带勾选的表格 / 底部状态条。
五个模块**统一使用三段式页面布局**:顶部工具条 / 中间带勾选的表格 / 底部状态条。
详见 [05 界面规范](05-ui-specification.md)。
PDD 商品之所以单独一个模块,是因为它在数据上就是**独立实体**
(自己的表、自己的采集状态,可以被多个蝦皮商品共用),
见 [03 数据模型 §4](03-data-model.md)。挂在蝦皮模块下面的话,
同一个 PDD 商品被两个蝦皮商品引用时就说不清该显示在谁名下。
### 4.1 蝦皮数据模块
**顶部工具条:** 导入按钮、商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。
@@ -90,13 +99,18 @@ Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行
| 采集状态 | 见 §6 |
| 更新时间 | |
**双击行打开编辑弹窗**,这是 PDD 链接唯一的录入口:
**双击行打开编辑弹窗**,这是**从蝦皮商品出发**填 PDD 链接、发起采集的入口:
- 可编辑:PDD 链接、颜色、尺码、建议;
- 弹窗内提供【采集】按钮,紧挨 PDD 链接输入框;
- `[必须]` 采集按钮**只出现在弹窗里**,不要放在每个表格行——
- `[必须]` 采集按钮**在本页只出现在弹窗里**,不要放在每个表格行——
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
`[注意]` PDD 商品模块(§4.2)也能录入链接和发起采集,两处并存。
本页的入口目前**还是骨架**(点了返回 501),
两者要不要合并、以哪个为准,属于「蝦皮↔PDD 关联入口」的范围,
由那张工单统一决定,不要在本模块单独改。
**其他要求:**
- `[必须]` 支持**手动新增**一条 SKU。报表只含有销售成绩的 SKU(样本里平均每商品仅 1.17 个),
@@ -106,7 +120,59 @@ Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行
**底部状态条:** 最近一次导入的时间、条数、失败行数。
### 4.2 顺运宝数据模块
### 4.2 PDD 商品模块
维护拼多多商品档案,并发起采集。这条链路**不依赖蝦皮和顺运宝的任何数据**,
可以单独跑通:建商品 → 建采集任务 → Client 领走执行 → 提交结果 → 页面显示已采集。
**顶部工具条:** 创建按钮、创建采集任务按钮、采集状态筛选、商品 ID/链接搜索框、搜索按钮、删除按钮。
**中间表格:**
| 列 | 说明 |
|---|---|
| 勾选 | 支持批量删除、批量建采集任务 |
| 商品 ID | 从链接解析出来的 `goods_id` |
| 标题 | 采集回来的,未采集时显示占位文案 |
| PDD 链接 | 截断显示,可点开 |
| 采集状态 | 见 §6.1,用中文文字,不能只靠颜色 |
| 规格数 | 从 `skus_json` 算 |
| 采集时间 | 未采集显示 `—` |
| 更新时间 | |
`[必须]` **规格数必须显示。** 采到 1 个和采到 20 个差别很大——
只采到 1 个通常意味着客户端没点开规格面板,是采集有问题。
不显示这一列的话,要点进每个商品才能发现。
**创建:** `[必须]` 只填 PDD 链接,其余字段全靠采集回填。
`[必须]` **链接必须能解析出 `goods_id`,解析不出来直接报错并且不写库**。
不接受短链接——`goods_id` 上有 UNIQUE 约束,防重全靠它,
拿不到就没法查重,同一个商品会存成好几行。
**双击行打开弹窗:** 查看商品信息,可编辑链接,并显示采回来的规格和价格。
- `[必须]` **规格和价格必须显示**,这是采集结果的全部价值所在——
不显示的话操作员没法确认"采得对不对、是不是我要的那个商品"。
- `[必须]` 维度顺序按 `skus_json` 里的 `dimensions`(Go 的 map 无序,必须靠它定顺序)。
- `[必须]` 价格显示成 `¥12.56`,底层存整数分;`price_cent` 为 `null` 时显示"未采到",
**不得显示成 ¥0.00**。
- `[必须]` 编辑链接时新链接必须还是**同一个商品**。换成别的商品要新建一条:
这一行上挂着采集结果和 SKU 映射,`goods_id` 一换那些数据就全指到错的商品上了。
**创建采集任务:**
- `[必须]` **不指定客户端**,谁领到就在领取时标记谁。采集是纯读取操作,
哪台机器跑都一样,指定了反而会在那台机器关着的时候干等。
- `[必须]` 按 `goods_id` 去重;`collecting` 状态的跳过,并在结果里说明跳过了几个。
- `[必须]` 按钮文案用「**创建采集任务**」,不要用「采集」。它做的是建一个任务,
不是立刻去采——真正的采集要等 Client 来领、去手机上跑,可能几秒也可能几分钟。
**删除:** 软删除。二次确认要写明"重新创建同一链接可恢复,**但采集结果会清空**"。
不硬删是因为 SKU 映射指向它,硬删会把人工攒了很久的匹配成果一起带走。
**底部状态条:** 各采集状态的条数统计。
### 4.3 顺运宝数据模块
**顶部工具条:** 同步按钮、订单号搜索框、搜索按钮、创建采购任务按钮、删除按钮。
@@ -143,7 +209,7 @@ Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行
**底部状态条:** 最近同步时间、待匹配条数。
### 4.3 采购任务模块
### 4.4 采购任务模块
**顶部工具条:** 订单号搜索框、搜索按钮、删除按钮。
@@ -164,7 +230,7 @@ Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行
**底部状态条:** 各状态的任务条数统计。
### 4.4 客户端列表模块
### 4.5 客户端列表模块
**顶部工具条:** 客户端名称搜索框、搜索按钮、删除按钮。
@@ -261,7 +327,7 @@ Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行
MVP 包含:
- 四模块页面框架和统一的三段式布局;
- 五模块页面框架和统一的三段式布局;
- SQLite 建库与迁移;
- 蝦皮 Excel 导入(upsert)、搜索、批量删除、手动新增、编辑弹窗;
- PDD 链接录入与发起采集;
@@ -308,7 +374,10 @@ MVP 之后:
**已定案:**
- PDD 链接**人工填写**,入口在蝦皮数据模块的编辑弹窗,采集按钮也在那里。
- PDD 链接**人工填写**,不自动抓取。
- PDD 商品是**独立模块**,创建商品和创建采集任务都在那里;
蝦皮数据模块的编辑弹窗负责的是「这个蝦皮商品对应哪个 PDD 商品」。
- **采集任务不指定客户端**,谁领到算谁的;采购任务仍可分配,也允许留空。
- 任务**分配给指定客户端**,Client 只领分给自己的。
- **不加心跳接口**;设置页显式登记,领取接口保留兼容登记,在线状态由最近活动时间派生。
- SKU 映射**独立成表且可复用**,同一蝦皮 SKU 只人工匹配一次。
+174 -15
View File
@@ -8,18 +8,18 @@
## 1. 设计目标
- 四个模块**长得一样**,写完第一个,其余三个照抄结构改字段;
- 五个模块**长得一样**,写完第一个,其余四个照抄结构改字段;
- 操作员一眼看出"这条数据卡在哪一步、下一步该点什么";
- 不引入前端框架和构建流程;
- 破坏性操作(删除、导入)必须有确认,不能手滑。
## 2. 统一的三段式布局
`[必须]` 四个模块全部用这个结构,**不要给某个模块搞特殊**:
`[必须]` 五个模块全部用这个结构,**不要给某个模块搞特殊**:
```text
┌────────────────────────────────────────────────────────────┐
│ [导航] 蝦皮数据 │ 顺运宝数据 │ 采购任务 │ 客户端列表 │
│ [导航] 蝦皮数据 │ PDD 商品 │ 顺运宝数据 │ 采购任务 │ 客户端 │
├────────────────────────────────────────────────────────────┤
│ 顶部工具条:[操作按钮…] [搜索框] [搜索] [删除] │
├────────────────────────────────────────────────────────────┤
@@ -33,7 +33,7 @@
```
`[必须]` 三段做成 `templates/partials/` 里的公共片段复用,
**不要每个页面复制一份**——改一次样式要改四处,必然改漏。
**不要每个页面复制一份**——改一次样式要改五处,必然改漏。
`[必须]` 页面在 1366×768 上可用。列多了让表格**横向滚动**,不要压缩列宽把字挤成两行。
@@ -42,7 +42,9 @@
- `[必须]` 第一列是勾选框,表头有全选。
- `[必须]` 行的身份用**业务主键**(商品規格ID、货运单ID、任务编号、序列号),
**不得用行号**——排序和筛选一变行号就错位。
- `[必须]` 批量删除要二次确认,弹窗里写清"**将删除 N 条,不可恢复**"。
- `[必须]` 批量删除要二次确认,弹窗里写清**删几条、能不能恢复**。
硬删写"将删除 N 条,不可恢复";软删除的(如 PDD 商品)要说明恢复方式和会丢什么,
见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。
- `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。
- `[建议]` 默认按 `更新时间 DESC` 排序。
- `[建议]` 一页 50 条,超过分页。搜索走数据库,不要一次查出来在内存里过滤。
@@ -81,7 +83,12 @@
### 4.3 编辑弹窗(双击行打开)
这是 **PDD 链接唯一的录入口**,也是采集的唯一发起点。
这是**从蝦皮商品出发**填 PDD 链接、发起采集的入口。
> `[注意]` PDD 商品页([§5](#5-pdd-商品页))也能录入链接和发起采集,
> 两处并存。本页的入口目前**还是骨架**(点了返回 501),
> 两者要不要合并、以哪个为准,属于「蝦皮↔PDD 关联入口」的范围,
> 由那张工单统一决定,不要在本页单独改。
```text
┌─────────────────────────────────────────┐
@@ -103,7 +110,7 @@
`[必须]` 几条硬规则:
- **采集按钮只在这里出现**,不要放到表格每一行。一个商品有 N 个 SKU 行,
- **本页的采集按钮只在这个弹窗里出现**,不要放到表格每一行。一个商品有 N 个 SKU 行,
放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
- **规格原文只读且永远显示**。操作员靠它判断颜色尺码该怎么填。
- PDD 链接为空时**采集按钮置灰**,旁边提示"请先填写 PDD 链接"。
@@ -119,17 +126,169 @@
新增的行 `is_manual = 1`,`[必须]` 后续导入**不得删除**它们。
## 5. 顺运宝数据页
## 5. PDD 商品页
页面 `objectName` 为 `pddProductPage`,路由 `/pdd`。
这个页面**不依赖蝦皮和顺运宝的任何数据**,可以单独跑通整条闭环:
建商品 → 建采集任务 → Client 领走执行 → 提交结果 → 刷新看到"已采集"和规格数。
### 5.1 工具条
```text
[创建] [创建采集任务] 采集状态[全部▾] 商品ID/链接[______] [搜索] [删除]
```
- **创建** → 弹窗,只填 PDD 链接。
- **创建采集任务** → 勾选多行后可用。
- **采集状态筛选** → 全部 / 未采集 / 采集中 / 已采集 / 采集失败。
`[必须]` 这个筛选是**刚需**,不是锦上添花:这个页面的主要用途是维护
(找出失败的重采、找出还没采的),只按 ID 搜的话要翻页去找。
- **删除** → 勾选多行后可用。
`[必须]` 「创建采集任务」和「删除」共用**同一个表单**(靠按钮上的 `formaction`
决定提交到哪个地址),表格里的勾选框只挂一份。
为每个操作各复制一套勾选框的话,两套迟早会不同步。
### 5.2 表格列
| 列 | 显示规则 |
|---|---|
| 勾选 | 值是 `goods_id`,支持批量删除、批量建采集任务 |
| 商品 ID | `goods_id` |
| 标题 | 采集回来的;未采集时显示"(未采集,采集后自动回填)" |
| PDD 链接 | 截断显示,可点开(`target="_blank"` 要带 `rel="noopener noreferrer"`) |
| 采集状态 | 中文文字,**不能只靠颜色**;失败时在下面补一行原因 |
| 规格数 | 从 `skus_json` 算 |
| 采集时间 | 本地时区;未采集显示 `—` |
| 更新时间 | 本地时区 |
`[必须]` **规格数这一列不能省。** 采到 1 个和采到 20 个差别很大——
只采到 1 个通常意味着客户端没点开规格面板,是采集有问题。
`[必须]` **未采集显示 `—`,采到 0 个要如实显示 `0`。** 这两种是不同的情况:
前者是还没采,后者是采集出了问题(商品下架、页面改版、解析器没认出来),
都显示成 `—` 就看不出区别了。
`[建议]` 规格数用 SQLite 的 `json_array_length(skus_json, '$.skus')` 直接算,
不要把整个 JSON 读进 Go 再数。`[必须]` 外面要包一层 `json_valid`——
`skus_json` 万一存进了坏数据,`json_array_length` 会让**整条查询报错**,页面直接打不开。
`[必须]` 空状态分两种文案:从没创建过 → 引导去点「创建」;筛选无结果 → 给"查看全部"的入口。
### 5.3 创建弹窗
`[必须]` **只填 PDD 链接**,其余字段全靠采集回填。
`[必须]` **链接必须能解析出 `goods_id`,解析不出来直接报错并且不写库。**
不接受短链接(`p.pinduoduo.com/xxxx`)——`goods_id` 上有 UNIQUE 约束,
防重全靠它,拿不到就没法查重,同一个商品会存成好几行。
`[必须]` 报错要说清**下一步怎么办**,例如"请在拼多多 App 的商品页点
「分享」→「复制链接」,拿到带 `goods_id=` 的完整链接"。光说"链接无效"操作员不知道改什么。
### 5.4 编辑弹窗(双击行打开)
```text
┌─────────────────────────────────────────────┐
│ PDD 商品 737116531267 │
├─────────────────────────────────────────────┤
│ 商品 ID 737116531267 (只读) │
│ PDD 链接 [___________________](可编辑) │
│ 标题 西装外套三件套 (只读) │
│ 采集状态 已采集 │
│ 失败原因 — │
│ 采集时间 2026-08-07 15:20 │
├─────────────────────────────────────────────┤
│ 规格(3 个) │
│ 颜色分类 尺码 价格 有货 │
│ 黑色 M ¥12.56 是 │
│ 白色 M ¥12.56 否 │
│ 红色 L 未采到 是 │
├─────────────────────────────────────────────┤
│ [重新采集] [取消] [保存] │
└─────────────────────────────────────────────┘
```
`[必须]` **规格和价格必须显示。** 这是采集结果的全部价值所在——
不显示的话操作员没法确认"采得对不对、是不是我要的那个商品"。
`[必须]` 维度列的顺序按 `skus_json` 里的 `dimensions` 排,表头用 `name`。
Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新页面列的先后都可能变。
`[必须]` 价格显示成 `¥12.56`,底层存的是**整数分**。
`price_cent` 为 `null` 时显示"未采到",**不得显示成 ¥0.00**——
0 元和采不到价格是两回事,而这个数要参与价格保护比对(会花钱)。
`[必须]` 三种"没有规格"要分开显示,处理方式不一样:
| 情况 | 显示 |
|---|---|
| 还没采过 | "尚未采集",并说明怎么发起采集 |
| 采完了但一个规格都没有 | 提示可能已下架或没点开规格面板,建议重采 |
| `skus_json` 解析不了 | 明说数据坏了需要重新采集,**不要装作"没有规格"** |
`[必须]` 编辑链接时,新链接必须还是**同一个商品**,否则拒绝并提示改用「创建」。
这一行上挂着采集结果和 SKU 映射,`goods_id` 一换那些数据就全指到错的商品上了,
之后按它下单就是买错东西。
`[必须]` 弹窗内容由**服务端渲染**(`GET /pdd/detail?id=…` 返回 HTML 片段),
前端只负责取回来、放进壳子、显示隐藏。维度顺序、价格格式、`null` 的处理都是业务规则,
散到前端就没人维护得住了。
### 5.5 创建采集任务
`[必须]` **不指定客户端**(`assigned_client` 为 `NULL`,`status` 为 `pending`),
谁领到就在领取时标记谁。采集是纯读取操作,哪台机器跑都一样,
指定了反而会在那台机器关着的时候干等。
- 勾选多行 → 按 `goods_id` 去重 → 建任务;
- `collect_status` 已是 `collecting` 的**跳过**;
- 建成功后把状态置为 `collecting`;
- 任务的 `pdd_goods_url` 和 `pdd_goods_id` 必须填(Client 契约要求 `goods_url` 必填)。
`[必须]` 按钮文案用「**创建采集任务**」,不要用「采集」。它做的是建一个任务,
不是立刻去采——真正的采集要等 Client 来领、去手机上跑,可能几秒也可能几分钟。
点完页面上只有状态从"未采集"变成"采集中",文案不说清楚操作员会以为没生效。
`[必须]` 结果在状态条明确提示,**跳过了几个也要说**:
```text
已创建 3 个采集任务,等待客户端领取(跳过 1 个采集中或已删除的)
```
静默跳过的话,操作员会以为任务都建上了,等半天没动静也不知道为什么。
### 5.6 删除
软删除。`[必须]` 二次确认要写明能不能恢复、恢复后会丢什么:
> 将删除 3 条记录。删除后重新创建同一链接可恢复,**但采集结果会清空**。
(复活时清空旧采集结果是既定行为——记录被删过一次,旧数据不该再当有效的用。)
### 5.7 底部状态条
```text
共 24 条 · 已采集 18 · 未采集 4 · 采集中 1 · 采集失败 1
```
`[必须]` 统计的是**全部未删除记录**,不随筛选变化——状态条是全局概览。
有筛选时在前面加一句 `筛选出 N 条 /`,免得操作员把筛选后的行数当成全部行数,以为记录被删了。
刚做完写操作时,状态条**先显示操作结果**,再接统计。
## 6. 顺运宝数据页
### 6.1 工具条
```text
[同步] [创建采购任务] 订单号 [________] [搜索] [删除]
```
`[待定]` MVP 阶段**同步按钮只做占位**:点击提示"同步功能待接入",不发请求。
### 5.2 表格列
### 6.2 表格列
☐ / 货运单ID / 订单号 / 商品标题 / 蝦皮商品ID / 规格SKU / 数量 /
价格(台币)/ 图片 / **匹配状态** / 更新时间
@@ -138,7 +297,7 @@
- 匹配状态是**算出来的**(`sku_mappings` 里有没有记录),不是存的字段。
- 完整货运单 JSON 不作为列显示,在详情里看。
### 5.3 规格匹配弹窗(双击行打开)
### 6.3 规格匹配弹窗(双击行打开)
```text
┌──────────────────────────────────────────────────┐
@@ -167,7 +326,7 @@
并给一个跳转链接。
- 保存写的是 `sku_mappings`(可复用),**不是这一张订单的临时数据**。
### 5.4 创建采购任务
### 6.4 创建采购任务
勾选若干行 → 点按钮 → 逐条校验(见 [01 需求](01-requirements.md) §5)。
@@ -182,7 +341,7 @@
`[必须]` 创建前弹出确认框,让操作员选**分配给哪个客户端**,并确认价格上限。
价格上限默认从 `pdd_data` 带出,可改,**不允许为空**。
## 6. 采购任务页
## 7. 采购任务页
工具条:`订单号 [____] [搜索] [删除]`
@@ -197,7 +356,7 @@
底部状态条显示各状态的条数统计。
## 7. 客户端列表页
## 8. 客户端列表页
工具条:`名称 [____] [搜索] [删除]`
@@ -210,7 +369,7 @@
- `[建议]` 界面上说明一句:"客户端执行长任务期间可能显示为离线,属正常现象。"
因为没有心跳,这是已知且接受的取舍(见 [04](04-client-api.md) §3)。
## 8. 反馈方式
## 9. 反馈方式
| 场景 | 怎么反馈 |
|---|---|
@@ -224,7 +383,7 @@
`[必须]` 报错要说清**哪一步失败、下一步做什么**,不要把 Go 的错误堆栈贴到页面上。
堆栈写日志。
## 9. 可访问性与细节
## 10. 可访问性与细节
- `[必须]` 表单控件有可见 `<label>`,占位符不能当标签用。
- `[必须]` 状态不能只靠颜色,必须有文字。