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>
This commit is contained in:
@@ -248,6 +248,12 @@ func (h *Handler) handleSubmit(c *gin.Context, submit submitFunc, event string)
|
|||||||
apiError(c, http.StatusForbidden, "TASK_NOT_ASSIGNED",
|
apiError(c, http.StatusForbidden, "TASK_NOT_ASSIGNED",
|
||||||
"该任务从未分配给这个客户端", false)
|
"该任务从未分配给这个客户端", false)
|
||||||
|
|
||||||
|
case errors.Is(err, service.ErrCollectMismatch):
|
||||||
|
// 采回来的商品不是请求的那个(链接跳转、采错商品)。
|
||||||
|
// 内容本身是合法 JSON,只是业务上对不上,所以是 422 不是 400。
|
||||||
|
apiError(c, http.StatusUnprocessableEntity, "COLLECT_GOODS_MISMATCH",
|
||||||
|
err.Error(), false)
|
||||||
|
|
||||||
case errors.Is(err, service.ErrIdempotencyConflict):
|
case errors.Is(err, service.ErrIdempotencyConflict):
|
||||||
apiError(c, http.StatusConflict, "IDEMPOTENCY_CONFLICT",
|
apiError(c, http.StatusConflict, "IDEMPOTENCY_CONFLICT",
|
||||||
"相同幂等键提交了不同内容。内容变了应该用新的 attempt_id 生成新键", false)
|
"相同幂等键提交了不同内容。内容变了应该用新的 attempt_id 生成新键", false)
|
||||||
|
|||||||
+59
-13
@@ -32,33 +32,63 @@ func ParseISO(s string) (time.Time, bool) {
|
|||||||
|
|
||||||
// ---------- 蝦皮 ----------
|
// ---------- 蝦皮 ----------
|
||||||
|
|
||||||
// CollectStatus 是某个蝦皮商品对应的 PDD 商品数据采到没有。
|
// CollectStatus 是一个 **PDD 商品**的采集进度。
|
||||||
// 取值见 docs/admin/01-requirements.md §6.1。
|
//
|
||||||
|
// 注意它挂在 PddProduct 上,不在 ShopeeProduct 上——被采集的是 PDD 商品。
|
||||||
|
// 两个蝦皮商品指向同一个 PDD 链接时,状态只有一份,不会各记一份还对不上。
|
||||||
|
//
|
||||||
|
// 这里**没有"未填链接"**:pdd_products 里有这一行,就说明链接已经填了。
|
||||||
|
// "未填链接"是蝦皮侧的状态(ShopeeProduct.PddGoodsID 为空)。
|
||||||
|
// 界面上仍然显示 5 种,只是数据来源不同,见 docs/admin/01-requirements.md §6.1。
|
||||||
type CollectStatus string
|
type CollectStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CollectNoLink CollectStatus = "no_link" // 未填 PDD 链接
|
|
||||||
CollectPending CollectStatus = "pending" // 已填链接,未发起采集
|
CollectPending CollectStatus = "pending" // 已填链接,未发起采集
|
||||||
CollectCollecting CollectStatus = "collecting" // 采集中,不允许再建任务
|
CollectCollecting CollectStatus = "collecting" // 采集中,不允许再建任务
|
||||||
CollectCollected CollectStatus = "collected" // 已采集
|
CollectCollected CollectStatus = "collected" // 已采集
|
||||||
CollectFailed CollectStatus = "failed" // 采集失败,可重新采集
|
CollectFailed CollectStatus = "failed" // 采集失败,可重新采集
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PddProduct 是一个拼多多商品。
|
||||||
|
//
|
||||||
|
// 它和蝦皮商品是**两个独立的东西**:蝦皮链接相对稳定,
|
||||||
|
// 而 PDD 商品下架换代很频繁——A 买不到了就得换 B。
|
||||||
|
// 所以两者的对应关系放在 ShopeeProduct.PddGoodsID 上,随时可改。
|
||||||
|
type PddProduct struct {
|
||||||
|
ID int64
|
||||||
|
GoodsID string // 从 URL 解析,UNIQUE,防重靠它
|
||||||
|
URL string // 操作员填的链接原文
|
||||||
|
Title string // 采集回来,人工核对用
|
||||||
|
SkusJSON string // schema_version + dimensions + skus
|
||||||
|
|
||||||
|
CollectStatus CollectStatus
|
||||||
|
CollectMsg string // 失败原因
|
||||||
|
ArtifactRef string // 诊断产物位置,例如 client-001:artifacts/PDD-0001/xxx/
|
||||||
|
CollectedAt string
|
||||||
|
|
||||||
|
DeletedAt string // 软删除;非空表示已删除,但记录还在
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDeleted 判断这条记录是不是已经被软删除了。
|
||||||
|
func (p PddProduct) IsDeleted() bool {
|
||||||
|
return p.DeletedAt != ""
|
||||||
|
}
|
||||||
|
|
||||||
// ShopeeProduct 是蝦皮商品(商品级)。
|
// ShopeeProduct 是蝦皮商品(商品级)。
|
||||||
//
|
//
|
||||||
// PddGoodsURL 和 PddData 是我们自己维护的,蝦皮报表里没有,
|
// PddGoodsURL 和 PddGoodsID 是我们自己维护的,蝦皮报表里没有,
|
||||||
// Excel 导入时**绝不能覆盖**,见 docs/admin/03-data-model.md §3.3。
|
// Excel 导入时**绝不能覆盖**,见 docs/admin/03-data-model.md §3.3。
|
||||||
|
//
|
||||||
|
// 采集结果和采集状态**不在这里**——它们属于 PDD 商品,见 PddProduct。
|
||||||
type ShopeeProduct struct {
|
type ShopeeProduct struct {
|
||||||
GoodsID string
|
GoodsID string
|
||||||
Title string
|
Title string
|
||||||
ShopeeStatus string
|
ShopeeStatus string
|
||||||
MainSKUCode string
|
MainSKUCode string
|
||||||
PddGoodsURL string
|
PddGoodsURL string
|
||||||
PddGoodsID string
|
PddGoodsID string // 指向 PddProduct.GoodsID,为空表示还没填链接
|
||||||
PddData string // 采集回来的 PDD 商品 JSON
|
|
||||||
CollectStatus CollectStatus
|
|
||||||
CollectError string
|
|
||||||
CollectedAt string
|
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
@@ -104,12 +134,28 @@ type SybOrder struct {
|
|||||||
|
|
||||||
// SKUMapping 是「蝦皮的这个规格 = 拼多多的那个规格」。
|
// SKUMapping 是「蝦皮的这个规格 = 拼多多的那个规格」。
|
||||||
//
|
//
|
||||||
// 匹配一次以后可以复用:下次遇到同一个蝦皮 SKU 自动带出,
|
// # 为什么主键要带上 PddGoodsID
|
||||||
// 操作员只需确认。这是省人工的关键。
|
//
|
||||||
|
// PDD 商品下架换代很频繁。假设蝦皮商品 X 原来对应 PDD 商品 A,
|
||||||
|
// 操作员匹配好了"黑色/M → 黑色/M码";后来 A 下架,换成了 B。
|
||||||
|
//
|
||||||
|
// 如果映射只按 ShopeeSKUID 存,那条旧映射还在,但它描述的是 **A 的规格**:
|
||||||
|
//
|
||||||
|
// - 运气好:B 没有"黑色/M码",建任务时找不到会报错,还算安全;
|
||||||
|
// - 运气坏:B 恰好也有"黑色/M码",但完全是另一件衣服
|
||||||
|
// —— **静默买错,而且事后查不出来**。
|
||||||
|
//
|
||||||
|
// 把 PddGoodsID 放进主键后,查映射永远带上"当前对应的 PDD 商品"这个条件,
|
||||||
|
// 换成 B 就自然查不到 A 的映射,界面显示"待匹配"。
|
||||||
|
// 不需要在换商品时记得去删旧数据——靠查询条件天然隔离,忘不了。
|
||||||
|
//
|
||||||
|
// 附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。
|
||||||
type SKUMapping struct {
|
type SKUMapping struct {
|
||||||
ShopeeSKUID string
|
ShopeeSKUID string
|
||||||
GoodsID string
|
PddGoodsID string // 这条映射属于哪个 PDD 商品
|
||||||
PddOptions string // JSON,如 {"color":"黑色","size":"M码"}
|
PddOptionKey string // 规范化后的组合键,见 service.OptionKey
|
||||||
|
PddOptions string // 原始 options 对象 JSON,显示用
|
||||||
|
GoodsID string // 蝦皮商品 ID,方便按商品批量查
|
||||||
MappedAt string
|
MappedAt string
|
||||||
MappedBy string
|
MappedBy string
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-12
@@ -109,22 +109,16 @@ var migrations = [][]string{
|
|||||||
shopee_status TEXT,
|
shopee_status TEXT,
|
||||||
main_sku_code TEXT,
|
main_sku_code TEXT,
|
||||||
|
|
||||||
-- 下面三个是人工维护的,报表里没有,导入时绝不能覆盖
|
-- 人工维护的,蝦皮报表里没有这两列,Excel 导入时绝不能覆盖。
|
||||||
|
-- pdd_goods_id 指向 pdd_products.goods_id,表示"这个蝦皮商品
|
||||||
|
-- 当前对应哪个 PDD 商品"。PDD 商品下架换新时改这里。
|
||||||
pdd_goods_url TEXT,
|
pdd_goods_url TEXT,
|
||||||
pdd_goods_id TEXT,
|
pdd_goods_id TEXT,
|
||||||
pdd_data TEXT,
|
|
||||||
|
|
||||||
collect_status TEXT NOT NULL DEFAULT 'no_link'
|
|
||||||
CHECK (collect_status IN (
|
|
||||||
'no_link', 'pending', 'collecting',
|
|
||||||
'collected', 'failed'
|
|
||||||
)),
|
|
||||||
collect_error TEXT,
|
|
||||||
collected_at TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);`,
|
);`,
|
||||||
`CREATE INDEX idx_shopee_products_status ON shopee_products(collect_status);`,
|
`CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);`,
|
||||||
`CREATE TABLE shopee_skus (
|
`CREATE TABLE shopee_skus (
|
||||||
sku_id TEXT PRIMARY KEY,
|
sku_id TEXT PRIMARY KEY,
|
||||||
goods_id TEXT NOT NULL,
|
goods_id TEXT NOT NULL,
|
||||||
@@ -141,6 +135,36 @@ var migrations = [][]string{
|
|||||||
);`,
|
);`,
|
||||||
`CREATE INDEX idx_shopee_skus_goods ON shopee_skus(goods_id);`,
|
`CREATE INDEX idx_shopee_skus_goods ON shopee_skus(goods_id);`,
|
||||||
`CREATE INDEX idx_shopee_skus_parse ON shopee_skus(parse_ok);`,
|
`CREATE INDEX idx_shopee_skus_parse ON shopee_skus(parse_ok);`,
|
||||||
|
`CREATE TABLE pdd_products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
||||||
|
-- 从 PDD 链接里解析出来。它不是主键,所以**必须加 UNIQUE**:
|
||||||
|
-- 少了这条约束,同一个 PDD 商品会被存成好几行,
|
||||||
|
-- 采好几遍,映射还说不清指向哪一行。
|
||||||
|
goods_id TEXT NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
url TEXT NOT NULL, -- 操作员填的链接原文
|
||||||
|
title TEXT, -- 采集回来,人工核对"是不是我要的那个商品"
|
||||||
|
skus_json TEXT, -- schema_version + dimensions + skus
|
||||||
|
|
||||||
|
-- 注意这里**没有 no_link**:这张表里有这一行,就说明链接已经填了。
|
||||||
|
-- "未填链接"是蝦皮侧的状态(shopee_products.pdd_goods_id 为空)。
|
||||||
|
collect_status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (collect_status IN (
|
||||||
|
'pending', 'collecting', 'collected', 'failed'
|
||||||
|
)),
|
||||||
|
collect_msg TEXT, -- 失败原因,要能定位问题
|
||||||
|
artifact_ref TEXT, -- 诊断产物在哪台机器哪个目录
|
||||||
|
collected_at TEXT,
|
||||||
|
|
||||||
|
-- 软删除。不硬删是因为 sku_mappings 指向它,
|
||||||
|
-- 硬删会把人工攒了很久的匹配成果一起带走。
|
||||||
|
deleted_at TEXT,
|
||||||
|
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);`,
|
||||||
|
`CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);`,
|
||||||
`CREATE TABLE syb_orders (
|
`CREATE TABLE syb_orders (
|
||||||
syb_id TEXT PRIMARY KEY,
|
syb_id TEXT PRIMARY KEY,
|
||||||
order_no TEXT NOT NULL,
|
order_no TEXT NOT NULL,
|
||||||
@@ -158,14 +182,18 @@ var migrations = [][]string{
|
|||||||
`CREATE INDEX idx_syb_orders_goods ON syb_orders(shopee_goods_id);`,
|
`CREATE INDEX idx_syb_orders_goods ON syb_orders(shopee_goods_id);`,
|
||||||
`CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);`,
|
`CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);`,
|
||||||
`CREATE TABLE sku_mappings (
|
`CREATE TABLE sku_mappings (
|
||||||
shopee_sku_id TEXT PRIMARY KEY,
|
shopee_sku_id TEXT NOT NULL,
|
||||||
goods_id TEXT NOT NULL,
|
pdd_goods_id TEXT NOT NULL,
|
||||||
|
pdd_option_key TEXT NOT NULL,
|
||||||
pdd_options TEXT NOT NULL,
|
pdd_options TEXT NOT NULL,
|
||||||
|
goods_id TEXT NOT NULL,
|
||||||
mapped_at TEXT NOT NULL,
|
mapped_at TEXT NOT NULL,
|
||||||
mapped_by TEXT,
|
mapped_by TEXT,
|
||||||
|
PRIMARY KEY (shopee_sku_id, pdd_goods_id),
|
||||||
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
|
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
|
||||||
);`,
|
);`,
|
||||||
`CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);`,
|
`CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);`,
|
||||||
|
`CREATE INDEX idx_sku_mappings_pdd ON sku_mappings(pdd_goods_id);`,
|
||||||
`CREATE TABLE tasks (
|
`CREATE TABLE tasks (
|
||||||
task_id TEXT PRIMARY KEY,
|
task_id TEXT PRIMARY KEY,
|
||||||
task_type TEXT NOT NULL CHECK (task_type IN ('collect', 'purchase')),
|
task_type TEXT NOT NULL CHECK (task_type IN ('collect', 'purchase')),
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"cmautobuy/admin/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetCollectResult 把采集回来的 PDD 商品数据存到商品级。
|
|
||||||
//
|
|
||||||
// `[必须]` pdd_data 存在**商品级**(shopee_products),不是订单级——
|
|
||||||
// 一个 PDD 商品采一次,所有相关订单共用这份结果。
|
|
||||||
// 见 docs/admin/03-data-model.md §3.1。
|
|
||||||
func SetCollectResult(q Execer, goodsID, pddData string) error {
|
|
||||||
if goodsID == "" {
|
|
||||||
return nil // 任务没关联蝦皮商品(比如手工造的测试任务),跳过
|
|
||||||
}
|
|
||||||
now := model.NowISO()
|
|
||||||
_, err := q.Exec(`
|
|
||||||
UPDATE shopee_products
|
|
||||||
SET pdd_data = ?, collect_status = 'collected',
|
|
||||||
collect_error = NULL, collected_at = ?, updated_at = ?
|
|
||||||
WHERE goods_id = ?`,
|
|
||||||
pddData, now, now, goodsID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("保存商品 %s 的采集结果失败: %w", goodsID, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCollectFailed 标记采集失败,并记下原因。
|
|
||||||
//
|
|
||||||
// 错误信息要能在界面上看见,否则操作员不知道为什么采不到。
|
|
||||||
func SetCollectFailed(q Execer, goodsID, errMsg string) error {
|
|
||||||
if goodsID == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
now := model.NowISO()
|
|
||||||
_, err := q.Exec(`
|
|
||||||
UPDATE shopee_products
|
|
||||||
SET collect_status = 'failed', collect_error = ?, updated_at = ?
|
|
||||||
WHERE goods_id = ?`,
|
|
||||||
errMsg, now, goodsID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("标记商品 %s 采集失败出错: %w", goodsID, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
+24
-11
@@ -165,19 +165,32 @@ func HasEverClaimed(q Execer, taskID, clientID string) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskExists 判断任务在不在,并返回它的类型和关联的蝦皮商品编号。
|
// TaskInfo 是提交结果时需要知道的任务基本信息。
|
||||||
func TaskExists(q Execer, taskID string) (exists bool, taskType model.TaskType, goodsID string, err error) {
|
type TaskInfo struct {
|
||||||
var gid sql.NullString
|
TaskType model.TaskType
|
||||||
e := q.QueryRow(
|
// GoodsID 是关联的**蝦皮**商品编号。
|
||||||
`SELECT task_type, goods_id FROM tasks WHERE task_id = ?`,
|
GoodsID string
|
||||||
taskID).Scan(&taskType, &gid)
|
// PddGoodsID 是要采集/购买的**拼多多**商品编号。
|
||||||
if e == sql.ErrNoRows {
|
// 采集结果落到 pdd_products 时用的是它,不是 GoodsID —— 被采集的是 PDD 商品。
|
||||||
return false, "", "", nil
|
PddGoodsID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskInfo 查任务的类型和关联商品。任务不存在时返回 (nil, nil)。
|
||||||
|
func GetTaskInfo(q Execer, taskID string) (*TaskInfo, error) {
|
||||||
|
var info TaskInfo
|
||||||
|
var gid, pddGID sql.NullString
|
||||||
|
err := q.QueryRow(
|
||||||
|
`SELECT task_type, goods_id, pdd_goods_id FROM tasks WHERE task_id = ?`,
|
||||||
|
taskID).Scan(&info.TaskType, &gid, &pddGID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
if e != nil {
|
if err != nil {
|
||||||
return false, "", "", fmt.Errorf("查询任务 %s 失败: %w", taskID, e)
|
return nil, fmt.Errorf("查询任务 %s 失败: %w", taskID, err)
|
||||||
}
|
}
|
||||||
return true, taskType, gid.String, nil
|
info.GoodsID = gid.String
|
||||||
|
info.PddGoodsID = pddGID.String
|
||||||
|
return &info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkTaskSucceeded 记录成功结果。
|
// MarkTaskSucceeded 记录成功结果。
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OptionKey 把一个规格组合变成**稳定的字符串标识**。
|
||||||
|
//
|
||||||
|
// {"size":"M","color":"黑色"} -> {"color":"黑色","size":"M"}
|
||||||
|
// {"color":"黑色","size":"M"} -> {"color":"黑色","size":"M"} (同一个结果)
|
||||||
|
//
|
||||||
|
// # 为什么需要它
|
||||||
|
//
|
||||||
|
// 采集回来的 PDD 数据里**没有 SKU 编号**,一个规格只能靠它的
|
||||||
|
// options 组合来认。而 JSON 对象的键是无序的:
|
||||||
|
//
|
||||||
|
// 存映射时:{"color":"黑色","size":"M"}
|
||||||
|
// 采回来时:{"size":"M","color":"黑色"}
|
||||||
|
//
|
||||||
|
// 这两个是同一个规格,但字符串不相等。直接比原始 JSON 会匹配不上,
|
||||||
|
// 而且是**静默失效**——不报错,只是查不到,最后表现为"明明匹配过却说待匹配"。
|
||||||
|
//
|
||||||
|
// # 为什么只能有这一处实现
|
||||||
|
//
|
||||||
|
// 存映射用它算 key,查规格也用它算 key,两边必须**逐字节一致**。
|
||||||
|
// 如果 Client 那边也算一份、或者别处再写一个"差不多"的版本,
|
||||||
|
// 只要有一点点不同(空格、转义、键序),映射就会静默对不上。
|
||||||
|
// 所以:**Client 只上报 options 对象,key 一律由 Admin 这一个函数算。**
|
||||||
|
//
|
||||||
|
// 实现上直接用 json.Marshal —— Go 序列化 map 时会**按键名排序**,
|
||||||
|
// 这正好就是我们要的规范化,不用自己拼字符串(自己拼容易漏掉
|
||||||
|
// 值里含分隔符、含引号之类的边界情况)。
|
||||||
|
func OptionKey(options map[string]string) (string, error) {
|
||||||
|
if len(options) == 0 {
|
||||||
|
return "", fmt.Errorf("规格组合不能为空")
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(options)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("规格组合无法序列化: %w", err)
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionKeyFromJSON 从原始 options JSON 算出 key。
|
||||||
|
//
|
||||||
|
// 用于处理采集回来的数据:先解析成 map,再走 OptionKey,
|
||||||
|
// 这样键序、空格、缩进的差异都会被抹平。
|
||||||
|
func OptionKeyFromJSON(raw string) (string, error) {
|
||||||
|
var options map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &options); err != nil {
|
||||||
|
return "", fmt.Errorf("options 不是合法的字符串对象: %w", err)
|
||||||
|
}
|
||||||
|
return OptionKey(options)
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── OptionKey ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// 键序不同的同一个规格必须算出同一个 key。
|
||||||
|
// 这是整个匹配机制的地基:采集回来的 JSON 键序和存映射时不一定一样,
|
||||||
|
// 算不出同一个 key,匹配就会**静默失效**——不报错,只是查不到。
|
||||||
|
func TestOptionKey_键序不影响结果(t *testing.T) {
|
||||||
|
a, err := OptionKey(map[string]string{"color": "黑色", "size": "M"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := OptionKey(map[string]string{"size": "M", "color": "黑色"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a != b {
|
||||||
|
t.Errorf("同一个规格算出了不同的 key:\n%s\n%s", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptionKey_不同规格算出不同key(t *testing.T) {
|
||||||
|
a, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"})
|
||||||
|
b, _ := OptionKey(map[string]string{"color": "黑色", "size": "L"})
|
||||||
|
if a == b {
|
||||||
|
t.Error("不同规格不该算出同一个 key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 维度数量不固定——可能有第三个维度(款式),key 要能容纳。
|
||||||
|
func TestOptionKey_支持任意多个维度(t *testing.T) {
|
||||||
|
k, err := OptionKey(map[string]string{"color": "黑色", "size": "M", "style": "加绒"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("三个维度应该合法: %v", err)
|
||||||
|
}
|
||||||
|
if k == "" {
|
||||||
|
t.Error("key 不该为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptionKey_空组合报错(t *testing.T) {
|
||||||
|
if _, err := OptionKey(map[string]string{}); err == nil {
|
||||||
|
t.Error("空规格组合应该报错,不能算出一个看起来正常的 key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 JSON 原文算 key,和从 map 算的结果必须一致——
|
||||||
|
// 存映射走一条路径、查规格走另一条路径,两边算不出同一个值就白搭。
|
||||||
|
func TestOptionKeyFromJSON_与map版本一致(t *testing.T) {
|
||||||
|
fromMap, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"})
|
||||||
|
fromJSON, err := OptionKeyFromJSON(`{"size":"M","color":"黑色"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fromMap != fromJSON {
|
||||||
|
t.Errorf("两条路径算出不同的 key:\nmap %s\njson %s", fromMap, fromJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDD 商品的建立与复活 ───────────────────────────────
|
||||||
|
|
||||||
|
func TestEnsurePddProduct_新建(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
|
||||||
|
p, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("新建失败: %v", err)
|
||||||
|
}
|
||||||
|
if p.GoodsID != "PDD-1" {
|
||||||
|
t.Errorf("goods_id 不对: %s", p.GoodsID)
|
||||||
|
}
|
||||||
|
if p.CollectStatus != model.CollectPending {
|
||||||
|
t.Errorf("新建的商品应为待采集,实际 %s", p.CollectStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一个商品重复保存链接,不能产生第二行——
|
||||||
|
// goods_id 上有 UNIQUE,重复了会直接插入失败。
|
||||||
|
func TestEnsurePddProduct_重复保存不产生重复行(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1"); err != nil {
|
||||||
|
t.Fatalf("第 %d 次保存失败: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id='PDD-1'`).Scan(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("应只有 1 行,实际 %d 行", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已采集的商品再次保存链接,不能把采集结果冲掉。
|
||||||
|
func TestEnsurePddProduct_不覆盖已有采集结果(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
|
||||||
|
if err := repository.SetCollectResult(db, "PDD-1", "商品甲", `{"skus":[1]}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1?from=share"); err != nil {
|
||||||
|
t.Fatalf("再次保存失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, _ := repository.GetPddProductByGoodsID(db, "PDD-1")
|
||||||
|
if p.CollectStatus != model.CollectCollected {
|
||||||
|
t.Errorf("采集状态被冲掉了,实际 %s", p.CollectStatus)
|
||||||
|
}
|
||||||
|
if p.SkusJSON == "" {
|
||||||
|
t.Error("采集结果被清空了")
|
||||||
|
}
|
||||||
|
if p.URL != "https://x/1?from=share" {
|
||||||
|
t.Errorf("链接原文应更新,实际 %s", p.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软删除后再填同一个链接要能复活。
|
||||||
|
// 不复活的话,goods_id 上的 UNIQUE 会让插入失败,
|
||||||
|
// 操作员会看到一个莫名其妙的错误。
|
||||||
|
func TestEnsurePddProduct_软删除后可复活(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
|
||||||
|
repository.SetCollectResult(db, "PDD-1", "商品甲", `{"skus":[1]}`)
|
||||||
|
|
||||||
|
if err := repository.SoftDeletePddProduct(db, "PDD-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, _ := repository.GetPddProductByGoodsID(db, "PDD-1")
|
||||||
|
if !p.IsDeleted() {
|
||||||
|
t.Fatal("前置条件不对,应已软删除")
|
||||||
|
}
|
||||||
|
|
||||||
|
revived, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("复活失败: %v", err)
|
||||||
|
}
|
||||||
|
if revived.IsDeleted() {
|
||||||
|
t.Error("复活后不该还带删除标记")
|
||||||
|
}
|
||||||
|
if revived.CollectStatus != model.CollectPending {
|
||||||
|
t.Errorf("复活后应回到待采集,实际 %s", revived.CollectStatus)
|
||||||
|
}
|
||||||
|
// 删过一次的记录,旧采集结果不能再当有效的用
|
||||||
|
if revived.SkusJSON != "" {
|
||||||
|
t.Error("复活后应清空旧采集结果,避免用到删除前的过期数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id='PDD-1'`).Scan(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("复活不该新增行,实际 %d 行", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 采集中不允许重复发起 ───────────────────────────────
|
||||||
|
|
||||||
|
func TestMarkCollecting_采集中不允许再次发起(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
|
||||||
|
|
||||||
|
ok, err := repository.MarkCollecting(db, "PDD-1")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("第一次应成功: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err = repository.MarkCollecting(db, "PDD-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Error("已在采集中,不该允许再建一个采集任务")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkCollecting_失败后可重新采集(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
repository.EnsurePddProduct(db, "PDD-1", "https://x/1")
|
||||||
|
repository.SetCollectFailed(db, "PDD-1", "页面超时", "")
|
||||||
|
|
||||||
|
ok, err := repository.MarkCollecting(db, "PDD-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Error("采集失败的商品应该允许重新采集")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 映射隔离:本工单的安全核心 ─────────────────────────
|
||||||
|
|
||||||
|
// insertShopeeSKU 造蝦皮商品和它的一个规格。
|
||||||
|
// sku_mappings 有外键指向 shopee_skus,映射相关的测试都要先造它。
|
||||||
|
func insertShopeeSKU(t *testing.T, db *sql.DB, goodsID, skuID string) {
|
||||||
|
t.Helper()
|
||||||
|
now := model.NowISO()
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO shopee_products (goods_id, title, created_at, updated_at)
|
||||||
|
VALUES (?, '测试商品', ?, ?)
|
||||||
|
ON CONFLICT(goods_id) DO NOTHING`, goodsID, now, now); err != nil {
|
||||||
|
t.Fatalf("插入蝦皮商品失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, created_at, updated_at)
|
||||||
|
VALUES (?, ?, '黑色,M', ?, ?)`, skuID, goodsID, now, now); err != nil {
|
||||||
|
t.Fatalf("插入蝦皮规格失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertMapping 造一条规格映射。
|
||||||
|
func insertMapping(t *testing.T, db *sql.DB, shopeeSKU, pddGoodsID string, options map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := OptionKey(options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO sku_mappings (shopee_sku_id, pdd_goods_id, pdd_option_key,
|
||||||
|
pdd_options, goods_id, mapped_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'SHOPEE-1', ?)`,
|
||||||
|
shopeeSKU, pddGoodsID, key, key, model.NowISO()); err != nil {
|
||||||
|
t.Fatalf("插入映射失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMapping 模拟业务查询:**永远带上当前的 PDD 商品**。
|
||||||
|
// 这个条件就是隔离机制本身,少了它旧映射就会被误用。
|
||||||
|
func findMapping(t *testing.T, db *sql.DB, shopeeSKU, currentPddGoodsID string) (string, bool) {
|
||||||
|
t.Helper()
|
||||||
|
var key string
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT pdd_option_key FROM sku_mappings
|
||||||
|
WHERE shopee_sku_id = ? AND pdd_goods_id = ?`,
|
||||||
|
shopeeSKU, currentPddGoodsID).Scan(&key)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 换了 PDD 商品之后,旧映射必须查不到。
|
||||||
|
//
|
||||||
|
// 不隔离的话:B 恰好也有"黑色/M码"这个组合,但完全是另一件衣服,
|
||||||
|
// 就会**静默买错东西**,而且事后查不出来。
|
||||||
|
func TestSKUMapping_换PDD商品后查不到旧映射(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
|
||||||
|
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
|
||||||
|
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
|
||||||
|
|
||||||
|
// 蝦皮 SKU 原来匹配到 PDD 商品 A 的"黑色/M码"
|
||||||
|
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
|
||||||
|
|
||||||
|
if _, ok := findMapping(t, db, "SKU-1", "PDD-A"); !ok {
|
||||||
|
t.Fatal("前置条件不对:A 的映射应该查得到")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 下架,换成 B。查 B 的映射应该查不到 —— 界面显示"待匹配"
|
||||||
|
if key, ok := findMapping(t, db, "SKU-1", "PDD-B"); ok {
|
||||||
|
t.Errorf("换成 B 之后不该查到映射,却拿到了 %q —— "+
|
||||||
|
"这正是会导致买错商品的情况", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 换回原来的 PDD 商品,旧映射要还能用。
|
||||||
|
// 这是把 pdd_goods_id 放进主键(而不是换商品时删掉旧映射)换来的好处:
|
||||||
|
// PDD 商品补货换回去时,之前的人工匹配成果直接复用。
|
||||||
|
func TestSKUMapping_换回原商品旧映射仍可用(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
|
||||||
|
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
|
||||||
|
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
|
||||||
|
|
||||||
|
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
|
||||||
|
// 换到 B,也匹配一次
|
||||||
|
insertMapping(t, db, "SKU-1", "PDD-B", map[string]string{"color": "黑色", "size": "M"})
|
||||||
|
|
||||||
|
// 换回 A
|
||||||
|
key, ok := findMapping(t, db, "SKU-1", "PDD-A")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("换回 A 之后,A 的旧映射应该还在")
|
||||||
|
}
|
||||||
|
expect, _ := OptionKey(map[string]string{"color": "黑色", "size": "M码"})
|
||||||
|
if key != expect {
|
||||||
|
t.Errorf("拿到的是别的商品的映射:期望 %s,实际 %s", expect, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一个蝦皮 SKU 可以同时有多个 PDD 商品的映射,互不干扰。
|
||||||
|
func TestSKUMapping_同一SKU可对多个PDD商品各有映射(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
|
||||||
|
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
|
||||||
|
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
|
||||||
|
|
||||||
|
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
|
||||||
|
insertMapping(t, db, "SKU-1", "PDD-B", map[string]string{"color": "黑", "size": "均码"})
|
||||||
|
|
||||||
|
var n int
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM sku_mappings WHERE shopee_sku_id='SKU-1'`).Scan(&n)
|
||||||
|
if n != 2 {
|
||||||
|
t.Errorf("应能同时存在 2 条映射,实际 %d 条", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
kA, _ := findMapping(t, db, "SKU-1", "PDD-A")
|
||||||
|
kB, _ := findMapping(t, db, "SKU-1", "PDD-B")
|
||||||
|
if kA == kB {
|
||||||
|
t.Error("两个 PDD 商品的映射不该相同")
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
-12
@@ -20,8 +20,47 @@ var (
|
|||||||
ErrNeverClaimed = errors.New("该任务从未分配给这个客户端")
|
ErrNeverClaimed = errors.New("该任务从未分配给这个客户端")
|
||||||
// ErrIdempotencyConflict 同一个键提交了不同内容 -> 409
|
// ErrIdempotencyConflict 同一个键提交了不同内容 -> 409
|
||||||
ErrIdempotencyConflict = repository.ErrIdempotencyConflict
|
ErrIdempotencyConflict = repository.ErrIdempotencyConflict
|
||||||
|
// ErrCollectMismatch 客户端采回来的商品不是请求的那个 -> 422
|
||||||
|
ErrCollectMismatch = errors.New("采集结果与请求的商品不一致")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// collectedData 是采集结果里 Admin 关心的那几项。
|
||||||
|
//
|
||||||
|
// 客户端提交的完整内容会原样存进 tasks.result_data,这里只挑业务要用的解析出来。
|
||||||
|
// 用不到的字段不写进结构体,多余的 JSON 字段会被忽略,不影响向前兼容。
|
||||||
|
type collectedData struct {
|
||||||
|
GoodsID string `json:"goods_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
SKUs []struct {
|
||||||
|
Options map[string]string `json:"options"`
|
||||||
|
PriceCent *int64 `json:"price_cent"` // 指针:采不到价格时是 null,不是 0
|
||||||
|
Available bool `json:"available"`
|
||||||
|
RawPrice string `json:"raw_price"`
|
||||||
|
} `json:"skus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCollected 解析采集结果。解析不了直接报错,不要当成"采到 0 个规格"——
|
||||||
|
// 那是两回事:一个是客户端发的东西有问题,一个是商品确实没规格。
|
||||||
|
func parseCollected(raw string) (*collectedData, error) {
|
||||||
|
var c collectedData
|
||||||
|
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||||
|
return nil, fmt.Errorf("采集结果不是合法结构: %w", err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// artifactRef 从失败上报里取诊断产物位置。
|
||||||
|
//
|
||||||
|
// 按已定案的 Artifact 策略(docs/client/04 §10 待确认 #3),
|
||||||
|
// 客户端**只报本地引用、不上传文件**。所以这里存的是一个位置字符串,
|
||||||
|
// 告诉操作员去哪台客户端的哪个目录捞截图,Admin 自己显示不了图。
|
||||||
|
func artifactRef(req FailureRequest) string {
|
||||||
|
if len(req.Diagnostics) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(req.Diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
// ResultRequest 是客户端提交成功结果的请求体。
|
// ResultRequest 是客户端提交成功结果的请求体。
|
||||||
type ResultRequest struct {
|
type ResultRequest struct {
|
||||||
TaskVersion int `json:"task_version"`
|
TaskVersion int `json:"task_version"`
|
||||||
@@ -70,7 +109,7 @@ func SubmitResult(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return submitInTx(db, taskID, clientID, idemKey, rawBody,
|
return submitInTx(db, taskID, clientID, idemKey, rawBody,
|
||||||
func(tx *sql.Tx, taskType model.TaskType, goodsID string) (map[string]any, error) {
|
func(tx *sql.Tx, info *repository.TaskInfo) (map[string]any, error) {
|
||||||
pddData := string(req.PddData)
|
pddData := string(req.PddData)
|
||||||
if strings.TrimSpace(pddData) == "" {
|
if strings.TrimSpace(pddData) == "" {
|
||||||
pddData = "{}"
|
pddData = "{}"
|
||||||
@@ -79,9 +118,35 @@ func SubmitResult(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte)
|
|||||||
if err := repository.MarkTaskSucceeded(tx, taskID, pddData); err != nil {
|
if err := repository.MarkTaskSucceeded(tx, taskID, pddData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 采集任务的结果还要落到商品级,供后续规格匹配使用
|
|
||||||
if taskType == model.TaskCollect {
|
// 采集任务的结果还要落到 PDD 商品上,供后续规格匹配使用。
|
||||||
if err := repository.SetCollectResult(tx, goodsID, pddData); err != nil {
|
// 注意用的是 PddGoodsID —— 被采集的是 PDD 商品,不是蝦皮商品。
|
||||||
|
if info.TaskType == model.TaskCollect {
|
||||||
|
collected, err := parseCollected(pddData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验一:客户端采回来的商品必须就是我们要的那个。
|
||||||
|
// 链接跳转、采错商品时如果不拦,会把 B 的规格价格
|
||||||
|
// 存到 A 名下,后面按它下单就是买错东西。
|
||||||
|
if info.PddGoodsID != "" && collected.GoodsID != "" &&
|
||||||
|
collected.GoodsID != info.PddGoodsID {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: 请求采集的是商品 %s,客户端返回的却是 %s",
|
||||||
|
ErrCollectMismatch, info.PddGoodsID, collected.GoodsID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验二:一个规格都没采到,对业务毫无用处
|
||||||
|
// (商品下架、页面改版、解析器没认出来)。
|
||||||
|
// 这种情况必须算失败,不能显示"已采集"让操作员空欢喜。
|
||||||
|
if len(collected.SKUs) == 0 {
|
||||||
|
if err := repository.SetCollectFailed(tx, info.PddGoodsID,
|
||||||
|
"未采集到任何规格,请检查商品是否已下架", ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if err := repository.SetCollectResult(
|
||||||
|
tx, info.PddGoodsID, collected.Title, pddData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,18 +174,19 @@ func SubmitFailure(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return submitInTx(db, taskID, clientID, idemKey, rawBody,
|
return submitInTx(db, taskID, clientID, idemKey, rawBody,
|
||||||
func(tx *sql.Tx, taskType model.TaskType, goodsID string) (map[string]any, error) {
|
func(tx *sql.Tx, info *repository.TaskInfo) (map[string]any, error) {
|
||||||
if err := repository.MarkTaskFailure(
|
if err := repository.MarkTaskFailure(
|
||||||
tx, taskID, newStatus, req.Error.Code, req.Error.Message); err != nil {
|
tx, taskID, newStatus, req.Error.Code, req.Error.Message); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 采集失败要让操作员在蝦皮数据页看得见原因
|
// 采集失败要让操作员看得见原因和诊断产物位置
|
||||||
if taskType == model.TaskCollect {
|
if info.TaskType == model.TaskCollect {
|
||||||
msg := req.Error.Message
|
msg := req.Error.Message
|
||||||
if req.Error.Code != "" {
|
if req.Error.Code != "" {
|
||||||
msg = req.Error.Code + ": " + msg
|
msg = req.Error.Code + ": " + msg
|
||||||
}
|
}
|
||||||
if err := repository.SetCollectFailed(tx, goodsID, msg); err != nil {
|
if err := repository.SetCollectFailed(
|
||||||
|
tx, info.PddGoodsID, msg, artifactRef(req)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +223,7 @@ func mapFailureStatus(reported string) (model.TaskStatus, bool) {
|
|||||||
// 业务写入部分由 apply 提供,它拿到的 tx 和外层是同一个。
|
// 业务写入部分由 apply 提供,它拿到的 tx 和外层是同一个。
|
||||||
func submitInTx(
|
func submitInTx(
|
||||||
db *sql.DB, taskID, clientID, idemKey string, rawBody []byte,
|
db *sql.DB, taskID, clientID, idemKey string, rawBody []byte,
|
||||||
apply func(tx *sql.Tx, taskType model.TaskType, goodsID string) (map[string]any, error),
|
apply func(tx *sql.Tx, info *repository.TaskInfo) (map[string]any, error),
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
|
||||||
hash := repository.HashRequest(rawBody)
|
hash := repository.HashRequest(rawBody)
|
||||||
@@ -176,11 +242,11 @@ func submitInTx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 任务得存在
|
// 2. 任务得存在
|
||||||
exists, taskType, goodsID, err := repository.TaskExists(tx, taskID)
|
info, err := repository.GetTaskInfo(tx, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if !exists {
|
if info == nil {
|
||||||
return "", ErrTaskNotFound
|
return "", ErrTaskNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +261,7 @@ func submitInTx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 业务写入
|
// 4. 业务写入
|
||||||
resp, err := apply(tx, taskType, goodsID)
|
resp, err := apply(tx, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-36
@@ -240,43 +240,119 @@ func TestSubmitResult_任务不存在(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 采集任务的结果要落到商品级 ─────────────────────────
|
// ── 采集任务的结果要落到 PDD 商品 ──────────────────────
|
||||||
|
|
||||||
func TestSubmitResult_采集结果写入商品级(t *testing.T) {
|
// insertPddProduct 造一个待采集的 PDD 商品。
|
||||||
db := newTestDB(t)
|
func insertPddProduct(t *testing.T, db *sql.DB, pddGoodsID string) {
|
||||||
|
t.Helper()
|
||||||
now := model.NowISO()
|
now := model.NowISO()
|
||||||
|
|
||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(`
|
||||||
INSERT INTO shopee_products (goods_id, title, pdd_goods_url,
|
INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at)
|
||||||
collect_status, created_at, updated_at)
|
VALUES (?, ?, 'collecting', ?, ?)`,
|
||||||
VALUES ('G-1','测试商品','https://x/1','collecting',?,?)`, now, now); err != nil {
|
pddGoodsID, "https://mobile.yangkeduo.com/goods.html?goods_id="+pddGoodsID,
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(`
|
|
||||||
INSERT INTO tasks (task_id, task_type, status, assigned_client, goods_id,
|
|
||||||
pdd_goods_url, created_at, updated_at)
|
|
||||||
VALUES ('TASK-C','collect','assigned','client-001','G-1','https://x/1',?,?)`,
|
|
||||||
now, now); err != nil {
|
now, now); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatalf("插入 PDD 商品失败: %v", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertCollectTask 造一个采集任务,关联指定的蝦皮商品和 PDD 商品。
|
||||||
|
func insertCollectTask(t *testing.T, db *sql.DB, taskID, client, shopeeGoodsID, pddGoodsID string) {
|
||||||
|
t.Helper()
|
||||||
|
now := model.NowISO()
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO tasks (task_id, task_type, status, assigned_client,
|
||||||
|
goods_id, pdd_goods_id, pdd_goods_url, created_at, updated_at)
|
||||||
|
VALUES (?, 'collect', 'assigned', ?, ?, ?, 'https://x/1', ?, ?)`,
|
||||||
|
taskID, client, shopeeGoodsID, pddGoodsID, now, now); err != nil {
|
||||||
|
t.Fatalf("插入采集任务失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pddProductState 读回 PDD 商品的采集状态、标题和结果。
|
||||||
|
func pddProductState(t *testing.T, db *sql.DB, goodsID string) (status, title, skus, msg string) {
|
||||||
|
t.Helper()
|
||||||
|
var ti, sk, ms sql.NullString
|
||||||
|
if err := db.QueryRow(`
|
||||||
|
SELECT collect_status, title, skus_json, collect_msg
|
||||||
|
FROM pdd_products WHERE goods_id = ?`, goodsID,
|
||||||
|
).Scan(&status, &ti, &sk, &ms); err != nil {
|
||||||
|
t.Fatalf("查询 PDD 商品失败: %v", err)
|
||||||
|
}
|
||||||
|
return status, ti.String, sk.String, ms.String
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectBody = `{"task_version":1,"attempt_id":"a-1","result_type":"collect",
|
||||||
|
"pdd_data":{"schema_version":1,"goods_id":"PDD-1","title":"测试商品",
|
||||||
|
"dimensions":[{"key":"color","name":"颜色分类"}],
|
||||||
|
"skus":[{"options":{"color":"黑色","size":"M"},"price_cent":1256,
|
||||||
|
"available":true,"raw_price":"¥12.56"}]}}`
|
||||||
|
|
||||||
|
func TestSubmitResult_采集结果写入PDD商品(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
insertPddProduct(t, db, "PDD-1")
|
||||||
|
insertCollectTask(t, db, "TASK-C", "client-001", "SHOPEE-1", "PDD-1")
|
||||||
claimTask(t, db, "TASK-C", "client-001")
|
claimTask(t, db, "TASK-C", "client-001")
|
||||||
|
|
||||||
body := `{"task_version":1,"attempt_id":"a-1","result_type":"collect","pdd_data":{"schema_version":1,"skus":[]}}`
|
if _, err := SubmitResult(db, "TASK-C", "client-001", "key-c", []byte(collectBody)); err != nil {
|
||||||
if _, err := SubmitResult(db, "TASK-C", "client-001", "key-c", []byte(body)); err != nil {
|
|
||||||
t.Fatalf("提交采集结果失败: %v", err)
|
t.Fatalf("提交采集结果失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var status, data string
|
status, title, skus, _ := pddProductState(t, db, "PDD-1")
|
||||||
if err := db.QueryRow(
|
|
||||||
`SELECT collect_status, COALESCE(pdd_data,'') FROM shopee_products WHERE goods_id='G-1'`,
|
|
||||||
).Scan(&status, &data); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if status != "collected" {
|
if status != "collected" {
|
||||||
t.Errorf("采集状态应为 collected,实际 %s", status)
|
t.Errorf("采集状态应为 collected,实际 %s", status)
|
||||||
}
|
}
|
||||||
if data == "" {
|
if title != "测试商品" {
|
||||||
t.Error("pdd_data 应被写入商品级,实际为空")
|
t.Errorf("标题应从采集结果里取出来,实际 %q", title)
|
||||||
|
}
|
||||||
|
if skus == "" {
|
||||||
|
t.Error("skus_json 应被写入,实际为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 采到 0 个规格必须算失败——数据对业务毫无用处,
|
||||||
|
// 显示"已采集"会让操作员以为好了,等建任务时才发现不对。
|
||||||
|
func TestSubmitResult_采到零个规格算失败(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
insertPddProduct(t, db, "PDD-1")
|
||||||
|
insertCollectTask(t, db, "TASK-C", "client-001", "SHOPEE-1", "PDD-1")
|
||||||
|
claimTask(t, db, "TASK-C", "client-001")
|
||||||
|
|
||||||
|
body := `{"attempt_id":"a-1","result_type":"collect",
|
||||||
|
"pdd_data":{"schema_version":1,"goods_id":"PDD-1","skus":[]}}`
|
||||||
|
if _, err := SubmitResult(db, "TASK-C", "client-001", "key-c", []byte(body)); err != nil {
|
||||||
|
t.Fatalf("提交不该报错,应该记成采集失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, _, _, msg := pddProductState(t, db, "PDD-1")
|
||||||
|
if status != "failed" {
|
||||||
|
t.Errorf("采到 0 个规格应记为 failed,实际 %s", status)
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
t.Error("应写明失败原因,否则操作员不知道为什么")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端采回来的商品和请求的对不上时必须拒绝——
|
||||||
|
// 不拦就会把 B 的规格价格存到 A 名下,之后按它下单就是买错东西。
|
||||||
|
func TestSubmitResult_采错商品被拒绝(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
insertPddProduct(t, db, "PDD-1")
|
||||||
|
insertCollectTask(t, db, "TASK-C", "client-001", "SHOPEE-1", "PDD-1")
|
||||||
|
claimTask(t, db, "TASK-C", "client-001")
|
||||||
|
|
||||||
|
// 请求采 PDD-1,客户端却返回了 PDD-999
|
||||||
|
body := `{"attempt_id":"a-1","result_type":"collect",
|
||||||
|
"pdd_data":{"schema_version":1,"goods_id":"PDD-999","title":"别的商品",
|
||||||
|
"skus":[{"options":{"color":"黑色"},"price_cent":100,"available":true}]}}`
|
||||||
|
_, err := SubmitResult(db, "TASK-C", "client-001", "key-c", []byte(body))
|
||||||
|
if !errors.Is(err, ErrCollectMismatch) {
|
||||||
|
t.Fatalf("期望 ErrCollectMismatch,实际 %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事务应整体回滚,PDD 商品不能被写脏
|
||||||
|
status, _, skus, _ := pddProductState(t, db, "PDD-1")
|
||||||
|
if status == "collected" || skus != "" {
|
||||||
|
t.Errorf("采错商品时不该写入任何结果,实际 status=%s skus=%q", status, skus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,14 +397,10 @@ func TestSubmitFailure_非法状态被拒绝(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubmitFailure_采集失败写回商品级(t *testing.T) {
|
func TestSubmitFailure_采集失败写回PDD商品(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
now := model.NowISO()
|
insertPddProduct(t, db, "PDD-1")
|
||||||
db.Exec(`INSERT INTO shopee_products (goods_id,title,collect_status,created_at,updated_at)
|
insertCollectTask(t, db, "TASK-C", "client-001", "SHOPEE-1", "PDD-1")
|
||||||
VALUES ('G-1','测试商品','collecting',?,?)`, now, now)
|
|
||||||
db.Exec(`INSERT INTO tasks (task_id,task_type,status,assigned_client,goods_id,
|
|
||||||
pdd_goods_url,created_at,updated_at)
|
|
||||||
VALUES ('TASK-C','collect','assigned','client-001','G-1','https://x/1',?,?)`, now, now)
|
|
||||||
claimTask(t, db, "TASK-C", "client-001")
|
claimTask(t, db, "TASK-C", "client-001")
|
||||||
|
|
||||||
body := `{"attempt_id":"a-1","status":"failed",
|
body := `{"attempt_id":"a-1","status":"failed",
|
||||||
@@ -337,14 +409,12 @@ func TestSubmitFailure_采集失败写回商品级(t *testing.T) {
|
|||||||
t.Fatalf("提交失败: %v", err)
|
t.Fatalf("提交失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var status, errMsg string
|
status, _, _, msg := pddProductState(t, db, "PDD-1")
|
||||||
db.QueryRow(`SELECT collect_status, COALESCE(collect_error,'')
|
|
||||||
FROM shopee_products WHERE goods_id='G-1'`).Scan(&status, &errMsg)
|
|
||||||
if status != "failed" {
|
if status != "failed" {
|
||||||
t.Errorf("采集状态应为 failed,实际 %s", status)
|
t.Errorf("采集状态应为 failed,实际 %s", status)
|
||||||
}
|
}
|
||||||
if errMsg == "" {
|
if msg == "" {
|
||||||
t.Error("失败原因应写入 collect_error,否则操作员看不到为什么采不到")
|
t.Error("失败原因应写入 collect_msg,否则操作员看不到为什么采不到")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
| 规格原文 | 蝦皮报表里没拆开的那一列,例如 `黑色,M【建議40-50公斤】` | `shopee_skus.spec_raw`,**永远原样保留** |
|
| 规格原文 | 蝦皮报表里没拆开的那一列,例如 `黑色,M【建議40-50公斤】` | `shopee_skus.spec_raw`,**永远原样保留** |
|
||||||
| 建议 | 蝦皮规格里的建议体重,例如 `40-50公斤` | 不是"建议采购链接",别理解错 |
|
| 建议 | 蝦皮规格里的建议体重,例如 `40-50公斤` | 不是"建议采购链接",别理解错 |
|
||||||
| SKU 映射 | "蝦皮的这个规格 = 拼多多的那个规格"的对应关系 | `sku_mappings` 表。**匹配一次,以后同商品自动带出** |
|
| SKU 映射 | "蝦皮的这个规格 = 拼多多的那个规格"的对应关系 | `sku_mappings` 表。**匹配一次,以后同商品自动带出** |
|
||||||
| 采集状态 | 某个蝦皮商品对应的 PDD 商品数据采到没有 | `shopee_products.collect_status` |
|
| 采集状态 | 一个 **PDD 商品**的数据采到没有 | `pdd_products.collect_status`。挂在 PDD 商品上,不在蝦皮商品上——被采集的是 PDD 商品 |
|
||||||
|
|
||||||
## 2. 技术名词
|
## 2. 技术名词
|
||||||
|
|
||||||
|
|||||||
@@ -197,18 +197,24 @@ Admin 的职责是把这三方的数据串起来,最终产出 Client 能执行
|
|||||||
|
|
||||||
## 6. 状态定义
|
## 6. 状态定义
|
||||||
|
|
||||||
### 6.1 采集状态(蝦皮商品)
|
### 6.1 采集状态
|
||||||
|
|
||||||
| 值 | 中文 | 含义 |
|
界面上显示 5 种,但**数据来自两张表**——采集的对象是 PDD 商品,
|
||||||
|---|---|---|
|
所以状态存在 `pdd_products` 上,不在蝦皮商品上。
|
||||||
| `no_link` | 未填链接 | 还没填 PDD 链接 |
|
|
||||||
| `pending` | 未采集 | 已填链接,还没发起采集 |
|
| 界面显示 | 怎么判断 |
|
||||||
| `collecting` | 采集中 | 采集任务已创建,尚未回结果 |
|
|---|---|
|
||||||
| `collected` | 已采集 | `pdd_data` 已就绪 |
|
| 未填链接 | `shopee_products.pdd_goods_id` 为空 |
|
||||||
| `failed` | 采集失败 | Client 报告失败,可重新采集 |
|
| 未采集 | `pdd_products.collect_status = 'pending'` |
|
||||||
|
| 采集中 | `= 'collecting'` |
|
||||||
|
| 已采集 | `= 'collected'` |
|
||||||
|
| 采集失败 | `= 'failed'`,原因在 `collect_msg` |
|
||||||
|
|
||||||
`[必须]` `collecting` 状态的商品不允许再建采集任务,按钮置灰并提示"已在采集中"。
|
`[必须]` `collecting` 状态的商品不允许再建采集任务,按钮置灰并提示"已在采集中"。
|
||||||
|
|
||||||
|
`[必须]` 状态挂在 PDD 商品上,好处是**两个蝦皮商品指向同一个 PDD 链接时,
|
||||||
|
状态只有一份**——不会各记一份还可能不一致,也不会把同一个商品采两遍。
|
||||||
|
|
||||||
### 6.2 任务状态(采集任务和采购任务通用)
|
### 6.2 任务状态(采集任务和采购任务通用)
|
||||||
|
|
||||||
这是 **Admin 侧的状态**,和 Client 本地的 8 个状态是两套,不要混
|
这是 **Admin 侧的状态**,和 Client 本地的 8 个状态是两套,不要混
|
||||||
|
|||||||
+203
-43
@@ -99,31 +99,21 @@ CREATE TABLE shopee_products (
|
|||||||
shopee_status TEXT, -- 蝦皮「商品當前狀態」
|
shopee_status TEXT, -- 蝦皮「商品當前狀態」
|
||||||
main_sku_code TEXT, -- 蝦皮「主商品貨號」
|
main_sku_code TEXT, -- 蝦皮「主商品貨號」
|
||||||
|
|
||||||
-- 下面三个是我们自己维护的,报表里没有,导入时绝不能覆盖
|
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
|
||||||
pdd_goods_url TEXT, -- ★ 人工填写
|
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
|
||||||
pdd_goods_id TEXT, -- 从 url 解析出来
|
pdd_goods_id TEXT, -- 从 url 解析,指向 pdd_products.goods_id
|
||||||
pdd_data TEXT, -- ★ 采集结果 JSON
|
|
||||||
|
|
||||||
collect_status TEXT NOT NULL DEFAULT 'no_link'
|
|
||||||
CHECK (collect_status IN (
|
|
||||||
'no_link', 'pending', 'collecting',
|
|
||||||
'collected', 'failed'
|
|
||||||
)),
|
|
||||||
collect_error TEXT,
|
|
||||||
collected_at TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_shopee_products_status
|
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
|
||||||
ON shopee_products(collect_status);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `pdd_data` 存 Client 采回来的 PDD 商品数据,结构和
|
`[必须]` **采集结果和采集状态不在这张表里**,它们属于 PDD 商品,见 §4。
|
||||||
[Client 侧 `pdd_data`](../client/03-data-model.md) §8.1 一致,
|
|
||||||
里面有 `dimensions` 和 `skus`,匹配弹窗右侧就是从它渲染的。
|
`pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。
|
||||||
- `[必须]` `pdd_data` 放在**商品级**,不是订单级。
|
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
|
||||||
一个 PDD 商品采一次,所有相关订单共用。
|
|
||||||
|
|
||||||
### 3.2 `shopee_skus` SKU 级
|
### 3.2 `shopee_skus` SKU 级
|
||||||
|
|
||||||
@@ -221,7 +211,120 @@ ON CONFLICT(goods_id) DO UPDATE SET
|
|||||||
导入结束返回 `{商品数, SKU数, 解析失败行号列表}`,页面上显示出来。
|
导入结束返回 `{商品数, SKU数, 解析失败行号列表}`,页面上显示出来。
|
||||||
**不要静默跳过失败行。**
|
**不要静默跳过失败行。**
|
||||||
|
|
||||||
## 4. `syb_orders` 顺运宝货运单
|
## 4. `pdd_products` 拼多多商品
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE pdd_products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
goods_id TEXT NOT NULL UNIQUE, -- 从 PDD 链接解析
|
||||||
|
url TEXT NOT NULL, -- 操作员填的链接原文
|
||||||
|
title TEXT, -- 采集回来,人工核对用
|
||||||
|
skus_json TEXT, -- 采集结果,结构见 §4.2
|
||||||
|
|
||||||
|
collect_status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (collect_status IN (
|
||||||
|
'pending', 'collecting', 'collected', 'failed'
|
||||||
|
)),
|
||||||
|
collect_msg TEXT, -- 失败原因
|
||||||
|
artifact_ref TEXT, -- 诊断产物位置
|
||||||
|
collected_at TEXT,
|
||||||
|
|
||||||
|
deleted_at TEXT, -- 软删除
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 几个关键决定
|
||||||
|
|
||||||
|
**为什么 `goods_id` 不是主键却必须 UNIQUE**
|
||||||
|
|
||||||
|
主键用了自增 `id`,那 `goods_id` 就**不再天然防重**了。少了 UNIQUE,
|
||||||
|
同一个 PDD 商品会被存成好几行:采好几遍、映射说不清指向哪一行。
|
||||||
|
|
||||||
|
配套规则:`[必须]` 保存链接时**先从 URL 解析出 `goods_id`,按它查重**,
|
||||||
|
不要按 URL 查重——同一个商品的 URL 有很多写法(带不带分享参数),
|
||||||
|
按 URL 查会漏掉,照样存重复。解析不出来就报错,让操作员给完整链接。
|
||||||
|
|
||||||
|
**为什么状态里没有"未填链接"**
|
||||||
|
|
||||||
|
这张表里有这一行,就说明链接已经填了。"未填链接"是**蝦皮侧**的状态
|
||||||
|
(`shopee_products.pdd_goods_id` 为空)。界面上仍然显示 5 种,
|
||||||
|
只是数据来源不同,见 [01 需求](01-requirements.md) §6.1。
|
||||||
|
|
||||||
|
**为什么用软删除**
|
||||||
|
|
||||||
|
`sku_mappings` 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。
|
||||||
|
软删除后界面不再显示,但记录和映射都还在。
|
||||||
|
|
||||||
|
`[必须]` 操作员重新填同一个链接时**要能复活**(清 `deleted_at`、状态置回
|
||||||
|
`pending`、清空旧采集结果)。不复活的话 `goods_id` 的 UNIQUE 会让插入失败,
|
||||||
|
操作员会看到一个莫名其妙的错误。
|
||||||
|
|
||||||
|
**为什么不存截图**
|
||||||
|
|
||||||
|
按已定案的 Artifact 策略([Client 契约](../client/04-admin-api-contract.md) §10 待确认 #3),
|
||||||
|
客户端**只报本地引用、不上传文件**。截图在客户端那台机器上,Admin 显示不了。
|
||||||
|
所以存 `artifact_ref`(形如 `client-001:artifacts/PDD-0001/attempt-xxx/`),
|
||||||
|
告诉操作员去哪台机器的哪个目录捞。
|
||||||
|
|
||||||
|
### 4.2 `skus_json` 的结构
|
||||||
|
|
||||||
|
由 Client 采集后原样提交,Admin **不做转换**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"goods_id": "737116531267",
|
||||||
|
"title": "【现货】西装外套三件套",
|
||||||
|
"captured_at": "2026-08-07T08:00:00Z",
|
||||||
|
"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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每个字段都对应 Admin 的一个实际用途,没有多余的:
|
||||||
|
|
||||||
|
| 字段 | Admin 拿它干什么 | 不给会怎样 |
|
||||||
|
|---|---|---|
|
||||||
|
| `skus[].options` | 匹配弹窗列出规格供人选 | 没东西可选,匹配做不了 |
|
||||||
|
| `skus[].price_cent` | 建采购任务时带出 `max_price_cent` | 价格保护填不了,Client 会拒绝执行 |
|
||||||
|
| `skus[].available` | 不给缺货规格建任务 | 白跑一趟,Client 到手机上才发现卖光 |
|
||||||
|
| `goods_id` / `title` | 核对"采的是不是要的那个商品" | 链接跳转、采错商品时静默存错 |
|
||||||
|
| `dimensions` | 界面按顺序渲染下拉框 | Go 的 map 无序,不知道该先显示颜色还是尺码 |
|
||||||
|
| `raw_price` | 价格解析出错时对账 | 只有数字,出错了没法查 |
|
||||||
|
|
||||||
|
`[必须]` 几条硬规则:
|
||||||
|
|
||||||
|
- **`price_cent` 是整数分**,不是 `12.56` 也不是 `"12.56"`。这个数要参与价格保护比对,是会花钱的判断,禁止浮点。
|
||||||
|
- **采不到价格时给 `null`,不要给 0**。Admin 遇到 `null` 当"未知"处理并拒绝建任务,绝不当成 0 元。
|
||||||
|
- **`options` 嵌一层,不平铺 `color`/`size`**。支持任意多个维度,碰到三维商品(颜色/尺码/款式)平铺的结构直接装不下。
|
||||||
|
- **`dimensions` 只给 `key` 和 `name`,不给 values**。values 能从 `skus` 去重推出来,存两份迟早不一致。
|
||||||
|
|
||||||
|
### 4.3 落库时的两条校验
|
||||||
|
|
||||||
|
`[必须]` Client 提交采集结果时,Admin 必须校验:
|
||||||
|
|
||||||
|
1. **返回的 `goods_id` 必须等于请求采集的那个。** 不等说明链接跳转了或采错商品,
|
||||||
|
要拒绝(`422 COLLECT_GOODS_MISMATCH`)并整体回滚。不拦的话,会把 B 的规格价格
|
||||||
|
存到 A 名下,之后按它下单就是买错东西。
|
||||||
|
2. **`skus` 为空要记成 `failed`,不是 `collected`。** 采到 0 个规格对业务毫无用处
|
||||||
|
(商品下架、页面改版、解析器没认出来),显示"已采集"会让操作员以为好了,
|
||||||
|
等建任务时才发现不对。
|
||||||
|
|
||||||
|
完整的采集提交原文仍然存进 `tasks.result_data`,审计链不断。
|
||||||
|
|
||||||
|
## 5. `syb_orders` 顺运宝货运单
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE syb_orders (
|
CREATE TABLE syb_orders (
|
||||||
@@ -258,30 +361,82 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
|
|||||||
|
|
||||||
**"匹配状态"是派生的,不存字段**:`sku_mappings` 里有对应记录就是"已匹配"。
|
**"匹配状态"是派生的,不存字段**:`sku_mappings` 里有对应记录就是"已匹配"。
|
||||||
|
|
||||||
## 5. `sku_mappings` 规格映射
|
## 6. `sku_mappings` 规格映射
|
||||||
|
|
||||||
这张表是"蝦皮的这个规格 = 拼多多的那个规格",**匹配一次,以后复用**。
|
"蝦皮的这个规格 = 拼多多的那个规格",**匹配一次,以后复用**。
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE sku_mappings (
|
CREATE TABLE sku_mappings (
|
||||||
shopee_sku_id TEXT PRIMARY KEY,
|
shopee_sku_id TEXT NOT NULL,
|
||||||
goods_id TEXT NOT NULL,
|
pdd_goods_id TEXT NOT NULL, -- ★ 这条映射属于哪个 PDD 商品
|
||||||
pdd_options TEXT NOT NULL, -- JSON: {"color":"黑色","size":"M码"}
|
pdd_option_key TEXT NOT NULL, -- 规范化的组合键,见 §6.2
|
||||||
|
pdd_options TEXT NOT NULL, -- 原始 options 对象,显示用
|
||||||
|
goods_id TEXT NOT NULL, -- 蝦皮商品 ID,方便按商品批量查
|
||||||
mapped_at TEXT NOT NULL,
|
mapped_at TEXT NOT NULL,
|
||||||
mapped_by TEXT,
|
mapped_by TEXT,
|
||||||
|
PRIMARY KEY (shopee_sku_id, pdd_goods_id),
|
||||||
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
|
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);
|
CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);
|
||||||
|
CREATE INDEX idx_sku_mappings_pdd ON sku_mappings(pdd_goods_id);
|
||||||
```
|
```
|
||||||
|
|
||||||
- `pdd_options` 用 JSON 而不是固定的"颜色/尺码"两列,
|
### 6.1 为什么主键要带上 `pdd_goods_id`
|
||||||
因为 PDD 商品可能有第三个规格维度
|
|
||||||
(Client 侧 [01](../client/01-requirements.md) §11 已明确要求按任意维度设计)。
|
|
||||||
- `[必须]` 打开匹配弹窗时**先查这张表**,有记录就自动带出,操作员只需确认。
|
|
||||||
这是省人工的关键,不要做成每张订单都从头匹配。
|
|
||||||
|
|
||||||
## 6. `tasks` 任务
|
PDD 商品下架换代很频繁——A 买不到了就得换 B。
|
||||||
|
|
||||||
|
假设蝦皮商品 X 原来对应 PDD 商品 A,操作员匹配好了"黑色/M → 黑色/M码";
|
||||||
|
后来 A 下架,换成了 B。如果映射只按 `shopee_sku_id` 存,那条旧映射还在,
|
||||||
|
但它描述的是 **A 的规格**:
|
||||||
|
|
||||||
|
| | 后果 |
|
||||||
|
|---|---|
|
||||||
|
| 运气好 | B 没有"黑色/M码",建任务时找不到会报错,还算安全 |
|
||||||
|
| **运气坏** | B 恰好也有"黑色/M码",但完全是另一件衣服 → **静默买错,事后查不出来** |
|
||||||
|
|
||||||
|
把 `pdd_goods_id` 放进主键后,`[必须]` 查映射**永远带上"当前对应的 PDD 商品"**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT ... FROM sku_mappings
|
||||||
|
WHERE shopee_sku_id = ?
|
||||||
|
AND pdd_goods_id = (蝦皮商品当前的 pdd_goods_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
换成 B 就自然查不到 A 的映射,界面显示"待匹配"。**不需要在换商品时记得去删旧数据**
|
||||||
|
——靠查询条件天然隔离,忘不了。
|
||||||
|
|
||||||
|
附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。
|
||||||
|
|
||||||
|
### 6.2 `pdd_option_key` 的规范化
|
||||||
|
|
||||||
|
采集回来的 PDD 数据里**没有 SKU 编号**,一个规格只能靠它的 options 组合来认。
|
||||||
|
而 JSON 对象的键是无序的:
|
||||||
|
|
||||||
|
```
|
||||||
|
存映射时:{"color":"黑色","size":"M"}
|
||||||
|
采回来时:{"size":"M","color":"黑色"}
|
||||||
|
```
|
||||||
|
|
||||||
|
这两个是同一个规格,但字符串不相等。直接比原始 JSON 会匹配不上,
|
||||||
|
而且是**静默失效**——不报错,只是查不到,最后表现为"明明匹配过却说待匹配"。
|
||||||
|
|
||||||
|
`[必须]` 所以要有一个规范化函数,`service.OptionKey()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
OptionKey(map[string]string{"size": "M", "color": "黑色"})
|
||||||
|
// -> {"color":"黑色","size":"M"}
|
||||||
|
```
|
||||||
|
|
||||||
|
实现直接用 `json.Marshal` —— Go 序列化 map 时**会按键名排序**,正好就是我们要的
|
||||||
|
规范化,不用自己拼字符串(自己拼容易漏掉值里含分隔符、含引号之类的边界情况)。
|
||||||
|
|
||||||
|
`[必须]` **这个函数只能有一处实现。** 存映射用它算 key,查规格也用它算 key,
|
||||||
|
两边必须逐字节一致。如果 Client 那边也算一份、或者别处再写一个"差不多"的版本,
|
||||||
|
只要有一点点不同(空格、转义、键序),映射就会静默对不上。
|
||||||
|
**Client 只上报 `options` 对象,key 一律由 Admin 这一个函数算。**
|
||||||
|
|
||||||
|
## 7. `tasks` 任务
|
||||||
|
|
||||||
采集任务和采购任务共用一张表,用 `task_type` 区分。
|
采集任务和采购任务共用一张表,用 `task_type` 区分。
|
||||||
|
|
||||||
@@ -337,7 +492,7 @@ CREATE INDEX idx_tasks_order ON tasks(order_no);
|
|||||||
|
|
||||||
状态含义见 [01 需求](01-requirements.md) §6.2。
|
状态含义见 [01 需求](01-requirements.md) §6.2。
|
||||||
|
|
||||||
## 7. `task_claims` 领取历史
|
## 8. `task_claims` 领取历史
|
||||||
|
|
||||||
记录"哪台客户端领过哪个任务"。
|
记录"哪台客户端领过哪个任务"。
|
||||||
|
|
||||||
@@ -363,7 +518,7 @@ CREATE INDEX idx_task_claims_client ON task_claims(client_id);
|
|||||||
`[必须]` 领取成功时写入;提交结果时用它做权限判断。
|
`[必须]` 领取成功时写入;提交结果时用它做权限判断。
|
||||||
同一客户端重复领同一任务只更新时间,不报错。
|
同一客户端重复领同一任务只更新时间,不报错。
|
||||||
|
|
||||||
## 8. `clients` 客户端
|
## 9. `clients` 客户端
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE clients (
|
CREATE TABLE clients (
|
||||||
@@ -385,22 +540,27 @@ CREATE TABLE clients (
|
|||||||
- `[必须]` 设置页使用独立登记接口幂等新增或更新 Client,claim 保留隐式登记作为兼容兜底。
|
- `[必须]` 设置页使用独立登记接口幂等新增或更新 Client,claim 保留隐式登记作为兼容兜底。
|
||||||
- `[必须]` 不设心跳接口;登记、claim、result 和 failure 都刷新 `last_seen_at`,见 [04](04-client-api.md) §1.1、§3。
|
- `[必须]` 不设心跳接口;登记、claim、result 和 failure 都刷新 `last_seen_at`,见 [04](04-client-api.md) §1.1、§3。
|
||||||
|
|
||||||
## 9. 数据关系总览
|
## 10. 数据关系总览
|
||||||
|
|
||||||
```text
|
```text
|
||||||
shopee_products ──1:N──→ shopee_skus
|
shopee_products ──1:N──→ shopee_skus
|
||||||
│ │
|
│ │
|
||||||
│ pdd_data │ 1:1
|
│ pdd_goods_id │ shopee_sku_id
|
||||||
│ (采集结果) ↓
|
│ (当前对应哪个 ↓
|
||||||
│ sku_mappings
|
│ PDD 商品,可换) sku_mappings ──pdd_goods_id──┐
|
||||||
│ ↑
|
↓ │
|
||||||
│ │ 查映射
|
pdd_products ←────────────────────────────────────────┘
|
||||||
syb_orders ────────────────────┘
|
(skus_json 里是所有规格和价格)
|
||||||
│
|
|
||||||
└──创建──→ tasks ──分配──→ clients
|
syb_orders ──创建──→ tasks ──分配──→ clients
|
||||||
```
|
```
|
||||||
|
|
||||||
## 10. 与 Client 数据模型的关系
|
两条关联都可以变,这是有意的:
|
||||||
|
|
||||||
|
- `shopee_products.pdd_goods_id`:PDD 商品下架换代时改
|
||||||
|
- `sku_mappings` 按 `(蝦皮SKU, PDD商品)` 存:换了商品自然查不到旧映射
|
||||||
|
|
||||||
|
## 11. 与 Client 数据模型的关系
|
||||||
|
|
||||||
Admin 和 Client **各有一个 SQLite,互不相通**,只通过接口交换数据。
|
Admin 和 Client **各有一个 SQLite,互不相通**,只通过接口交换数据。
|
||||||
|
|
||||||
@@ -408,7 +568,7 @@ Admin 和 Client **各有一个 SQLite,互不相通**,只通过接口交换
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 任务编号 | `tasks.task_id` | `pdd_tasks.remote_task_id` |
|
| 任务编号 | `tasks.task_id` | `pdd_tasks.remote_task_id` |
|
||||||
| 任务状态 | 7 个(§6) | 8 个,是本机执行状态 |
|
| 任务状态 | 7 个(§6) | 8 个,是本机执行状态 |
|
||||||
| 采集结果 | `shopee_products.pdd_data` | `pdd_tasks.pdd_data` |
|
| 采集结果 | `pdd_products.skus_json` | `pdd_tasks.pdd_data` |
|
||||||
|
|
||||||
`[必须]` **两边的状态是两套,不要试图同步。**
|
`[必须]` **两边的状态是两套,不要试图同步。**
|
||||||
Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。
|
Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ Idempotency-Key: <task_id>:<attempt_id>:result-v1
|
|||||||
|
|
||||||
1. 用 `Idempotency-Key` 查是否处理过 → 处理过就返回上次的结果,**不重复落库**;
|
1. 用 `Idempotency-Key` 查是否处理过 → 处理过就返回上次的结果,**不重复落库**;
|
||||||
2. 把 `pdd_data` 写进 `tasks.result_data`;
|
2. 把 `pdd_data` 写进 `tasks.result_data`;
|
||||||
3. 采集任务:同时写进 `shopee_products.pdd_data`,`collect_status` 置 `collected`;
|
3. 采集任务:校验返回的 `goods_id` 与请求一致(不一致返回 `422 COLLECT_GOODS_MISMATCH`),
|
||||||
|
再写进 `pdd_products.skus_json`,`collect_status` 置 `collected`;
|
||||||
|
**`skus` 为空要置 `failed` 而不是 `collected`**,见 [03 数据模型](03-data-model.md) §4.3;
|
||||||
4. `tasks.status` 置 `succeeded`;
|
4. `tasks.status` 置 `succeeded`;
|
||||||
5. 刷新客户端 `last_seen_at`;
|
5. 刷新客户端 `last_seen_at`;
|
||||||
6. 返回 `{"accepted": true, "result_id": "...", "accepted_at": "..."}`。
|
6. 返回 `{"accepted": true, "result_id": "...", "accepted_at": "..."}`。
|
||||||
@@ -201,8 +203,8 @@ Idempotency-Key: <task_id>:<attempt_id>:failure-v1
|
|||||||
| `failed` | `failed` | |
|
| `failed` | `failed` | |
|
||||||
| `cancelled` | `cancelled` | |
|
| `cancelled` | `cancelled` | |
|
||||||
|
|
||||||
采集任务失败时,同步把 `shopee_products.collect_status` 置 `failed`,
|
采集任务失败时,同步把 `pdd_products.collect_status` 置 `failed`,
|
||||||
并把错误信息写进 `collect_error`,界面上要看得见。
|
错误信息写进 `collect_msg`、诊断产物位置写进 `artifact_ref`,界面上要看得见。
|
||||||
|
|
||||||
`[必须]` §4.1 的无条件接受**同样适用于本接口**。
|
`[必须]` §4.1 的无条件接受**同样适用于本接口**。
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
| 商品名称 | 截断 + 悬停完整 |
|
| 商品名称 | 截断 + 悬停完整 |
|
||||||
| 颜色 / 尺码 / 建议 | 解析失败的显示为 `—` 并**整行标黄**,提示需人工补 |
|
| 颜色 / 尺码 / 建议 | 解析失败的显示为 `—` 并**整行标黄**,提示需人工补 |
|
||||||
| PDD 链接 | 空的显示"**未填写**"并标红,这是最需要操作员注意的状态 |
|
| PDD 链接 | 空的显示"**未填写**"并标红,这是最需要操作员注意的状态 |
|
||||||
| 采集状态 | 未填链接 / 未采集 / 采集中 / 已采集 / 采集失败 |
|
| 采集状态 | 未填链接 / 未采集 / 采集中 / 已采集 / 采集失败。**数据来自两张表**,判断方式见 [01 需求](01-requirements.md) §6.1 |
|
||||||
| 更新时间 | 本地时区 |
|
| 更新时间 | 本地时区 |
|
||||||
|
|
||||||
`[必须]` 状态不能只靠颜色区分,必须有文字。
|
`[必须]` 状态不能只靠颜色区分,必须有文字。
|
||||||
|
|||||||
Reference in New Issue
Block a user