Files
MyGoNavi/internal/sqlaudit/export.go
Syngnat 7c8e8d8dd3 feat(sql-audit): 新增 SQL 审计中心并完善事务追踪
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出
- 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口
- 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局
- 补充多语言、健康状态、数据目录迁移和回归测试
2026-07-12 15:24:27 +08:00

236 lines
6.9 KiB
Go

package sqlaudit
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
var ErrUnsupportedExportFormat = errors.New("unsupported SQL audit export format")
var (
ErrExportRecordLimit = errors.New("SQL audit export record limit exceeded")
ErrExportSizeLimit = errors.New("SQL audit export size limit exceeded")
)
const (
maxExportRecords int64 = 100_000
maxExportBytes = 64 * 1024 * 1024
)
// BuildExport returns every event matching filter. Pagination fields are
// intentionally ignored so exporting from a paged workbench does not silently
// export only the visible page.
func (s *Store) BuildExport(filter Filter, format string) ([]byte, error) {
return s.buildExportWithLimits(filter, format, maxExportRecords, maxExportBytes)
}
// BuildExportWithLimits applies caller-specific resource caps. It is used by
// the browser server, whose RPC envelope creates additional copies of content.
func (s *Store) BuildExportWithLimits(filter Filter, format string, recordLimit int64, byteLimit int) ([]byte, error) {
if recordLimit <= 0 || recordLimit > maxExportRecords {
recordLimit = maxExportRecords
}
if byteLimit <= 0 || byteLimit > maxExportBytes {
byteLimit = maxExportBytes
}
return s.buildExportWithLimits(filter, format, recordLimit, byteLimit)
}
func (s *Store) buildExportWithLimits(filter Filter, format string, recordLimit int64, byteLimit int) ([]byte, error) {
if err := s.ensureOpen(); err != nil {
return nil, err
}
format = strings.ToLower(strings.TrimSpace(format))
if format != "json" && format != "csv" {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedExportFormat, format)
}
filter = normalizeFilter(filter)
filter.Page = 0
filter.PageSize = 0
events, err := s.loadExportEvents(filter, recordLimit, byteLimit)
if err != nil {
return nil, err
}
switch format {
case "json":
output := newBoundedBuffer(byteLimit)
if _, err := output.Write([]byte{'['}); err != nil {
return nil, err
}
for index, event := range events {
payload, marshalErr := json.Marshal(event)
if marshalErr != nil {
return nil, fmt.Errorf("encode SQL audit JSON export: %w", marshalErr)
}
if index > 0 {
if _, err := output.Write([]byte{','}); err != nil {
return nil, err
}
}
if _, err := output.Write(payload); err != nil {
return nil, err
}
}
if _, err := output.Write([]byte("]\n")); err != nil {
return nil, err
}
return output.Bytes(), nil
case "csv":
output := newBoundedBuffer(byteLimit)
writer := csv.NewWriter(output)
if err := writer.Write(exportCSVHeader); err != nil {
return nil, fmt.Errorf("write SQL audit CSV header: %w", err)
}
for _, event := range events {
if err := writer.Write(eventCSVRecord(event)); err != nil {
return nil, fmt.Errorf("write SQL audit CSV event: %w", err)
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, fmt.Errorf("flush SQL audit CSV export: %w", err)
}
return output.Bytes(), nil
}
return nil, fmt.Errorf("%w: %s", ErrUnsupportedExportFormat, format)
}
func (s *Store) loadExportEvents(filter Filter, recordLimit int64, byteLimit int) ([]Event, error) {
where, args := buildFilterWhere(filter)
var total int64
if err := s.db.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM sql_audit_events`+where, args...,
).Scan(&total); err != nil {
return nil, fmt.Errorf("count SQL audit export events: %w", err)
}
if recordLimit > 0 && total > recordLimit {
return nil, fmt.Errorf("%w: total=%d limit=%d", ErrExportRecordLimit, total, recordLimit)
}
rows, err := s.db.QueryContext(context.Background(), selectEventColumns+where+
` ORDER BY timestamp DESC, sequence DESC`, args...)
if err != nil {
return nil, fmt.Errorf("query SQL audit export: %w", err)
}
events := make([]Event, 0, int(total))
retainedStringBytes := 0
for rows.Next() {
if recordLimit > 0 && int64(len(events)) >= recordLimit {
_ = rows.Close()
return nil, fmt.Errorf("%w: limit=%d", ErrExportRecordLimit, recordLimit)
}
event, scanErr := scanEvent(rows)
if scanErr != nil {
_ = rows.Close()
return nil, scanErr
}
retainedStringBytes += eventStringBytes(event)
if byteLimit > 0 && retainedStringBytes > byteLimit {
_ = rows.Close()
return nil, fmt.Errorf("%w: limit=%d", ErrExportSizeLimit, byteLimit)
}
events = append(events, event)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, fmt.Errorf("iterate SQL audit export: %w", err)
}
if err := rows.Close(); err != nil {
return nil, fmt.Errorf("close SQL audit export rows: %w", err)
}
return events, nil
}
func eventStringBytes(event Event) int {
return len(event.ID) + len(event.EventType) + len(event.Status) + len(event.ConnectionID) +
len(event.ConnectionFingerprint) + len(event.DBType) + len(event.Database) + len(event.QueryID) +
len(event.TransactionID) + len(event.Source) + len(event.BoundaryMode) + len(event.CommitMode) +
len(event.SQLText) + len(event.SQLFingerprint) + len(event.Error) + len(event.PrevHash) + len(event.Hash)
}
type boundedBuffer struct {
buffer bytes.Buffer
limit int
}
func newBoundedBuffer(limit int) *boundedBuffer {
return &boundedBuffer{limit: limit}
}
func (b *boundedBuffer) Write(payload []byte) (int, error) {
if b.limit > 0 && b.buffer.Len()+len(payload) > b.limit {
return 0, fmt.Errorf("%w: limit=%d", ErrExportSizeLimit, b.limit)
}
return b.buffer.Write(payload)
}
func (b *boundedBuffer) Bytes() []byte {
return b.buffer.Bytes()
}
var exportCSVHeader = []string{
"id", "sequence", "timestamp", "eventType", "status", "connectionId",
"connectionFingerprint", "dbType", "database", "queryId", "transactionId",
"source", "boundaryMode", "commitMode", "sqlText", "sqlRedacted",
"sqlFingerprint", "statementIndex", "statementCount", "durationMs",
"rowsAffected", "rowsReturned", "error", "prevHash", "hash",
}
func eventCSVRecord(event Event) []string {
return protectCSVRecord([]string{
event.ID,
strconv.FormatInt(event.Sequence, 10),
strconv.FormatInt(event.Timestamp, 10),
event.EventType,
event.Status,
event.ConnectionID,
event.ConnectionFingerprint,
event.DBType,
event.Database,
event.QueryID,
event.TransactionID,
event.Source,
event.BoundaryMode,
event.CommitMode,
event.SQLText,
strconv.FormatBool(event.SQLRedacted),
event.SQLFingerprint,
strconv.Itoa(event.StatementIndex),
strconv.Itoa(event.StatementCount),
strconv.FormatInt(event.DurationMs, 10),
strconv.FormatInt(event.RowsAffected, 10),
strconv.FormatInt(event.RowsReturned, 10),
event.Error,
event.PrevHash,
event.Hash,
})
}
func protectCSVRecord(record []string) []string {
protected := make([]string, len(record))
for index, cell := range record {
protected[index] = protectCSVFormula(cell)
}
return protected
}
func protectCSVFormula(value string) string {
trimmed := strings.TrimLeft(value, " \t\r\n")
if trimmed == "" {
return value
}
switch trimmed[0] {
case '=', '+', '-', '@':
return "'" + value
default:
return value
}
}