Admin:PDD 商品数据独立成表 #16

Closed
opened 2026-08-07 10:24:56 +08:00 by ila · 2 comments
Owner

基本信息

  • 类型:重构(数据模型)
  • 父级大工单:#14
  • 所属 MVP / 版本:#15 / MVP
  • 阶段:3. PDD 商品数据与规格匹配

要解决什么

问题一:pdd_data 挂在 shopee_products 上。 两个蝦皮商品指向同一个 PDD 链接时会各存一份、各采一次;collect_status 描述的是 PDD 商品的状态,却挂在蝦皮商品上,两份可能不一致。

问题二(会买错东西):PDD 商品变动频繁。 商品 A 下架就得换成 B。但 sku_mappings 只按 shopee_sku_id 做键,换商品后旧映射还在:

  • B 没有同名规格 → 建任务报错,尚且安全
  • B 恰好有同名规格但完全是另一件货 → 静默买错,事后查不出来

用户已确认 PDD 商品变动大,这不是理论风险。

做什么 / 不做什么

做:

  1. 新增 pdd_products 表(id 主键,goods_id UNIQUE)
  2. shopee_products 去掉 pdd_data / collect_status / collect_error / collected_at,pdd_goods_id 改为引用
  3. sku_mappings 主键改为 (shopee_sku_id, pdd_goods_id),新增 pdd_option_key
  4. 新增 OptionKey() 规范化函数
  5. SetCollectResult / SetCollectFailed 改写 pdd_products
  6. 提交采集结果时新增两条校验
  7. 同步 docs/admin/01 / 03 / 05

不做(各自独立工单):

  • 货运单一对多拆表
  • 填 PDD 链接、匹配弹窗的界面实现
  • 「PDD 商品已换,需重新匹配」的界面提示
  • 截图上传(Artifact 策略已定为只报引用)

怎么做

pdd_products

  • collect_status 只有 4 个值:pending / collecting / collected / failed。
    去掉 no_link——有这一行就说明链接填了;「未填链接」是 shopee_products.pdd_goods_id IS NULL。
  • 软删除可复活:按 goods_id 查(含已删除的),查到已删除的就清 deleted_at、状态回 pending,不新增行。
  • 所有查询带 WHERE deleted_at IS NULL,且只写在 repository 层。

skus_json 结构

Client 按此结构采集并提交,Admin 原样存,零转换:

{"schema_version":1,
 "dimensions":[{"key":"color","name":"颜色分类"},{"key":"size","name":"尺码"}],
 "skus":[{"options":{"color":"黑色","size":"M"},
          "price_cent":1256,"available":true,"raw_price":"¥12.56"}]}
  • price_cent 整数分,禁止浮点/字符串;允许 null 表示未采到,Admin 遇 null 拒绝建任务,绝不当 0
  • options 嵌一层,支持任意多个维度
  • dimensions 只给 key + name(Go map 无序,需要它定下拉框顺序),不给 values

OptionKey()

用 json.Marshal 实现——Go 序列化 map 时按键名排序,天然规范化,不用自己拼、不用处理转义(规格文字里可能含 = 或 ;)。

存映射和查 SKU 必须用同一个函数,各写一遍迟早算出不一样的结果,而且是静默失效。

sku_mappings 改键

查映射永远带上当前的 PDD 商品:

WHERE shopee_sku_id = ? AND pdd_goods_id = ?

换成 B 后查不到 B 的映射 → 界面显示「待匹配」,不会误用 A 的。不需要换商品时删数据,靠查询条件天然隔离;A 的映射留着,换回 A 时直接复用。

采集结果落库

  • Client 返回的 goods_id 必须等于请求采集的那个。不等说明链接跳转或采错商品,拒绝并整体回滚,不得静默存下
  • skus 为空数组时置 failed,不是 collected

验收标准

  • pdd_products 建表成功,goods_id 有 UNIQUE 约束
  • collect_status 只接受 4 个值
  • shopee_products 不再有 pdd_data / collect_status / collect_error / collected_at
  • sku_mappings 主键为 (shopee_sku_id, pdd_goods_id)
  • OptionKey:键顺序不同的等价 options 产出同一个 key
  • OptionKey:值含特殊字符不影响正确性
  • OptionKey:空 map 有明确行为,不 panic
  • 软删除后默认查询查不到
  • 软删除后同 goods_id 再保存,复活原行而不是 UNIQUE 冲突
  • 采集成功写入 skus_json,状态 collected
  • 返回的 goods_id 与请求不符 → 拒绝,不写 skus_json
  • skus 为空数组 → 置 failed
  • 换 PDD 商品后查 B 的映射查不到 A 的
  • 换回 A 后 A 的映射仍然可用
  • go vet / gofmt -l . / go test ./... 全过
  • 文档三处同步,链接与章节引用校验通过

怎么验证

cd D:\chengma\cmautobuy\admin
go vet ./...
gofmt -l .
go test ./... -count=1
go run .

不需要真机,不需要 Client。

风险和回退

