382 lines
13 KiB
Go
382 lines
13 KiB
Go
// 蝦皮 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
|
|||
|
|
}
|