=== 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 个新死锁回归用例)
326 lines
8.1 KiB
Go
326 lines
8.1 KiB
Go
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]) + "…"
|
||
}
|