添加异步导出功能,支持大文件处理和进度追踪
This commit is contained in:
commit
ff5f831b61
|
|
@ -0,0 +1,24 @@
|
|||
## 安装
|
||||
|
||||
```bash
|
||||
$ go get gitea.cdlsxd.cn/self-tools/l_ai_excel_header_match
|
||||
```
|
||||
|
||||
|
||||
## 使用
|
||||
```go
|
||||
func TestAddress(t *testing.T) {
|
||||
res, err := ExcelMatch(context.Background(), a, b, "", "")
|
||||
t.Log(res, err)
|
||||
}
|
||||
|
||||
var (
|
||||
a = []string{
|
||||
"条码", "分类名称", "货品名称", "货品编号", "商品货号", "品牌", "单位", "规格参数", "货品说明", "保质期", "保质期单位", "链接", "货品图片", "电商销售价格", "销售价", "供应商报价", "税率", "默认供应商", "默认存放仓库", "第三方商品编码", "备注", "长", "宽", "高", "重量", "SPU编码", "SPU名称",
|
||||
}
|
||||
|
||||
b = []string{
|
||||
"商品名称(手工输入)", "品牌(单选)", "商品型号(手工输入)", "商品条码/ISBN/ISSN(手工输入)", "条形码资质1(手工输入)", "条形码资质2(手工输入)", "条形码资质3(手工输入)", "产地(国家)(单选)", "产地(省份)(单选)", "产地(市)(单选)", "长度(手工输入,单位:毫米)", "宽度(手工输入,单位:毫米)", "高度(手工输入,单位:毫米)", "体积(手工输入,单位:立方厘米)", "毛重(手工输入,单位:千克)", "厂家包装含量(手工输入)", "商品税率(单选)(单选)", "采购单位", "供应商商品编码(手工输入)", "商品详情(手工输入)", "发货清单1(手工输入)", "发货清单2(手工输入)", "发货清单3(手工输入)", "发货清单4(手工输入)", "发货清单5(手工输入)", "商品标题(手工输入)", "商品卖点(手工输入)", "促销常规卖点(手工输入)", "促销常规卖点生效时间", "促销常规卖点失效时间", "促销高级卖点(手工输入)", "促销高级卖点生效时间", "促销高级卖点失效时间", "活动关联文案(手工输入)", "电脑端链接(手工输入)", "移动端链接(手工输入)", "活动链接生效时间", "活动链接失效时间", "商品图片1(手工输入)", "商品图片2(手工输入)", "商品图片3(手工输入)", "商品图片4(手工输入)", "商品图片5(手工输入)", "生产者(制造商)名称(手工输入)", "生产商(制造商)地址(手工输入)", "执行标准(手工输入)", "类别(单选)", "茶具类型(单选)", "国产/进口(单选)", "茶具材质(单选)", "上市时间(月)(手工输入)", "茶盘材质(多选)(可选项为:石质,电木,树脂,陶瓷,竹质,木质,其它)", "工艺(单选)", "风格(单选)", "适用人数(单选)", "功能(单选)", "容量(手工输入,单位:毫升)",
|
||||
}
|
||||
)
|
||||
```
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package l_export_async
|
||||
|
||||
import "context"
|
||||
|
||||
// DataProviderFn 定义数据提供函数的类型别名
|
||||
type DataProviderFn func(ctx context.Context, pageNum, limit int) ([][]interface{}, error)
|
||||
|
||||
// Uploader 这里主要是为了调用attachment.Upload方法
|
||||
type Uploader struct {
|
||||
Host string
|
||||
System string
|
||||
Business string
|
||||
FieldFormName string
|
||||
}
|
||||
|
||||
type ExportOption func(*ExportAsync)
|
||||
|
||||
// WithCustomUploader 自定义上传配置
|
||||
func WithCustomUploader(sys string, business string, fieldFormName string) ExportOption {
|
||||
return func(b *ExportAsync) {
|
||||
b.uploader = &Uploader{
|
||||
System: sys,
|
||||
Business: business,
|
||||
FieldFormName: fieldFormName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithCustomBatchSize 每一批次导出数量,数据库每页行数,默认10000行
|
||||
func WithCustomBatchSize(batchSize int) ExportOption {
|
||||
return func(b *ExportAsync) {
|
||||
b.batchSize = batchSize
|
||||
}
|
||||
}
|
||||
|
||||
// WithCustomBufferSize csv转excel的批量写入缓冲区大小,逐行写入设置为0
|
||||
func WithCustomBufferSize(bufferSize int) ExportOption {
|
||||
return func(b *ExportAsync) {
|
||||
b.csvToExcelBatch = bufferSize
|
||||
}
|
||||
}
|
||||
|
||||
// WithCustomSuffixFileName 自定后缀
|
||||
func WithCustomSuffixFileName(suffix string) ExportOption {
|
||||
return func(b *ExportAsync) {
|
||||
b.fileName = b.fileName + "_" + suffix
|
||||
}
|
||||
}
|
||||
|
||||
// WithCustomWorkNum 自定义协程数
|
||||
func WithCustomWorkNum(num int) ExportOption {
|
||||
return func(b *ExportAsync) {
|
||||
b.workerNum = num
|
||||
}
|
||||
}
|
||||
|
||||
// WithCustomExtension 自定义扩展名
|
||||
func WithCustomExtension(extension string) ExportOption {
|
||||
return func(b *ExportAsync) {
|
||||
b.extension = extension
|
||||
}
|
||||
}
|
||||
|
||||
// WithCustomSheetName 自定义注脚
|
||||
func WithCustomSheetName(sheetName string) ExportOption {
|
||||
return func(b *ExportAsync) {
|
||||
b.sheetName = sheetName
|
||||
}
|
||||
}
|
||||
|
||||
// WithProcess 需要进行进度统计
|
||||
func WithProcess(dataCount int) ExportOption {
|
||||
return func(b *ExportAsync) {
|
||||
b.dataCount = dataCount
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogPrint 日志输出组件
|
||||
func WithLogPrint(logTool LogTool) ExportOption {
|
||||
return func(b *ExportAsync) {
|
||||
b.logTool = NewLogPrint(logTool)
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxRowPerFile 每个Xlsx的行数,默认10000行
|
||||
func WithMaxRowPerFile(maxRowPerFile int) ExportOption {
|
||||
return func(b *ExportAsync) {
|
||||
b.maxRowPerFile = maxRowPerFile
|
||||
}
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Process int32 `json:"process"` //需除10000,上限是100 //初始化10,生成csv60,合并数据20,上传数据10,ProcessScore
|
||||
Err string `json:"err"`
|
||||
Source string `json:"source"`
|
||||
RowCount int64 `json:"row_count"`
|
||||
}
|
||||
|
||||
type ProcessScore int32
|
||||
|
||||
const (
|
||||
INIT ProcessScore = 100000
|
||||
CSV ProcessScore = 600000
|
||||
XLSX ProcessScore = 200000
|
||||
ATT ProcessScore = 100000
|
||||
)
|
||||
|
||||
func (p ProcessScore) int() int32 {
|
||||
return int32(p)
|
||||
}
|
||||
|
||||
func (p ProcessScore) float64() float64 {
|
||||
return float64(p)
|
||||
}
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
package l_export_async
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
var exportAsyncPool = &sync.Pool{
|
||||
New: func() interface{} {
|
||||
return &ExportAsync{
|
||||
extension: ".xlsx",
|
||||
sheetName: "Sheet1",
|
||||
batchSize: 10000,
|
||||
maxRowPerFile: 10000,
|
||||
csvToExcelBatch: 1000,
|
||||
uploader: &Uploader{
|
||||
FieldFormName: "file",
|
||||
System: "crmApi",
|
||||
Business: "download",
|
||||
},
|
||||
task: &Task{},
|
||||
workerNum: 1, //runtime.NumCPU() * 2,
|
||||
logTool: NewLogPrint(nil),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ExportAsync 异步导出任务配置->默认配置往上看
|
||||
type ExportAsync struct {
|
||||
//// 导出文件名(不含扩展名)
|
||||
fileName string
|
||||
|
||||
// 文件扩展名(如 .xlsx),默认.xlsx->WithCustomExtension
|
||||
extension string
|
||||
|
||||
//xlsx注脚,默认Sheet1->WithCustomSheetName
|
||||
sheetName string //sheet名称
|
||||
|
||||
//每一批次导出数量,数据库每页行数,默认10000行->WithCustomBatchSize
|
||||
batchSize int
|
||||
|
||||
//每个Xlsx的行数,默认10000行->WithMaxRowPerFile
|
||||
maxRowPerFile int
|
||||
|
||||
//csv转excel的批量写入缓冲区大小,逐行写入设置为1000->WithCustomBufferSize
|
||||
csvToExcelBatch int
|
||||
|
||||
//!!!导出数总量,这个参数非常重要,可以通过WithProcess设置
|
||||
//1.计算进度使用
|
||||
//2.当workerNum>1时,因为线程的乱序(虽然可以通过通道解决,但是就没有线程存在的意义了),会导致查询很多空值出来
|
||||
//比如:如果dataProvider走的mysql,那么会执行很多空查询,增加数据库压力
|
||||
dataCount int
|
||||
|
||||
//日志输出->WithLogPrint
|
||||
logTool LogTool
|
||||
// Excel 表头
|
||||
header []string
|
||||
|
||||
// 上传配置->WithCustomUploader
|
||||
uploader *Uploader
|
||||
|
||||
// 数据提供函数
|
||||
dataProvider DataProviderFn
|
||||
|
||||
// 任务状态存储(如 Redis);
|
||||
taskSaveTool TaskSaveTool
|
||||
|
||||
// 并发协程数(务必大于1),默认runtime.NumCPU() * 2->WithCustomWorkNum
|
||||
workerNum int
|
||||
|
||||
//任务状态
|
||||
task *Task
|
||||
}
|
||||
|
||||
func NewExportAsync(
|
||||
fileName string,
|
||||
header []string,
|
||||
domain string,
|
||||
dataProvider DataProviderFn,
|
||||
TaskSaveTool TaskSaveTool,
|
||||
args ...ExportOption,
|
||||
) *ExportAsync {
|
||||
exporter := exportAsyncPool.Get().(*ExportAsync)
|
||||
exporter.fileName = fileName
|
||||
exporter.header = header
|
||||
exporter.dataProvider = dataProvider
|
||||
exporter.taskSaveTool = TaskSaveTool
|
||||
exporter.uploader.Host = domain
|
||||
exporter.task.Name = fileName
|
||||
for _, arg := range args {
|
||||
arg(exporter)
|
||||
}
|
||||
|
||||
return exporter
|
||||
}
|
||||
|
||||
func (e *ExportAsync) Run(ctx context.Context) (string, error) {
|
||||
//新建任务
|
||||
tempDir, err := e.createTask(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建任务失败: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 执行导出任务
|
||||
subCtx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
source, err := e.export(subCtx, tempDir)
|
||||
if err != nil {
|
||||
e.logTool.Errorf("导出错误:\n任务:%s,错误原因:%s", e.task.Id, err.Error())
|
||||
e.task.Err = err.Error()
|
||||
_ = e.updateTask(subCtx)
|
||||
}
|
||||
e.logTool.Infof("异步导出任务:%s,导出完成,总计导出%d条数据,下载地址:%s", e.task.Id, e.task.RowCount, source)
|
||||
os.RemoveAll(tempDir)
|
||||
}()
|
||||
return e.task.Id, nil
|
||||
}
|
||||
func (e *ExportAsync) export(ctx context.Context, tempDir string) (source string, err error) {
|
||||
|
||||
e.processAdd(ctx, INIT.int())
|
||||
|
||||
e.logTool.Infof("异步导出任务:%s,开始导出到csv", e.task.Id)
|
||||
csvFiles, err := e.exportToCsv(ctx, tempDir)
|
||||
e.task.Process = CSV.int() + INIT.int()
|
||||
e.processAdd(ctx, 0)
|
||||
// 合并csv文件
|
||||
e.logTool.Infof("任务:%s,开始合并到xlsx", e.task.Id)
|
||||
excelsDir, err := e.mergeCSVsToExcelFiles(csvFiles, tempDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
e.processAdd(ctx, XLSX.int())
|
||||
|
||||
// 打包
|
||||
e.logTool.Infof("异步导出任务:%s,开始打包xlsx", e.task.Id)
|
||||
source = e.zipFile(tempDir)
|
||||
if err = e.folderToZip(excelsDir, source); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(e.uploader.Host) > 0 {
|
||||
e.logTool.Infof("异步导出任务:%s,开始上传", e.task.Id)
|
||||
source, err = e.upload(source)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
e.task.Source = source
|
||||
e.processAdd(ctx, ATT.int())
|
||||
return
|
||||
}
|
||||
|
||||
func (e *ExportAsync) exportToCsv(ctx context.Context, tempDir string) (csvFiles []string, err error) {
|
||||
var (
|
||||
perPageProcess int32
|
||||
pageLimit int = -1
|
||||
csvFilesMap sync.Map
|
||||
)
|
||||
// 计算每页进度
|
||||
if e.dataCount > 0 {
|
||||
pageLimit = (e.dataCount + e.batchSize - 1) / e.batchSize
|
||||
perPageProcess = CSV.int() / int32(pageLimit) //6
|
||||
}
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(e.workerNum) // 限制并发数
|
||||
|
||||
// 使用原子计数器生成页面编号
|
||||
var pageNum int64 = 1
|
||||
stopProcessing := false
|
||||
|
||||
for i := 0; i < e.workerNum; i++ {
|
||||
g.Go(func() error {
|
||||
for {
|
||||
if stopProcessing {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 原子获取下一页
|
||||
page := int(atomic.AddInt64(&pageNum, 1) - 1)
|
||||
if pageLimit != -1 && page > pageLimit {
|
||||
return nil
|
||||
}
|
||||
// 获取数据
|
||||
rows, err := e.dataProvider(ctx, page, e.batchSize)
|
||||
if err != nil {
|
||||
e.logTool.Errorf("异步导出任务:%s,第%d页查询失败:%s", e.task.Id, page, err.Error())
|
||||
return fmt.Errorf("第%d页查询失败: %w", page, err)
|
||||
}
|
||||
|
||||
// 检查是否是最后一页
|
||||
if len(rows) == 0 || len(rows) < e.batchSize {
|
||||
stopProcessing = true
|
||||
}
|
||||
|
||||
// 如果没有数据,结束处理
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
fileName := filepath.Join(tempDir, fmt.Sprintf("/csv/%d.csv", page))
|
||||
// 原子增加行数
|
||||
atomic.AddInt64(&e.task.RowCount, int64(len(rows)))
|
||||
|
||||
// 保存数据到临时文件
|
||||
if err := e.savePageToCSV(rows, fileName); err != nil {
|
||||
e.logTool.Errorf("任务:%s,导出到csv错误:%s", e.task.Id, err.Error())
|
||||
return fmt.Errorf("保存第%d页失败: %w", page, err)
|
||||
}
|
||||
// 存储文件名
|
||||
csvFilesMap.Store(page, fileName)
|
||||
// 更新进度
|
||||
e.processAdd(ctx, perPageProcess)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 等待所有goroutine完成
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 将csv文件名称进行排序
|
||||
csvFiles = getSortedValues(&csvFilesMap)
|
||||
return csvFiles, nil
|
||||
}
|
||||
|
||||
func (e *ExportAsync) upload(file string) (string, error) {
|
||||
resp, err := Upload(e.uploader.Host, file, e.uploader.System, e.uploader.Business, e.uploader.FieldFormName)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return GeneratePreviewPrivateUrl(e.uploader.Host, "", resp.Url, "", strings.TrimSuffix(e.fileName, ".zip"), time.Now().Unix()+300), nil
|
||||
}
|
||||
|
||||
func (e *ExportAsync) folderToZip(excelsDir, zipFilePath string) error {
|
||||
// 创建文件
|
||||
zipFile, err := os.Create(zipFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zipFile.Close()
|
||||
|
||||
// 创建zip writer
|
||||
archive := zip.NewWriter(zipFile)
|
||||
defer archive.Close()
|
||||
|
||||
// 遍历文件夹
|
||||
err = filepath.Walk(excelsDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 忽略文件夹自身
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 打开文件
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 创建zip文件条目
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更改工作目录到zip路径
|
||||
header.Name = filepath.ToSlash(path[len(excelsDir):])
|
||||
|
||||
// 创建zip文件条目
|
||||
writer, err := archive.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 将文件内容写入zip文件条目
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *ExportAsync) zipFile(tempDir string) string {
|
||||
|
||||
return e.dirZip(tempDir) + e.fileName + ".zip"
|
||||
}
|
||||
|
||||
// mergeCSVsToExcelFiles 将多个CSV文件合并为多个Excel文件(流式处理)
|
||||
func (e *ExportAsync) mergeCSVsToExcelFiles(csvFiles []string, tempDir string) (outputDir string, err error) {
|
||||
outputDir = e.dirXlsx(tempDir)
|
||||
m := NewMerge(
|
||||
Reader{Files: csvFiles, Index: len(csvFiles) - 1},
|
||||
Writer{File: outputDir + e.fileName + e.extension, Limit: e.maxRowPerFile, BufferSize: e.csvToExcelBatch},
|
||||
e.logTool,
|
||||
)
|
||||
if err = m.Merge(); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (e *ExportAsync) release() {
|
||||
// 清空敏感或动态数据
|
||||
e.fileName = ""
|
||||
e.header = nil
|
||||
e.dataProvider = nil
|
||||
e.taskSaveTool = nil
|
||||
exportAsyncPool.Put(e)
|
||||
}
|
||||
|
||||
func getSortedValues(sm *sync.Map) []string {
|
||||
// 1. 预分配切片(假设已知大致数量)
|
||||
items := make([]struct {
|
||||
key int
|
||||
value string
|
||||
}, 0, 16) // 初始容量可调整
|
||||
|
||||
// 2. 收集数据(单次遍历)
|
||||
sm.Range(func(key, value interface{}) bool {
|
||||
items = append(items, struct {
|
||||
key int
|
||||
value string
|
||||
}{key.(int), value.(string)})
|
||||
return true
|
||||
})
|
||||
|
||||
// 3. 排序
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].key < items[j].key
|
||||
})
|
||||
|
||||
// 4. 提取值
|
||||
sortedValues := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
sortedValues[i] = item.value
|
||||
}
|
||||
|
||||
return sortedValues
|
||||
}
|
||||
|
||||
func (e *ExportAsync) createTask(ctx context.Context) (tempDir string, err error) {
|
||||
uid, err := uuid.NewUUID()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("UUid创建失败: %w", err)
|
||||
return
|
||||
}
|
||||
e.task.Id = uid.String()
|
||||
tempDir, err = e.createDefaultDir(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("初始化默认文件夹失败: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = e.updateTask(ctx)
|
||||
return tempDir, nil
|
||||
}
|
||||
|
||||
func (e *ExportAsync) updateTask(ctx context.Context) (err error) {
|
||||
taskByte, err := json.Marshal(e.task)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = e.taskSaveTool.Set(ctx, e.task.Id, string(taskByte), 0).Err()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("更新任务失败: %w", err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (e *ExportAsync) processAdd(ctx context.Context, addNum int32) {
|
||||
|
||||
atomic.AddInt32(&e.task.Process, addNum)
|
||||
e.logTool.Infof("异步导出任务:%s,当前进度:%d", e.task.Id, e.task.Process)
|
||||
_ = e.updateTask(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
func (e *ExportAsync) createDefaultDir(ctx context.Context) (string, error) {
|
||||
// 创建临时目录
|
||||
tempDir, err := os.MkdirTemp("", e.task.Id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建临时目录失败: %v", err)
|
||||
}
|
||||
//csv
|
||||
if err = os.Mkdir(e.dirCsv(tempDir), 0755); err != nil {
|
||||
return "", fmt.Errorf("创建csv目录失败: %v", err)
|
||||
}
|
||||
//xlsx
|
||||
if err = os.Mkdir(e.dirXlsx(tempDir), 0755); err != nil {
|
||||
return "", fmt.Errorf("创建xlsx目录失败: %v", err)
|
||||
}
|
||||
//zip
|
||||
if err = os.Mkdir(e.dirZip(tempDir), 0755); err != nil {
|
||||
return "", fmt.Errorf("创建zip目录失败: %v", err)
|
||||
}
|
||||
return tempDir, nil
|
||||
}
|
||||
|
||||
func (e *ExportAsync) dirCsv(tempDir string) string {
|
||||
return tempDir + "/csv/"
|
||||
}
|
||||
|
||||
func (e *ExportAsync) dirXlsx(tempDir string) string {
|
||||
return tempDir + "/xlsx/"
|
||||
}
|
||||
|
||||
func (e *ExportAsync) dirZip(tempDir string) string {
|
||||
return tempDir + "/zip/"
|
||||
}
|
||||
|
||||
// savePageToCSV 将单页数据保存为CSV文件
|
||||
func (e *ExportAsync) savePageToCSV(data [][]interface{}, filename string) error {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建CSV文件失败: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := csv.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
|
||||
// 写入表头(如果尚未写入)
|
||||
if err := writer.Write(e.header); err != nil {
|
||||
return fmt.Errorf("写入CSV表头失败: %v", err)
|
||||
}
|
||||
|
||||
// 写入数据行
|
||||
for _, row := range data {
|
||||
csvRow := make([]string, 0, len(e.header))
|
||||
for _, val := range row {
|
||||
csvRow = append(csvRow, fmt.Sprintf("%v", val))
|
||||
}
|
||||
if err := writer.Write(csvRow); err != nil {
|
||||
return fmt.Errorf("写入CSV行失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package l_export_async
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_Merge(t *testing.T) {
|
||||
path := "/tmp/f63b2047-dd7b-11f0-b1a8-00155d5ef0f92960656377/csv/"
|
||||
outputDir := "/tmp/f63b2047-dd7b-11f0-b1a8-00155d5ef0f92960656377/xlsx/aaa.xlsx"
|
||||
csvFiles, err := listFiles(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
m := NewMerge(
|
||||
Reader{Files: csvFiles, Index: len(csvFiles) - 1},
|
||||
Writer{File: outputDir, Limit: 30000, BufferSize: 3000},
|
||||
NewLogPrint(nil),
|
||||
)
|
||||
err = m.Merge()
|
||||
t.Log(err)
|
||||
}
|
||||
|
||||
func listFiles(dirPath string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() { // 只添加文件(排除子目录)
|
||||
files = append(files, dirPath+entry.Name())
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
module gitea.cdlsxd.cn/self-tools/l-export-async
|
||||
|
||||
go 1.21.10
|
||||
|
||||
require (
|
||||
github.com/duke-git/lancet/v2 v2.2.8
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/xuri/excelize/v2 v2.9.0
|
||||
golang.org/x/sync v0.8.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
|
||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/net v0.30.0 // indirect
|
||||
golang.org/x/text v0.19.0 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package l_export_async
|
||||
|
||||
import "fmt"
|
||||
|
||||
type LogTool interface {
|
||||
Errorf(format string, a ...interface{})
|
||||
Infof(format string, a ...interface{})
|
||||
}
|
||||
type LogPrint struct {
|
||||
tool LogTool
|
||||
}
|
||||
|
||||
func NewLogPrint(tool LogTool) LogTool {
|
||||
return &LogPrint{tool: tool}
|
||||
}
|
||||
|
||||
func (r *LogPrint) Errorf(format string, a ...interface{}) {
|
||||
if r.tool != nil {
|
||||
r.tool.Errorf(format, a...)
|
||||
return
|
||||
}
|
||||
fmt.Printf(format, a...)
|
||||
}
|
||||
|
||||
func (r *LogPrint) Infof(format string, a ...interface{}) {
|
||||
if r.tool != nil {
|
||||
r.tool.Infof(format, a...)
|
||||
return
|
||||
}
|
||||
fmt.Printf(format, a...)
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
package l_export_async
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type (
|
||||
Reader struct {
|
||||
Files []string
|
||||
Index int
|
||||
}
|
||||
Writer struct {
|
||||
File string
|
||||
Limit int
|
||||
BufferSize int // 缓冲区大小
|
||||
}
|
||||
Merge struct {
|
||||
reader Reader
|
||||
writer Writer
|
||||
log LogTool
|
||||
buffer [][]interface{} // 批量写入缓冲区
|
||||
file *excelize.File
|
||||
sw *excelize.StreamWriter
|
||||
titles []interface{}
|
||||
fileIndex int
|
||||
total int
|
||||
rowIndex int // 当前已写入的行数(从0开始)
|
||||
}
|
||||
)
|
||||
|
||||
func NewMerge(r Reader, w Writer, log LogTool) *Merge {
|
||||
m := &Merge{
|
||||
reader: r,
|
||||
writer: w,
|
||||
log: log,
|
||||
}
|
||||
m.open()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Merge) Merge() error {
|
||||
defer func() {
|
||||
if err := m.Save(); err != nil {
|
||||
m.log.Errorf(err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i <= m.reader.Index; i++ {
|
||||
csvOpen, err := os.Open(m.reader.Files[i])
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("打开读取文件%s失败:%w", m.reader.Files[i], err)
|
||||
}
|
||||
csvReader := csv.NewReader(csvOpen)
|
||||
frist := true
|
||||
for {
|
||||
record, err := csvReader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("读取文件%s错误:%w", m.reader.Files[i], err)
|
||||
}
|
||||
|
||||
row := transform(record)
|
||||
|
||||
// 不是第一个文件时,跳过第一条数据
|
||||
if frist {
|
||||
frist = false
|
||||
if i == 0 {
|
||||
m.WriteTitle(row)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m.writer.BufferSize > 0 {
|
||||
m.WriteBatch(row)
|
||||
} else {
|
||||
m.Write(row)
|
||||
}
|
||||
}
|
||||
csvOpen.Close()
|
||||
}
|
||||
|
||||
// 确保所有缓冲区数据都被写入
|
||||
if m.writer.BufferSize > 0 {
|
||||
if err := m.flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Merge) WriteTitle(titles []interface{}) error {
|
||||
if titles != nil {
|
||||
m.titles = titles
|
||||
}
|
||||
if m.titles != nil {
|
||||
// 标题写入第一行
|
||||
cell, err := excelize.CoordinatesToCellName(1, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.sw.SetRow(cell, m.titles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 标题写入后,行索引为1
|
||||
m.rowIndex = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Merge) WriteBatch(values []interface{}) error {
|
||||
m.buffer = append(m.buffer, values) // 存入缓冲区
|
||||
|
||||
// 如果缓冲区已满,执行批量写入
|
||||
if len(m.buffer) >= m.writer.BufferSize {
|
||||
if err := m.flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Merge) flush() error {
|
||||
if len(m.buffer) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 计算起始行(从当前行索引+1开始,因为Excel行号从1开始)
|
||||
startRow := m.rowIndex + 1
|
||||
|
||||
// 批量写入
|
||||
for i, row := range m.buffer {
|
||||
cell, err := excelize.CoordinatesToCellName(1, startRow+i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.sw.SetRow(cell, row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 更新行索引
|
||||
m.rowIndex += len(m.buffer)
|
||||
m.total += len(m.buffer)
|
||||
|
||||
// 清空缓冲区
|
||||
m.buffer = m.buffer[:0]
|
||||
|
||||
// 检查是否达到限制
|
||||
if m.rowIndex >= m.writer.GetLimit() {
|
||||
if err := m.reset(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Merge) Write(values []interface{}) error {
|
||||
// 当前行索引+1作为Excel行号
|
||||
cell, err := excelize.CoordinatesToCellName(1, m.rowIndex+1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.sw.SetRow(cell, values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新计数
|
||||
m.rowIndex++
|
||||
m.total++
|
||||
|
||||
// 检查是否达到限制
|
||||
if m.rowIndex >= m.writer.GetLimit() {
|
||||
if err := m.reset(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Merge) reset() (err error) {
|
||||
// 先保存当前文件
|
||||
if err := m.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 重置状态并打开新文件
|
||||
m.fileIndex++
|
||||
m.rowIndex = 0
|
||||
m.total = 0
|
||||
return m.open()
|
||||
}
|
||||
|
||||
func (m *Merge) open() (err error) {
|
||||
m.file = excelize.NewFile()
|
||||
m.sw, err = m.file.NewStreamWriter("Sheet1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果已有标题,写入标题
|
||||
if m.titles != nil {
|
||||
return m.WriteTitle(nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Merge) Save() error {
|
||||
// 确保刷新缓冲区
|
||||
if m.writer.BufferSize > 0 {
|
||||
if err := m.flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 忽略只有标题的文件
|
||||
if m.titles != nil && m.rowIndex <= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := m.sw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
fileName := m.writer.GetFileName(m.fileIndex)
|
||||
err := m.file.SaveAs(fileName)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetFileName 获取文件名
|
||||
func (w *Writer) GetFileName(fileIndex int) string {
|
||||
if fileIndex == 0 {
|
||||
return w.File
|
||||
}
|
||||
|
||||
extRegex := regexp.MustCompile(`(\.[^.]+)$`)
|
||||
matches := extRegex.FindStringSubmatch(w.File)
|
||||
if len(matches) > 0 {
|
||||
ext := matches[1]
|
||||
baseName := w.File[:len(w.File)-len(ext)]
|
||||
return fmt.Sprintf("%s_%d%s", baseName, fileIndex, ext)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%d", w.File, fileIndex)
|
||||
}
|
||||
|
||||
func (w *Writer) GetLimit() int {
|
||||
// excel 单表最大100w行数据
|
||||
return int(math.Min(float64(w.Limit), 1000000))
|
||||
}
|
||||
|
||||
func transform(record []string) []interface{} {
|
||||
result := make([]interface{}, len(record))
|
||||
for i2, s := range record {
|
||||
result[i2] = s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package l_export_async
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type redisTaskStore struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func (r *redisTaskStore) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) TaskErr {
|
||||
return r.client.Set(ctx, key, value, expiration)
|
||||
}
|
||||
|
||||
func (r *redisTaskStore) Del(ctx context.Context, keys ...string) TaskErr {
|
||||
//实际运行中并没有去执行这个,因为不确定是否真的需要删除,如果需要可以自行写入
|
||||
return r.client.Del(ctx, keys...)
|
||||
}
|
||||
|
||||
func (r *redisTaskStore) Get(ctx context.Context, key string) TaskGet {
|
||||
return r.client.Get(ctx, key)
|
||||
}
|
||||
|
||||
func NewRedisTaskStore(client *redis.Client) TaskSaveTool {
|
||||
return &redisTaskStore{client: client}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package l_export_async
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TaskInfo struct {
|
||||
taskSaveTool TaskSaveTool // 任务状态存储(如 Redis)
|
||||
}
|
||||
|
||||
type TaskErr interface {
|
||||
Err() error
|
||||
}
|
||||
|
||||
type TaskGet interface {
|
||||
TaskErr
|
||||
Val() string
|
||||
}
|
||||
|
||||
type TaskSaveTool interface {
|
||||
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) TaskErr
|
||||
Del(ctx context.Context, keys ...string) TaskErr
|
||||
Get(ctx context.Context, key string) TaskGet
|
||||
}
|
||||
|
||||
func NewTask(taskSaveTool TaskSaveTool) *TaskInfo {
|
||||
return &TaskInfo{
|
||||
taskSaveTool: taskSaveTool,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TaskInfo) GetTaskInfo(ctx context.Context, taskId string) TaskGet {
|
||||
return t.taskSaveTool.Get(ctx, taskId)
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package l_export_async
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/duke-git/lancet/v2/retry"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
TokenSalt = "LanSeXiongDi!@#&*("
|
||||
UrlPreview = "v1/attachment/preview"
|
||||
)
|
||||
|
||||
type UploadResp struct {
|
||||
Url string `json:"url"`
|
||||
PreviewUrl string `json:"previewUrl"`
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
// 返回值:oss地址,预览地址,错误
|
||||
func Upload(host, filePath, system, business, fieldFormName string) (*UploadResp, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "打开待上传文件失败")
|
||||
}
|
||||
defer f.Close()
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
formData := map[string]string{
|
||||
"system": system,
|
||||
"business": business,
|
||||
}
|
||||
for k, v := range formData {
|
||||
err = writer.WriteField(k, v)
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "构建form-data失败")
|
||||
}
|
||||
}
|
||||
// 使用给出的属性名paramName和文件名filePath创建一个新的form-data头
|
||||
part, err := writer.CreateFormFile(fieldFormName, filePath)
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "创建文件流失败")
|
||||
}
|
||||
// 将源复制到目标,将file写入到part 是按默认的缓冲区32k循环操作的,不会将内容一次性全写入内存中,这样就能解决大文件的问题
|
||||
_, err = io.Copy(part, f)
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "复制文件流失败")
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "close writer失败")
|
||||
}
|
||||
httpClient := http.Client{
|
||||
Timeout: time.Minute * 10,
|
||||
}
|
||||
url := fmt.Sprintf("%s/v1/attachment/upload", host)
|
||||
req, err := http.NewRequest("POST", url, body)
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "new request 失败")
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
// 请求服务器
|
||||
uploadResp := &UploadResp{}
|
||||
requestHttp := func() error {
|
||||
var requestErr error
|
||||
var respHttp *http.Response
|
||||
respHttp, requestErr = httpClient.Do(req)
|
||||
if requestErr != nil {
|
||||
return errors.WithMessage(err, "上传响应失败")
|
||||
}
|
||||
defer respHttp.Body.Close()
|
||||
respBody, requestErr := io.ReadAll(respHttp.Body)
|
||||
if requestErr != nil {
|
||||
return errors.WithMessage(err, "读取响应体失败")
|
||||
}
|
||||
if respHttp.StatusCode != http.StatusOK {
|
||||
respMap := make(map[string]string)
|
||||
_ = json.Unmarshal(respBody, &respMap)
|
||||
if respMap["message"] != "" {
|
||||
// 非正常响应体
|
||||
return errors.Errorf("上传响应状态异常,响应码:%d,响应体:%s", respHttp.StatusCode, string(respBody))
|
||||
}
|
||||
return errors.Errorf("响应错误:%s", respMap["message"])
|
||||
}
|
||||
|
||||
requestErr = json.Unmarshal(respBody, &uploadResp)
|
||||
if requestErr != nil {
|
||||
// json失败为非正常响应体
|
||||
if respHttp.StatusCode != http.StatusOK {
|
||||
return errors.Errorf("上传响应状态异常,响应码:%d,响应体:%s", respHttp.StatusCode, string(respBody))
|
||||
}
|
||||
return errors.WithMessage(err, "json decode响应值失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = retry.Retry(func() error {
|
||||
err = requestHttp()
|
||||
return err
|
||||
}, retry.RetryTimes(5), retry.RetryDuration(time.Second*3))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return uploadResp, nil
|
||||
}
|
||||
|
||||
// GeneratePreviewPrivateUrl 生成私有预览地址
|
||||
func GeneratePreviewPrivateUrl(domain, param, attachmentUrl, water, fileName string, expireAt int64) string {
|
||||
token := Signature(attachmentUrl, water, expireAt)
|
||||
params := url.Values{}
|
||||
params.Add("url", attachmentUrl)
|
||||
params.Add("water", water)
|
||||
params.Add("token", token)
|
||||
params.Add("param", param)
|
||||
params.Add("fileName", fileName)
|
||||
params.Add("expireAt", strconv.FormatInt(expireAt, 10))
|
||||
return fmt.Sprintf("%s/%s?%s", domain, UrlPreview, params.Encode())
|
||||
}
|
||||
|
||||
// Signature 附件加签
|
||||
func Signature(attachmentUrl, water string, expireAt int64) string {
|
||||
s := fmt.Sprintf("%s,%s,%s,%d", TokenSalt, attachmentUrl, water, expireAt)
|
||||
return MD5Sign(s)
|
||||
}
|
||||
|
||||
func MD5Sign(s string) string {
|
||||
sum := md5.Sum([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
Loading…
Reference in New Issue