Files
cmautobuy/admin/service/shopee_import.go
T
chengmaandClaude Opus 5 e29d6683ad feat: 蝦皮 Excel 报表导入 (#38)
蝦皮数据模块此前整个是骨架,四个入口全返回 501,excelize 连依赖都没装。
本工单做导入和列表,Save/Delete/Collect 保持 501。

真实样本实测:5195 个商品 / 6092 个 SKU,23 行解析失败。

upsert 白名单式,pdd_goods_url / pdd_goods_id 既不在 INSERT 列清单里
也不在 DO UPDATE SET 里。报表没有这两列,写进去就是写空值——操作员
攒几周的 PDD 链接会被一次导入洗光,而且不报错,等到建采购任务才发现。
有独立测试守着:往 DO UPDATE SET 里加回这一行,测试立刻变红。

按 sheet 名字取「最佳表現商品」,不用第 0 个。工作簿有 5 个 sheet,
另外 4 个是广告报表,连 商品規格ID 列都没有;蝦皮调顺序时按下标
会静默导入一张完全不相干的表。

按列名找索引。40 列里 32 列是统计指标,蝦皮加一列所有列号就错位,
而且不报错——会把「點擊率」当成「商品規格」存进去。

规格解析三种格式:含【】53.4%、逗号+空格 26.4%、只有逗号 20.2%,
只认【】会漏掉 46.6%。括号不配对(21 行)和右半整个被【】包住(2 行)
一律判整体失败,不逐段硬凑——错法不统一,针对每种错法写规则等于在猜,
而且没有人工核对过的正确答案可验证。失败时 color/size/advice 必须全空,
原文存进 spec_raw 交人工补。

不写 DELETE + INSERT 的全量替换,将来手动新增的 SKU 会被删掉。

excelize v2.9.1 的 go 指令正好是 1.23.0,与项目固定版本一致。

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

382 lines
13 KiB
Go
Raw 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.
// 蝦皮 Excel 报表导入。
//
// 见 docs/admin/03-data-model.md §3.3(五步,缺一不可)和工单 #38。
// 改动前必读 admin/AGENTS.md 的分层约定:本层不碰 HTTP,也不拼 SQL。
package service
import (
"bytes"
"database/sql"
"fmt"
"path/filepath"
"strings"
"github.com/xuri/excelize/v2"
"cmautobuy/admin/repository"
)
// ---------- 文件安全 ----------
// MaxShopeeUploadBytes 是上传文件的大小上限。
//
// 真实样本 1.9MB,50MB 已经是它的 26 倍,正常的报表增长不会碰到这个上限;
// 设上限是为了防止恶意或误传的超大文件把磁盘和内存占满。
const MaxShopeeUploadBytes = 50 * 1024 * 1024
// xlsxMagic 是 zip 本地文件头的魔数。xlsx 本质上是一个 zip 包,
// 光看扩展名判断不了内容——文件改个后缀就能绕过,所以要连内容一起查。
var xlsxMagic = []byte{0x50, 0x4B, 0x03, 0x04}
// ValidateShopeeUpload 校验上传的文件是否可以接受。
// head 至少要传前 4 个字节(调用方从流里读出来,不需要读整个文件)。
func ValidateShopeeUpload(filename string, size int64, head []byte) error {
if size <= 0 {
return fmt.Errorf("文件是空的")
}
if size > MaxShopeeUploadBytes {
return fmt.Errorf("文件超过 50MB 上限(当前约 %.1fMB)", float64(size)/1024/1024)
}
if !strings.EqualFold(filepath.Ext(filename), ".xlsx") {
return fmt.Errorf("只允许 .xlsx 文件,收到的是 %q", filepath.Ext(filename))
}
if !bytes.HasPrefix(head, xlsxMagic) {
return fmt.Errorf("文件内容不像一个 xlsx(zip 格式)文件,可能是改了扩展名的其他文件")
}
return nil
}
// NewShopeeUploadFilename 生成落盘用的文件名。
//
// `[必须]` 不能用用户上传时的原始文件名拼路径——那是外部输入,
// 塞一个 "../../etc/passwd" 之类的名字就是路径穿越。
// 自己生成的名字只含十六进制字符,天然安全。
func NewShopeeUploadFilename() string {
return "shopee-" + newID() + ".xlsx"
}
// ---------- 导入 ----------
// ShopeeSheetName 是报表里真正装数据的那个 sheet。
//
// `[必须]` 按名字取,不许用第 0 个下标。工作簿另外还有 4 个 sheet
// (新上架商品 / 高潛力廣告商品 / 優化商品廣告 / 追蹤商品廣告成效),
// 全是广告报表,连「商品規格ID」列都没有。它现在恰好是第 0 个,
// 但蝦皮调整报表顺序后,按下标取会静默导入一张完全不相干的表。
const ShopeeSheetName = "最佳表現商品"
// shopeeRequiredColumns 是导入必须用到的 8 个业务列。
//
// `[必须]` 按列名找索引,不写死列号:报表现在是 40 列,
// 蝦皮加一列统计指标所有列号就全部错位,而且不报错——
// 会把「點擊率」当成「商品規格」存进去。
var shopeeRequiredColumns = []string{
"商品ID", "商品名稱", "商品當前狀態", "商品規格ID",
"商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號",
}
// ImportFailure 是一行解析失败的记录,行号是 Excel 里的真实行号
// (从 1 开始,含表头),方便操作员直接去 Excel 里核对。
//
// `[必须]` 不许静默跳过失败行——返回结构里必须带行号、原文和原因,
// 界面上要能全部看到,不能只显示"失败 N 行"。
type ImportFailure struct {
Row int
Raw string
Reason string
}
// ImportResult 是一次导入的统计结果,要显示给操作员看。
type ImportResult struct {
ProductCount int
SKUCount int
Failures []ImportFailure
}
// shopeeProductRow / shopeeSKURow 是解析阶段的中间结果。
//
// 为什么先解析进内存、再统一写库,而不是边读边写:
// SKU 行要靠 goods_id 外键指向 shopee_products,写入顺序必须是
// "全部商品先写完,再写 SKU"。工单没有保证 Excel 里商品汇总行
// 一定排在它的 SKU 行前面(样本里恰好是,但不能依赖这个巧合)。
// 两阶段比"假设文件里的行序"更可靠,11287 行的内存开销可以忽略。
//
// 这不违反"流式读,不要 GetRows()"的要求——那条针对的是 excelize
// 内部一次性把 40 列全部读出来的开销,这里用 Rows() 逐行读、
// 只把用得到的 8 个字段摘出来存进自己的小结构体。
type shopeeProductRow struct {
GoodsID string
Title string
ShopeeStatus string
MainSKUCode string
}
type shopeeSKURow struct {
SKUID string
GoodsID string
SpecRaw string
Color string
Size string
Advice string
ParseOK bool
SKUCode string
}
// ImportShopeeExcel 解析蝦皮报表并 upsert 进库。
//
// 五步(docs/admin/03-data-model.md §3.3):
// 1. 按 sheet 名字取数据,分行(商品規格ID == "-" 是商品汇总行,其余是 SKU 行);
// 2. 按列名找索引;
// 3. 解析规格原文,解析失败就留空、不猜;
// 4. upsert,绝不清空、绝不碰人工字段;
// 5. 返回统计(含失败行号)。
func ImportShopeeExcel(db *sql.DB, path string) (*ImportResult, error) {
f, err := excelize.OpenFile(path)
if err != nil {
return nil, fmt.Errorf("打开 Excel 文件失败: %w", err)
}
defer f.Close()
if !hasSheet(f, ShopeeSheetName) {
return nil, fmt.Errorf(
"工作簿里没有「%s」这个 sheet,实际有的 sheet 是: %s",
ShopeeSheetName, strings.Join(f.GetSheetList(), "、"))
}
rows, err := f.Rows(ShopeeSheetName)
if err != nil {
return nil, fmt.Errorf("读取 sheet「%s」失败: %w", ShopeeSheetName, err)
}
defer rows.Close()
if !rows.Next() {
return nil, fmt.Errorf("sheet「%s」是空的,连表头都没有", ShopeeSheetName)
}
header, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("读取表头失败: %w", err)
}
colIdx, err := shopeeColumnIndex(header)
if err != nil {
return nil, err
}
var (
products []shopeeProductRow
skus []shopeeSKURow
failures []ImportFailure
)
seenGoods := map[string]bool{}
rowNum := 1 // 表头是第 1 行
for rows.Next() {
rowNum++
cells, err := rows.Columns()
if err != nil {
failures = append(failures, ImportFailure{
Row: rowNum, Reason: "读取这一行失败: " + err.Error(),
})
continue
}
goodsID := strings.TrimSpace(cellAt(cells, colIdx["商品ID"]))
if goodsID == "" {
failures = append(failures, ImportFailure{Row: rowNum, Reason: "商品ID 为空,整行跳过"})
continue
}
specID := strings.TrimSpace(cellAt(cells, colIdx["商品規格ID"]))
if specID == "" || specID == "-" {
// 商品汇总行:一个商品在报表里只出现一次,但防御性地去重,
// 避免万一撞见重复行时把同一个商品塞进 products 两次,
// 让 ProductCount 统计出错。
if !seenGoods[goodsID] {
seenGoods[goodsID] = true
products = append(products, shopeeProductRow{
GoodsID: goodsID,
Title: strings.TrimSpace(cellAt(cells, colIdx["商品名稱"])),
ShopeeStatus: strings.TrimSpace(cellAt(cells, colIdx["商品當前狀態"])),
MainSKUCode: strings.TrimSpace(cellAt(cells, colIdx["主商品貨號"])),
})
}
continue
}
// SKU 行。解析失败不代表这一行不导入——spec_raw 仍然要存,
// 只是 color/size/advice 留空、parse_ok=0,操作员在界面上人工补,
// 见 ParseSpec 的注释。
specRaw := cellAt(cells, colIdx["商品規格"])
color, size, advice, ok := ParseSpec(specRaw)
if !ok {
failures = append(failures, ImportFailure{
Row: rowNum, Raw: specRaw,
Reason: "规格原文解析不了,已按原文保存,parse_ok=0,需要人工补颜色/尺码",
})
}
skus = append(skus, shopeeSKURow{
SKUID: specID,
GoodsID: goodsID,
SpecRaw: specRaw,
Color: color,
Size: size,
Advice: advice,
ParseOK: ok,
SKUCode: strings.TrimSpace(cellAt(cells, colIdx["商品選項貨號"])),
})
}
if err := rows.Error(); err != nil {
return nil, fmt.Errorf("读取 sheet「%s」出错: %w", ShopeeSheetName, err)
}
// `[建议]` 整个导入放一个事务里,失败整体回滚——
// 导入一半的数据比没导入更难收拾。
tx, err := db.Begin()
if err != nil {
return nil, fmt.Errorf("开始导入事务失败: %w", err)
}
defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全
for _, p := range products {
if err := repository.UpsertShopeeProduct(tx, p.GoodsID, p.Title, p.ShopeeStatus, p.MainSKUCode); err != nil {
return nil, err
}
}
for _, s := range skus {
if err := repository.UpsertShopeeSKU(
tx, s.SKUID, s.GoodsID, s.SpecRaw, s.Color, s.Size, s.Advice, s.ParseOK, s.SKUCode,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交导入事务失败: %w", err)
}
return &ImportResult{
ProductCount: len(products),
SKUCount: len(skus),
Failures: failures,
}, nil
}
func hasSheet(f *excelize.File, name string) bool {
for _, s := range f.GetSheetList() {
if s == name {
return true
}
}
return false
}
// shopeeColumnIndex 按表头文字算出每个必需列的下标。
// 缺列时把缺的列名全部列出来,不要只报第一个——操作员一次能看全,
// 不用改一列、重传、再发现缺下一列。
func shopeeColumnIndex(header []string) (map[string]int, error) {
idx := map[string]int{}
for i, name := range header {
idx[strings.TrimSpace(name)] = i
}
var missing []string
for _, name := range shopeeRequiredColumns {
if _, ok := idx[name]; !ok {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("表头缺少必需列: %s(实际表头: %s)",
strings.Join(missing, "、"), strings.Join(header, "、"))
}
return idx, nil
}
// cellAt 安全取某一列的值。
// excelize 的 Columns() 只返回到这一行最后一个非空单元格为止,
// 行尾有空单元格被省略是正常情况,不是数据问题,越界当空值处理。
func cellAt(cells []string, i int) string {
if i < 0 || i >= len(cells) {
return ""
}
return cells[i]
}
// ---------- 规格解析 ----------
// ParseSpec 把蝦皮的规格原文拆成颜色、尺码、建议。
//
// 输入样例(实测两种格式各占一半):
//
// "黑色,M【建議40-50公斤】" -> 黑色 / M / 40-50公斤 / true
// "卡其色拼黑色,L 建議50-57.5kg" -> 卡其色拼黑色 / L / 50-57.5kg / true
// "黑色,M" -> 黑色 / M / "" / true
// "莫名其妙的格式" -> "" / "" / "" / false
//
// 最后一个返回值是 ok;false 时前三个必须为空,**不要猜**——
// 猜出来的颜色尺码会一路传到采购任务,最后买错东西。
//
// # 21 行括号不配对的脏数据怎么处理
//
// 实测样本里有 21 行(约 0.3%)出现【】不配对,例如:
//
// "紅色,3XL建議80-90公斤】" 缺左括号
// "粉色,3XL【寬鬆版 82.5-92.5kg" 缺右括号,而且用的是"寬鬆版"不是"建議"
//
// 这里选择的行为是:**只要整条原文里【和】数量不相等,就整体判定
// ok=false**,不去猜哪一段该算尺码、哪一段该算建议。
//
// 理由:这 21 行的错法并不统一——有的缺左括号、有的缺右括号、
// 有的干脆用别的词代替"建議"。想"能提取多少算多少"就得针对每种
// 错法单独写规则,规则越写越多,而且没有办法验证这些规则对不对
// (没有人工核对过的正确答案)。相比之下,"括号不配对就是脏数据,
// 交给人工看"是一条简单、每次都一样、不会越猜越错的规则,
// 符合 admin/AGENTS.md「蝦皮规格原文永远保留,解析不出来就留空,
// 不要瞎猜」。原文仍然会存进 spec_raw,界面上标出来即可。
func ParseSpec(raw string) (color, size, advice string, ok bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", "", "", false
}
if strings.Count(trimmed, "【") != strings.Count(trimmed, "】") {
return "", "", "", false
}
// 实测「颜色,尺码」之间的逗号数恒为 1(6092 条 SKU 无例外)。
// 不是恰好一个逗号,说明格式超出了已知范围,不要猜哪个逗号才是分隔符。
if strings.Count(trimmed, ",") != 1 {
return "", "", "", false
}
parts := strings.SplitN(trimmed, ",", 2)
color = strings.TrimSpace(parts[0])
right := strings.TrimSpace(parts[1])
if color == "" || right == "" {
return "", "", "", false
}
openIdx := strings.Index(right, "【")
closeIdx := strings.LastIndex(right, "】")
switch {
case openIdx >= 0 && closeIdx > openIdx:
// 「M【建議40-50公斤】」「L 【57.5/70公斤】」这类格式:
// 括号里是建议,括号外是尺码。
size = strings.TrimSpace(right[:openIdx])
advice = strings.TrimSpace(right[openIdx+len("【") : closeIdx])
advice = strings.TrimSpace(strings.TrimPrefix(advice, "建議"))
case strings.Contains(right, "建議"):
// 「L 建議50-57.5kg」这类没有括号、靠"建議"二字分隔的格式。
i := strings.Index(right, "建議")
size = strings.TrimSpace(right[:i])
advice = strings.TrimSpace(right[i+len("建議"):])
default:
// 「M」这种只有尺码、没有建议的格式。
size = right
}
if size == "" {
return "", "", "", false
}
return color, size, advice, true
}