# 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-.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)