diff --git a/docs/task/16-admin-pdd-商品数据独立成表.md b/docs/task/16-admin-pdd-商品数据独立成表.md new file mode 100644 index 0000000..f0ce232 --- /dev/null +++ b/docs/task/16-admin-pdd-商品数据独立成表.md @@ -0,0 +1,139 @@ +# 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) diff --git a/docs/task/17-admin-claim-支持领取无主任务.md b/docs/task/17-admin-claim-支持领取无主任务.md new file mode 100644 index 0000000..38005ca --- /dev/null +++ b/docs/task/17-admin-claim-支持领取无主任务.md @@ -0,0 +1,146 @@ +# 17 Admin claim 支持领取无主任务 + +- 类型:需求(含接口契约变更) +- 父级大工单:#14 +- 所属 MVP / 版本:#15 / MVP +- 状态:待验收 +- 日期:2026-08-07 +- Gitea 工单:http://ilaer.eicp.net:8418/chengma/cmautobuy/issues/17 + +## 背景与目标 + +PDD 商品页要能「创建采集任务」而**不指定客户端**——采集只是浏览商品页, +没有副作用,哪台设备采都一样,没必要每次都挑一台。 + +但原来 `claim` 的查询是: + +```sql +WHERE assigned_client = ? AND status = 'assigned' +``` + +**无主任务(`assigned_client IS NULL` + `status='pending'`)永远不会被任何客户端领到**, +建出来就是死的。 + +本工单**推翻了一条已定案的规则**:`docs/client/04-admin-api-contract.md` §5.1 +原写「Admin 只把任务分配给指定的 Client」。 + +## 最终方案 + +### 查询同时覆盖两种任务 + +```sql +WHERE ( (assigned_client = ? AND status = 'assigned') -- 指定给本机的 + OR (assigned_client IS NULL AND status = 'pending') ) -- 无主的 +ORDER BY (assigned_client IS NULL), priority DESC, created_at +``` + +**指定给本机的优先于无主的**。`(assigned_client IS NULL)` 求值为 0/1,0 排前面。 +理由:显式分配是人为决定,应当先兑现;无主任务谁抢都一样,可以等。 + +### 原子抢占合成一条语句 + +```sql +UPDATE tasks + SET status = 'claimed', assigned_client = ?, claimed_at = ?, updated_at = ? + WHERE task_id = ? + AND ( (status = 'assigned' AND assigned_client = ?) + OR (status = 'pending' AND assigned_client IS NULL) ) +``` + +对「指定给我的」那种,写 `assigned_client` 是写同一个值,无副作用; +对无主的,这一步就是**「谁领到就标记谁」**。检查影响行数防并发, +为 0 说明被抢先了,换下一条候选。 + +### 分配策略 + +| 任务类型 | 分配 | 理由 | +|---|---|---| +| 采集 | **不指定** | 只是浏览商品页,无副作用,哪台设备采都一样 | +| 采购 | **可指定,允许留空** | 涉及钱和账号——不同设备可能登着不同的拼多多账号,需要指定时能指定;留空即接受「谁先抢到谁去下单」 | + +Client 侧对这两种**没有任何区别**:调 `claim`,拿到任务就做,做完提交, +不需要知道任务原来有没有主。 + +### 未改动 + +表结构不用动——`assigned_client` 本来可空,`status` 的 `pending` 也已存在。 + +## 与建单方案的差异 + +无。按工单实施。 + +## 改了哪些 + +- `admin/repository/task.go`:`ClaimNextTask` 的查询条件、排序和原子更新。 +- `admin/service/claim_unassigned_test.go`:新建,7 个测试。 +- `docs/client/04-admin-api-contract.md`:§5.1 改写为两种任务并存并说明各自适用场景; + §10「已定案」那条同步。 +- `docs/admin/04-client-api.md`:§2 处理步骤、SQL 示例、§9 实现清单。 + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| 无主的采集任务能被领到 | 通过 | +| 领到后 `assigned_client` 被写成领取者编号 | 通过 | +| `status` 变 `claimed`,`claimed_at` 有值 | 通过 | +| **指定给本机的优先于无主的** | 通过 | +| 仍然领不到指定给别的客户端的任务 | 通过 | +| `supported_types` 过滤仍生效 | 通过 | +| **并发:多客户端抢同一无主任务,只有一个拿到** | 通过 | +| 领取后 `task_claims` 有对应记录 | 通过 | +| 两侧契约文档已更新,不再声称「只分配给指定 Client」 | 通过(全库无残留) | +| `go vet` / `gofmt` / `go test` 全过 | 通过 | + +## 测试 + +执行的命令(Go 1.23.0,`admin/` 目录): + +``` +go vet ./... 无输出 +gofmt -l . 无输出 +go test ./... -count=1 ok,62 个测试全 PASS(新增 7 个) +go test ./service/ -run '并发抢无主' -count=20 ok +``` + +新增的 7 个测试: + +``` +TestClaim_无主任务能被领到并标记领取者 +TestClaim_无主任务只能被领一次 +TestClaim_指定给本机的优先于无主的 +TestClaim_指定给别人的仍然领不到 +TestClaim_无主任务也受supported_types约束 +TestClaim_并发抢无主任务只有一个拿到 +TestClaim_无主任务领取后也记领取历史 +``` + +优先级那条特意构造成「无主任务的 `created_at` 更早」——没有优先级规则的话, +按 `created_at` 排序会先拿到无主的,测试就会红。 + +并发那条断言:8 个客户端抢同一条无主任务,**正好 1 个拿到**, +且库里 `assigned_client` 记的就是那个赢家。重复 20 次稳定。 + +**未验证到的部分:** + +- 未做 HTTP 层端到端验证(只验证了 service 层)。`claim` 接口本身未改, + 改动全在 repository 的查询里。 +- 未与真实 Client 联调——Client 的 `HttpAdminGateway` 目前只实现了 + `register_client`,`claim_next` 尚未实现。 + +## 遗留问题 + +**`tasks` 表没有字段标记「该任务需要真实下单」。** + +契约要求「不向只声明 `dry_run` 的 Client 分配需要真实下单的任务」, +但没有这个字段就无法执行。本工单把无主任务开放给所有客户端后, +这条风险被放大:一台声明 `dry_run` 的客户端可能抢到本该真实下单的任务。 + +当前真实下单开关默认关闭、全部走演练模式,不会出问题。 +**开启真实下单前必须补该字段**,与采购安全门禁(`docs/admin/06` §3)一并处理。 + +用户已确认暂不为此单独建工单,留在此处与 #14 Epic 的全局风险中记录。 + +## 相关提交 + +- `dd387e3` feat: claim 支持领取无主任务 (#17)