Files
cmautobuy/docs/task/16-admin-pdd-商品数据独立成表.md
T
chengmaandClaude Opus 5 10b724f62a docs: 归档 #38;改正六份归档里未经验证的 Go 版本说法
#38 归档记录了打回一次的经过(ParseSpec 两个失败分支无测试覆盖,
其中 size=="" 就是真实数据里 2 行走的分支),以及架构角色第一轮
变异打在死分支上、差点误报「测试没牙」的过程。

改正:/usr/local/go 从 2026-07-02 起一直是 1.26.5,而六份归档都写着
「架构角色亲自执行(Go 1.23.0)」——那是照项目固定版本抄的,
没有实际验证过工具链,违反 CLAUDE.md §9「说验证过必须真的跑过」。
措辞改为不宣称具体版本。已用 GOTOOLCHAIN=go1.23.0 补验,结论不变。

admin/AGENTS.md 加两条 [必须]:
- 交付前至少跑一次带 GOTOOLCHAIN=go1.23.0 的验证。开发机装的可能更新,
  Go 会默默用它编译,测过的不是要交付的那个版本。新加依赖时尤其要跑。
- 改数据库时不要只测全新库,先列出现实中存在哪些 schema 状态再一个个验(来自 #20)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:59:34 +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 处失效) |
## 测试
执行的命令(`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)