Files
cmautobuy/admin/repository/pdd.go
T

431 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"database/sql"
"fmt"
"strings"
"time"
"cmautobuy/admin/model"
)
// pddColumns 是所有查询共用的列清单。
// 写成常量是为了让下面几个查询的列顺序和 scanPddProduct 永远对得上——
// 改列的时候只改这一处,改漏了会 Scan 到错的字段上,而且不报错。
const pddColumns = `id, goods_id, url, title, shop_name, skus_json,
collect_status, collect_msg, artifact_ref, collected_at,
deleted_at, created_at, updated_at`
// 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, shopName, skus, msg, artifact, collectedAt, deletedAt sql.NullString
dest := []any{
&p.ID, &p.GoodsID, &p.URL, &title, &shopName, &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.ShopName = shopName.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 链接、点保存时调它。三种情况:
//
// 没有这条记录 -> 新建,状态 pending(待采集)
// 有且未删除 -> 直接返回,不动它(保留已有的采集结果)
// 有但已软删除 -> **复活**:清空 deleted_at,状态置回 pending
//
// 复活是必要的:goods_id 上有 UNIQUE 约束,软删除的行还占着那个值,
// 不复活就会插入冲突,操作员会看到一个莫名其妙的错误。
func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error) {
if goodsID == "" {
return nil, fmt.Errorf("goods_id 不能为空")
}
if url == "" {
return nil, fmt.Errorf("url 不能为空")
}
existing, err := GetPddProductByGoodsID(q, goodsID)
if err != nil {
return nil, err
}
now := model.NowISO()
if existing == nil {
res, err := q.Exec(`
INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)`,
goodsID, url, now, now)
if err != nil {
return nil, fmt.Errorf("新建 PDD 商品 %s 失败: %w", goodsID, err)
}
id, _ := res.LastInsertId()
return &model.PddProduct{
ID: id, GoodsID: goodsID, URL: url,
CollectStatus: model.CollectPending,
CreatedAt: now, UpdatedAt: now,
}, nil
}
if existing.IsDeleted() {
// 复活:清掉删除标记,状态回到待采集。
// 采集结果一并清空——记录被删过一次,旧数据不能再当成有效的用。
//
// title 和 shop_name 也要清:它们是**采集回来的**(SetCollectResult 写的),
// 属于采集结果的一部分。留着的话状态显示"未采集"、标题却有值,
// 操作员会以为已经采过了。
_, err := q.Exec(`
UPDATE pdd_products
SET deleted_at = NULL, url = ?, collect_status = 'pending',
title = NULL, shop_name = NULL, skus_json = NULL, collect_msg = NULL,
artifact_ref = NULL, collected_at = NULL, updated_at = ?
WHERE goods_id = ?`,
url, now, goodsID)
if err != nil {
return nil, fmt.Errorf("复活 PDD 商品 %s 失败: %w", goodsID, err)
}
return GetPddProductByGoodsID(q, goodsID)
}
// 已存在且有效:链接可能写法不同(带不带参数),更新一下原文,
// 但**不碰采集结果和状态** —— 同一个商品不必因为换了个链接写法就重采。
if existing.URL != url {
if _, err := q.Exec(
`UPDATE pdd_products SET url = ?, updated_at = ? WHERE goods_id = ?`,
url, now, goodsID); err != nil {
return nil, fmt.Errorf("更新 PDD 商品 %s 链接失败: %w", goodsID, err)
}
existing.URL = url
}
return existing, nil
}
// GetPddProductByGoodsID 按 goods_id 查,**包括已软删除的**。
//
// 之所以连删除的也查出来,是因为 EnsurePddProduct 要靠它判断该不该复活。
// 给界面用的查询请用 ListPddProducts,那个会过滤掉已删除的。
func GetPddProductByGoodsID(q Execer, goodsID string) (*model.PddProduct, error) {
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
}
// 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`
// pddListFilter 统一生成 PDD 列表和总数查询的筛选条件。
// 两个查询必须共用这一处,避免新增筛选时只改列表、漏改页数。
func pddListFilter(keyword string, status model.CollectStatus) (string, []any) {
where := ` WHERE deleted_at IS NULL`
args := make([]any, 0, 3)
if keyword = strings.TrimSpace(keyword); keyword != "" {
pattern := "%" + escapeLike(keyword) + "%"
where += ` AND (goods_id LIKE ? ESCAPE '\' OR url LIKE ? ESCAPE '\')`
args = append(args, pattern, pattern)
}
if status != "" {
where += ` AND collect_status = ?`
args = append(args, string(status))
}
return where, args
}
// ListPddProducts 查一页列表和当前筛选下的总数。
// keyword 匹配 goods_id 或链接,status 为空表示不筛选。
//
// 只返回未删除的。软删除的记录还在库里(sku_mappings 指着它),
// 但界面上不该看见。
func ListPddProducts(q Execer, keyword string, status model.CollectStatus, limit, offset int) ([]PddProductRow, int, error) {
where, args := pddListFilter(keyword, status)
var total int
if err := q.QueryRow(`SELECT COUNT(*) FROM pdd_products`+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("统计 PDD 商品列表失败: %w", err)
}
sqlText := `SELECT ` + pddColumns + `,` + pddSkuCountExpr + `
FROM pdd_products` + where
// 刚动过的排前面,操作员点完"创建采集任务"一眼能看到结果
sqlText += ` ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`
listArgs := append(append([]any{}, args...), limit, offset)
rows, err := q.Query(sqlText, listArgs...)
if err != nil {
return nil, 0, 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, 0, fmt.Errorf("读取 PDD 商品列表失败: %w", err)
}
r.PddProduct = *p
list = append(list, r)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return list, total, nil
}
// 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 商品数据。
//
// `[必须]` 按 **PDD 的 goods_id** 定位,不是蝦皮的。采集的对象是 PDD 商品。
//
// title 和 shopName 由调用方从采集结果里取出来,用于人工核对商品来源。
// shopName 为空时保留数据库已有值:这次没采到不代表上次采到的店铺名失效。
func SetCollectResult(q Execer, pddGoodsID, title, shopName, skusJSON string) error {
if pddGoodsID == "" {
return fmt.Errorf("pdd goods_id 不能为空")
}
now := model.NowISO()
res, err := q.Exec(`
UPDATE pdd_products
SET skus_json = ?, title = ?,
shop_name = CASE WHEN TRIM(?) = '' THEN shop_name ELSE ? END,
collect_status = 'collected',
collect_msg = NULL, collected_at = ?, updated_at = ?
WHERE goods_id = ? AND deleted_at IS NULL`,
skusJSON, title, shopName, shopName, now, now, pddGoodsID)
if err != nil {
return fmt.Errorf("保存 PDD 商品 %s 的采集结果失败: %w", pddGoodsID, err)
}
// 影响 0 行说明这个商品不存在或已被删除。
// 不当错误处理——结果照样在 tasks.result_data 里留了痕,不会丢。
if n, _ := res.RowsAffected(); n == 0 {
return nil
}
return nil
}
// SetCollectFailed 标记采集失败,并记下原因和诊断产物位置。
//
// 错误信息要能在界面上看见,否则操作员不知道为什么采不到。
// artifactRef 可以为空;有值时形如 client-001:artifacts/PDD-0001/attempt-xxx/,
// 告诉操作员去哪台客户端的哪个目录捞截图和控件树。
func SetCollectFailed(q Execer, pddGoodsID, msg, artifactRef string) error {
if pddGoodsID == "" {
return fmt.Errorf("pdd goods_id 不能为空")
}
now := model.NowISO()
_, err := q.Exec(`
UPDATE pdd_products
SET collect_status = 'failed', collect_msg = ?, artifact_ref = ?,
updated_at = ?
WHERE goods_id = ? AND deleted_at IS NULL`,
msg, artifactRef, now, pddGoodsID)
if err != nil {
return fmt.Errorf("标记 PDD 商品 %s 采集失败出错: %w", pddGoodsID, err)
}
return nil
}
// MarkCollecting 把商品置为"采集中"。创建采集任务时调。
//
// 允许发起采集的状态:
// - pending / failed —— 没有任务在跑;
// - collecting **且已经超时**(`updated_at` 早于 `now - model.CollectStaleAfter`)
// —— 客户端离线、崩溃或任务被删都会让一个 collecting 卡住不动,
// 这些是常态不是异常,界面上必须有出口,见 #24。
//
// `[必须]` 超时判定在**读取的这一刻现算**(直接拼进这条 UPDATE 的 WHERE 里),
// 不是靠后台协程定期把超时的状态改回 pending。本项目已经在并发上栽过两次
// (PRAGMA 没作用到连接池、`BEGIN DEFERRED` 死锁),能不引入并发就不引入——
// 现算没有调度、没有窗口期,天然不会有"扫到一半客户端正好提交了结果"这种竞态。
//
// `[必须]` 用**字符串比较** `updated_at < ?`,不解析成 time.Time 再比。
// 这是安全的,但依赖 model.NowISO() 永远产出定宽 UTC 格式,见那里的注释。
//
// `[已知取舍]` 超时后允许重新建任务,但**老任务还留在队列里**,
// 客户端上线后可能把新旧两个任务都领走,同一个商品被采两次。
// 这是可以接受的:采集是只读操作,没有任何副作用,后一次的结果覆盖前一次,
// 数据仍然正确。**不要看到"可能采两次"就加锁或加租约去"修"它**
// ——租约和心跳是被明确移除过的设计,见 docs/client/00-glossary.md
// 「为什么没有租约和心跳」一节;这条取舍详见 docs/admin/03-data-model.md §4.1。
//
// 返回 false 表示当前状态不允许发起采集(真的在采集且没超时、已采集、
// 或商品不存在/已删除),调用方应跳过并把原因告诉操作员。
func MarkCollecting(q Execer, pddGoodsID string) (bool, error) {
now := time.Now()
nowISO := now.UTC().Format(model.TimeLayout)
staleBefore := now.Add(-model.CollectStaleAfter).UTC().Format(model.TimeLayout)
res, err := q.Exec(`
UPDATE pdd_products
SET collect_status = 'collecting', updated_at = ?
WHERE goods_id = ? AND deleted_at IS NULL
AND ( collect_status IN ('pending', 'failed')
OR (collect_status = 'collecting' AND updated_at < ?) )`,
nowISO, pddGoodsID, staleBefore)
if err != nil {
return false, fmt.Errorf("标记 PDD 商品 %s 采集中失败: %w", pddGoodsID, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n == 1, nil
}
// SoftDeletePddProduct 软删除。
//
// 不硬删是因为 sku_mappings 指向它,硬删会把人工攒了很久的匹配成果一起带走。
// 删除后界面上不再显示,但记录和映射都还在;
// 操作员重新填同一个链接时会被 EnsurePddProduct 复活。
func SoftDeletePddProduct(q Execer, pddGoodsID string) error {
now := model.NowISO()
_, err := q.Exec(
`UPDATE pdd_products SET deleted_at = ?, updated_at = ? WHERE goods_id = ?`,
now, now, pddGoodsID)
if err != nil {
return fmt.Errorf("删除 PDD 商品 %s 失败: %w", pddGoodsID, err)
}
return nil
}