Files
cmautobuy/admin/model/model.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

267 lines
8.6 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 model 只放数据结构,不导入 Gin,也不导入数据库驱动。
//
// 这样领域概念才能被单元测试直接使用,不用起服务器、不用连数据库。
// 字段含义的权威定义在 docs/admin/03-data-model.md。
package model
import "time"
// ---------- 时间 ----------
// TimeLayout 是全项目统一的时间格式:带时区的 ISO 8601。
// 库里存 UTC,页面上再转本地时区显示。
const TimeLayout = time.RFC3339
// NowISO 返回当前 UTC 时间的字符串形式。
// 所有写库的时间戳都要用它,不要各写各的格式。
func NowISO() string {
return time.Now().UTC().Format(TimeLayout)
}
// ParseISO 解析库里存的时间字符串。解析不了返回零值和 false。
func ParseISO(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
ts, err := time.Parse(TimeLayout, s)
if err != nil {
return time.Time{}, false
}
return ts, true
}
// ---------- 蝦皮 ----------
// CollectStatus 是一个 **PDD 商品**的采集进度。
//
// 注意它挂在 PddProduct 上,不在 ShopeeProduct 上——被采集的是 PDD 商品。
// 两个蝦皮商品指向同一个 PDD 链接时,状态只有一份,不会各记一份还对不上。
//
// 这里**没有"未填链接"**:pdd_products 里有这一行,就说明链接已经填了。
// "未填链接"是蝦皮侧的状态(ShopeeProduct.PddGoodsID 为空)。
// 界面上仍然显示 5 种,只是数据来源不同,见 docs/admin/01-requirements.md §6.1。
type CollectStatus string
const (
CollectPending CollectStatus = "pending" // 已填链接,未发起采集
CollectCollecting CollectStatus = "collecting" // 采集中,不允许再建任务
CollectCollected CollectStatus = "collected" // 已采集
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 是蝦皮商品(商品级)。
//
// PddGoodsURL 和 PddGoodsID 是我们自己维护的,蝦皮报表里没有,
// Excel 导入时**绝不能覆盖**,见 docs/admin/03-data-model.md §3.3。
//
// 采集结果和采集状态**不在这里**——它们属于 PDD 商品,见 PddProduct。
type ShopeeProduct struct {
GoodsID string
Title string
ShopeeStatus string
MainSKUCode string
PddGoodsURL string
PddGoodsID string // 指向 PddProduct.GoodsID,为空表示还没填链接
CreatedAt string
UpdatedAt string
}
// ShopeeSKU 是蝦皮的一个规格(SKU 级)。
//
// SpecRaw 是报表里的规格原文,例如「黑色,M【建議40-50公斤】」,
// **永远原样保留**。Color/Size/Advice 是尽力解析的结果,
// 解析失败时留空并把 ParseOK 置 false,不要瞎猜。
type ShopeeSKU struct {
SKUID string
GoodsID string
SpecRaw string
Color string
Size string
Advice string // 建议体重,如「40-50公斤」
ParseOK bool
SKUCode string
IsManual bool // 人工新增的,后续导入不得删除
CreatedAt string
UpdatedAt string
}
// ---------- 顺运宝 ----------
// SybOrder 是一张顺运宝货运单。
//
// PriceTwdCent 是**台币分**,跟采购任务的人民币价格上限没有换算关系,
// 不要互相赋值,见 docs/admin/01-requirements.md §7。
type SybOrder struct {
SybID string
OrderNo string
Title string
ShopeeGoodsID string
ShopeeSKUID string
Quantity int
PriceTwdCent int64
ImageURL string
SybData string // 完整货运单 JSON,原样保留
CreatedAt string
UpdatedAt string
}
// SKUMapping 是「蝦皮的这个规格 = 拼多多的那个规格」。
//
// # 为什么主键要带上 PddGoodsID
//
// PDD 商品下架换代很频繁。假设蝦皮商品 X 原来对应 PDD 商品 A,
// 操作员匹配好了"黑色/M → 黑色/M码";后来 A 下架,换成了 B。
//
// 如果映射只按 ShopeeSKUID 存,那条旧映射还在,但它描述的是 **A 的规格**:
//
// - 运气好:B 没有"黑色/M码",建任务时找不到会报错,还算安全;
// - 运气坏:B 恰好也有"黑色/M码",但完全是另一件衣服
// —— **静默买错,而且事后查不出来**。
//
// 把 PddGoodsID 放进主键后,查映射永远带上"当前对应的 PDD 商品"这个条件,
// 换成 B 就自然查不到 A 的映射,界面显示"待匹配"。
// 不需要在换商品时记得去删旧数据——靠查询条件天然隔离,忘不了。
//
// 附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。
type SKUMapping struct {
ShopeeSKUID string
PddGoodsID string // 这条映射属于哪个 PDD 商品
PddOptionKey string // 规范化后的组合键,见 service.OptionKey
PddOptions string // 原始 options 对象 JSON,显示用
GoodsID string // 蝦皮商品 ID,方便按商品批量查
MappedAt string
MappedBy string
}
// ---------- 任务 ----------
// TaskType 区分采集任务和采购任务。
type TaskType string
const (
TaskCollect TaskType = "collect"
TaskPurchase TaskType = "purchase"
)
// TaskStatus 是 **Admin 侧**的任务状态。
//
// 注意它和 Client 本地的 8 个状态是两套,不要混。
// Admin 看不到客户端执行到哪一步(没有心跳,是有意的),
// claimed 之后就只能等结果。
type TaskStatus string
const (
TaskPending TaskStatus = "pending" // 待分配
TaskAssigned TaskStatus = "assigned" // 待领取
TaskClaimed TaskStatus = "claimed" // 已领取,正在执行
TaskSucceeded TaskStatus = "succeeded" // 成功
TaskManualReview TaskStatus = "manual_review" // 需人工
TaskFailed TaskStatus = "failed" // 失败
TaskCancelled TaskStatus = "cancelled" // 已取消
)
// Task 是发给 Client 执行的一个任务。
//
// PddGoodsURL 必填——Client 那边是 NOT NULL,空了它执行不了。
// 采购任务的 Quantity 和 MaxPriceCent 也必填,这是价格保护,
// 见 docs/client/04-admin-api-contract.md §4。
type Task struct {
TaskID string
TaskType TaskType
Status TaskStatus
Version int
Priority int
AssignedClient string
ClaimedAt string
SybID string
OrderNo string
GoodsID string
ShopeeSKUID string
PddGoodsURL string
PddGoodsID string
PddOptions string // JSON,采购任务的目标规格
Quantity int
MaxPriceCent int64 // 人民币分
ResultData string
ErrorCode string
ErrorMessage string
FinishedAt string
CreatedAt string
UpdatedAt string
}
// ---------- 客户端 ----------
// Client 是一台执行任务的客户端。
//
// 注意**没有 Status 字段**:在线状态是算出来的(见 IsOnline),
// 存成字段会和真实情况不同步。
type Client struct {
ClientID string
Name string
DeviceAddress string
Platform string
PddPackage string
Capabilities string
LastSeenAt string
CreatedAt string
UpdatedAt string
}
// IsOnline 判断客户端此刻算不算在线。
//
// 规则很简单:最近活动时间在 threshold 之内就算在线。
// **没有心跳是有意的**,所以客户端执行长任务期间不调接口,
// 可能显示成离线——这是已知且接受的取舍,
// 见 docs/admin/04-client-api.md §3。
//
// LastSeenAt 解析不了时一律当离线,不要当在线。
func (c Client) IsOnline(now time.Time, threshold time.Duration) bool {
seen, ok := ParseISO(c.LastSeenAt)
if !ok {
return false
}
return now.Sub(seen) < threshold
}
// StatusText 返回给页面显示的中文状态。
// 状态不能只靠颜色区分,必须有文字。
func (c Client) StatusText(now time.Time, threshold time.Duration) string {
if c.IsOnline(now, threshold) {
return "在线"
}
return "离线"
}