风险 应对
直接改 migrations v1 会让已有 admin/data/admin.db 结构对不上 该库只有测试数据,删掉重建
现有两个采集测试会红 预期的,行为确实变了,改对了才会绿
OptionKey 两处实现不一致 只允许一处实现
忘记加 WHERE deleted_at IS NULL 查询只写在 repository 层

回退:改动集中在 admin/,git revert 即可;数据库删掉重建。

## 基本信息 - 类型:重构(数据模型) - 父级大工单:#14 - 所属 MVP / 版本:#15 / MVP - 阶段:3. PDD 商品数据与规格匹配 ## 要解决什么 **问题一:`pdd_data` 挂在 `shopee_products` 上。** 两个蝦皮商品指向同一个 PDD 链接时会各存一份、各采一次;`collect_status` 描述的是 PDD 商品的状态,却挂在蝦皮商品上,两份可能不一致。 **问题二(会买错东西):PDD 商品变动频繁。** 商品 A 下架就得换成 B。但 `sku_mappings` 只按 `shopee_sku_id` 做键,换商品后旧映射还在: - B 没有同名规格 → 建任务报错,尚且安全 - **B 恰好有同名规格但完全是另一件货 → 静默买错,事后查不出来** 用户已确认 PDD 商品变动大,这不是理论风险。 ## 做什么 / 不做什么 做: 1. 新增 `pdd_products` 表(`id` 主键,`goods_id` UNIQUE) 2. `shopee_products` 去掉 `pdd_data` / `collect_status` / `collect_error` / `collected_at`,`pdd_goods_id` 改为引用 3. `sku_mappings` 主键改为 `(shopee_sku_id, pdd_goods_id)`,新增 `pdd_option_key` 4. 新增 `OptionKey()` 规范化函数 5. `SetCollectResult` / `SetCollectFailed` 改写 `pdd_products` 6. 提交采集结果时新增两条校验 7. 同步 `docs/admin/01` / `03` / `05` 不做(各自独立工单): - 货运单一对多拆表 - 填 PDD 链接、匹配弹窗的界面实现 - 「PDD 商品已换,需重新匹配」的界面提示 - 截图上传(Artifact 策略已定为只报引用) ## 怎么做 ### pdd_products - `collect_status` 只有 4 个值:`pending` / `collecting` / `collected` / `failed`。 去掉 `no_link`——有这一行就说明链接填了;「未填链接」是 `shopee_products.pdd_goods_id IS NULL`。 - 软删除可复活:按 `goods_id` 查(含已删除的),查到已删除的就清 `deleted_at`、状态回 `pending`,不新增行。 - 所有查询带 `WHERE deleted_at IS NULL`,且只写在 repository 层。 ### skus_json 结构 Client 按此结构采集并提交,Admin 原样存,零转换: ```json {"schema_version":1, "dimensions":[{"key":"color","name":"颜色分类"},{"key":"size","name":"尺码"}], "skus":[{"options":{"color":"黑色","size":"M"}, "price_cent":1256,"available":true,"raw_price":"¥12.56"}]} ``` - `price_cent` **整数分**,禁止浮点/字符串;允许 `null` 表示未采到,Admin 遇 `null` 拒绝建任务,绝不当 0 - `options` 嵌一层,支持任意多个维度 - `dimensions` 只给 key + name(Go map 无序,需要它定下拉框顺序),不给 values ### OptionKey() 用 `json.Marshal` 实现——Go 序列化 map 时按键名排序,天然规范化,不用自己拼、不用处理转义(规格文字里可能含 `=` 或 `;`)。 **存映射和查 SKU 必须用同一个函数**,各写一遍迟早算出不一样的结果,而且是静默失效。 ### sku_mappings 改键 查映射永远带上当前的 PDD 商品: ```sql WHERE shopee_sku_id = ? AND pdd_goods_id = ? ``` 换成 B 后查不到 B 的映射 → 界面显示「待匹配」,不会误用 A 的。不需要换商品时删数据,靠查询条件天然隔离;A 的映射留着,换回 A 时直接复用。 ### 采集结果落库 - Client 返回的 `goods_id` 必须等于请求采集的那个。不等说明链接跳转或采错商品,**拒绝并整体回滚**,不得静默存下 - `skus` 为空数组时置 `failed`,不是 `collected` ## 验收标准 - [ ] `pdd_products` 建表成功,`goods_id` 有 UNIQUE 约束 - [ ] `collect_status` 只接受 4 个值 - [ ] `shopee_products` 不再有 `pdd_data` / `collect_status` / `collect_error` / `collected_at` - [ ] `sku_mappings` 主键为 `(shopee_sku_id, pdd_goods_id)` - [ ] OptionKey:键顺序不同的等价 options 产出同一个 key - [ ] OptionKey:值含特殊字符不影响正确性 - [ ] OptionKey:空 map 有明确行为,不 panic - [ ] 软删除后默认查询查不到 - [ ] 软删除后同 `goods_id` 再保存,复活原行而不是 UNIQUE 冲突 - [ ] 采集成功写入 `skus_json`,状态 `collected` - [ ] 返回的 `goods_id` 与请求不符 → 拒绝,不写 `skus_json` - [ ] `skus` 为空数组 → 置 `failed` - [ ] **换 PDD 商品后查 B 的映射查不到 A 的** - [ ] **换回 A 后 A 的映射仍然可用** - [ ] `go vet` / `gofmt -l .` / `go test ./...` 全过 - [ ] 文档三处同步,链接与章节引用校验通过 ## 怎么验证 ```powershell cd D:\chengma\cmautobuy\admin go vet ./... gofmt -l . go test ./... -count=1 go run . ``` 不需要真机,不需要 Client。 ## 风险和回退 | 风险 | 应对 | |---|---| | 直接改 migrations v1 会让已有 `admin/data/admin.db` 结构对不上 | 该库只有测试数据,删掉重建 | | 现有两个采集测试会红 | 预期的,行为确实变了,改对了才会绿 | | OptionKey 两处实现不一致 | 只允许一处实现 | | 忘记加 `WHERE deleted_at IS NULL` | 查询只写在 repository 层 | 回退:改动集中在 `admin/`,`git revert` 即可;数据库删掉重建。
Author
Owner

