refactor: 切换蝦皮数据到目录接口 (#135)

This commit is contained in:
chengma
2026-08-11 10:16:48 +08:00
parent 8f68845dfd
commit 0588ef18c8
20 changed files with 84 additions and 1043 deletions
-5
View File
@@ -24,11 +24,6 @@ var ErrNotImplemented = errors.New("功能尚未实现")
// ErrTaskNotVisible 同时表示任务不存在和不在当前账号范围,避免泄露他人任务。
var ErrTaskNotVisible = errors.New("任务不存在或不在当前账号可见范围")
// ---------- 蝦皮 Excel 导入 ----------
//
// ImportResult / ImportShopeeExcel / ParseSpec 的实现见 shopee_import.go,
// 那个文件还负责上传文件的安全校验(大小、扩展名、内容魔数)。
// ---------- 采集 ----------
// CreateCollectTasks 为若干蝦皮商品创建采集任务。
-381
View File
@@ -1,381 +0,0 @@
// 蝦皮 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
}
-381
View File
@@ -1,381 +0,0 @@
package service
import (
"database/sql"
"path/filepath"
"strings"
"testing"
"github.com/xuri/excelize/v2"
"cmautobuy/admin/model"
)
// ── ParseSpec ──────────────────────────────────────────
func TestParseSpec(t *testing.T) {
cases := []struct {
name string
raw string
wantColor string
wantSize string
wantAdvice string
wantOK bool
}{
{
name: "带【】的建议", raw: "黑色,M【建議40-50公斤】",
wantColor: "黑色", wantSize: "M", wantAdvice: "40-50公斤", wantOK: true,
},
{
name: "空格分隔的建议", raw: "卡其色拼黑色,L 建議50-57.5kg",
wantColor: "卡其色拼黑色", wantSize: "L", wantAdvice: "50-57.5kg", wantOK: true,
},
{
name: "只有颜色和尺码没有建议", raw: "黑色,M",
wantColor: "黑色", wantSize: "M", wantAdvice: "", wantOK: true,
},
{
name: "颜色自己也带【】", raw: "黑色 【夏裝單件T恤】,L 【57.5/70公斤】",
wantColor: "黑色 【夏裝單件T恤】", wantSize: "L", wantAdvice: "57.5/70公斤", wantOK: true,
},
{
// 真实样本里的脏数据:缺左括号。见 ParseSpec 注释里"21 行括号
// 不配对怎么处理"一节——整体判定失败,不猜。
name: "括号不配对_缺左括号", raw: "紅色,3XL建議80-90公斤】",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
// 真实样本里的另一种脏数据:缺右括号,而且用"寬鬆版"代替"建議"。
name: "括号不配对_缺右括号", raw: "粉色,3XL【寬鬆版 82.5-92.5kg",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
name: "完全认不出的格式_没有逗号", raw: "莫名其妙的格式",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
name: "两个逗号_超出已知范围", raw: "黑色,M,多余",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
name: "空字符串", raw: "",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
// raw 本身非空(trim 前有内容),但 trim 后为空——用来跟"空字符串"
// 那条区分开:如果 trimmed=="" 分支直接把 raw 当返回值,
// 这条能抓到(raw!=""),"空字符串"那条抓不到(raw 本身就是 "")。
name: "只有空白字符", raw: " ",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
// 逗号右侧为空——只有颜色,没有尺码信息。
name: "逗号右侧为空", raw: "黑色,",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
// 逗号左侧为空——只有尺码,没有颜色信息。
name: "逗号左侧为空", raw: ",M",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
// 真实样本第 5454 行:右半整个被【】包住,括号外没有尺码文字,
// 提取后 size 为空。这是真实数据里 23 行失败中的 2 行之一,
// 直接用真实原文而不是自造用例,证明的是"这个真实格式我们
// 确实认不出来,而且认不出来时不猜",不只是代码逻辑自洽。
name: "右半整个被【】包住_size提取后为空", raw: "面膜安全褲3條膚色,【2xl 70-85公斤可穿】",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
color, size, advice, ok := ParseSpec(tc.raw)
if ok != tc.wantOK {
t.Fatalf("ok = %v,想要 %v(raw=%q)", ok, tc.wantOK, tc.raw)
}
if !ok {
// [必须] ok=false 时前三个必须全为空,不许出现"猜出来的一半"。
if color != "" || size != "" || advice != "" {
t.Fatalf("ok=false 时 color/size/advice 必须全为空,得到 %q/%q/%q",
color, size, advice)
}
return
}
if color != tc.wantColor || size != tc.wantSize || advice != tc.wantAdvice {
t.Fatalf("got %q/%q/%q,want %q/%q/%q",
color, size, advice, tc.wantColor, tc.wantSize, tc.wantAdvice)
}
})
}
}
// ── 测试固件 ──────────────────────────────────────────
// shopeeHeader 是真实报表的表头,只留导入用得到的 8 个业务列——
// 和真实报表一样按名字定位,列的相对顺序特意打乱一下(把 8 列之外的
// 顺序无所谓,这里保持简单,只放业务列)。
var shopeeHeader = []string{
"商品ID", "商品名稱", "商品當前狀態", "商品規格ID",
"商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號",
}
// buildShopeeWorkbook 现造一个 xlsx 固件,覆盖工单要求的场景:
// 正常行、三种规格格式、括号不配对、以及无关的广告报表 sheet
// (用来验证"按名字取 sheet,不用下标 0")。
//
// [必须] 测试不依赖 raw_data/,固件必须是现造或者提交进 admin/testdata/
// 的小文件,见工单 #38。这里选择现造:文件是二进制,提交进仓库不好 review。
func buildShopeeWorkbook(t *testing.T, header []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
defer f.Close()
// 默认 sheet 改名成一个无关的广告报表,模拟真实工作簿里
// "最佳表現商品"不是第 0 个 sheet 这种情况(虽然样本里恰好是,
// 但不能依赖这个巧合,见 ImportShopeeExcel 的实现和它的注释)。
f.SetSheetName("Sheet1", "追蹤商品廣告成效")
f.NewSheet("最佳表現商品")
if header != nil {
for col, name := range header {
cellRef, _ := excelize.CoordinatesToCellName(col+1, 1)
f.SetCellStr("最佳表現商品", cellRef, name)
}
}
for r, row := range rows {
for col, val := range row {
cellRef, _ := excelize.CoordinatesToCellName(col+1, r+2)
f.SetCellStr("最佳表現商品", cellRef, val)
}
}
path := filepath.Join(t.TempDir(), "shopee_test.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("保存测试固件失败: %v", err)
}
return path
}
// row 按 shopeeHeader 的列顺序拼一行,减少每个测试用例里的列对齐负担。
func row(goodsID, title, status, specID, spec, specStatus, skuCode, mainSKUCode string) []string {
return []string{goodsID, title, status, specID, spec, specStatus, skuCode, mainSKUCode}
}
// ── ImportShopeeExcel ──────────────────────────────────
func TestImportShopeeExcel_基本导入(t *testing.T) {
db := newTestDB(t)
rows := [][]string{
// 商品 A:汇总行 + 3 种规格格式的 SKU 行
row("1001", "测试商品A", "正常", "-", "-", "", "", "货号A"),
row("1001", "测试商品A", "正常", "9001", "黑色,M【建議40-50公斤】", "正常", "sku-9001", "货号A"),
row("1001", "测试商品A", "正常", "9002", "卡其色拼黑色,L 建議50-57.5kg", "正常", "sku-9002", "货号A"),
row("1001", "测试商品A", "正常", "9003", "黑色,M", "正常", "sku-9003", "货号A"),
// 商品 B:汇总行 + 1 个解析失败的 SKU(括号不配对)
row("1002", "测试商品B", "正常", "-", "-", "", "", "货号B"),
row("1002", "测试商品B", "正常", "9004", "紅色,3XL建議80-90公斤】", "正常", "sku-9004", "货号B"),
}
path := buildShopeeWorkbook(t, shopeeHeader, rows)
result, err := ImportShopeeExcel(db, path)
if err != nil {
t.Fatalf("导入失败: %v", err)
}
if result.ProductCount != 2 {
t.Errorf("ProductCount = %d,想要 2", result.ProductCount)
}
if result.SKUCount != 4 {
t.Errorf("SKUCount = %d,想要 4", result.SKUCount)
}
if len(result.Failures) != 1 {
t.Fatalf("Failures 数量 = %d,想要 1(括号不配对那一行)", len(result.Failures))
}
if result.Failures[0].Raw != "紅色,3XL建議80-90公斤】" {
t.Errorf("失败行原文 = %q,不对", result.Failures[0].Raw)
}
if result.Failures[0].Row != 7 {
// 表头第 1 行 + 6 条数据行,"紅色..."是第 6 条数据 -> Excel 行号 7
t.Errorf("失败行行号 = %d,想要 7", result.Failures[0].Row)
}
// 解析失败的 SKU 仍然要写进库:spec_raw 照存,color/size/advice 为空,parse_ok=0。
var color, size, advice sql.NullString
var parseOK int
err = db.QueryRow(
`SELECT color, size, advice, parse_ok FROM shopee_skus WHERE sku_id = ?`, "9004",
).Scan(&color, &size, &advice, &parseOK)
if err != nil {
t.Fatalf("查询失败 SKU 出错: %v", err)
}
if color.Valid && color.String != "" {
t.Errorf("解析失败的行 color 应为空,得到 %q", color.String)
}
if parseOK != 0 {
t.Errorf("解析失败的行 parse_ok 应为 0,得到 %d", parseOK)
}
}
func TestImportShopeeExcel_错误sheet名(t *testing.T) {
db := newTestDB(t)
f := excelize.NewFile()
f.SetSheetName("Sheet1", "新上架商品")
f.NewSheet("高潛力廣告商品")
path := filepath.Join(t.TempDir(), "no_target_sheet.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("保存测试固件失败: %v", err)
}
f.Close()
_, err := ImportShopeeExcel(db, path)
if err == nil {
t.Fatal("缺少「最佳表現商品」sheet 应该报错")
}
msg := err.Error()
if !strings.Contains(msg, "最佳表現商品") || !strings.Contains(msg, "新上架商品") || !strings.Contains(msg, "高潛力廣告商品") {
t.Errorf("错误信息应列出实际有哪些 sheet,得到: %s", msg)
}
}
func TestImportShopeeExcel_缺列(t *testing.T) {
db := newTestDB(t)
// 表头缺了「商品規格ID」,模拟蝦皮加/删列导致列错位的场景。
badHeader := []string{"商品ID", "商品名稱", "商品當前狀態", "商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號"}
path := buildShopeeWorkbook(t, badHeader, nil)
_, err := ImportShopeeExcel(db, path)
if err == nil {
t.Fatal("缺少必需列应该报错")
}
if !strings.Contains(err.Error(), "商品規格ID") {
t.Errorf("错误信息应说明缺的是哪一列,得到: %s", err.Error())
}
}
// TestImportShopeeExcel_不覆盖人工字段 是工单 #38 里最要命的一条:
// 报表里没有 pdd_goods_url / pdd_goods_id 这两列,upsert 绝不能把它们
// 写成空值,否则操作员攒的 PDD 链接会被一次导入洗光,而且不报错。
func TestImportShopeeExcel_不覆盖人工字段(t *testing.T) {
db := newTestDB(t)
rows := [][]string{
row("2001", "测试商品C", "正常", "-", "-", "", "", "货号C"),
row("2001", "测试商品C", "正常", "8001", "黑色,M", "正常", "sku-8001", "货号C"),
}
path := buildShopeeWorkbook(t, shopeeHeader, rows)
// 先导入一次,让商品存在。
if _, err := ImportShopeeExcel(db, path); err != nil {
t.Fatalf("第一次导入失败: %v", err)
}
// 模拟操作员手工填了 PDD 链接。
const manualURL = "https://mobile.yangkeduo.com/goods.html?goods_id=999999"
if _, err := db.Exec(
`UPDATE shopee_products SET pdd_goods_url = ?, pdd_goods_id = ? WHERE goods_id = ?`,
manualURL, "999999", "2001",
); err != nil {
t.Fatalf("模拟人工填链接失败: %v", err)
}
// 再导入同一个文件(同一个商品名字也可能变了,模拟蝦皮改了标题)。
rows[0][1] = "测试商品C改名后"
rows[1][1] = "测试商品C改名后"
path2 := buildShopeeWorkbook(t, shopeeHeader, rows)
if _, err := ImportShopeeExcel(db, path2); err != nil {
t.Fatalf("第二次导入失败: %v", err)
}
var gotURL, gotGoodsID, gotTitle sql.NullString
err := db.QueryRow(
`SELECT pdd_goods_url, pdd_goods_id, title FROM shopee_products WHERE goods_id = ?`, "2001",
).Scan(&gotURL, &gotGoodsID, &gotTitle)
if err != nil {
t.Fatalf("查询商品失败: %v", err)
}
if gotURL.String != manualURL {
t.Errorf("pdd_goods_url 被导入洗掉了:got %q, want %q", gotURL.String, manualURL)
}
if gotGoodsID.String != "999999" {
t.Errorf("pdd_goods_id 被导入洗掉了:got %q, want %q", gotGoodsID.String, "999999")
}
// 标题这类报表字段应该正常刷新,证明"不覆盖"只针对人工字段,不是整行不更新。
if gotTitle.String != "测试商品C改名后" {
t.Errorf("title 应该被报表里的新值刷新:got %q", gotTitle.String)
}
}
// TestImportShopeeExcel_不删除人工新增SKU 对应「05」§4.4 提到的人工新增 SKU
// (本工单不实现新增入口,但导入必须留出这个口子,不能做成
// "先 DELETE 再 INSERT" 的全量替换)。
func TestImportShopeeExcel_不删除人工新增SKU(t *testing.T) {
db := newTestDB(t)
rows := [][]string{
row("3001", "测试商品D", "正常", "-", "-", "", "", "货号D"),
row("3001", "测试商品D", "正常", "7001", "黑色,M", "正常", "sku-7001", "货号D"),
}
path := buildShopeeWorkbook(t, shopeeHeader, rows)
if _, err := ImportShopeeExcel(db, path); err != nil {
t.Fatalf("导入失败: %v", err)
}
// 模拟操作员手动新增一个报表里没有的 SKU。
now := model.NowISO()
if _, err := db.Exec(`
INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code, is_manual, created_at, updated_at)
VALUES ('manual-1', '3001', '手动新增-均码', '均码', '', '', 1, '', 1, ?, ?)`,
now, now); err != nil {
t.Fatalf("插入人工新增 SKU 失败: %v", err)
}
// 再导入同一个文件。
if _, err := ImportShopeeExcel(db, path); err != nil {
t.Fatalf("第二次导入失败: %v", err)
}
var isManual int
err := db.QueryRow(`SELECT is_manual FROM shopee_skus WHERE sku_id = 'manual-1'`).Scan(&isManual)
if err != nil {
t.Fatalf("人工新增的 SKU 被导入删掉了: %v", err)
}
if isManual != 1 {
t.Errorf("is_manual 应该还是 1,得到 %d", isManual)
}
}
func TestImportShopeeExcel_二次导入不翻倍(t *testing.T) {
db := newTestDB(t)
rows := [][]string{
row("4001", "测试商品E", "正常", "-", "-", "", "", "货号E"),
row("4001", "测试商品E", "正常", "6001", "黑色,M", "正常", "sku-6001", "货号E"),
row("4001", "测试商品E", "正常", "6002", "白色,L", "正常", "sku-6002", "货号E"),
}
path := buildShopeeWorkbook(t, shopeeHeader, rows)
first, err := ImportShopeeExcel(db, path)
if err != nil {
t.Fatalf("第一次导入失败: %v", err)
}
second, err := ImportShopeeExcel(db, path)
if err != nil {
t.Fatalf("第二次导入失败: %v", err)
}
if first.ProductCount != second.ProductCount || first.SKUCount != second.SKUCount {
t.Fatalf("两次导入统计不一致: %+v vs %+v", first, second)
}
var productCount, skuCount int
db.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE goods_id = '4001'`).Scan(&productCount)
db.QueryRow(`SELECT COUNT(*) FROM shopee_skus WHERE goods_id = '4001'`).Scan(&skuCount)
if productCount != 1 {
t.Errorf("shopee_products 应该只有 1 行,得到 %d(重复导入产生了重复行)", productCount)
}
if skuCount != 2 {
t.Errorf("shopee_skus 应该只有 2 行,得到 %d(重复导入产生了重复行)", skuCount)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// 蝦皮数据列表页的查询和界面文字组装。
//
// 分成单独文件是因为它和 shopee_import.go 职责不同:这里只读,不写库。
// 分成单独文件是因为它只负责蝦皮列表查询视图,不承担商品目录批量写入。
package service
import (