Skip to content

Commit bbf12af

Browse files
authored
Merge pull request #2978 from actiontech/2250-cp
feat(utils): add CSVBuilder for CSV file generation with Excel compatibility
2 parents e7f95b8 + faf3d44 commit bbf12af

File tree

4 files changed

+183
-20
lines changed

4 files changed

+183
-20
lines changed

sqle/api/controller/v1/audit_plan.go

+6-11
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package v1
22

33
import (
4-
"bytes"
54
"context"
6-
"encoding/csv"
75
"fmt"
86
"mime"
97
"net"
@@ -1539,7 +1537,6 @@ func spliceAuditResults(auditResults []model.AuditResult) string {
15391537
// @router /v1/projects/{project_name}/audit_plans/{audit_plan_name}/reports/{audit_plan_report_id}/export [get]
15401538
func ExportAuditPlanReportV1(c echo.Context) error {
15411539
s := model.GetStorage()
1542-
buff := new(bytes.Buffer)
15431540
reportIdStr := c.Param("audit_plan_report_id")
15441541
auditPlanName := c.Param("audit_plan_name")
15451542
projectName := c.Param("project_name")
@@ -1548,8 +1545,6 @@ func ExportAuditPlanReportV1(c echo.Context) error {
15481545
if err != nil {
15491546
return controller.JSONBaseErrorReq(c, err)
15501547
}
1551-
csvWriter := csv.NewWriter(buff)
1552-
buff.WriteString("\xEF\xBB\xBF") // 写入UTF-8 BOM
15531548
reportInfo, exist, err := s.GetReportWithAuditPlanByReportID(reportId)
15541549
if !exist {
15551550
return controller.JSONBaseErrorReq(c, fmt.Errorf("not found audit report"))
@@ -1572,18 +1567,19 @@ func ExportAuditPlanReportV1(c echo.Context) error {
15721567
{"数据库类型", reportInfo.AuditPlan.DBType},
15731568
{"审核的数据库", reportInfo.AuditPlan.InstanceDatabase},
15741569
}
1575-
err = csvWriter.WriteAll(baseInfo)
1570+
csvBuilder := utils.NewCSVBuilder()
1571+
err = csvBuilder.WriteRows(baseInfo)
15761572
if err != nil {
15771573
return controller.JSONBaseErrorReq(c, err)
15781574
}
15791575

15801576
// Add a split line between report information and sql audit information
1581-
err = csvWriter.Write([]string{})
1577+
err = csvBuilder.WriteRow([]string{})
15821578
if err != nil {
15831579
return controller.JSONBaseErrorReq(c, err)
15841580
}
15851581

1586-
err = csvWriter.Write([]string{"编号", "SQL", "审核结果"})
1582+
err = csvBuilder.WriteRow([]string{"编号", "SQL", "审核结果"})
15871583
if err != nil {
15881584
return controller.JSONBaseErrorReq(c, err)
15891585
}
@@ -1593,17 +1589,16 @@ func ExportAuditPlanReportV1(c echo.Context) error {
15931589
sqlInfo = append(sqlInfo, []string{strconv.Itoa(idx + 1), sql.SQL, spliceAuditResults(sql.AuditResults)})
15941590
}
15951591

1596-
err = csvWriter.WriteAll(sqlInfo)
1592+
err = csvBuilder.WriteRows(sqlInfo)
15971593
if err != nil {
15981594
return controller.JSONBaseErrorReq(c, err)
15991595
}
16001596

1601-
csvWriter.Flush()
16021597

16031598
fileName := fmt.Sprintf("扫描任务报告_%s_%s.csv", auditPlanName, time.Now().Format("20060102150405"))
16041599
c.Response().Header().Set(echo.HeaderContentDisposition, mime.FormatMediaType("attachment", map[string]string{
16051600
"filename": fileName,
16061601
}))
16071602

1608-
return c.Blob(http.StatusOK, "text/csv", buff.Bytes())
1603+
return c.Blob(http.StatusOK, "text/csv", csvBuilder.FlushAndGetBuffer().Bytes())
16091604
}

sqle/api/controller/v1/task.go

+4-9
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package v1
22

33
import (
4-
"bytes"
54
"context"
6-
"encoding/csv"
75
"fmt"
86
"mime"
97
"mime/multipart"
@@ -488,10 +486,8 @@ func DownloadTaskSQLReportFile(c echo.Context) error {
488486
if err != nil {
489487
return controller.JSONBaseErrorReq(c, err)
490488
}
491-
buff := &bytes.Buffer{}
492-
buff.WriteString("\xEF\xBB\xBF") // 写入UTF-8 BOM
493-
cw := csv.NewWriter(buff)
494-
err = cw.Write([]string{"序号", "SQL", "SQL审核状态", "SQL审核结果", "SQL执行状态", "SQL执行结果", "SQL对应的回滚语句", "SQL描述"})
489+
csvBuilder := utils.NewCSVBuilder()
490+
err = csvBuilder.WriteHeader([]string{"序号", "SQL", "SQL审核状态", "SQL审核结果", "SQL执行状态", "SQL执行结果", "SQL对应的回滚语句", "SQL描述"})
495491
if err != nil {
496492
return controller.JSONBaseErrorReq(c, errors.New(errors.WriteDataToTheFileError, err))
497493
}
@@ -501,7 +497,7 @@ func DownloadTaskSQLReportFile(c echo.Context) error {
501497
AuditStatus: td.AuditStatus,
502498
}
503499
taskSql.ExecStatus = td.ExecStatus
504-
err := cw.Write([]string{
500+
err := csvBuilder.WriteRow([]string{
505501
strconv.FormatUint(uint64(td.Number), 10),
506502
td.ExecSQL,
507503
taskSql.GetAuditStatusDesc(),
@@ -515,11 +511,10 @@ func DownloadTaskSQLReportFile(c echo.Context) error {
515511
return controller.JSONBaseErrorReq(c, errors.New(errors.WriteDataToTheFileError, err))
516512
}
517513
}
518-
cw.Flush()
519514
fileName := fmt.Sprintf("SQL审核报告_%v_%v.csv", task.InstanceName(), taskId)
520515
c.Response().Header().Set(echo.HeaderContentDisposition,
521516
mime.FormatMediaType("attachment", map[string]string{"filename": fileName}))
522-
return c.Blob(http.StatusOK, "text/csv", buff.Bytes())
517+
return c.Blob(http.StatusOK, "text/csv", csvBuilder.FlushAndGetBuffer().Bytes())
523518
}
524519

525520
// @Summary 下载指定扫描任务的SQL文件

sqle/utils/csv_builder.go

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package utils
2+
3+
import (
4+
"bytes"
5+
"encoding/csv"
6+
"fmt"
7+
)
8+
9+
/*
10+
该文件用于维护SQLE中创建CSV文件的构建器
11+
*/
12+
13+
const (
14+
// MaxNumberPerCell Excel 单元格字符数限制
15+
MaxNumberPerCell int = 32767
16+
)
17+
18+
var ErrCSVColumnCountNotMatch = fmt.Errorf("csv column count not match")
19+
20+
// CSVBuilder 用于构建CSV文件,支持Excel兼容性处理
21+
//
22+
// 主要功能:
23+
// - 自动添加UTF-8 BOM头
24+
// - 处理Excel单元格字符数限制
25+
// - 提供简单的API进行CSV文件构建
26+
//
27+
// 参考:https://support.microsoft.com/search/results?query=excel-specifications-and-limits
28+
//
29+
// 使用示例:
30+
//
31+
// builder := NewCSVBuilder()
32+
// builder.WriteHeader([]string{"Name", "Age"}) // 若设置了表头,则每一行的长度需要与表头长度匹配
33+
// builder.WriteRows([][]string{{"John", "30"}, {"Alice", "25"}})
34+
// buffer := builder.FlushAndGetBuffer()
35+
type CSVBuilder struct {
36+
columnCount uint
37+
buffer *bytes.Buffer
38+
csvWriter *csv.Writer
39+
}
40+
41+
// NewCSVBuilder 创建并返回一个新的CSVBuilder实例
42+
//
43+
// 返回:
44+
//
45+
// *CSVBuilder: 新的CSVBuilder实例
46+
func NewCSVBuilder() *CSVBuilder {
47+
buffer := new(bytes.Buffer)
48+
// 写入 UTF-8 BOM 有助于Excel正确识别文件的编码格式
49+
buffer.WriteString("\xEF\xBB\xBF")
50+
csvWriter := csv.NewWriter(buffer)
51+
return &CSVBuilder{
52+
buffer: buffer,
53+
csvWriter: csvWriter,
54+
}
55+
}
56+
57+
// WriteHeader 写入CSV文件的表头
58+
//
59+
// 参数:
60+
//
61+
// header: 表头字符串数组
62+
//
63+
// 返回:
64+
//
65+
// error: 如果写入失败则返回错误
66+
func (b *CSVBuilder) WriteHeader(header []string) error {
67+
b.columnCount = uint(len(header))
68+
return b.csvWriter.Write(header)
69+
}
70+
71+
// WriteRows 写入多行数据到CSV文件
72+
//
73+
// 参数:
74+
//
75+
// rows: 二维字符串数组,每行代表一条记录
76+
//
77+
// 返回:
78+
//
79+
// error: 如果写入失败则返回错误
80+
func (b *CSVBuilder) WriteRows(rows [][]string) error {
81+
for _, row := range rows {
82+
err := b.WriteRow(row)
83+
if err != nil {
84+
return err
85+
}
86+
}
87+
return nil
88+
}
89+
90+
// WriteRow 写入单行数据到CSV文件
91+
//
92+
// 参数:
93+
//
94+
// row: 字符串数组,代表一条记录
95+
//
96+
// 返回:
97+
//
98+
// error: 如果列数不匹配或写入失败则返回错误
99+
func (b *CSVBuilder) WriteRow(row []string) error {
100+
// 检查列数是否匹配
101+
if b.columnCount > 0 && b.columnCount != uint(len(row)) {
102+
return ErrCSVColumnCountNotMatch
103+
}
104+
// 超过最大字符数,则截断并标记 Excel 单元格
105+
for idx, cell := range row {
106+
if len(cell) > MaxNumberPerCell {
107+
row[idx] = TruncateAndMarkForExcelCell(cell)
108+
}
109+
}
110+
return b.csvWriter.Write(row)
111+
}
112+
113+
// FlushAndGetBuffer 刷新缓冲区并返回包含CSV内容的缓冲区
114+
//
115+
// 返回:
116+
//
117+
// *bytes.Buffer: 包含CSV文件内容的缓冲区
118+
func (b *CSVBuilder) FlushAndGetBuffer() *bytes.Buffer {
119+
b.csvWriter.Flush()
120+
return b.buffer
121+
}
122+
123+
// Error 返回CSV写入过程中遇到的任何错误
124+
//
125+
// 返回:
126+
//
127+
// error: 如果存在错误则返回错误,否则返回nil
128+
func (b *CSVBuilder) Error() error {
129+
return b.csvWriter.Error()
130+
}

sqle/utils/util.go

+43
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,46 @@ func GenerateRandomString(halfLength int) string {
353353
rand.Read(bytes)
354354
return fmt.Sprintf("%x", bytes)
355355
}
356+
357+
// TruncateStringByRunes 按字符数截取字符串
358+
func TruncateStringByRunes(s string, maxRunes uint) string {
359+
// 字节数不大于 maxRunes ,那字符数肯定不大于 maxRunes
360+
if uint(len(s)) <= maxRunes {
361+
return s
362+
}
363+
364+
// UTF-8一个字符的字节数是不确定的,如:s="a一b二c",汉字为多字节字符,len(s)=9
365+
// s的hexdump结果:
366+
// 00000000 61 e4 b8 80 62 e4 ba 8c 63 |a...b...c|
367+
//
368+
// 当想截取头两个字符:“a一”,即 maxRunes 为2时,
369+
// 直接返回s[:maxRunes]的话得到是:“61 e4”这两个字节组成的字符串,并非“a一”,“a一”是“61 e4 b8 80”这四个字节,此时应取s[:4]
370+
//
371+
// 为得到s[:4]中4这个索引,可以“range s”:逐个rune遍历s,i为每个rune起始的字节索引
372+
// i依次为 0  1  4  5  8
373+
//  ^a ^一 ^b ^二 ^c
374+
// 遍历 maxRunes (2)次后,i为下一个字符(b)的起始索引,即4,此时s[:i]就是要截取的头两个字符“a一”
375+
var runesCount uint
376+
for i := range s {
377+
if runesCount == maxRunes {
378+
// 达到截取的字符数了,将字符截取至此时rune的字节索引
379+
return s[:i]
380+
}
381+
// 未达到要截取的字符数,继续获取下一个rune
382+
runesCount++
383+
}
384+
// 字符串字符数不足 maxRunes
385+
return s
386+
}
387+
388+
const excelCellMaxRunes = 32766
389+
390+
// TruncateAndMarkForExcelCell 对超长字符串进行截取,以符合Excel类工具对单元格字符数上限的限制
391+
func TruncateAndMarkForExcelCell(s string) string {
392+
truncated := TruncateStringByRunes(s, excelCellMaxRunes-4)
393+
if truncated != s {
394+
// 截取了的话,做标记
395+
return truncated + " ..."
396+
}
397+
return s
398+
}

0 commit comments

Comments
 (0)