diff --git a/internal/app/import_pipeline.go b/internal/app/import_pipeline.go
index f1d28eed..a0d51a35 100644
--- a/internal/app/import_pipeline.go
+++ b/internal/app/import_pipeline.go
@@ -12,7 +12,6 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
- "github.com/xuri/excelize/v2"
)
const (
@@ -276,8 +275,10 @@ func streamImportFile(filePath string, consumer importFileConsumer) error {
return streamJSONImportFile(filePath, consumer)
case strings.HasSuffix(lower, ".csv"):
return streamCSVImportFile(filePath, consumer)
- case strings.HasSuffix(lower, ".xlsx"), strings.HasSuffix(lower, ".xls"):
- return streamExcelImportFile(filePath, consumer)
+ case strings.HasSuffix(lower, ".xlsx"):
+ return streamXLSXImportFile(filePath, consumer)
+ case strings.HasSuffix(lower, ".xls"):
+ return streamLegacyExcelImportFile(filePath, consumer)
default:
return fmt.Errorf("Unsupported file format")
}
@@ -361,57 +362,6 @@ func streamCSVImportFile(filePath string, consumer importFileConsumer) error {
}
}
-func streamExcelImportFile(filePath string, consumer importFileConsumer) error {
- workbook, err := excelize.OpenFile(filePath)
- if err != nil {
- return fmt.Errorf("Excel Parse Error: %w", err)
- }
- defer workbook.Close()
-
- sheetName := workbook.GetSheetName(0)
- if sheetName == "" {
- return fmt.Errorf("Excel file has no sheets")
- }
-
- rows, err := workbook.Rows(sheetName)
- if err != nil {
- return fmt.Errorf("Excel Read Error: %w", err)
- }
- defer rows.Close()
-
- if !rows.Next() {
- if err := rows.Error(); err != nil {
- return fmt.Errorf("Excel Read Error: %w", err)
- }
- return fmt.Errorf("Excel empty or missing header")
- }
- header, err := rows.Columns()
- if err != nil {
- return fmt.Errorf("Excel Read Error: %w", err)
- }
- columns := cloneImportColumns(header)
- if !hasImportUsableColumns(columns) {
- return fmt.Errorf("Excel empty or missing header")
- }
- if err := consumer.SetColumns(columns); err != nil {
- return err
- }
-
- for rows.Next() {
- record, err := rows.Columns()
- if err != nil {
- return fmt.Errorf("Excel Read Error: %w", err)
- }
- if err := consumer.ConsumeRow(buildImportRowFromValues(columns, record)); err != nil {
- return err
- }
- }
- if err := rows.Error(); err != nil {
- return fmt.Errorf("Excel Read Error: %w", err)
- }
- return nil
-}
-
func buildImportInsertQuery(dbType, tableName string, columns []string, row map[string]interface{}, columnTypeMap map[string]string) (string, error) {
quotedCols := make([]string, 0, len(columns))
values := make([]string, 0, len(columns))
diff --git a/internal/app/memory_reclaim.go b/internal/app/memory_reclaim.go
new file mode 100644
index 00000000..21a8f3dc
--- /dev/null
+++ b/internal/app/memory_reclaim.go
@@ -0,0 +1,79 @@
+package app
+
+import (
+ "os"
+ "runtime"
+ "runtime/debug"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "GoNavi-Wails/internal/logger"
+)
+
+const (
+ fileTransferMemoryTrimRowsThreshold int64 = 100000
+ fileTransferMemoryTrimFileSizeThreshold int64 = 64 * 1024 * 1024
+ fileTransferMemoryTrimMinInterval = 3 * time.Second
+)
+
+var (
+ fileTransferMemoryTrimRunning atomic.Bool
+ fileTransferMemoryTrimLastAt atomic.Int64
+
+ runFileTransferMemoryTrimAsync = func(fn func()) {
+ go fn()
+ }
+ fileTransferMemoryTrimFn = func() {
+ runtime.GC()
+ debug.FreeOSMemory()
+ }
+)
+
+func maybeReleaseFileTransferMemory(reason string, rows int64, filePath string) {
+ if !shouldReleaseFileTransferMemory(rows, filePath) {
+ return
+ }
+ if !fileTransferMemoryTrimRunning.CompareAndSwap(false, true) {
+ return
+ }
+
+ runFileTransferMemoryTrimAsync(func() {
+ defer fileTransferMemoryTrimRunning.Store(false)
+
+ if delay := nextFileTransferMemoryTrimDelay(); delay > 0 {
+ time.Sleep(delay)
+ }
+
+ logger.Infof("大文件导入导出任务结束,尝试回收进程内存:reason=%s rows=%d file=%s", strings.TrimSpace(reason), rows, strings.TrimSpace(filePath))
+ fileTransferMemoryTrimFn()
+ fileTransferMemoryTrimLastAt.Store(time.Now().UnixNano())
+ })
+}
+
+func shouldReleaseFileTransferMemory(rows int64, filePath string) bool {
+ if rows >= fileTransferMemoryTrimRowsThreshold {
+ return true
+ }
+ filePath = strings.TrimSpace(filePath)
+ if filePath == "" {
+ return false
+ }
+ info, err := os.Stat(filePath)
+ if err != nil {
+ return false
+ }
+ return info.Size() >= fileTransferMemoryTrimFileSizeThreshold
+}
+
+func nextFileTransferMemoryTrimDelay() time.Duration {
+ lastUnixNano := fileTransferMemoryTrimLastAt.Load()
+ if lastUnixNano <= 0 {
+ return 0
+ }
+ elapsed := time.Since(time.Unix(0, lastUnixNano))
+ if elapsed >= fileTransferMemoryTrimMinInterval {
+ return 0
+ }
+ return fileTransferMemoryTrimMinInterval - elapsed
+}
diff --git a/internal/app/memory_reclaim_test.go b/internal/app/memory_reclaim_test.go
new file mode 100644
index 00000000..ba4917ae
--- /dev/null
+++ b/internal/app/memory_reclaim_test.go
@@ -0,0 +1,88 @@
+package app
+
+import (
+ "os"
+ "testing"
+)
+
+func TestShouldReleaseFileTransferMemory_UsesRowsThreshold(t *testing.T) {
+ if !shouldReleaseFileTransferMemory(fileTransferMemoryTrimRowsThreshold, "") {
+ t.Fatal("达到行数阈值时应触发内存回收")
+ }
+ if shouldReleaseFileTransferMemory(fileTransferMemoryTrimRowsThreshold-1, "") {
+ t.Fatal("未达到行数阈值时不应仅凭行数触发内存回收")
+ }
+}
+
+func TestShouldReleaseFileTransferMemory_UsesFileSizeThreshold(t *testing.T) {
+ f, err := os.CreateTemp("", "gonavi-memory-trim-*.bin")
+ if err != nil {
+ t.Fatalf("创建临时文件失败: %v", err)
+ }
+ defer os.Remove(f.Name())
+ defer f.Close()
+
+ if err := f.Truncate(fileTransferMemoryTrimFileSizeThreshold); err != nil {
+ t.Fatalf("设置临时文件大小失败: %v", err)
+ }
+
+ if !shouldReleaseFileTransferMemory(0, f.Name()) {
+ t.Fatal("达到文件大小阈值时应触发内存回收")
+ }
+}
+
+func TestMaybeReleaseFileTransferMemory_TriggersTrimForLargeJobs(t *testing.T) {
+ originalAsync := runFileTransferMemoryTrimAsync
+ originalTrim := fileTransferMemoryTrimFn
+ t.Cleanup(func() {
+ runFileTransferMemoryTrimAsync = originalAsync
+ fileTransferMemoryTrimFn = originalTrim
+ fileTransferMemoryTrimRunning.Store(false)
+ fileTransferMemoryTrimLastAt.Store(0)
+ })
+
+ fileTransferMemoryTrimRunning.Store(false)
+ fileTransferMemoryTrimLastAt.Store(0)
+
+ triggered := 0
+ runFileTransferMemoryTrimAsync = func(fn func()) {
+ fn()
+ }
+ fileTransferMemoryTrimFn = func() {
+ triggered++
+ }
+
+ maybeReleaseFileTransferMemory("test-large-job", fileTransferMemoryTrimRowsThreshold, "")
+
+ if triggered != 1 {
+ t.Fatalf("大任务完成后应触发一次内存回收,got=%d", triggered)
+ }
+}
+
+func TestMaybeReleaseFileTransferMemory_SkipsSmallJobs(t *testing.T) {
+ originalAsync := runFileTransferMemoryTrimAsync
+ originalTrim := fileTransferMemoryTrimFn
+ t.Cleanup(func() {
+ runFileTransferMemoryTrimAsync = originalAsync
+ fileTransferMemoryTrimFn = originalTrim
+ fileTransferMemoryTrimRunning.Store(false)
+ fileTransferMemoryTrimLastAt.Store(0)
+ })
+
+ fileTransferMemoryTrimRunning.Store(false)
+ fileTransferMemoryTrimLastAt.Store(0)
+
+ triggered := 0
+ runFileTransferMemoryTrimAsync = func(fn func()) {
+ fn()
+ }
+ fileTransferMemoryTrimFn = func() {
+ triggered++
+ }
+
+ maybeReleaseFileTransferMemory("test-small-job", 10, "")
+
+ if triggered != 0 {
+ t.Fatalf("小任务不应触发内存回收,got=%d", triggered)
+ }
+}
diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go
index 2bb306b5..3342cde8 100644
--- a/internal/app/methods_file.go
+++ b/internal/app/methods_file.go
@@ -24,7 +24,6 @@ import (
"GoNavi-Wails/internal/utils"
"github.com/wailsapp/wails/v2/pkg/runtime"
- "github.com/xuri/excelize/v2"
)
const minExportQueryTimeout = 5 * time.Minute
@@ -2045,14 +2044,19 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName,
runtime.EventsEmit(a.ctx, "import:progress", state)
})
if err := streamImportFile(filePath, consumer); err != nil {
+ resultData := consumer.Result()
+ maybeReleaseFileTransferMemory("import-stream-error", int64(resultData.Total), filePath)
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := consumer.Flush(); err != nil {
+ resultData := consumer.Result()
+ maybeReleaseFileTransferMemory("import-flush-error", int64(resultData.Total), filePath)
return connection.QueryResult{Success: false, Message: err.Error()}
}
resultData := consumer.Result()
if resultData.Total == 0 {
+ maybeReleaseFileTransferMemory("import-empty", 0, filePath)
return connection.QueryResult{Success: true, Message: "无可导入数据"}
}
@@ -2064,6 +2068,7 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName,
"errorSummary": fmt.Sprintf("Imported: %d, Failed: %d", resultData.Success, resultData.Failed),
}
+ maybeReleaseFileTransferMemory("import-finished", int64(resultData.Total), filePath)
return connection.QueryResult{Success: true, Data: result, Message: fmt.Sprintf("Imported: %d, Failed: %d", resultData.Success, resultData.Failed)}
}
@@ -2170,6 +2175,7 @@ func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName
reporter.Finalizing(0)
reporter.Done(0)
+ maybeReleaseFileTransferMemory("export-table-sql-finished", 0, filename)
return connection.QueryResult{Success: true, Message: "导出完成"}
}
@@ -2185,9 +2191,11 @@ func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName
rowCount, _, err := exportQueryResultToFile(f, dbInst, runConfig, query, options, reporter)
if err != nil {
reporter.Error(rowCount, "写入失败:"+err.Error())
+ maybeReleaseFileTransferMemory("export-table-error", rowCount, filename)
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
}
reporter.Done(rowCount)
+ maybeReleaseFileTransferMemory("export-table-finished", rowCount, filename)
return connection.QueryResult{Success: true, Message: "导出完成"}
}
@@ -3488,11 +3496,13 @@ func (a *App) ExportDataWithOptions(data []map[string]interface{}, columns []str
if err != nil {
logger.Warnf("ExportData 写入失败:file=%s err=%v", filename, err)
reporter.Error(writtenRows, "写入失败:"+err.Error())
+ maybeReleaseFileTransferMemory("export-data-error", writtenRows, filename)
return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()}
}
logger.Infof("ExportData 完成:file=%s rows=%d", filename, len(data))
reporter.Done(writtenRows)
+ maybeReleaseFileTransferMemory("export-data-finished", writtenRows, filename)
return connection.QueryResult{Success: true, Message: "导出完成"}
}
@@ -3550,11 +3560,13 @@ func (a *App) ExportQueryWithOptions(config connection.ConnectionConfig, dbName
if err != nil {
logger.Warnf("ExportQuery 查询失败:type=%s db=%s err=%v sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), err, sqlSnippet(query))
reporter.Error(rowCount, err.Error())
+ maybeReleaseFileTransferMemory("export-query-error", rowCount, filename)
return connection.QueryResult{Success: false, Message: err.Error()}
}
logger.Infof("ExportQuery 完成:file=%s rows=%d cols=%d", filename, rowCount, len(columns))
reporter.Done(rowCount)
+ maybeReleaseFileTransferMemory("export-query-finished", rowCount, filename)
return connection.QueryResult{Success: true, Message: "导出完成"}
}
@@ -3997,128 +4009,6 @@ func (w *htmlExportFileWriter) Close() error {
return w.writer.Flush()
}
-type xlsxExportFileWriter struct {
- filename string
- workbook *excelize.File
- stream *excelize.StreamWriter
- sheet string
- columns []string
- header []interface{}
- rowBuf []interface{}
- nextRow int
- sheetNo int
- rowCount int
- maxRows int
-}
-
-func newXLSXExportFileWriter(filename string, maxRowsPerSheet int) (*xlsxExportFileWriter, error) {
- workbook := excelize.NewFile()
- sheet := workbook.GetSheetName(workbook.GetActiveSheetIndex())
- stream, err := workbook.NewStreamWriter(sheet)
- if err != nil {
- _ = workbook.Close()
- return nil, err
- }
- return &xlsxExportFileWriter{
- filename: filename,
- workbook: workbook,
- stream: stream,
- sheet: sheet,
- sheetNo: 1,
- nextRow: 2,
- maxRows: normalizeXLSXRowsPerSheet(maxRowsPerSheet),
- }, nil
-}
-
-func (w *xlsxExportFileWriter) SetColumns(columns []string) error {
- w.columns = append([]string(nil), columns...)
- w.rowCount = 0
- w.nextRow = 2
- w.header = make([]interface{}, len(columns))
- w.rowBuf = make([]interface{}, len(columns))
- for i, col := range columns {
- w.header[i] = col
- }
- return w.stream.SetRow("A1", w.header)
-}
-
-func (w *xlsxExportFileWriter) rotateSheet() error {
- if err := w.stream.Flush(); err != nil {
- return err
- }
- w.sheetNo++
- w.sheet = fmt.Sprintf("Sheet%d", w.sheetNo)
- if _, err := w.workbook.NewSheet(w.sheet); err != nil {
- return err
- }
- stream, err := w.workbook.NewStreamWriter(w.sheet)
- if err != nil {
- return err
- }
- w.stream = stream
- w.rowCount = 0
- w.nextRow = 2
- return w.stream.SetRow("A1", w.header)
-}
-
-func (w *xlsxExportFileWriter) ConsumeRow(row map[string]interface{}) error {
- if w.rowCount >= w.maxRows {
- if err := w.rotateSheet(); err != nil {
- return err
- }
- }
- values := w.rowBuf
- for i, col := range w.columns {
- val := row[col]
- if val == nil {
- values[i] = "NULL"
- continue
- }
- values[i] = formatExportCellText(val)
- }
- cell := "A" + strconv.Itoa(w.nextRow)
- w.nextRow++
- w.rowCount++
- return w.stream.SetRow(cell, values)
-}
-
-func (w *xlsxExportFileWriter) ConsumeRowValues(values []interface{}) error {
- if w.rowCount >= w.maxRows {
- if err := w.rotateSheet(); err != nil {
- return err
- }
- }
- rowBuf := w.rowBuf
- for i := range w.columns {
- var value interface{}
- if i < len(values) {
- value = values[i]
- }
- if value == nil {
- rowBuf[i] = "NULL"
- continue
- }
- rowBuf[i] = formatExportCellText(value)
- }
- cell := "A" + strconv.Itoa(w.nextRow)
- w.nextRow++
- w.rowCount++
- return w.stream.SetRow(cell, rowBuf)
-}
-
-func (w *xlsxExportFileWriter) Close() error {
- if err := w.stream.Flush(); err != nil {
- _ = w.workbook.Close()
- return err
- }
- saveErr := w.workbook.SaveAs(w.filename)
- closeErr := w.workbook.Close()
- if saveErr != nil {
- return saveErr
- }
- return closeErr
-}
-
type sqlInsertExportConsumer struct {
w *bufio.Writer
dbType string
@@ -4304,11 +4194,7 @@ func newExportFileWriter(f *os.File, options ExportFileOptions) (exportFileWrite
case "html":
return newHTMLExportFileWriter(f), nil
case "xlsx":
- filename := f.Name()
- if err := f.Close(); err != nil {
- return nil, err
- }
- return newXLSXExportFileWriter(filename, options.XLSXMaxRowsPerSheet)
+ return newXLSXExportFileWriter(f, options.XLSXMaxRowsPerSheet)
default:
return nil, fmt.Errorf("unsupported format: %s", options.Format)
}
@@ -4756,29 +4642,23 @@ func normalizeExportJSONValue(val interface{}) interface{} {
// writeRowsToXlsx 使用 excelize 写入真正的 xlsx 格式文件
func writeRowsToXlsx(filename string, data []map[string]interface{}, columns []string) error {
- xlsx := excelize.NewFile()
- defer xlsx.Close()
-
- sheet := "Sheet1"
-
- // 写入表头
- for i, col := range columns {
- cell, _ := excelize.CoordinatesToCellName(i+1, 1)
- xlsx.SetCellValue(sheet, cell, col)
+ file, err := os.Create(filename)
+ if err != nil {
+ return err
}
+ defer file.Close()
- // 写入数据行
- for rowIdx, rowMap := range data {
- for colIdx, col := range columns {
- cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
- val := rowMap[col]
- if val == nil {
- xlsx.SetCellValue(sheet, cell, "NULL")
- } else {
- xlsx.SetCellValue(sheet, cell, formatExportCellText(val))
- }
+ writer, err := newXLSXExportFileWriter(file, 0)
+ if err != nil {
+ return err
+ }
+ if err := writer.SetColumns(columns); err != nil {
+ return err
+ }
+ for _, rowMap := range data {
+ if err := writer.ConsumeRow(rowMap); err != nil {
+ return err
}
}
-
- return xlsx.SaveAs(filename)
+ return writer.Close()
}
diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go
index df668771..831f7043 100644
--- a/internal/app/methods_file_export_test.go
+++ b/internal/app/methods_file_export_test.go
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "fmt"
"io"
"os"
"strings"
@@ -44,6 +45,13 @@ type fakeValueStreamExportDB struct {
valueHits int
}
+type fakeGeneratedValueStreamExportDB struct {
+ streamCols []string
+ rowCount int
+ streamHits int
+ valueHits int
+}
+
func (f *fakeExportQueryDB) Connect(config connection.ConnectionConfig) error { return nil }
func (f *fakeExportQueryDB) Close() error { return nil }
func (f *fakeExportQueryDB) Ping() error { return nil }
@@ -154,6 +162,82 @@ func (f *fakeValueStreamExportDB) StreamQueryContext(_ context.Context, query st
return nil
}
+func (f *fakeGeneratedValueStreamExportDB) Connect(config connection.ConnectionConfig) error {
+ return nil
+}
+
+func (f *fakeGeneratedValueStreamExportDB) Close() error { return nil }
+
+func (f *fakeGeneratedValueStreamExportDB) Ping() error { return nil }
+
+func (f *fakeGeneratedValueStreamExportDB) Query(query string) ([]map[string]interface{}, []string, error) {
+ return nil, nil, context.DeadlineExceeded
+}
+
+func (f *fakeGeneratedValueStreamExportDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
+ return nil, nil, context.DeadlineExceeded
+}
+
+func (f *fakeGeneratedValueStreamExportDB) Exec(query string) (int64, error) { return 0, nil }
+
+func (f *fakeGeneratedValueStreamExportDB) GetDatabases() ([]string, error) { return nil, nil }
+
+func (f *fakeGeneratedValueStreamExportDB) GetTables(dbName string) ([]string, error) {
+ return nil, nil
+}
+
+func (f *fakeGeneratedValueStreamExportDB) GetCreateStatement(dbName, tableName string) (string, error) {
+ return "", nil
+}
+
+func (f *fakeGeneratedValueStreamExportDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
+ return nil, nil
+}
+
+func (f *fakeGeneratedValueStreamExportDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
+ return nil, nil
+}
+
+func (f *fakeGeneratedValueStreamExportDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
+ return nil, nil
+}
+
+func (f *fakeGeneratedValueStreamExportDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
+ return nil, nil
+}
+
+func (f *fakeGeneratedValueStreamExportDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
+ return nil, nil
+}
+
+func (f *fakeGeneratedValueStreamExportDB) StreamQuery(query string, consumer db.QueryStreamConsumer) error {
+ return f.StreamQueryContext(context.Background(), query, consumer)
+}
+
+func (f *fakeGeneratedValueStreamExportDB) StreamQueryContext(_ context.Context, query string, consumer db.QueryStreamConsumer) error {
+ f.streamHits++
+ if err := consumer.SetColumns(f.streamCols); err != nil {
+ return err
+ }
+ valueConsumer, ok := consumer.(db.QueryStreamValueConsumer)
+ if !ok {
+ return fmt.Errorf("value stream consumer required")
+ }
+ for i := 0; i < f.rowCount; i++ {
+ f.valueHits++
+ if err := valueConsumer.ConsumeRowValues([]interface{}{
+ i + 1,
+ "benchmark-user",
+ "plain export payload without timezone marker",
+ "2026-06-17 12:34:56",
+ "enabled",
+ }); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
func TestFormatExportCellText_FloatNoScientificNotation(t *testing.T) {
got := formatExportCellText(1.445663e+06)
if strings.Contains(strings.ToLower(got), "e+") || strings.Contains(strings.ToLower(got), "e-") {
@@ -730,6 +814,35 @@ func BenchmarkExportQueryResultToFile_XLSX_StreamValues_20000Rows(b *testing.B)
}
}
+func BenchmarkExportQueryResultToFile_XLSX_StreamGenerated_50000Rows(b *testing.B) {
+ streamDB := &fakeGeneratedValueStreamExportDB{
+ streamCols: []string{"id", "name", "note", "created_at", "status"},
+ rowCount: 50000,
+ }
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ f, err := os.CreateTemp("", "gonavi-export-stream-generated-*.xlsx")
+ if err != nil {
+ b.Fatalf("创建临时文件失败: %v", err)
+ }
+ name := f.Name()
+ if _, _, err := exportQueryResultToFile(
+ f,
+ streamDB,
+ connection.ConnectionConfig{Type: "mysql", Timeout: 10},
+ "SELECT * FROM users",
+ ExportFileOptions{Format: "xlsx"},
+ nil,
+ ); err != nil {
+ _ = os.Remove(name)
+ b.Fatalf("流式生成导出失败: %v", err)
+ }
+ if err := os.Remove(name); err != nil {
+ b.Fatalf("删除临时文件失败: %v", err)
+ }
+ }
+}
+
func BenchmarkDumpTableSQL_SQLBackup_StreamMap_20000Rows(b *testing.B) {
rows, columns := benchmarkExportRows(20000)
streamDB := &fakeStreamExportDB{
diff --git a/internal/app/methods_file_import_test.go b/internal/app/methods_file_import_test.go
index b23f278b..582b5aea 100644
--- a/internal/app/methods_file_import_test.go
+++ b/internal/app/methods_file_import_test.go
@@ -8,6 +8,8 @@ import (
"reflect"
"strings"
"testing"
+
+ "github.com/xuri/excelize/v2"
)
func TestReadImportedConnectionConfigFileRejectsOversizedFiles(t *testing.T) {
@@ -82,6 +84,94 @@ func TestBuildImportRowFromValuesPreservesPositionsWhenHeaderContainsBlankColumn
}
}
+func TestBuildImportPreviewXLSXStreamSupportsInlineStrings(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "inline.xlsx")
+ file, err := os.Create(path)
+ if err != nil {
+ t.Fatalf("创建 xlsx 文件失败: %v", err)
+ }
+
+ writer, err := newXLSXExportFileWriter(file, 0)
+ if err != nil {
+ t.Fatalf("创建 xlsx writer 失败: %v", err)
+ }
+ if err := writer.SetColumns([]string{"id", "name"}); err != nil {
+ t.Fatalf("SetColumns 失败: %v", err)
+ }
+ if err := writer.ConsumeRowValues([]interface{}{1, "alice"}); err != nil {
+ t.Fatalf("写入第 1 行失败: %v", err)
+ }
+ if err := writer.ConsumeRowValues([]interface{}{2, "bob"}); err != nil {
+ t.Fatalf("写入第 2 行失败: %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("关闭 xlsx writer 失败: %v", err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatalf("关闭 xlsx 文件失败: %v", err)
+ }
+
+ preview, err := buildImportPreview(path, 5)
+ if err != nil {
+ t.Fatalf("buildImportPreview 返回错误: %v", err)
+ }
+ if !reflect.DeepEqual(preview.Columns, []string{"id", "name"}) {
+ t.Fatalf("unexpected columns: %#v", preview.Columns)
+ }
+ if preview.TotalRows != 2 {
+ t.Fatalf("expected 2 rows, got %d", preview.TotalRows)
+ }
+ if got := preview.PreviewRows[1]["name"]; got != "bob" {
+ t.Fatalf("expected second row name bob, got %#v", got)
+ }
+}
+
+func TestBuildImportPreviewXLSXStreamSupportsSharedStrings(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "shared.xlsx")
+ workbook := excelize.NewFile()
+ if err := workbook.SetCellValue("Sheet1", "A1", "id"); err != nil {
+ t.Fatalf("设置表头失败: %v", err)
+ }
+ if err := workbook.SetCellValue("Sheet1", "B1", "name"); err != nil {
+ t.Fatalf("设置表头失败: %v", err)
+ }
+ if err := workbook.SetCellValue("Sheet1", "A2", "1"); err != nil {
+ t.Fatalf("设置数据失败: %v", err)
+ }
+ if err := workbook.SetCellValue("Sheet1", "B2", "alice"); err != nil {
+ t.Fatalf("设置数据失败: %v", err)
+ }
+ if err := workbook.SetCellValue("Sheet1", "A3", "2"); err != nil {
+ t.Fatalf("设置数据失败: %v", err)
+ }
+ if err := workbook.SetCellValue("Sheet1", "B3", "bob"); err != nil {
+ t.Fatalf("设置数据失败: %v", err)
+ }
+ if err := workbook.SaveAs(path); err != nil {
+ t.Fatalf("保存 shared-string xlsx 失败: %v", err)
+ }
+ if err := workbook.Close(); err != nil {
+ t.Fatalf("关闭 shared-string xlsx 失败: %v", err)
+ }
+
+ preview, err := buildImportPreview(path, 5)
+ if err != nil {
+ t.Fatalf("buildImportPreview 返回错误: %v", err)
+ }
+ if !reflect.DeepEqual(preview.Columns, []string{"id", "name"}) {
+ t.Fatalf("unexpected columns: %#v", preview.Columns)
+ }
+ if preview.TotalRows != 2 {
+ t.Fatalf("expected 2 rows, got %d", preview.TotalRows)
+ }
+ if got := preview.PreviewRows[0]["name"]; got != "alice" {
+ t.Fatalf("expected first row name alice, got %#v", got)
+ }
+ if got := preview.PreviewRows[1]["id"]; got != "2" {
+ t.Fatalf("expected second row id 2, got %#v", got)
+ }
+}
+
type fakeImportRowWriter struct {
columns []string
batchCalls int
diff --git a/internal/app/xlsx_import_stream.go b/internal/app/xlsx_import_stream.go
new file mode 100644
index 00000000..c13219a6
--- /dev/null
+++ b/internal/app/xlsx_import_stream.go
@@ -0,0 +1,544 @@
+package app
+
+import (
+ "archive/zip"
+ "bufio"
+ "encoding/binary"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+
+ "github.com/xuri/excelize/v2"
+)
+
+const (
+ xlsxWorkbookXMLPath = "xl/workbook.xml"
+ xlsxWorkbookRelsXMLPath = "xl/_rels/workbook.xml.rels"
+ xlsxSharedStringsXML = "xl/sharedStrings.xml"
+)
+
+type xlsxSharedStringStore struct {
+ path string
+ file *os.File
+ writer *bufio.Writer
+ offsets []int64
+ size int64
+}
+
+func newXLSXSharedStringStore() (*xlsxSharedStringStore, error) {
+ file, err := os.CreateTemp("", "gonavi-xlsx-shared-strings-*.bin")
+ if err != nil {
+ return nil, err
+ }
+ return &xlsxSharedStringStore{
+ path: file.Name(),
+ file: file,
+ writer: bufio.NewWriterSize(file, 1024*256),
+ }, nil
+}
+
+func (s *xlsxSharedStringStore) Add(value string) error {
+ if s == nil || s.file == nil || s.writer == nil {
+ return fmt.Errorf("shared string store unavailable")
+ }
+ s.offsets = append(s.offsets, s.size)
+ if err := binary.Write(s.writer, binary.LittleEndian, uint32(len(value))); err != nil {
+ return err
+ }
+ written, err := s.writer.WriteString(value)
+ s.size += 4 + int64(written)
+ return err
+}
+
+func (s *xlsxSharedStringStore) Get(index int) (string, error) {
+ if s == nil {
+ return "", nil
+ }
+ if index < 0 || index >= len(s.offsets) {
+ return "", fmt.Errorf("shared string index out of range: %d", index)
+ }
+ if err := s.flush(); err != nil {
+ return "", err
+ }
+ if _, err := s.file.Seek(s.offsets[index], io.SeekStart); err != nil {
+ return "", err
+ }
+ var length uint32
+ if err := binary.Read(s.file, binary.LittleEndian, &length); err != nil {
+ return "", err
+ }
+ buf := make([]byte, int(length))
+ if _, err := io.ReadFull(s.file, buf); err != nil {
+ return "", err
+ }
+ return string(buf), nil
+}
+
+func (s *xlsxSharedStringStore) flush() error {
+ if s == nil || s.writer == nil {
+ return nil
+ }
+ return s.writer.Flush()
+}
+
+func (s *xlsxSharedStringStore) Close() error {
+ if s == nil {
+ return nil
+ }
+ if s.writer != nil {
+ _ = s.writer.Flush()
+ }
+ var err error
+ if s.file != nil {
+ err = s.file.Close()
+ }
+ if s.path != "" {
+ _ = os.Remove(s.path)
+ }
+ return err
+}
+
+func streamXLSXImportFile(filePath string, consumer importFileConsumer) error {
+ reader, err := zip.OpenReader(filePath)
+ if err != nil {
+ return fmt.Errorf("Excel Parse Error: %w", err)
+ }
+ defer reader.Close()
+
+ entryByPath := make(map[string]*zip.File, len(reader.File))
+ for _, entry := range reader.File {
+ entryByPath[entry.Name] = entry
+ }
+
+ sheetPath, err := resolveXLSXFirstSheetPath(entryByPath)
+ if err != nil {
+ return fmt.Errorf("Excel Parse Error: %w", err)
+ }
+
+ sharedStrings, err := loadXLSXSharedStrings(entryByPath[xlsxSharedStringsXML])
+ if err != nil {
+ return fmt.Errorf("Excel Parse Error: %w", err)
+ }
+ if sharedStrings != nil {
+ defer sharedStrings.Close()
+ }
+
+ sheetEntry := entryByPath[sheetPath]
+ if sheetEntry == nil {
+ return fmt.Errorf("Excel Parse Error: worksheet not found: %s", sheetPath)
+ }
+ if err := streamXLSXSheetRows(sheetEntry, sharedStrings, consumer); err != nil {
+ return fmt.Errorf("Excel Read Error: %w", err)
+ }
+ return nil
+}
+
+func streamLegacyExcelImportFile(filePath string, consumer importFileConsumer) error {
+ workbook, err := excelize.OpenFile(filePath)
+ if err != nil {
+ return fmt.Errorf("Excel Parse Error: %w", err)
+ }
+ defer workbook.Close()
+
+ sheetName := workbook.GetSheetName(0)
+ if sheetName == "" {
+ return fmt.Errorf("Excel file has no sheets")
+ }
+
+ rows, err := workbook.Rows(sheetName)
+ if err != nil {
+ return fmt.Errorf("Excel Read Error: %w", err)
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ if err := rows.Error(); err != nil {
+ return fmt.Errorf("Excel Read Error: %w", err)
+ }
+ return fmt.Errorf("Excel empty or missing header")
+ }
+ header, err := rows.Columns()
+ if err != nil {
+ return fmt.Errorf("Excel Read Error: %w", err)
+ }
+ columns := cloneImportColumns(header)
+ if !hasImportUsableColumns(columns) {
+ return fmt.Errorf("Excel empty or missing header")
+ }
+ if err := consumer.SetColumns(columns); err != nil {
+ return err
+ }
+
+ for rows.Next() {
+ record, err := rows.Columns()
+ if err != nil {
+ return fmt.Errorf("Excel Read Error: %w", err)
+ }
+ if err := consumer.ConsumeRow(buildImportRowFromValues(columns, record)); err != nil {
+ return err
+ }
+ }
+ if err := rows.Error(); err != nil {
+ return fmt.Errorf("Excel Read Error: %w", err)
+ }
+ return nil
+}
+
+func resolveXLSXFirstSheetPath(entryByPath map[string]*zip.File) (string, error) {
+ workbookEntry := entryByPath[xlsxWorkbookXMLPath]
+ if workbookEntry == nil {
+ return "", fmt.Errorf("workbook.xml missing")
+ }
+ workbookReader, err := workbookEntry.Open()
+ if err != nil {
+ return "", err
+ }
+ defer workbookReader.Close()
+
+ firstSheetRelID, err := readXLSXFirstSheetRelID(workbookReader)
+ if err != nil {
+ return "", err
+ }
+ if firstSheetRelID == "" {
+ return "", fmt.Errorf("workbook has no sheets")
+ }
+
+ relsEntry := entryByPath[xlsxWorkbookRelsXMLPath]
+ if relsEntry == nil {
+ return "", fmt.Errorf("workbook rels missing")
+ }
+ relsReader, err := relsEntry.Open()
+ if err != nil {
+ return "", err
+ }
+ defer relsReader.Close()
+
+ target, err := readXLSXWorkbookRelTarget(relsReader, firstSheetRelID)
+ if err != nil {
+ return "", err
+ }
+ if target == "" {
+ return "", fmt.Errorf("worksheet target missing for relationship %s", firstSheetRelID)
+ }
+ target = strings.TrimPrefix(strings.TrimSpace(target), "/")
+ if strings.HasPrefix(target, "xl/") {
+ return path.Clean(target), nil
+ }
+ return path.Clean(path.Join("xl", target)), nil
+}
+
+func readXLSXFirstSheetRelID(reader io.Reader) (string, error) {
+ decoder := xml.NewDecoder(reader)
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ if err == io.EOF {
+ return "", nil
+ }
+ return "", err
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok || start.Name.Local != "sheet" {
+ continue
+ }
+ for _, attr := range start.Attr {
+ if attr.Name.Local == "id" {
+ return strings.TrimSpace(attr.Value), nil
+ }
+ }
+ }
+}
+
+func readXLSXWorkbookRelTarget(reader io.Reader, relID string) (string, error) {
+ decoder := xml.NewDecoder(reader)
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ if err == io.EOF {
+ return "", nil
+ }
+ return "", err
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok || start.Name.Local != "Relationship" {
+ continue
+ }
+ var id string
+ var target string
+ for _, attr := range start.Attr {
+ switch attr.Name.Local {
+ case "Id":
+ id = strings.TrimSpace(attr.Value)
+ case "Target":
+ target = strings.TrimSpace(attr.Value)
+ }
+ }
+ if id == relID {
+ return target, nil
+ }
+ }
+}
+
+func loadXLSXSharedStrings(entry *zip.File) (*xlsxSharedStringStore, error) {
+ if entry == nil {
+ return nil, nil
+ }
+ reader, err := entry.Open()
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+
+ store, err := newXLSXSharedStringStore()
+ if err != nil {
+ return nil, err
+ }
+ if err := parseXLSXSharedStrings(reader, store); err != nil {
+ _ = store.Close()
+ return nil, err
+ }
+ if err := store.flush(); err != nil {
+ _ = store.Close()
+ return nil, err
+ }
+ return store, nil
+}
+
+func parseXLSXSharedStrings(reader io.Reader, store *xlsxSharedStringStore) error {
+ decoder := xml.NewDecoder(reader)
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ if err == io.EOF {
+ return nil
+ }
+ return err
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok || start.Name.Local != "si" {
+ continue
+ }
+ value, err := readXLSXSharedStringItem(decoder)
+ if err != nil {
+ return err
+ }
+ if err := store.Add(value); err != nil {
+ return err
+ }
+ }
+}
+
+func readXLSXSharedStringItem(decoder *xml.Decoder) (string, error) {
+ var builder strings.Builder
+ depth := 1
+ for depth > 0 {
+ token, err := decoder.Token()
+ if err != nil {
+ return "", err
+ }
+ switch typed := token.(type) {
+ case xml.StartElement:
+ if typed.Name.Local == "si" {
+ depth++
+ continue
+ }
+ if typed.Name.Local == "t" {
+ text, err := readXMLTextNode(decoder, typed.Name.Local)
+ if err != nil {
+ return "", err
+ }
+ builder.WriteString(text)
+ }
+ case xml.EndElement:
+ if typed.Name.Local == "si" {
+ depth--
+ }
+ }
+ }
+ return builder.String(), nil
+}
+
+func streamXLSXSheetRows(entry *zip.File, sharedStrings *xlsxSharedStringStore, consumer importFileConsumer) error {
+ reader, err := entry.Open()
+ if err != nil {
+ return err
+ }
+ defer reader.Close()
+
+ decoder := xml.NewDecoder(reader)
+ var columns []string
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ return err
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok || start.Name.Local != "row" {
+ continue
+ }
+ values, err := readXLSXRow(decoder, sharedStrings)
+ if err != nil {
+ return err
+ }
+ if columns == nil {
+ columns = cloneImportColumns(values)
+ if !hasImportUsableColumns(columns) {
+ return fmt.Errorf("Excel empty or missing header")
+ }
+ if err := consumer.SetColumns(columns); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := consumer.ConsumeRow(buildImportRowFromValues(columns, values)); err != nil {
+ return err
+ }
+ }
+ if columns == nil {
+ return fmt.Errorf("Excel empty or missing header")
+ }
+ return nil
+}
+
+func readXLSXRow(decoder *xml.Decoder, sharedStrings *xlsxSharedStringStore) ([]string, error) {
+ values := make([]string, 0, 16)
+ currentColumn := 0
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return nil, err
+ }
+ switch typed := token.(type) {
+ case xml.StartElement:
+ if typed.Name.Local != "c" {
+ continue
+ }
+ columnIndex := currentColumn + 1
+ cellType := ""
+ for _, attr := range typed.Attr {
+ switch attr.Name.Local {
+ case "r":
+ if idx := xlsxCellRefColumnIndex(attr.Value); idx > 0 {
+ columnIndex = idx
+ }
+ case "t":
+ cellType = strings.TrimSpace(attr.Value)
+ }
+ }
+ if columnIndex <= 0 {
+ columnIndex = currentColumn + 1
+ }
+ cellValue, err := readXLSXCell(decoder, cellType, sharedStrings)
+ if err != nil {
+ return nil, err
+ }
+ for len(values) < columnIndex {
+ values = append(values, "")
+ }
+ values[columnIndex-1] = cellValue
+ currentColumn = columnIndex
+ case xml.EndElement:
+ if typed.Name.Local == "row" {
+ return values, nil
+ }
+ }
+ }
+}
+
+func readXLSXCell(decoder *xml.Decoder, cellType string, sharedStrings *xlsxSharedStringStore) (string, error) {
+ var rawValue strings.Builder
+ var inlineValue strings.Builder
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return "", err
+ }
+ switch typed := token.(type) {
+ case xml.StartElement:
+ switch typed.Name.Local {
+ case "v":
+ text, err := readXMLTextNode(decoder, typed.Name.Local)
+ if err != nil {
+ return "", err
+ }
+ rawValue.WriteString(text)
+ case "t":
+ text, err := readXMLTextNode(decoder, typed.Name.Local)
+ if err != nil {
+ return "", err
+ }
+ inlineValue.WriteString(text)
+ }
+ case xml.EndElement:
+ if typed.Name.Local != "c" {
+ continue
+ }
+ switch cellType {
+ case "s":
+ indexText := strings.TrimSpace(rawValue.String())
+ if indexText == "" {
+ return "", nil
+ }
+ index, err := strconv.Atoi(indexText)
+ if err != nil {
+ return "", err
+ }
+ return sharedStrings.Get(index)
+ case "inlineStr":
+ return inlineValue.String(), nil
+ default:
+ if inlineValue.Len() > 0 {
+ return inlineValue.String(), nil
+ }
+ return rawValue.String(), nil
+ }
+ }
+ }
+}
+
+func readXMLTextNode(decoder *xml.Decoder, endLocal string) (string, error) {
+ var builder strings.Builder
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return "", err
+ }
+ switch typed := token.(type) {
+ case xml.CharData:
+ builder.Write([]byte(typed))
+ case xml.EndElement:
+ if typed.Name.Local == endLocal {
+ return builder.String(), nil
+ }
+ }
+ }
+}
+
+func xlsxCellRefColumnIndex(ref string) int {
+ ref = strings.TrimSpace(ref)
+ if ref == "" {
+ return 0
+ }
+ value := 0
+ for i := 0; i < len(ref); i++ {
+ ch := ref[i]
+ switch {
+ case ch >= 'A' && ch <= 'Z':
+ value = value*26 + int(ch-'A'+1)
+ case ch >= 'a' && ch <= 'z':
+ value = value*26 + int(ch-'a'+1)
+ default:
+ if value > 0 {
+ return value
+ }
+ }
+ }
+ return value
+}
diff --git a/internal/app/xlsx_stream_writer.go b/internal/app/xlsx_stream_writer.go
new file mode 100644
index 00000000..ea50be6b
--- /dev/null
+++ b/internal/app/xlsx_stream_writer.go
@@ -0,0 +1,443 @@
+package app
+
+import (
+ "archive/zip"
+ "bufio"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/xuri/excelize/v2"
+)
+
+const (
+ xlsxMainNamespace = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
+ xlsxRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+ xlsxPackageRelationshipsNS = "http://schemas.openxmlformats.org/package/2006/relationships"
+ xlsxWorksheetType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
+ xlsxStylesType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
+ xlsxThemeType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"
+ xlsxWorkbookContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
+ xlsxWorksheetContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
+ xlsxStylesContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"
+ xlsxThemeContentType = "application/vnd.openxmlformats-officedocument.theme+xml"
+ xlsxCoreContentType = "application/vnd.openxmlformats-package.core-properties+xml"
+ xlsxAppContentType = "application/vnd.openxmlformats-officedocument.extended-properties+xml"
+)
+
+var (
+ xlsxTemplatePartsOnce sync.Once
+ xlsxTemplateParts map[string][]byte
+ xlsxTemplatePartsErr error
+)
+
+type xlsxExportTempSheet struct {
+ name string
+ path string
+ file *os.File
+ writer *bufio.Writer
+ closed bool
+}
+
+type xlsxExportFileWriter struct {
+ file *os.File
+ columns []string
+ columnRefs []string
+ rowBuf []string
+ sheets []*xlsxExportTempSheet
+ current *xlsxExportTempSheet
+ nextRow int
+ sheetNo int
+ rowCount int
+ maxRows int
+}
+
+func newXLSXExportFileWriter(f *os.File, maxRowsPerSheet int) (*xlsxExportFileWriter, error) {
+ if f == nil {
+ return nil, fmt.Errorf("file required")
+ }
+ return &xlsxExportFileWriter{
+ file: f,
+ maxRows: normalizeXLSXRowsPerSheet(maxRowsPerSheet),
+ }, nil
+}
+
+func (w *xlsxExportFileWriter) SetColumns(columns []string) error {
+ w.columns = append([]string(nil), columns...)
+ w.columnRefs = make([]string, len(columns))
+ w.rowBuf = make([]string, len(columns))
+ for i := range columns {
+ ref, err := excelize.ColumnNumberToName(i + 1)
+ if err != nil {
+ return err
+ }
+ w.columnRefs[i] = ref
+ }
+ return w.rotateSheet()
+}
+
+func (w *xlsxExportFileWriter) rotateSheet() error {
+ if err := w.closeCurrentSheet(); err != nil {
+ return err
+ }
+
+ tmpFile, err := os.CreateTemp("", "gonavi-export-sheet-*.xml")
+ if err != nil {
+ return err
+ }
+
+ sheet := &xlsxExportTempSheet{
+ name: fmt.Sprintf("Sheet%d", w.sheetNo+1),
+ path: tmpFile.Name(),
+ file: tmpFile,
+ writer: bufio.NewWriterSize(tmpFile, 1024*256),
+ }
+ if err := writeXLSXSheetHeader(sheet.writer); err != nil {
+ _ = tmpFile.Close()
+ _ = os.Remove(tmpFile.Name())
+ return err
+ }
+
+ w.sheetNo++
+ w.current = sheet
+ w.sheets = append(w.sheets, sheet)
+ w.rowCount = 0
+ w.nextRow = 2
+ return w.writeStringRow(1, w.columns)
+}
+
+func (w *xlsxExportFileWriter) ConsumeRow(row map[string]interface{}) error {
+ if w.rowCount >= w.maxRows {
+ if err := w.rotateSheet(); err != nil {
+ return err
+ }
+ }
+
+ values := w.rowBuf
+ for i, col := range w.columns {
+ val := row[col]
+ if val == nil {
+ values[i] = "NULL"
+ continue
+ }
+ values[i] = formatExportCellText(val)
+ }
+ if err := w.writeStringRow(w.nextRow, values); err != nil {
+ return err
+ }
+ w.nextRow++
+ w.rowCount++
+ return nil
+}
+
+func (w *xlsxExportFileWriter) ConsumeRowValues(values []interface{}) error {
+ if w.rowCount >= w.maxRows {
+ if err := w.rotateSheet(); err != nil {
+ return err
+ }
+ }
+
+ record := w.rowBuf
+ for i := range w.columns {
+ var value interface{}
+ if i < len(values) {
+ value = values[i]
+ }
+ if value == nil {
+ record[i] = "NULL"
+ continue
+ }
+ record[i] = formatExportCellText(value)
+ }
+ if err := w.writeStringRow(w.nextRow, record); err != nil {
+ return err
+ }
+ w.nextRow++
+ w.rowCount++
+ return nil
+}
+
+func (w *xlsxExportFileWriter) Close() error {
+ if err := w.closeCurrentSheet(); err != nil {
+ w.cleanupTempSheets()
+ return err
+ }
+ if len(w.sheets) == 0 {
+ w.cleanupTempSheets()
+ return nil
+ }
+
+ if err := w.file.Truncate(0); err != nil {
+ w.cleanupTempSheets()
+ return err
+ }
+ if _, err := w.file.Seek(0, 0); err != nil {
+ w.cleanupTempSheets()
+ return err
+ }
+
+ zw := zip.NewWriter(w.file)
+ defer w.cleanupTempSheets()
+
+ if err := writeXLSXZipFile(zw, w.sheets); err != nil {
+ _ = zw.Close()
+ return err
+ }
+ return zw.Close()
+}
+
+func (w *xlsxExportFileWriter) closeCurrentSheet() error {
+ if w.current == nil || w.current.closed {
+ return nil
+ }
+ if _, err := w.current.writer.WriteString(``); err != nil {
+ return err
+ }
+ if err := w.current.writer.Flush(); err != nil {
+ return err
+ }
+ if err := w.current.file.Close(); err != nil {
+ return err
+ }
+ w.current.closed = true
+ w.current = nil
+ return nil
+}
+
+func (w *xlsxExportFileWriter) cleanupTempSheets() {
+ for _, sheet := range w.sheets {
+ if sheet == nil {
+ continue
+ }
+ if !sheet.closed && sheet.file != nil {
+ _ = sheet.writer.Flush()
+ _ = sheet.file.Close()
+ }
+ if sheet.path != "" {
+ _ = os.Remove(sheet.path)
+ }
+ }
+}
+
+func (w *xlsxExportFileWriter) writeStringRow(rowNumber int, values []string) error {
+ if w.current == nil {
+ return fmt.Errorf("xlsx sheet not initialized")
+ }
+
+ if _, err := w.current.writer.WriteString(``); err != nil {
+ return err
+ }
+ rowRef := strconv.Itoa(rowNumber)
+ for i, value := range values {
+ if err := writeXLSXInlineStringCell(w.current.writer, w.columnRefs[i], rowRef, value); err != nil {
+ return err
+ }
+ }
+ _, err := w.current.writer.WriteString(`
`)
+ return err
+}
+
+func writeXLSXZipFile(zw *zip.Writer, sheets []*xlsxExportTempSheet) error {
+ parts, err := loadXLSXTemplateParts()
+ if err != nil {
+ return err
+ }
+
+ if err := writeZipBytes(zw, "[Content_Types].xml", buildXLSXContentTypes(len(sheets))); err != nil {
+ return err
+ }
+ if err := writeZipBytes(zw, "_rels/.rels", parts["_rels/.rels"]); err != nil {
+ return err
+ }
+ if err := writeZipBytes(zw, "docProps/app.xml", parts["docProps/app.xml"]); err != nil {
+ return err
+ }
+ if err := writeZipBytes(zw, "docProps/core.xml", parts["docProps/core.xml"]); err != nil {
+ return err
+ }
+ if err := writeZipBytes(zw, "xl/workbook.xml", buildXLSXWorkbookXML(sheets)); err != nil {
+ return err
+ }
+ if err := writeZipBytes(zw, "xl/_rels/workbook.xml.rels", buildXLSXWorkbookRels(sheets)); err != nil {
+ return err
+ }
+ if err := writeZipBytes(zw, "xl/styles.xml", parts["xl/styles.xml"]); err != nil {
+ return err
+ }
+ if err := writeZipBytes(zw, "xl/theme/theme1.xml", parts["xl/theme/theme1.xml"]); err != nil {
+ return err
+ }
+ for idx, sheet := range sheets {
+ if err := writeZipFileFromPath(zw, fmt.Sprintf("xl/worksheets/sheet%d.xml", idx+1), sheet.path); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func loadXLSXTemplateParts() (map[string][]byte, error) {
+ xlsxTemplatePartsOnce.Do(func() {
+ workbook := excelize.NewFile()
+ defer workbook.Close()
+
+ required := []string{
+ "_rels/.rels",
+ "docProps/app.xml",
+ "docProps/core.xml",
+ "xl/styles.xml",
+ "xl/theme/theme1.xml",
+ }
+ xlsxTemplateParts = make(map[string][]byte, len(required))
+ for _, path := range required {
+ value, ok := workbook.Pkg.Load(path)
+ if !ok {
+ xlsxTemplatePartsErr = fmt.Errorf("xlsx template part missing: %s", path)
+ return
+ }
+ xlsxTemplateParts[path] = append([]byte(nil), value.([]byte)...)
+ }
+ })
+ return xlsxTemplateParts, xlsxTemplatePartsErr
+}
+
+func writeZipBytes(zw *zip.Writer, name string, content []byte) error {
+ writer, err := zw.Create(name)
+ if err != nil {
+ return err
+ }
+ _, err = writer.Write(content)
+ return err
+}
+
+func writeZipFileFromPath(zw *zip.Writer, name string, path string) error {
+ writer, err := zw.Create(name)
+ if err != nil {
+ return err
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ _, err = io.Copy(writer, file)
+ return err
+}
+
+func writeXLSXSheetHeader(writer *bufio.Writer) error {
+ _, err := writer.WriteString(`` +
+ `` +
+ `` +
+ `` +
+ ``)
+ return err
+}
+
+func writeXLSXInlineStringCell(writer *bufio.Writer, columnRef string, rowRef string, value string) error {
+ if _, err := writer.WriteString(``); err != nil {
+ return err
+ }
+ if err := xml.EscapeText(writer, []byte(value)); err != nil {
+ return err
+ }
+ _, err := writer.WriteString(``)
+ return err
+}
+
+func buildXLSXContentTypes(sheetCount int) []byte {
+ var builder strings.Builder
+ builder.Grow(1024 + sheetCount*128)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ for i := 1; i <= sheetCount; i++ {
+ builder.WriteString(``)
+ }
+ builder.WriteString(``)
+ return []byte(builder.String())
+}
+
+func buildXLSXWorkbookXML(sheets []*xlsxExportTempSheet) []byte {
+ var builder strings.Builder
+ builder.Grow(512 + len(sheets)*96)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ for i, sheet := range sheets {
+ builder.WriteString(``)
+ }
+ builder.WriteString(``)
+ builder.WriteString(``)
+ return []byte(builder.String())
+}
+
+func buildXLSXWorkbookRels(sheets []*xlsxExportTempSheet) []byte {
+ var builder strings.Builder
+ builder.Grow(512 + len(sheets)*160)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ for i := range sheets {
+ builder.WriteString(``)
+ }
+ builder.WriteString(``)
+ builder.WriteString(``)
+ builder.WriteString(``)
+ return []byte(builder.String())
+}