Files
opsxsql/backend/internal/middleware/accesslog.go
T
opsx 0c57e18f19 feat(log): Gin 改结构化 JSON 访问日志 + 保留期自动清理(含 VACUUM 回收)
=== 一、审计结论(用户问:gin 日志是否标准、会不会打爆磁盘)===
现状是标准的:gin.Default() 写 stdout,代码里没有任何
gin.DefaultWriter / log.SetOutput / lumberjack / os.OpenFile 写日志文件;
容器内 /var/log 是空的,find / -name "*.log" 无结果;
kubelet containerLogMaxSize=10Mi / containerLogMaxFiles=5 自动轮转,
实测 /var/log 只占 120G 磁盘的 0.8%。

但发现两个真问题:
  1. 访问日志是 gin 默认文本格式,Loki 里没法按字段过滤
  2. 审计日志/查询历史等只配了保留天数,**没有任何自动清理** ——
     只有手动按钮,表会一直涨,SQLite 文件删了也不缩

=== 二、结构化 JSON 访问日志(middleware/accesslog.go)===
gin.Default() → gin.New() + AccessLog() + gin.Recovery() + CORSMiddleware
  - 一行一个 JSON:ts/level/type/method/path/status/latency_ms/bytes/ip/ua
  - 自动分级:5xx=error / 4xx=warn / 其余=info
  - 鉴权后带 user_id / username,便于审计
  - 健康检查与 /assets 静音(K8s 探针每 10s 一次,会淹没真实请求)
  - Recovery 显式加回(gin.Default 里包含它,漏了 panic 会打挂进程)

⚠️ 关键细节:Go 的 map 序列化按 key 字母序,ts 会排到最后,
   一行以 {"bytes":... 开头根本看不出时间。LogJSON 手工拼装,
   把 ts/level/type/msg 固定在最前,其余排序输出。

=== 三、保留期自动清理(handler/cleanup.go)===
启动 2 分钟后跑一次,之后每 24h 一次;cleanup_auto_enabled=false 可关。

覆盖 8 张会无限增长的表(原来只管 4 张):
  audit_logs / query_histories / slow_logs / tickets
  + operation_logs / workflow_execution_logs / scheduled_task_logs(新增)
  + refresh_tokens(按 expires_at < now,过期不清理会每次登录加一行)

设计取舍:
  - 每批删 5000 行、批间 sleep 50ms:SQLite 单写者,一条 DELETE 全删会把库锁死
  - 单表单轮最多 20 万行,剩下的留给下一轮
  - deleted 后看 freelist/page_count,>25% 才 VACUUM(DELETE 不还磁盘,必须 VACUUM)
  - 保留天数 <=0 视为不清理,避免误配把数据全删

手动接口同步改造:/settings/cleanup/{preview,run} 复用同一套逻辑,
返回结构改为 {deleted, total, vacuumed, freed_bytes, cost_ms}。

新增系统设置(seed 自动补,已实测 8 个 key 全部落库):
  operation_log_retention_days=365  workflow_log_retention_days=180
  scheduled_log_retention_days=180  cleanup_auto_enabled=true

=== 四、实测 ===
访问日志:{"ts":"2026-10-27T14:46:39.58Z","level":"info","type":"access","bytes":668,"ip":"...","latency_ms":78.6,"method":"POST","path":"/api/v1/auth/login","status":200,...}
health 日志行数: 0 ✅   [GIN] 文本行数: 0 ✅   非 JSON 行: 4(仅启动)
cleanup preview: 8 张表的 count/days ✅
cleanup run: {"cost_ms":1,"deleted":{},"freed_bytes":0,"vacuumed":false,"total":0} ✅

镜像:lghost/opsxsql:latest + v0.1 = sha256:30593de3fc09
2026-10-27 14:49:45 +00:00

135 lines
3.2 KiB
Go
Raw 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 middleware
import (
"bytes"
"encoding/json"
"os"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// LogJSON 输出一行 JSON 到标准输出(stdout)。
//
// 统一走 stdout 的原因:容器里日志由容器运行时接管(kubelet 侧
// containerLogMaxSize=10Mi / containerLogMaxFiles=5 自动轮转),
// 应用自己写文件会绕过这套轮转、把磁盘写爆。
//
// 关键细节:Go 的 map 序列化是**按 key 字母序**输出,ts 会排到最后,
// kubectl logs 里一行以 {"bytes":... 开头完全看不出时间。
// 这里手工拼装,把 ts/level/type/msg 固定在最前面,其余字段排序输出。
func LogJSON(fields map[string]any) {
head := []string{"ts", "level", "type", "msg"}
var buf bytes.Buffer
buf.WriteByte('{')
first := true
write := func(k string) {
v, ok := fields[k]
if !ok {
return
}
kb, err := json.Marshal(k)
if err != nil {
return
}
vb, err := json.Marshal(v)
if err != nil {
return
}
if !first {
buf.WriteByte(',')
}
buf.Write(kb)
buf.WriteByte(':')
buf.Write(vb)
first = false
}
for _, k := range head {
write(k)
}
rest := make([]string, 0, len(fields))
for k := range fields {
switch k {
case "ts", "level", "type", "msg":
continue
}
rest = append(rest, k)
}
sort.Strings(rest)
for _, k := range rest {
write(k)
}
buf.WriteString("}\n")
_, _ = os.Stdout.Write(buf.Bytes())
}
// AccessLog 结构化访问日志,替代 gin.Default() 自带的文本 Logger。
//
// 与 gin.Logger() 的差异:
// - 一行一个 JSON 对象,字段可直接在 Loki 里过滤(status>=500、latency_ms>1000)
// - 按状态码自动分级:5xx=error / 4xx=warn / 其余=info
// - 健康检查与静态资源不打日志(K8s 探针每 10s 一次,纯噪音)
func AccessLog() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
c.Next()
if isQuietPath(path) {
return
}
status := c.Writer.Status()
level := "info"
switch {
case status >= 500:
level = "error"
case status >= 400:
level = "warn"
}
entry := map[string]any{
"ts": time.Now().Format(time.RFC3339Nano),
"level": level,
"type": "access",
"method": c.Request.Method,
"path": path,
"status": status,
"latency_ms": float64(time.Since(start).Microseconds()) / 1000.0,
"bytes": c.Writer.Size(),
"ip": c.ClientIP(),
}
if q := c.Request.URL.RawQuery; q != "" {
entry["query"] = q
}
if ua := c.Request.UserAgent(); ua != "" {
entry["ua"] = ua
}
// 鉴权中间件把用户信息放在 context 里,日志带上便于审计
if uid, ok := c.Get("userId"); ok {
entry["user_id"] = uid
}
if uname, ok := c.Get("username"); ok {
entry["username"] = uname
}
if len(c.Errors) > 0 {
entry["error"] = c.Errors.String()
}
LogJSON(entry)
}
}
// isQuietPath 探针/静态资源不记访问日志,避免日志量被轮询请求淹没
func isQuietPath(p string) bool {
switch p {
case "/health", "/api/v1/health", "/favicon.ico", "/favicon.svg", "/robots.txt":
return true
}
return strings.HasPrefix(p, "/assets/")
}