feat: 支持批量导入 PDD 商品链接 (#104)
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// PDD 商品链接 Excel 批量导入。
|
||||
//
|
||||
// 本文件只负责文件校验、逐行解析、业务去重和事务编排;SQL 仍然全部在
|
||||
// repository,HTTP 上传细节仍然全部在 handler/web。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxPddUploadBytes 足够容纳 5000 条单列链接,同时限制误传大文件。
|
||||
MaxPddUploadBytes = 10 * 1024 * 1024
|
||||
// MaxPddImportRows 限制一次导入的非空数据行,避免误发超大批次。
|
||||
MaxPddImportRows = 5000
|
||||
pddLinkHeader = "拼多多链接"
|
||||
)
|
||||
|
||||
var pddXLSXMagic = []byte{0x50, 0x4B, 0x03, 0x04}
|
||||
|
||||
// ErrInvalidPddImport 区分“文件需要采购员修正”和“服务器写库失败”。
|
||||
// Handler 据此选择 400 或 500,避免把数据库内部错误直接显示到页面。
|
||||
var ErrInvalidPddImport = errors.New("PDD 导入文件无效")
|
||||
|
||||
// IsInvalidPddImport 判断错误是否来自上传文件内容或结构。
|
||||
func IsInvalidPddImport(err error) bool {
|
||||
return errors.Is(err, ErrInvalidPddImport)
|
||||
}
|
||||
|
||||
// PddImportFailure 是一条不能导入的 Excel 数据行。
|
||||
type PddImportFailure struct {
|
||||
Row int
|
||||
Raw string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// PddImportResult 是本次导入的完整统计。
|
||||
// GoodsIDs 保存全部合法且去重后的商品,供页面显式创建本批采集任务。
|
||||
type PddImportResult struct {
|
||||
TotalRows int
|
||||
CreatedCount int
|
||||
ExistingCount int
|
||||
RevivedCount int
|
||||
DuplicateCount int
|
||||
Failures []PddImportFailure
|
||||
GoodsIDs []string
|
||||
}
|
||||
|
||||
// ValidatePddUpload 在落盘和解析前校验上传文件的基本安全边界。
|
||||
func ValidatePddUpload(filename string, size int64, head []byte) error {
|
||||
if size <= 0 {
|
||||
return fmt.Errorf("文件是空的")
|
||||
}
|
||||
if size > MaxPddUploadBytes {
|
||||
return fmt.Errorf("文件超过 10MB 上限(当前约 %.1fMB)", float64(size)/1024/1024)
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(filename), ".xlsx") {
|
||||
return fmt.Errorf("只允许 .xlsx 文件,收到的是 %q", filepath.Ext(filename))
|
||||
}
|
||||
if !bytes.HasPrefix(head, pddXLSXMagic) {
|
||||
return fmt.Errorf("文件内容不像一个 xlsx(zip 格式)文件,可能是改了扩展名的其他文件")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type pddImportEntry struct {
|
||||
GoodsID string
|
||||
URL string
|
||||
}
|
||||
|
||||
// ImportPddExcel 导入第一个非空工作表的“拼多多链接”列。
|
||||
//
|
||||
// 格式错误只影响对应行,合法行仍会进入同一个数据库事务;数据库写入任一步
|
||||
// 失败则整体回滚,避免留下无法解释的半批数据。
|
||||
func ImportPddExcel(db *sql.DB, path string) (*PddImportResult, error) {
|
||||
book, err := excelize.OpenFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w:打开 Excel 失败(%v)", ErrInvalidPddImport, err)
|
||||
}
|
||||
defer book.Close()
|
||||
|
||||
result, entries, err := parsePddImportWorkbook(book)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w:%v", ErrInvalidPddImport, err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("开始 PDD 导入事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, entry := range entries {
|
||||
_, outcome, err := repository.EnsurePddProductWithOutcome(tx, entry.GoodsID, entry.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("写入 PDD 商品 %s 失败: %w", entry.GoodsID, err)
|
||||
}
|
||||
switch outcome {
|
||||
case repository.PddProductCreated:
|
||||
result.CreatedCount++
|
||||
case repository.PddProductExisting:
|
||||
result.ExistingCount++
|
||||
case repository.PddProductRevived:
|
||||
result.RevivedCount++
|
||||
default:
|
||||
return nil, fmt.Errorf("PDD 商品 %s 返回了未知导入结果 %q", entry.GoodsID, outcome)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("提交 PDD 导入事务失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parsePddImportWorkbook(book *excelize.File) (*PddImportResult, []pddImportEntry, error) {
|
||||
for _, sheet := range book.GetSheetList() {
|
||||
rows, err := book.Rows(sheet)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("读取工作表 %q 失败: %w", sheet, err)
|
||||
}
|
||||
result, entries, found, parseErr := parsePddImportRows(rows, sheet)
|
||||
closeErr := rows.Close()
|
||||
if parseErr != nil {
|
||||
return nil, nil, parseErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, nil, fmt.Errorf("关闭工作表 %q 失败: %w", sheet, closeErr)
|
||||
}
|
||||
if found {
|
||||
return result, entries, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("Excel 中没有非空工作表,请把链接填在第一列并使用表头“%s”", pddLinkHeader)
|
||||
}
|
||||
|
||||
func parsePddImportRows(rows *excelize.Rows, sheet string) (*PddImportResult, []pddImportEntry, bool, error) {
|
||||
result := &PddImportResult{}
|
||||
entries := make([]pddImportEntry, 0, 64)
|
||||
seen := make(map[string]struct{}, 64)
|
||||
foundHeader := false
|
||||
rowNumber := 0
|
||||
|
||||
for rows.Next() {
|
||||
rowNumber++
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("读取工作表 %q 第 %d 行失败: %w", sheet, rowNumber, err)
|
||||
}
|
||||
if rowIsBlank(columns) {
|
||||
continue
|
||||
}
|
||||
if !foundHeader {
|
||||
foundHeader = true
|
||||
header := firstColumn(columns)
|
||||
if header != pddLinkHeader {
|
||||
return nil, nil, false, fmt.Errorf(
|
||||
"工作表 %q 第一个非空行的第一列应为“%s”,实际是 %q",
|
||||
sheet, pddLinkHeader, header)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
result.TotalRows++
|
||||
if result.TotalRows > MaxPddImportRows {
|
||||
return nil, nil, false, fmt.Errorf(
|
||||
"非空数据超过 %d 条上限(在工作表 %q 第 %d 行发现超限),请拆成多个文件导入",
|
||||
MaxPddImportRows, sheet, rowNumber)
|
||||
}
|
||||
|
||||
raw := firstColumn(columns)
|
||||
if raw == "" {
|
||||
result.Failures = append(result.Failures, PddImportFailure{
|
||||
Row: rowNumber, Raw: "(空)", Reason: "第一列“拼多多链接”不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
goodsID, err := ParsePddGoodsID(raw)
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, PddImportFailure{
|
||||
Row: rowNumber, Raw: raw, Reason: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[goodsID]; duplicate {
|
||||
result.DuplicateCount++
|
||||
continue
|
||||
}
|
||||
seen[goodsID] = struct{}{}
|
||||
entries = append(entries, pddImportEntry{GoodsID: goodsID, URL: raw})
|
||||
result.GoodsIDs = append(result.GoodsIDs, goodsID)
|
||||
}
|
||||
if err := rows.Error(); err != nil {
|
||||
return nil, nil, false, fmt.Errorf("遍历工作表 %q 失败: %w", sheet, err)
|
||||
}
|
||||
if foundHeader && result.TotalRows == 0 {
|
||||
return nil, nil, false, fmt.Errorf(
|
||||
"工作表 %q 只有表头,下面没有 PDD 商品链接", sheet)
|
||||
}
|
||||
return result, entries, foundHeader, nil
|
||||
}
|
||||
|
||||
func rowIsBlank(columns []string) bool {
|
||||
for _, value := range columns {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func firstColumn(columns []string) string {
|
||||
if len(columns) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(columns[0])
|
||||
}
|
||||
Reference in New Issue
Block a user