package services import ( "encoding/json" "fmt" "sort" "strings" "time" "lokistack/models" "lokistack/repository" ) const ( // maxAuditLogs 审计日志条数上限。JSON DB 是全量读进内存、全量写盘, // 不设上限的话审计表会把内存和落盘时间一起拖垮。 maxAuditLogs = 20000 // 保留期改由系统设置配置(见 services/retention.go) ) // RecordAudit 写入一条审计日志。 // // 并发安全:直接拿 db 锁追加,与其它业务写入同一把锁,避免 RequestSave 时丢更新。 // 裁剪:超过条数上限或保留期时从最早开始删。 func RecordAudit(entry models.AuditLog) { db := repository.GetDB() if db == nil { return } db.Lock() defer db.Unlock() if entry.CreatedAt.IsZero() { entry.CreatedAt = time.Now() } // 单字段长度保护:UA/Detail 可能来自客户端,不能无限制落盘 entry.UserAgent = truncateRunes(entry.UserAgent, 200) entry.Detail = truncateRunes(entry.Detail, 500) entry.Before = truncateRunes(entry.Before, 1000) entry.After = truncateRunes(entry.After, 1000) entry.Error = truncateRunes(entry.Error, 500) entry.ResourceID = truncateRunes(entry.ResourceID, 128) nextID := uint(1) for _, a := range db.AuditLogs { if a.ID >= nextID { nextID = a.ID + 1 } } entry.ID = nextID db.AuditLogs = append(db.AuditLogs, entry) pruneAuditLocked(db) db.RequestSave() } // pruneAuditLocked 裁剪审计日志(调用方必须已持锁)。 // 从头部(最早)开始丢,直到满足条数与保留期两条约束。 func pruneAuditLocked(db *repository.JsonDB) { logs := db.AuditLogs cut := 0 // 保留期来自 系统设置->通用配置->日志保留时间,默认 90 天 // 注意:调用方已持写锁,必须用无锁读取版本,否则 RWMutex 会死锁 deadline := time.Now().Add(-auditLogRetentionLocked(db)) // 1) 先按保留期裁 for cut < len(logs) && logs[cut].CreatedAt.Before(deadline) { cut++ } // 2) 再按条数上限裁(保留最新的 maxAuditLogs 条) if remain := len(logs) - cut; remain > maxAuditLogs { cut += remain - maxAuditLogs } if cut > 0 { db.AuditLogs = append([]models.AuditLog{}, logs[cut:]...) } } // PruneAuditLogs 按当前保留期裁剪一次审计日志(供保存设置后立即生效)。 func PruneAuditLogs() { db := repository.GetDB() if db == nil { return } db.Lock() defer db.Unlock() before := len(db.AuditLogs) pruneAuditLocked(db) if len(db.AuditLogs) != before { db.RequestSave() } } // ListAuditLogs 分页查询审计日志(按时间倒序)。 func ListAuditLogs(q models.AuditQuery) ([]models.AuditLog, int64) { db := repository.GetDB() if db == nil { return nil, 0 } db.RLock() all := db.AuditLogs db.RUnlock() matched := make([]models.AuditLog, 0, len(all)) for _, a := range all { if q.Username != "" && !strings.Contains(strings.ToLower(a.Username), strings.ToLower(q.Username)) { continue } if q.Module != "" && a.Module != q.Module { continue } if q.Action != "" && a.Action != q.Action { continue } if q.ResourceID != "" && !strings.Contains(a.ResourceID, q.ResourceID) { continue } switch q.Status { case 1: if a.Status < 200 || a.Status >= 300 { continue } case 2: if a.Status < 400 { continue } } if q.StartTime != nil && a.CreatedAt.Before(*q.StartTime) { continue } if q.EndTime != nil && a.CreatedAt.After(*q.EndTime) { continue } matched = append(matched, a) } // 时间倒序 sort.Slice(matched, func(i, j int) bool { return matched[i].CreatedAt.After(matched[j].CreatedAt) }) total := int64(len(matched)) if q.PageSize <= 0 { q.PageSize = 20 } if q.Page <= 0 { q.Page = 1 } start := (q.Page - 1) * q.PageSize if start >= len(matched) { return []models.AuditLog{}, total } end := start + q.PageSize if end > len(matched) { end = len(matched) } return matched[start:end], total } // GetAuditLog 取单条(详情弹窗用)。 func GetAuditLog(id uint) (*models.AuditLog, error) { db := repository.GetDB() if db == nil { return nil, fmt.Errorf("数据库未初始化") } db.RLock() defer db.RUnlock() for i := range db.AuditLogs { if db.AuditLogs[i].ID == id { entry := db.AuditLogs[i] return &entry, nil } } return nil, fmt.Errorf("审计记录不存在") } // AuditStatistics 统计卡片数据。 func AuditStatistics() models.AuditStats { db := repository.GetDB() stats := models.AuditStats{} if db == nil { return stats } db.RLock() logs := db.AuditLogs db.RUnlock() now := time.Now() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) weekStart := todayStart.AddDate(0, 0, -6) moduleCount := map[string]int64{} userCount := map[string]int64{} for _, a := range logs { stats.TotalCount++ if a.CreatedAt.After(todayStart) { stats.TodayCount++ } if a.CreatedAt.After(weekStart) { stats.WeekCount++ } if a.Status >= 400 { stats.FailCount++ } moduleCount[a.Module]++ if a.Username != "" { userCount[a.Username]++ } } stats.TopModules = topN(moduleCount, 5) stats.TopUsers = topN(userCount, 5) return stats } func topN(m map[string]int64, n int) []models.AuditCount { out := make([]models.AuditCount, 0, len(m)) for k, v := range m { out = append(out, models.AuditCount{Name: k, Count: v}) } sort.Slice(out, func(i, j int) bool { if out[i].Count == out[j].Count { return out[i].Name < out[j].Name } return out[i].Count > out[j].Count }) if len(out) > n { out = out[:n] } return out } // ───────────────────────── 脱敏 / 摘要 ───────────────────────── // auditSensitiveKeys 这些字段一律不进审计(只留「已修改」的痕迹)。 var auditSensitiveKeys = map[string]bool{ "password": true, "oldpassword": true, "newpassword": true, "secretkey": true, "accesskey": true, "secretaccesskey": true, "privatekey": true, "token": true, "accesstoken": true, "refreshtoken": true, "encryptionkey": true, "jwtsecret": true, } // MaskAuditPayload 把请求体里的敏感值替换成 ***,返回脱敏后的 JSON 字符串。 // 解析失败时返回 "<非 JSON 请求体>",避免把表单里的密码原样落盘。 func MaskAuditPayload(body []byte, maxLen int) string { if len(body) == 0 { return "" } var v interface{} if err := json.Unmarshal(body, &v); err != nil { return "<非 JSON 请求体,已省略>" } masked := maskValue(v) out, err := json.Marshal(masked) if err != nil { return "" } return truncateRunes(string(out), maxLen) } func maskValue(v interface{}) interface{} { switch t := v.(type) { case map[string]interface{}: out := make(map[string]interface{}, len(t)) for k, val := range t { if auditSensitiveKeys[strings.ToLower(k)] { if s, ok := val.(string); ok && s == "" { out[k] = "" } else { out[k] = "***" } continue } out[k] = maskValue(val) } return out case []interface{}: out := make([]interface{}, len(t)) for i, val := range t { out[i] = maskValue(val) } return out default: return v } } // AuditDiff 生成「变更前后」摘要,只保留真正变化的字段。 // prev/next 传脱敏后的 JSON 字符串;解析失败时返回空(不硬编码原文)。 func AuditDiff(prev, next string) (string, string) { var a, b map[string]interface{} if prev == "" || next == "" || json.Unmarshal([]byte(prev), &a) != nil || json.Unmarshal([]byte(next), &b) != nil { return "", "" } before := map[string]interface{}{} after := map[string]interface{}{} for k, nv := range b { ov, existed := a[k] if !existed || fmt.Sprint(ov) != fmt.Sprint(nv) { if existed { before[k] = ov } after[k] = nv } } for k, ov := range a { if _, ok := b[k]; !ok { before[k] = ov } } if len(after) == 0 && len(before) == 0 { return "", "" } bs, _ := json.Marshal(before) as, _ := json.Marshal(after) return truncateRunes(string(bs), 1000), truncateRunes(string(as), 1000) } func truncateRunes(s string, n int) string { if n <= 0 { return s } r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" }