@@ -0,0 +1,243 @@
# 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 )