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