diff --git a/docs/task/18-admin-pdd-商品页面.md b/docs/task/18-admin-pdd-商品页面.md new file mode 100644 index 0000000..cc068a8 --- /dev/null +++ b/docs/task/18-admin-pdd-商品页面.md @@ -0,0 +1,179 @@ +# 18 Admin PDD 商品页面 + +- 类型:需求(界面) +- 父级大工单:#14 +- 所属 MVP / 版本:#15 / MVP +- 状态:待验收 +- 日期:2026-08-07 +- Gitea 工单:http://ilaer.eicp.net:8418/chengma/cmautobuy/issues/18 + +## 背景与目标 + +#16 把 PDD 商品做成了独立的 `pdd_products` 表,但没有任何界面能操作它—— +`MarkCollecting` 和 `SoftDeletePddProduct` 写完之后一直是死代码。 + +这个页面是**打通 Client 和 Admin 闭环的那一环**:操作员在这里录入 PDD 链接、 +发起采集任务,客户端领走去采,采回来的规格和价格显示在这里, +后面的规格匹配和采购任务才有数据可用。 + +## 最终方案 + +三段式布局,与其他页面一致:工具条 / 表格 / 底部状态条。 + +### 链接解析必须严格 + +`ErrBadPddURL` **不做容错兜底**。`goods_id` 上有 UNIQUE 约束,防重全靠它—— +解析不出来就直接报错让操作员重新复制链接。兜底猜一个的话,同一个商品会存成 +好几行,采好几遍,规格映射还说不清指向哪一行。 + +- 认 `yangkeduo.com` 和 `pinduoduo.com`(含子域名) +- 认 `goods_id` 和 `_x_goods_id` 两个参数名 +- **短链一律拒绝**,提示操作员回 App 里「分享 → 复制链接」拿完整链接 +- 卡域名是为了拦「粘错链接」——粘了淘宝链接却存进 PDD 表, + 要等客户端跑到手机上才会发现 + +### 创建采集任务:先占状态再建任务 + +```go +ok, err := repository.MarkCollecting(tx, goodsID) // 只在 pending/failed 时成功 +if !ok { skipped++; continue } +repository.InsertCollectTask(tx, newCollectTaskID(), p.GoodsID, p.URL) +``` + +`MarkCollecting` 同时起两个作用:占状态,以及**判断「这个商品有没有人已经在采」**。 +两者在同一个事务里,所以并发点多次只会建出一个任务。 + +采集任务**不指定客户端**(`assigned_client = NULL` + `status = 'pending'`), +靠 #17 的无主任务领取机制。 + +跳过了几个必须在提示里说出来,否则操作员会以为都建上了,等半天没动静。 + +### 规格显示需要 dimensions + +`submit.go` 的 `collectedData` 增加了 `Dimensions` 字段。没有它就只能按 Go 的 +map 遍历,而 map 是无序的——同一个商品每次刷新页面「颜色 / 尺码」的先后都可能变。 + +### 工具条用一个表单 + formaction + +工单画的是两个独立按钮,实现改成一个表单、按钮上用 `formaction` 区分动作。 +两套勾选框迟早不同步。 + +## 与建单方案的差异 + +实施中有四处超出工单字面: + +1. **`INSERT INTO tasks` 从 service 层提到 repository 层**。原先写在 + `service/pdd.go` 里,违反 `admin/AGENTS.md:71`「只有 repository 能写 SQL」。 + 改为 `repository.InsertCollectTask`。 +2. **工具条用一个表单 + `formaction`**,理由见上。 +3. **修了 #16 的一个缺陷**:`EnsurePddProduct` 复活分支清空了 `skus_json` + 却漏了 `title`,导致复活后状态显示「未采集」但标题还留着旧值。 + #16 验收要求「采集结果被清空」,`title` 是 `SetCollectResult` 写的, + 属于采集结果,所以一并清。 +4. **弹窗底部的「重新采集」**走同一条创建路径,只针对当前商品。工单未提。 + +## 改了哪些 + +- `admin/service/pdd.go`:新建。链接解析、列表查询组装、详情、 + `CreatePddCollectTasks`、状态文案、价格格式化、维度排序。 +- `admin/handler/web/pdd.go`:新建。六个路由的处理。 +- `admin/templates/pdd/list.html` / `edit_modal.html`:新建。 +- `admin/repository/pdd.go`:`pddColumns` 常量 + `scanPddProduct` 统一扫描; + 复活分支补清 `title`;列表查询与筛选。 +- `admin/repository/task.go`:`InsertCollectTask`。 +- `admin/service/submit.go`:`collectedData` 增加 `Dimensions`。 +- `admin/handler/web/web.go`:六个路由注册。 +- `admin/static/css/app.css` / `js/app.js`:弹窗、截断、下拉样式与交互。 +- `admin/templates/partials/header.html`:导航加「PDD 商品」。 +- `admin/service/pdd_page_test.go`:新建,32 个用例。 +- `docs/admin/01-requirements.md` / `05-ui-specification.md`:同步。 + +审查打回后另修: + +- `docs/admin/01-requirements.md` / `admin/handler/web/shopee.go` / + `admin/templates/shopee/list.html`:清理「PDD 链接唯一的录入口」的过期说法。 +- `admin/handler/web/shopee.go`:`no_link` 的过期 TODO。 + +## 验收结果 + +全部 12 项通过。架构角色独立复跑的重点项: + +| 检查 | 结果 | +|---|---| +| 链接解析:短链拒绝 | 400,且不写库 | +| 链接解析:非 PDD 域名拒绝 | 400,且不写库 | +| **并发:4 个请求同时对同一商品建采集任务** | **只产生 1 条**,商品置 `collecting` | +| CSRF:无 token 的 POST | 403 | +| 端到端:建商品 → 建任务 → 领取 → 提交 → 刷新 | 状态流转正确,规格与价格显示正常 | +| **商品标题 XSS** | 列表页与弹窗**均已转义**,原样输出 0 次 | +| 13 个规格的弹窗 | 14 行、3808 字节,`price_cent: null` 显示「未采到」 | +| 软删除后列表消失,重建同链接复活原行 | id 不变,`title` / `skus_json` 已清空 | +| 五个页面回归 | 全部 200 | + +## 测试 + +架构角色亲自执行(Go 1.23.0,`admin/` 目录): + +``` +go vet ./... 无输出 +gofmt -l . 无输出 +go test ./... -count=1 ok cmautobuy/admin/service 0.945s +``` + +本工单新增 32 个用例。 + +审查时补测了工单和实现报告都未覆盖的两项: + +``` +① 并发建采集任务 + 4 个 curl 同时 POST /pdd/collect,同一个 goods_id + → tasks 表里正好 1 条 COL-64cd722f5fb938ab + → pdd_products.collect_status = collecting + (MarkCollecting 在事务里同时充当"有没有人在采"的判断, + 单线程读代码看不出它扛不扛并发,跑一次才知道) + +② 商品标题 XSS + Client 提交 title = "正常标题" + 规格里含 "" + → 列表页原样