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