Files
cmautobuy/admin/repository/pdd.go
T
chengmaandClaude Opus 5 998c06a2bf feat: PDD 商品数据独立成表 (#16)
原来 pdd_data 是 shopee_products 上的一个 JSON 字段,两个蝦皮商品指向
同一个 PDD 链接时会各存一份、各采一次;collect_status 描述的是 PDD 商品的
状态,却挂在蝦皮商品上,两份可能不一致。

更要紧的是 PDD 商品变动频繁(A 下架就得换 B),而 sku_mappings 只按
shopee_sku_id 做键——换商品后旧映射还在,B 恰好有同名规格但完全是另一件货
时会静默买错,事后查不出来。

改动
- 新增 pdd_products 表:id 主键 + goods_id UNIQUE + 4 个状态值(去掉
  no_link,「未填链接」改由 shopee_products.pdd_goods_id 为空表达)+
  软删除可复活
- shopee_products 去掉 pdd_data / collect_status / collect_error /
  collected_at,pdd_goods_id 改为引用
- sku_mappings 主键改为 (shopee_sku_id, pdd_goods_id),新增 pdd_option_key。
  查映射永远带上当前 PDD 商品,换商品后天然查不到旧映射,不需要删数据;
  换回原商品时旧映射直接复用
- 新增 OptionKey():用 json.Marshal 实现(Go 序列化 map 按键名排序,
  天然规范化),不自己拼字符串——规格文字里可能含 = 或 ;。
  存映射和查 SKU 必须用同一个函数,各写一遍会静默算出不同结果
- 采集结果改落 pdd_products,新增两条校验:
  返回的 goods_id 与请求不符 → 整体回滚拒绝(422),不静默存下;
  skus 为空数组 → 置 failed 而非 collected,否则界面显示"已采集"
  但数据毫无用处

实施时超出工单但必要的三处
- TaskExists 重构为 GetTaskInfo:原函数只返回蝦皮 goods_id,
  而采集结果要按 PDD goods_id 落库,不改取不到正确的键
- 复活时一并清空旧采集结果(skus_json / collect_msg / collected_at),
  否则复活后会显示"已采集"但数据是删除前的
- 删除 repository/shopee.go:两个函数签名全变且已迁到 pdd.go,留着是死代码

已验证(Go 1.23.0)
- go vet / gofmt / go test 全过,55 个测试
- 端到端补验了工单未覆盖的 HTTP 层:goods_id 不符返回 422
  COLLECT_GOODS_MISMATCH 且整体回滚(skus_json 空、任务仍 claimed、
  幂等记录 0 条);skus 为空返回 200 但状态 failed

遗留
- MarkCollecting / SoftDeletePddProduct 暂无调用方,等界面工单接上
- artifact_ref 存 diagnostics 原始 JSON,未按 client-001:artifacts/... 规范化,
  因 Client 侧尚未定义 diagnostics 结构
- 界面未实现(工单明确排除),四个页面仍为骨架

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:27:39 +08:00

199 lines
6.9 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"
"cmautobuy/admin/model"
)
// 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() {
// 复活:清掉删除标记,状态回到待采集。
// 采集结果一并清空——记录被删过一次,旧数据不能再当成有效的用。
_, 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 = ?
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) {
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)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询 PDD 商品 %s 失败: %w", goodsID, 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
}
// SetCollectResult 保存采集回来的 PDD 商品数据。
//
// `[必须]` 按 **PDD 的 goods_id** 定位,不是蝦皮的。采集的对象是 PDD 商品。
//
// title 由调用方从采集结果里取出来传进来,用于人工核对"采的是不是要的那个商品"。
func SetCollectResult(q Execer, pddGoodsID, title, skusJSON string) error {
if pddGoodsID == "" {
return fmt.Errorf("pdd goods_id 不能为空")
}
now := model.NowISO()
res, err := q.Exec(`
UPDATE pdd_products
SET skus_json = ?, title = ?, collect_status = 'collected',
collect_msg = NULL, collected_at = ?, updated_at = ?
WHERE goods_id = ? AND deleted_at IS NULL`,
skusJSON, title, 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 的说明有任务在跑,再建一个就是重复采集,浪费一次。
// 返回 false 表示当前状态不允许,调用方应跳过并告诉操作员。
func MarkCollecting(q Execer, pddGoodsID string) (bool, error) {
now := model.NowISO()
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')`,
now, pddGoodsID)
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
}