功能:完善通用云管工作台、审计与诊断能力

This commit is contained in:
songyiming committed 2026-11-11 18:46:16 +08:00
1 parent 5ac692724d
commit 9f05aa20ea
225 files changed
+14266 -141

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
package server
import (
"bytes"
"database/sql"
"encoding/csv"
"fmt"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/gin-gonic/gin"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
const auditExportLimit = 10000
// Filters are shared by the paginated view and export. No implicit history cap.
func (s *Server) auditFilter(c *gin.Context) (string, []any, bool) {
where, args := " FROM audit_logs WHERE true", []any{}
add := func(expr string, value any) {
args = append(args, value)
where += fmt.Sprintf(" AND "+expr, len(args))
}
var from, to time.Time
for _, key := range []string{"from", "to"} {
if raw := c.Query(key); raw != "" {
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
s.fail(c, 400, "invalid_range", "时间格式无效,请使用含时区的时间")
return "", nil, false
}
if key == "from" {
from = parsed
add("created_at >= $%d", parsed)
} else {
to = parsed
add("created_at <= $%d", parsed)
}
}
}
if !from.IsZero() && !to.IsZero() && !to.After(from) {
s.fail(c, 400, "invalid_range", "结束时间必须晚于开始时间")
return "", nil, false
}
for _, key := range []string{"keyword", "actor", "action", "result"} {
value := c.Query(key)
if utf8.RuneCountInString(value) > 200 || strings.IndexByte(value, 0) >= 0 {
s.fail(c, 400, "invalid_request", "筛选条件最长 200 字,且不能包含空字符")
return "", nil, false
}
if value == "" {
continue
}
if key == "action" || key == "result" {
add(key+" = $%d", value)
continue
}
// Search terms are literal, including SQL LIKE wildcards supplied by users.
pattern := "%" + strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(value) + "%"
if key == "actor" {
add("actor ILIKE $%d", pattern)
} else {
args = append(args, pattern)
p := fmt.Sprintf("$%d", len(args))
where += " AND (id ILIKE " + p + " OR actor ILIKE " + p + " OR action ILIKE " + p + " OR target ILIKE " + p + " OR result ILIKE " + p + " OR request_id ILIKE " + p + ")"
}
}
return where, args, true
}
func (s *Server) audits(c *gin.Context) {
where, args, ok := s.auditFilter(c)
if !ok {
return
}
page, size := pageParams(c)
tx, err := s.DB.BeginTxx(c.Request.Context(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var total int
if err = tx.GetContext(c.Request.Context(), &total, "SELECT count(*)"+where, args...); err != nil {
s.dbError(c, err)
return
}
items := []model.Audit{}
query := "SELECT *" + where + fmt.Sprintf(" ORDER BY created_at DESC,id DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
args = append(args, size, (page-1)*size)
if err = tx.SelectContext(c.Request.Context(), &items, query, args...); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"items": items, "total": total, "page": page, "pageSize": size, "requestId": c.GetString("requestId")})
}
func safeCSVCell(value string) string {
trimmed := strings.TrimLeftFunc(value, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) || r == '\ufeff' })
if len(trimmed) > 0 && strings.ContainsRune("=+-@", rune(trimmed[0])) {
return "'" + value
}
return value
}
func (s *Server) exportAudits(c *gin.Context) {
where, args, ok := s.auditFilter(c)
if !ok {
return
}
// An extra row detects oversized exports without silently truncating evidence.
items := []model.Audit{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT *"+where+" ORDER BY created_at DESC,id DESC LIMIT 10001", args...); err != nil {
s.dbError(c, err)
return
}
if len(items) > auditExportLimit {
s.fail(c, 413, "export_too_large", "匹配记录超过 10000 条,请缩小时间范围或增加筛选条件后导出")
return
}
var content bytes.Buffer
content.WriteString("\ufeff")
w := csv.NewWriter(&content)
w.UseCRLF = true
_ = w.Write([]string{"时间(UTC)", "操作者", "动作", "对象", "结果", "请求标识", "审计标识"})
for _, row := range items {
cells := []string{row.CreatedAt.UTC().Format(time.RFC3339Nano), row.Actor, row.Action, row.Target, row.Result, row.RequestID, row.ID}
for i := range cells {
cells[i] = safeCSVCell(cells[i])
}
_ = w.Write(cells)
}
w.Flush()
if err := w.Error(); err != nil {
s.fail(c, 500, "export_failed", "导出生成失败,请重试")
return
}
// Record preparation, without storing credentials or the user's search terms.
if _, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,'export_audit_logs',$3,'success',$4)", store.ID("audit_"), s.admin(c).Username, fmt.Sprintf("rows=%d", len(items)), c.GetString("requestId")); err != nil {
s.dbError(c, err)
return
}
c.Header("Content-Disposition", `attachment; filename="audit-logs-`+time.Now().UTC().Format("20060102-150405")+`.csv"`)
c.Header("X-Export-Count", fmt.Sprint(len(items)))
c.Header("X-Content-Type-Options", "nosniff")
c.Data(200, "text/csv; charset=utf-8", content.Bytes())
}