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