实施完成,待验收

验证结果(Go 1.23.0)

go vet / gofmt -l . 无输出;go test ./... -count=1 55 个测试全 PASS。

审查时另行补验了工单未覆盖的 HTTP 层端到端:

  • 采回的 goods_id 与请求不符 → 422 COLLECT_GOODS_MISMATCH,
    拒绝后 skus_json 空、状态仍 collecting、任务仍 claimed、幂等记录 0 条
    (整体回滚干净;幂等记录若残留会导致客户端重试永远拿到缓存的失败响应)
  • 采到 0 个规格 → 200 但状态置 failed

16 条验收标准全部通过,含两条安全核心:换 PDD 商品后查不到旧映射、换回后旧映射仍可用。

三处超出工单但必要的决定

  1. TaskExists 重构为 GetTaskInfo——原函数只返回蝦皮 goods_id,采集结果要按 PDD goods_id 落库
  2. 「采错商品」校验做成整体回滚的硬拒绝——不拦会把 B 的规格价格存到 A 名下,之后按它下单就是买错东西
  3. 复活时清空旧采集结果——否则复活后显示「已采集」但数据是删除前的

未验证到的部分

  • 界面未实现(工单明确排除),留给 #18
  • MarkCollecting / SoftDeletePddProduct 目前无生产调用方,等 #18 接上
  • artifact_ref 存 diagnostics 原始 JSON,未规范化,因 Client 侧尚未定义该结构
## 实施完成,待验收 - **实现提交:** `998c06a` feat: PDD 商品数据独立成表 (#16) - **归档提交:** `fab20cf` docs: 归档任务 #16 #17 - **归档文档:** `docs/task/16-admin-pdd-商品数据独立成表.md` ### 验证结果(Go 1.23.0) `go vet` / `gofmt -l .` 无输出;`go test ./... -count=1` **55 个测试全 PASS**。 审查时另行补验了工单未覆盖的 HTTP 层端到端: - 采回的 `goods_id` 与请求不符 → `422 COLLECT_GOODS_MISMATCH`, 拒绝后 `skus_json` 空、状态仍 `collecting`、任务仍 `claimed`、**幂等记录 0 条** (整体回滚干净;幂等记录若残留会导致客户端重试永远拿到缓存的失败响应) - 采到 0 个规格 → `200` 但状态置 `failed` 16 条验收标准全部通过,含两条安全核心:换 PDD 商品后查不到旧映射、换回后旧映射仍可用。 ### 三处超出工单但必要的决定 1. `TaskExists` 重构为 `GetTaskInfo`——原函数只返回蝦皮 `goods_id`,采集结果要按 PDD `goods_id` 落库 2. 「采错商品」校验做成**整体回滚的硬拒绝**——不拦会把 B 的规格价格存到 A 名下,之后按它下单就是买错东西 3. 复活时清空旧采集结果——否则复活后显示「已采集」但数据是删除前的 ### 未验证到的部分 - 界面未实现(工单明确排除),留给 #18 - `MarkCollecting` / `SoftDeletePddProduct` 目前**无生产调用方**,等 #18 接上 - `artifact_ref` 存 `diagnostics` 原始 JSON,**未规范化**,因 Client 侧尚未定义该结构
Author
Owner

验收通过,关闭

用户确认验收通过。归档状态已更新(3fdab7b)。

  • 实现提交: 998c06a
  • 归档: docs/task/16-admin-pdd-商品数据独立成表.md

遗留:artifact_ref 仍未规范化,需与 Client 侧一起定格式(转 #31 处理采集结果字段时一并考虑)。

## 验收通过,关闭 用户确认验收通过。归档状态已更新(`3fdab7b`)。 - **实现提交:** `998c06a` - **归档:** `docs/task/16-admin-pdd-商品数据独立成表.md` 遗留:`artifact_ref` 仍未规范化,需与 Client 侧一起定格式(转 #31 处理采集结果字段时一并考虑)。
ila closed this issue 2026-08-07 17:18:34 +08:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: chengma/cmautobuy#16