Files
cmautobuy/admin/service/pdd_import.go
T

229 lines
6.7 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.
// 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])
}