vdi-cloud/platform/internal/server/task_history.go

154 lines
5.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package server
import (
"bytes"
"database/sql"
"encoding/csv"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
// The same filter serves SQL pagination, pending-operation polling and CSV.
func (s *Server) taskFilter(c *gin.Context) (string, []any, bool) {
where, args := " FROM tasks 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 != "" {
v, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
s.fail(c, 400, "invalid_range", "时间格式无效,请使用含时区的时间")
return "", nil, false
}
if key == "from" {
from = v
add("created_at >= $%d", v)
} else {
to = v
add("created_at <= $%d", v)
}
}
}
if !from.IsZero() && !to.IsZero() && !to.After(from) {
s.fail(c, 400, "invalid_range", "结束时间必须晚于开始时间")
return "", nil, false
}
if active := c.Query("active"); active != "" {
if active != "true" && active != "false" {
s.fail(c, 400, "invalid_request", "active 只能为 true 或 false")
return "", nil, false
}
if active == "true" {
where += " AND status IN ('queued','running','timeout')"
}
}
for _, key := range []string{"keyword", "actor", "action", "kind", "status", "targetId"} {
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 == "status" && value != "queued" && value != "running" && value != "success" && value != "failed" && value != "timeout" {
s.fail(c, 400, "invalid_request", "任务状态无效")
return "", nil, false
}
if key != "keyword" && key != "actor" {
column := key
if key == "targetId" {
column = "target_id"
}
add(column+" = $%d", value)
continue
}
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 target_id ILIKE " + p + " OR target_name ILIKE " + p + " OR actor ILIKE " + p + " OR action ILIKE " + p + " OR message ILIKE " + p + ")"
}
}
return where, args, true
}
func (s *Server) tasks(c *gin.Context) {
where, args, ok := s.taskFilter(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.Task{}
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 (s *Server) exportTasks(c *gin.Context) {
where, args, ok := s.taskFilter(c)
if !ok {
return
}
items := []model.Task{}
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) > 10000 {
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)", "更新时间(UTC)", "任务标识", "任务类型", "动作", "操作者", "目标标识", "目标名称", "状态", "执行结果"})
for _, t := range items {
cells := []string{t.CreatedAt.UTC().Format(time.RFC3339Nano), t.UpdatedAt.UTC().Format(time.RFC3339Nano), t.ID, t.Kind, t.Action, t.Actor, t.TargetID, t.TargetName, t.Status, t.Message}
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
}
if _, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,'export_tasks',$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="tasks-`+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())
}