Files
opsx 5e59347c40 feat(settings,analysis): 日志保留时间可配(发送历史保留 7 天)+ 分析页手动刷新;修复我引入的 RWMutex 死锁
=== 0. 【严重】修复上一版引入的死锁(平台写路径全卡死)===
上一版把保留期读取写成 `GetRetentionSettings()`(内部 db.RLock()),
却在 `db.Lock()` 已持有的情况下调用(recordDelivery / RecordAudit 的裁剪路径)。
Go 的 sync.RWMutex **不可重入** → 第一次写投递历史/审计日志就永久死锁。
现象:登录 28 秒返回空(登录也要写审计)、所有写接口挂住。
go build / go vet / 之前的单测(不触发写路径)全都查不出来。
修法:
  - 拆出 retentionFromDBLocked(db) / alertDeliveryRetentionLocked(db) /
    auditLogRetentionLocked(db),调用方已持锁时用无锁版本
  - upsertSettingLocked 里**不能**用 db.NextID()(内部 d.mu.Lock(),同样死锁),
    改为锁内自算 max(id)+1
  - 新增 retention_test.go:持写锁调用裁剪函数,2 秒超时未返回即判死锁
实测修复后:登录 103ms(原 28000ms)、panic/deadlock 命中 0。

=== 1. 平台数据保留期可配(系统设置 → 通用配置 → 日志保留时间)===
新增 GET/PUT /api/v1/settings/retention:
  alertDeliveryDays  告警发送历史保留天数,默认 7(原写死"最近 50 条")
  auditLogDays       审计日志保留天数,默认 90(原写死常量)
保存后立即按新策略裁剪一次,不必等下次触发。
校验:发送历史 1~365 天、审计 1~3650 天,超出返回明确中文原因(不是 500)。
注意与「Loki 日志保留期」区分:那个是部署级配置(组件部署 → 保留策略)。

=== 2. 发送历史:50 条硬上限 → 保留 7 天 ===
后端 Deliveries 接口原来写死 `len(out) < 50`,现在按保留期取(响应上限 500 可调),
标题改为「发送历史(保留 N 天)」,N 从设置里读。

=== 3. 菜单「日志服务」→「组件部署」===

=== 4. 分析页:去掉「全部命名空间」+ 改手动刷新 ===
- 移除「全部命名空间」选项:{namespace=~".+"} 要扫全集群(约 370GB/天),
  一次分析几十秒,得不偿失;历史值 __all__ 也会被忽略并回退到策略/第一个 ns
- 筛选改为「选择值 / 已生效值」分离:切换命名空间或时间范围**不再自动查询**,
  只把刷新按钮点亮并提示「筛选条件已改,点刷新生效」;
  点一次才应用并取数。
  原因:一次分析要跑多条 count_over_time,切一下下拉就重查一轮,
  快速连点会把 Loki 打满(并发护栏 429),还拖慢别人。

=== 实测(9739c7c092e7)===
  登录 103ms / token 273 字符
  GET retention → {alertDeliveryDays:7, auditLogDays:90}
  PUT 0 天 → 400「告警发送历史保留天数需在 1~365 之间」
  平台日志 panic/deadlock 命中 0
  go test ./... 全绿(含 2 个新死锁回归用例)
2026-10-29 04:16:52 +00:00

326 lines
8.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 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]) + "…"
}