Files
cmautobuy/docs/task/38-admin-蝦皮excel报表导入.md
chengmaandClaude Opus 5 6d40c1e986 docs: 归档 #41 #43,记录 #38 #41 #43 验收通过
#41 归档记录了两条防信息丢失的设计(SKU 数列、待补列),以及最危险的
「部分失败」场景实测:28431952912 颜色4/尺码5 数字看着正常,
里面有 4 个 SKU 解析失败,靠整行标黄才藏不住。

#43 归档记录了实现踩到的 html/template URL 上下文转义坑:
夹在字面量 & 中间的动态内容会被整体当成一个参数值转义,
?/= 变成 %3F/%3D 让链接失效,只有真跑起来看 HTML 才发现。

也记了架构角色第二个变异第一次打偏、显示「跳过」的事——
探针无效和代码有问题是两回事,#38 刚犯过一次。

三份都如实列了「未验证到的部分」,主要是浏览器实机交互和
列宽百分比在 table-cell 上的实际行为。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:06:42 +08:00

244 lines
11 KiB
Markdown
Raw Permalink 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.
# 38 Admin 蝦皮 Excel 报表导入
- 类型:需求(数据导入)
- 父级大工单:#14
- 所属 MVP / 版本:#15 / MVP
- 关联:#16(`pdd_products` 独立成表)、#20(迁移只追加)
- 状态:验收通过
- 日期:2026-08-08
- Gitea 工单:http://ilaer.eicp.net:8418/chengma/cmautobuy/issues/38
## 背景与目标
蝦皮数据模块整个是骨架,四个入口全部返回 501,
`service.ImportShopeeExcel` 直接 `return nil, ErrNotImplemented`,
`excelize` 连依赖都没装。蝦皮那 5195 个商品一直躺在 Excel 里进不了系统。
## 建单前确认的事实(读真实样本得出)
样本 `raw_data/蝦皮数据样本.xlsx`(1.9MB,gitignore 不进库):
### ① 工作簿有 5 个 sheet,只有一个有数据
| Sheet | 列数 | 数据行 | 規格ID 为 `-` | 有規格ID |
|---|---|---|---|---|
| **最佳表現商品** | **40** | **11287** | **5195** | **6092** |
| 新上架商品 | 36 | 1145 | 0 | 0 |
| 高潛力廣告商品 / 優化商品廣告 / 追蹤商品廣告成效 | 30 | 41 / 35 / 7 | 0 | 0 |
其余四个是广告报表,**连 `商品規格ID` 列都没有**。
### ② 规格原文的真实分布
```
含【】 3252 53.4% 黑色,M【建議40-50公斤】
逗号+空格 1609 26.4% 卡其色拼黑色,L 建議50-57.5kg
只有逗号 1231 20.2% 黑色,M
```
**只认【】会漏掉 46.6%。**
### ③ 数据比预想干净,但有两类脏数据
全部 6092 行**恰好一个逗号**(0 行没逗号、0 行多逗号),
所以「按第一个逗号切开」是安全的,不是碰运气。
脏数据共 23 行:
```
21 行 括号不配对 紅色,3XL建議80-90公斤】
2 行 右半整个被【】包住 面膜安全褲3條膚色,【2xl 70-85公斤可穿】
```
## 最终方案
### upsert 白名单式,绝不碰人工字段
```sql
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(goods_id) DO UPDATE SET
title = excluded.title, shopee_status = excluded.shopee_status,
main_sku_code = excluded.main_sku_code, updated_at = excluded.updated_at
```
`[必须]` `pdd_goods_url` / `pdd_goods_id` **既不在 INSERT 列清单里,
也不在 DO UPDATE SET 里**。报表没有这两列,写进去就是写空值——
操作员攒几周的 PDD 链接会被一次导入洗光,而且不报错,
等到建采购任务才发现,那时已经找不回来。
`[必须]` 不写 `DELETE + INSERT` 的全量替换,会删掉将来手动新增的 SKU
(`05` §4.4 那个入口的伏笔)。
### 按 sheet 名字取,按列名找索引
`[必须]` 按名字取「最佳表現商品」,**不得用第 0 个 sheet**。
它现在恰好是第一个,但蝦皮调顺序时按下标会静默导入一张完全不相干的表。
找不到要报错并列出实际有哪些 sheet,不退回用第一个。
`[必须]` 按列名找索引。40 列里 32 列是统计指标,蝦皮加一列所有列号就错位,
**而且不报错**——会把「點擊率」当成「商品規格」存进去。
### 解析失败不猜
`[必须]` `ok == false` 时 `color` / `size` / `advice` **必须全为空**,
`parse_ok` 存 0,`spec_raw` 照存原文,界面上标出来让人工补。
括号不配对和右半整个被【】包住的,一律**判整体失败**,不逐段硬凑。
理由(实现写在 `ParseSpec` 注释里,比工单要求的更清楚):
这些脏数据的错法不统一(缺左括号 / 缺右括号 / 用「寬鬆版」代替「建議」),
针对每种错法单独写规则等于在猜,而且**没有人工核对过的正确答案可验证**。
## 与建单方案的差异
1. **括号不配对判整体失败**(工单允许实现决定,要求写清理由并有测试固定)。
实测命中 **23 行**,比建单时统计的 21 行多 2 行——多出的是
「右半整个被【】包住、括号外没有尺码文字」这个建单时没单独归类的格式,
同样归为解析失败。
2. **`ShopeeList` 无分页**。工单未要求;PDD / 客户端等既有列表页也是单次全量查询,
保持风格一致。
3. **导入结果不走 303 跳转,直接渲染页面**。失败行可能几十条,
塞进跳转的查询参数既丑又有长度限制,无法满足「失败行要能全部看到」。
代价是导入后 F5 会触发浏览器「重新提交表单」确认,已在 handler 注释里写明取舍。
## 改了哪些
- `admin/go.mod` / `go.sum`:加 `github.com/xuri/excelize/v2 v2.9.1`。
- `admin/service/shopee_import.go`:新建。`ParseSpec` + `ImportShopeeExcel` +
文件安全校验。
- `admin/service/shopee_import_test.go`:新建。表驱动解析测试 + 6 个导入场景,
固件用 excelize 现造(不依赖 `raw_data/`)。
- `admin/service/shopee_list.go`:新建。列表视图组装。
- `admin/service/service.go`:删掉骨架版的重复声明。
- `admin/repository/shopee.go`:新建。白名单式 upsert、列表查询。
- `admin/handler/web/shopee.go`:`ShopeeList` / `ShopeeImport` 真实实现;
其余三个保持 501。
- `admin/templates/shopee/list.html`:表格渲染 + 失败行完整列表。
- `admin/testdata/README.md`:更新为「固件现造」说明。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 三种规格格式都能解析 | 通过 |
| 颜色部分含【】时仍正确 | 通过 |
| 脏数据有确定行为 + 测试固定 + 注释写清理由 | 通过 |
| **`ok=false` 时三个字段全为空** | 通过(5 个失败分支变异全部被抓到) |
| 按 sheet **名字**取,找不到报错列出实际 sheet | 通过 |
| 按**列名**找索引,缺列报错 | 通过 |
| `商品規格ID == "-"` 归商品,其余归 SKU | 通过 |
| **已有的 `pdd_goods_url` / `pdd_goods_id` 导入后仍在** | 通过(有独立测试 + 端到端验证) |
| `is_manual = 1` 的 SKU 不被删除 | 通过(无全量替换) |
| 失败行返回行号 + 原文 + 原因,界面全部可见 | 通过 |
| 流式读,不是 `GetRows()` | 通过 |
| 非 `.xlsx` 拒绝(看内容不只看扩展名) | 通过 |
| 落盘用自己生成的文件名 | 通过(`shopee-<hex>.xlsx`) |
| 超过 50MB 拒绝 | 通过 |
| 测试不依赖 `raw_data/` | 通过(固件现造) |
| `ShopeeSave` / `ShopeeDelete` / `ShopeeCollect` 仍是 501 | 通过(3 处) |
| `go vet` / `gofmt` / `go test` 全过 | 通过 |
## 测试
架构角色亲自执行(`admin/` 目录)。**本次特意用固定版本工具链复跑**:
```
GOTOOLCHAIN=go1.23.0 go vet ./... 无输出
GOTOOLCHAIN=go1.23.0 gofmt -l . 无输出
GOTOOLCHAIN=go1.23.0 go test ./... -count=1
ok cmautobuy/admin 0.034s
ok cmautobuy/admin/repository 0.669s
ok cmautobuy/admin/service 1.974s
```
`excelize v2.9.1` 的 `go` 指令正好是 `1.23.0`,与项目固定版本一致。
### 真实样本端到端
```
① 第一次导入 导入完成:5195 个商品 / 6092 个 SKU,23 行解析失败
DB 实查 shopee_products=5195 shopee_skus=6092
② 手工填链接 UPDATE shopee_products SET pdd_goods_url='https://...', pdd_goods_id='999'
③ 再导一次 DB 计数不变(5195 / 6092),无重复行
链接还在:17197130343 | https://mobile.yangkeduo.com/... | 999
title 正常刷新
```
### 变异测试(架构角色补做,工单未要求)
**人工字段保护**——往 `DO UPDATE SET` 里加回 `pdd_goods_url = excluded.pdd_goods_url`:
```
--- FAIL: TestImportShopeeExcel_不覆盖人工字段
```
**`ParseSpec` 的 5 个失败分支**,逐个改成 `return "泄漏","泄漏","泄漏", false`:
| 行 | 分支 | 结果 |
|---|---|---|
| 337 | 空输入 | ✅ 3 个子用例变红 |
| 341 | 括号不配对 | ✅ 3 个 |
| 347 | 逗号数不为 1 | ✅ 3 个 |
| 354 | 逗号一侧为空 | ✅ 3 个 |
| 378 | 提取后尺码为空 | ✅ 2 个 |
**未验证到的部分:**
- **Windows / PowerShell 环境未实测**。验证全在 WSL/Linux 完成。
- **浏览器实机渲染未走查**。只验证了 HTTP 响应和 HTML 内容,
6092 行表格在 1366×768 下的列宽、横向滚动表现未看过。
- **打包成 exe 后的行为未验证**(`data/uploads/` 落盘路径)。
原有代码路径未改动,理论上不受影响,但没有为本工单重新验证。
## 审查过程
打回一次。
**打回原因:** `ParseSpec` 有两个失败分支没有测试覆盖。
测试文件里**有**「`ok=false` ⇒ 三个字段必须为空」的断言,
问题是没有用例能走到 `color==""||right==""` 和 `size==""` 这两个分支。
其中 `size==""` 尤其要紧——**它就是真实数据里那 2 行走的分支**
(`面膜安全褲3條膚色,【2xl 70-85公斤可穿】`)。如果有人把它改成
`return right, "", "", false`(看着「更有信息量」),真实导入会给这 2 行
存进一个乱七八糟的颜色,而且没有任何测试变红,这个值会一路传到
规格匹配和采购任务。
**架构角色的一次误判:** 第一轮变异打在「输入为空」那个分支上,
把 `return "", "", "", false` 改成 `return raw, ...`——`raw` 本来就是空串,
等于没改,测试当然全绿,差点据此报告「测试没牙」。
逐行重打之后才看清是哪两个分支真的没覆盖。
**探针无效和代码有问题是两回事**,得先确认变异真的改变了行为。
**返工结果:** 补了 4 条用例(含实现自己发现的第 5 个缺口——
`trimmed==""` 分支原本也没被真正覆盖,唯一的空输入用例 `raw:""`
在变异后 `raw` 仍是 `""`,抓不到;补一条 `raw:" "` 才能暴露)。
实现代码一字未动。
## 遗留问题
1. Windows 环境、浏览器实机渲染、exe 打包后的落盘路径均未验证。
2. `ShopeeSave` / `ShopeeDelete` / `ShopeeCollect` 仍是 501,属后续工单。
3. 手动新增 SKU(`05` §4.4)未做。蝦皮报表只含**有销售成绩的** SKU
(平均每个商品仅 1.17 个),导入进来的数据本身就是不全的。
## 附带修正(不属于本工单范围)
审查中发现 `/usr/local/go` 从 2026-07-02 起一直是 **1.26.5**,
而 #16 / #17 / #18 / #19 / #20 / #24 六份归档都写着
「架构角色亲自执行(Go 1.23.0)」——那是照项目固定版本抄的,
**没有实际验证过工具链**,违反 `CLAUDE.md` §9。
已做两件事:
1. 六份归档的措辞改正为不宣称具体版本。
2. `admin/AGENTS.md` 的验证章节新增 `[必须]`:交付前至少跑一次带
`GOTOOLCHAIN=go1.23.0` 的验证,并写明本项目发生过什么。
同时补了一条「不要只测全新库,先列出现实中存在哪些 schema 状态」(来自 #20)。
本工单已按新规则用 1.23.0 复验,结论不变。
## 相关提交
- `e29d668` feat: 蝦皮 Excel 报表导入 (#38)