Files
cmautobuy/docs/task/16-admin-pdd-商品数据独立成表.md
T
chengmaandClaude Opus 5 fab20cfd2f docs: 归档任务 #16 #17
按 AGENTS.md §9 补上两份归档。内容是审查时实跑的结果,不是复述报告。

#16 PDD 商品数据独立成表
- 记录三处超出工单但必要的决定:TaskExists → GetTaskInfo、
  采错商品做成整体回滚的硬拒绝、复活时清空旧采集结果
- 记录审查时补验的 HTTP 层端到端:422 拒绝后幂等记录 0 条
  (若残留会导致客户端重试永远拿到缓存的失败响应)
- 三项未验证:界面未实现、MarkCollecting/SoftDeletePddProduct 无生产调用方、
  artifact_ref 未规范化

#17 claim 支持领取无主任务
- 记录契约变更:§5.1 从「只分配给指定 Client」改为两种并存
- 记录优先级测试的构造方式(无主任务 created_at 更早,
  没有优先级规则时会红)
- 遗留:tasks 表缺「需要真实下单」标记,本次把无主任务开放后风险被放大,
  开启真实下单前必须补

两个工单保持 open,等用户验收。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:02:21 +08:00

140 lines
6.8 KiB
Markdown
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.
# 16 Admin PDD 商品数据独立成表
- 类型:重构(数据模型)
- 父级大工单:#14
- 所属 MVP / 版本:#15 / MVP
- 状态:待验收
- 日期:2026-08-07
- Gitea 工单:http://ilaer.eicp.net:8418/chengma/cmautobuy/issues/16
## 背景与目标
原来 `pdd_data` 是 `shopee_products` 上的一个 JSON 字段,两个蝦皮商品指向同一个
PDD 链接时会各存一份、各采一次;`collect_status` 描述的是 PDD 商品的状态,
却挂在蝦皮商品上,两份可能不一致。
更要紧的是 **PDD 商品变动频繁**(A 下架就得换成 B),而 `sku_mappings` 只按
`shopee_sku_id` 做键,换商品后旧映射还在:
- B 没有同名规格 → 建任务报错,尚且安全
- **B 恰好有同名规格但完全是另一件货 → 静默买错,事后查不出来**
目标是把 PDD 商品变成独立实体,并让规格映射按「蝦皮 SKU + PDD 商品」隔离。
## 最终方案
- 新增 `pdd_products` 表:`id` 主键 + `goods_id UNIQUE` + `skus_json` +
`collect_status` / `collect_msg` / `artifact_ref` / `collected_at` + `deleted_at`。
- `collect_status` 只有 4 个值(`pending` / `collecting` / `collected` / `failed`)。
去掉 `no_link`——这张表里有这一行就说明链接填了;「未填链接」改由
`shopee_products.pdd_goods_id IS NULL` 表达。
- 软删除可复活:按 `goods_id` 查(含已删除的),查到已删除的就清 `deleted_at`、
状态回 `pending`,不新增行(`UNIQUE` 会冲突)。
- `shopee_products` 去掉 `pdd_data` / `collect_status` / `collect_error` /
`collected_at`,`pdd_goods_id` 改为指向 `pdd_products`。
- `sku_mappings` 主键改为 `(shopee_sku_id, pdd_goods_id)`,新增 `pdd_option_key`。
查映射永远带上当前 PDD 商品,换商品后天然查不到旧映射,**不需要删数据**;
换回原商品时旧映射直接复用。
- 新增 `OptionKey()`:用 `json.Marshal` 实现——Go 序列化 map 时按键名排序,
天然规范化,不用自己拼字符串(规格文字里可能含 `=` 或 `;`)。
**存映射和查 SKU 必须用同一个函数**,各写一遍会静默算出不同结果。
- 采集结果改落 `pdd_products`,按 **PDD** `goods_id` 定位(不是蝦皮的)。
- 新增两条校验:Client 返回的 `goods_id` 与请求不符 → 整体回滚拒绝;
`skus` 为空数组 → 置 `failed` 而非 `collected`。
- migrations 直接改 v1(当时无业务代码依赖),不加新迁移。
## 与建单方案的差异
实施中有三处超出工单字面,均已确认必要:
1. **`TaskExists` 重构为 `GetTaskInfo`。** 原函数只返回蝦皮 `goods_id`,
而采集结果要按 PDD `goods_id` 落库,不改取不到正确的键。
2. **「采错商品」校验做成整体回滚的硬拒绝**,并新增 `ErrCollectMismatch`(HTTP 422)。
工单只说「校验」,实现选了最严的做法:不拦的话会把 B 的规格价格存到 A 名下,
之后按它下单就是买错东西。
3. **复活时一并清空旧采集结果**(`skus_json` / `collect_msg` / `collected_at`)。
工单只说清 `deleted_at`。记录被删过一次,旧数据不该再当有效的用,
否则复活后会显示「已采集」但数据是删除前的。
另删除 `admin/repository/shopee.go`——两个函数签名全变且都迁到了 `pdd.go`,留着是死代码。
## 改了哪些
- `admin/repository/db.go`:migrations v1 重写(`pdd_products` 新增,
`shopee_products` / `sku_mappings` 调整)。
- `admin/repository/pdd.go`:新建。PDD 商品读写、`EnsurePddProduct` 三分支
(新建 / 直接用 / 复活)、`MarkCollecting`、`SoftDeletePddProduct`、
采集结果与失败落库。
- `admin/repository/shopee.go`:删除。
- `admin/repository/task.go`:`TaskExists` → `GetTaskInfo`,返回 `TaskInfo`。
- `admin/service/optionkey.go`:新建。`OptionKey()` 单一实现。
- `admin/service/submit.go`:采集分支改写新表,增加两条校验。
- `admin/model/model.go`:`PddProduct` 结构,`CollectStatus` 减为 4 个值。
- `admin/handler/api/client_api.go`:`ErrCollectMismatch` → 422 映射。
- `admin/service/pdd_test.go`:新建,14 个测试。
- `admin/service/submit_test.go`:两个采集测试改为针对 `pdd_products`。
- `docs/admin/00-glossary.md` / `01-requirements.md` / `03-data-model.md` /
`04-client-api.md` / `05-ui-specification.md`:同步。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| `pdd_products` 建表,`goods_id` 有 UNIQUE | 通过 |
| `collect_status` 只接受 4 个值 | 通过 |
| `shopee_products` 已去掉四个字段 | 通过(现剩 8 列) |
| `sku_mappings` 主键为 `(shopee_sku_id, pdd_goods_id)` | 通过 |
| OptionKey:键顺序不影响结果 | 通过 |
| OptionKey:特殊字符不影响 | 通过 |
| OptionKey:空组合有明确行为不 panic | 通过 |
| 软删除后默认查询查不到 | 通过 |
| 软删除后同 `goods_id` 再保存复活原行 | 通过 |
| 采集成功写入 `skus_json`,状态 `collected` | 通过 |
| `goods_id` 与请求不符 → 拒绝,不写 `skus_json` | 通过 |
| `skus` 为空 → 置 `failed` | 通过 |
| **换 PDD 商品后查 B 的映射查不到 A 的** | 通过 |
| **换回 A 后 A 的映射仍可用** | 通过 |
| `go vet` / `gofmt` / `go test` 全过 | 通过 |
| 文档同步,链接与章节引用校验 | 通过(各 0 处失效) |
## 测试
执行的命令(Go 1.23.0,`admin/` 目录):
```
go vet ./... 无输出
gofmt -l . 无输出
go test ./... -count=1 ok,55 个测试全 PASS
```
审查时另行补验了工单未覆盖的 HTTP 层端到端:
```
① 采回的 goods_id 与请求不符
→ 422 COLLECT_GOODS_MISMATCH
→ 消息「请求采集的是商品 PDD-111,客户端返回的却是 PDD-999」
→ 拒绝后:skus_json 空、状态仍 collecting、任务仍 claimed、幂等记录 0 条
(整体回滚干净,无半写状态;幂等记录若残留会导致客户端重试永远拿到缓存的失败响应)
② 采到 0 个规格
→ 200,但 collect_status = failed
→ msg「未采集到任何规格,请检查商品是否已下架」
```
**未验证到的部分:**
- 界面上「采集状态来自两张表」的判断逻辑未实现(工单明确排除界面),只改了文档说明。
- `MarkCollecting` / `SoftDeletePddProduct` 目前只有测试在调,**无生产调用方**,
等 #18 的界面接上。
- `artifact_ref` 目前直接存 `diagnostics` 的原始 JSON,**未按
`client-001:artifacts/...` 格式规范化**,因为 Client 侧尚未定义 `diagnostics` 结构。
## 遗留问题
1. `artifact_ref` 的格式需要与 Client 侧一起定,否则两边各存各的。
2. `MarkCollecting` / `SoftDeletePddProduct` 在 #18 完成前是死代码。
## 相关提交
- `998c06a` feat: PDD 商品数据独立成表 (#16)