功能:完善通用云管工作台、审计与诊断能力
This commit is contained in:
1 parent
5ac692724d
commit
9f05aa20ea
225 files changed
+14266
-141
No files matched your search
@@ -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())
|
||||
}
|
||||
Reference in new issue
Block a user