package middleware import ( "context" "database/sql" "encoding/json" "fmt" "log/slog" "net/http" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" ) // AuditEntry 是一条待落库的审计记录;Before/After 为任意可 JSON 序列化的快照。 type AuditEntry struct { // Action 用点号分层,关键动作单独成段,如 result.publish / result.void / user.create。 Action string EntityType string EntityID int64 // ActorID 非 0 时优先作为审计主体,用于登录等「身份刚建立、请求 ctx 里仍是 guest」的动作。 ActorID int64 Before any After any } type auditCollector struct { entries []AuditEntry } // Record 由 handler 调用,登记一条审计;仅在请求成功(2xx)时落库。 func Record(ctx context.Context, e AuditEntry) { if c, ok := ctx.Value(ctxKeyAudit).(*auditCollector); ok { c.entries = append(c.entries, e) } } // Auditor 把成功写操作的审计记录写入赛事库 audit_logs(只追加、不修改)。 type Auditor struct { db *sql.DB log *slog.Logger } // NewAuditor 创建审计中间件;db 为赛事库(event.db)。 func NewAuditor(db *sql.DB, logger *slog.Logger) *Auditor { if logger == nil { logger = slog.Default() } return &Auditor{db: db, log: logger} } // Middleware 收集 handler 登记的审计并在成功后落库。 func (a *Auditor) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} c := &auditCollector{} ctx := context.WithValue(r.Context(), ctxKeyAudit, c) next.ServeHTTP(sw, r.WithContext(ctx)) if sw.status >= http.StatusBadRequest || len(c.entries) == 0 { return } a.flush(ctx, r, c.entries) }) } func (a *Auditor) flush(ctx context.Context, r *http.Request, entries []AuditEntry) { if a.db == nil { return } u := User(ctx) requestID := GetRequestID(ctx) ip := ClientIP(r) for _, e := range entries { var entityID any if e.EntityID != 0 { entityID = e.EntityID } // 登录等动作在请求 ctx 里还没有登录身份,改由条目自带的 ActorID 补上。 actor := nullableID(u) if e.ActorID != 0 { actor = e.ActorID } _, err := a.db.ExecContext(ctx, `INSERT INTO audit_logs (user_id, action, entity_type, entity_id, before_json, after_json, ip, request_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, actor, e.Action, e.EntityType, entityID, marshalOrNil(e.Before), marshalOrNil(e.After), ip, requestID) if err != nil { // 审计失败不能吞掉业务结果,但必须留下痕迹。 a.log.Error("写入审计失败", "err", err, "action", e.Action, "requestId", requestID) continue } // 落库成功即「这次操作被放行」:默认 info 级别下这是唯一的放行痕迹, // 与拦截的 WARN 成对,现场才能对上「谁在什么时候做了什么」。 a.log.Info("操作已放行", "action", e.Action, "entity", e.EntityType, "entityId", e.EntityID, "actor", auditActorLabel(u, e.ActorID), "requestId", requestID) } } // auditActorLabel 是放行日志里的操作人:登录请求的身份来自条目自带的 ActorID, // 其余来自会话;两边都拿不到就是 guest。记「用户名#id」比裸 id 好认。 func auditActorLabel(u *auth.User, actorID int64) string { switch { case actorID != 0: return fmt.Sprintf("user#%d", actorID) case u != nil && u.ID != 0: return fmt.Sprintf("%s#%d", u.Username, u.ID) default: return "guest" } } func nullableID(u *auth.User) any { if u == nil || u.Role == auth.RoleGuest || u.ID == 0 { return nil } return u.ID } func marshalOrNil(v any) any { if v == nil { return nil } raw, err := json.Marshal(v) if err != nil { return nil } return string(raw) }