Files

229 lines
6.7 KiB
Go
Raw Permalink Normal View History

// 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])
}