mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-08 17:08:14 +08:00
feat(audit): 审计日志保留期清理(后端自动清理 + 审计页配置) (#83)
审计日志新增可配置保留期:AuditLogRepository.DeleteBefore + AuditService 保留期监控(每 6h 读取 audit_retention_days,0/缺省=永久保留);审计页新增管理员保留天数配置控件。后端 go test、前端 tsc+vite 通过。
This commit is contained in:
@@ -114,6 +114,8 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
schedulerService.SetAuditRecorder(auditService)
|
schedulerService.SetAuditRecorder(auditService)
|
||||||
// 审计日志外输:启动时用当前 settings 初始化 webhook,后续前端修改立即生效
|
// 审计日志外输:启动时用当前 settings 初始化 webhook,后续前端修改立即生效
|
||||||
settingsService.SetAuditWebhookConfigurer(ctx, auditService)
|
settingsService.SetAuditWebhookConfigurer(ctx, auditService)
|
||||||
|
// 审计日志保留期清理:每 6h 读取 audit_retention_days 设置并清理超期日志(0/缺省=永久保留)
|
||||||
|
auditService.StartRetentionMonitor(ctx, systemConfigRepo, 6*time.Hour)
|
||||||
|
|
||||||
// Database discovery(集群依赖在 agentService 创建后注入)
|
// Database discovery(集群依赖在 agentService 创建后注入)
|
||||||
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type AuditLogRepository interface {
|
|||||||
Create(ctx context.Context, log *model.AuditLog) error
|
Create(ctx context.Context, log *model.AuditLog) error
|
||||||
List(ctx context.Context, opts AuditLogListOptions) (*AuditLogListResult, error)
|
List(ctx context.Context, opts AuditLogListOptions) (*AuditLogListResult, error)
|
||||||
ListAll(ctx context.Context, opts AuditLogListOptions) ([]model.AuditLog, error)
|
ListAll(ctx context.Context, opts AuditLogListOptions) ([]model.AuditLog, error)
|
||||||
|
// DeleteBefore 删除 created_at 早于 cutoff 的审计日志,返回删除行数。用于保留期清理。
|
||||||
|
DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type gormAuditLogRepository struct {
|
type gormAuditLogRepository struct {
|
||||||
@@ -43,6 +45,11 @@ func (r *gormAuditLogRepository) Create(_ context.Context, log *model.AuditLog)
|
|||||||
return r.db.Create(log).Error
|
return r.db.Create(log).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *gormAuditLogRepository) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||||
|
result := r.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&model.AuditLog{})
|
||||||
|
return result.RowsAffected, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOptions) (*AuditLogListResult, error) {
|
func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOptions) (*AuditLogListResult, error) {
|
||||||
query := r.buildQuery(opts)
|
query := r.buildQuery(opts)
|
||||||
var total int64
|
var total int64
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"backupx/server/internal/config"
|
||||||
|
"backupx/server/internal/database"
|
||||||
|
"backupx/server/internal/logger"
|
||||||
|
"backupx/server/internal/model"
|
||||||
|
"backupx/server/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuditRetention(t *testing.T) {
|
||||||
|
baseDir := t.TempDir()
|
||||||
|
log, err := logger.New(config.LogConfig{Level: "error"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "a.db")}, log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
auditRepo := repository.NewAuditLogRepository(db)
|
||||||
|
configRepo := repository.NewSystemConfigRepository(db)
|
||||||
|
svc := NewAuditService(auditRepo)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// seed 写一条审计日志并把 created_at 强制改到 daysAgo 天前。
|
||||||
|
seed := func(daysAgo int) {
|
||||||
|
rec := &model.AuditLog{Username: "t", Category: "test", Action: "seed"}
|
||||||
|
if err := auditRepo.Create(ctx, rec); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
ts := time.Now().UTC().AddDate(0, 0, -daysAgo)
|
||||||
|
if err := db.Model(&model.AuditLog{}).Where("id = ?", rec.ID).Update("created_at", ts).Error; err != nil {
|
||||||
|
t.Fatalf("backdate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count := func() int64 {
|
||||||
|
var n int64
|
||||||
|
db.Model(&model.AuditLog{}).Count(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
seed(100)
|
||||||
|
seed(40)
|
||||||
|
seed(0)
|
||||||
|
if count() != 3 {
|
||||||
|
t.Fatalf("expected 3 seeded, got %d", count())
|
||||||
|
}
|
||||||
|
|
||||||
|
// days<=0 不清理。
|
||||||
|
if n, _ := svc.PurgeOlderThan(ctx, 0); n != 0 || count() != 3 {
|
||||||
|
t.Fatalf("days<=0 must not purge (n=%d count=%d)", n, count())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留 50 天 → 删除 100 天前那条。
|
||||||
|
n, err := svc.PurgeOlderThan(ctx, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("purge: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 || count() != 2 {
|
||||||
|
t.Fatalf("expected 1 purged / 2 remaining, got n=%d count=%d", n, count())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置驱动:retention=10 天 → 再删 40 天前那条。
|
||||||
|
if err := configRepo.Upsert(ctx, &model.SystemConfig{Key: SettingKeyAuditRetentionDays, Value: "10"}); err != nil {
|
||||||
|
t.Fatalf("upsert setting: %v", err)
|
||||||
|
}
|
||||||
|
svc.runRetentionOnce(ctx, configRepo)
|
||||||
|
if count() != 1 {
|
||||||
|
t.Fatalf("expected 1 remaining after retention=10, got %d", count())
|
||||||
|
}
|
||||||
|
|
||||||
|
// retention=0 → 永久保留,不再删除。
|
||||||
|
if err := configRepo.Upsert(ctx, &model.SystemConfig{Key: SettingKeyAuditRetentionDays, Value: "0"}); err != nil {
|
||||||
|
t.Fatalf("upsert setting: %v", err)
|
||||||
|
}
|
||||||
|
svc.runRetentionOnce(ctx, configRepo)
|
||||||
|
if count() != 1 {
|
||||||
|
t.Fatalf("retention=0 must keep all, got %d", count())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -51,6 +52,59 @@ func NewAuditService(repo repository.AuditLogRepository) *AuditService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PurgeOlderThan 删除早于 days 天前的审计日志,返回删除条数。days<=0 时不清理。
|
||||||
|
func (s *AuditService) PurgeOlderThan(ctx context.Context, days int) (int64, error) {
|
||||||
|
if days <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
||||||
|
return s.repo.DeleteBefore(ctx, cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartRetentionMonitor 启动后台审计保留期清理:按 interval 周期读取
|
||||||
|
// audit_retention_days 设置,>0 时删除超期审计日志。缺省/0 表示永久保留
|
||||||
|
// (向后兼容,默认不删任何历史)。ctx 取消后退出。
|
||||||
|
func (s *AuditService) StartRetentionMonitor(ctx context.Context, configs repository.SystemConfigRepository, interval time.Duration) {
|
||||||
|
if s == nil || configs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 6 * time.Hour
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
s.runRetentionOnce(ctx, configs) // 启动后立即跑一次
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.runRetentionOnce(ctx, configs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuditService) runRetentionOnce(ctx context.Context, configs repository.SystemConfigRepository) {
|
||||||
|
cfg, err := configs.GetByKey(ctx, SettingKeyAuditRetentionDays)
|
||||||
|
if err != nil || cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days, err := strconv.Atoi(strings.TrimSpace(cfg.Value))
|
||||||
|
if err != nil || days <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleted, err := s.PurgeOlderThan(ctx, days)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[audit] retention purge failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deleted > 0 {
|
||||||
|
log.Printf("[audit] retention purge: deleted %d logs older than %d days", deleted, days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SetWebhook 动态配置审计事件转发 URL 与签名密钥。
|
// SetWebhook 动态配置审计事件转发 URL 与签名密钥。
|
||||||
// - url 为空字符串时禁用转发
|
// - url 为空字符串时禁用转发
|
||||||
// - secret 非空时对 payload 计算 HMAC-SHA256,作为 X-BackupX-Signature header
|
// - secret 非空时对 payload 计算 HMAC-SHA256,作为 X-BackupX-Signature header
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ func (r *fakeAuditRepo) ListAll(context.Context, repository.AuditLogListOptions)
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *fakeAuditRepo) DeleteBefore(context.Context, time.Time) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuditService_WebhookDeliversSignedPayload(t *testing.T) {
|
func TestAuditService_WebhookDeliversSignedPayload(t *testing.T) {
|
||||||
var hits atomic.Int32
|
var hits atomic.Int32
|
||||||
var got struct {
|
var got struct {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const (
|
|||||||
SettingKeyBandwidthLimit = "bandwidth_limit"
|
SettingKeyBandwidthLimit = "bandwidth_limit"
|
||||||
SettingKeyAuditWebhookURL = "audit_webhook_url"
|
SettingKeyAuditWebhookURL = "audit_webhook_url"
|
||||||
SettingKeyAuditWebhookSecret = "audit_webhook_secret"
|
SettingKeyAuditWebhookSecret = "audit_webhook_secret"
|
||||||
|
// SettingKeyAuditRetentionDays 审计日志保留天数(0/缺省=永久保留)。
|
||||||
|
SettingKeyAuditRetentionDays = "audit_retention_days"
|
||||||
)
|
)
|
||||||
|
|
||||||
var settingsKeys = []string{
|
var settingsKeys = []string{
|
||||||
@@ -57,6 +59,7 @@ var settingsKeys = []string{
|
|||||||
SettingKeyBandwidthLimit,
|
SettingKeyBandwidthLimit,
|
||||||
SettingKeyAuditWebhookURL,
|
SettingKeyAuditWebhookURL,
|
||||||
SettingKeyAuditWebhookSecret,
|
SettingKeyAuditWebhookSecret,
|
||||||
|
SettingKeyAuditRetentionDays,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingsService) GetAll(ctx context.Context) (map[string]string, error) {
|
func (s *SettingsService) GetAll(ctx context.Context) (map[string]string, error) {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Button, DatePicker, Input, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
import { Button, DatePicker, Input, InputNumber, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||||
import type { ColumnProps } from '@arco-design/web-react/es/Table'
|
import type { ColumnProps } from '@arco-design/web-react/es/Table'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { exportAuditLogs, listAuditLogs } from '../../services/audit'
|
import { exportAuditLogs, listAuditLogs } from '../../services/audit'
|
||||||
|
import { fetchSettings, updateSettings } from '../../services/system'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { isAdmin } from '../../utils/permissions'
|
||||||
import type { AuditLog } from '../../types/audit'
|
import type { AuditLog } from '../../types/audit'
|
||||||
import { formatDateTime } from '../../utils/format'
|
import { formatDateTime } from '../../utils/format'
|
||||||
import { resolveErrorMessage } from '../../utils/error'
|
import { resolveErrorMessage } from '../../utils/error'
|
||||||
@@ -112,6 +115,9 @@ export function AuditLogsPage() {
|
|||||||
const [dateRange, setDateRange] = useState<string[] | null>(null)
|
const [dateRange, setDateRange] = useState<string[] | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
|
const admin = isAdmin(useAuthStore((state) => state.user))
|
||||||
|
const [retentionDays, setRetentionDays] = useState(0)
|
||||||
|
const [savingRetention, setSavingRetention] = useState(false)
|
||||||
|
|
||||||
const fetchData = useCallback(async (currentPage: number) => {
|
const fetchData = useCallback(async (currentPage: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -139,6 +145,31 @@ export function AuditLogsPage() {
|
|||||||
void fetchData(page)
|
void fetchData(page)
|
||||||
}, [page, fetchData])
|
}, [page, fetchData])
|
||||||
|
|
||||||
|
// 管理员加载当前审计日志保留期设置。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!admin) return
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const settings = await fetchSettings()
|
||||||
|
setRetentionDays(Number(settings.audit_retention_days ?? 0) || 0)
|
||||||
|
} catch {
|
||||||
|
/* 读取失败时保持默认 0(永久保留),不打断主流程 */
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [admin])
|
||||||
|
|
||||||
|
async function handleSaveRetention() {
|
||||||
|
setSavingRetention(true)
|
||||||
|
try {
|
||||||
|
await updateSettings({ audit_retention_days: String(retentionDays) })
|
||||||
|
Message.success(retentionDays > 0 ? `已设置:保留最近 ${retentionDays} 天` : '已设置:永久保留')
|
||||||
|
} catch (e) {
|
||||||
|
Message.error(resolveErrorMessage(e, '保存保留期失败'))
|
||||||
|
} finally {
|
||||||
|
setSavingRetention(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
try {
|
try {
|
||||||
@@ -171,6 +202,27 @@ export function AuditLogsPage() {
|
|||||||
style={{ paddingBottom: 0 }}
|
style={{ paddingBottom: 0 }}
|
||||||
title="审计日志"
|
title="审计日志"
|
||||||
subTitle="记录系统中所有关键操作,保障数据操作链可溯源。支持高级筛选与 CSV 导出(最多 10000 行)。"
|
subTitle="记录系统中所有关键操作,保障数据操作链可溯源。支持高级筛选与 CSV 导出(最多 10000 行)。"
|
||||||
|
extra={
|
||||||
|
admin ? (
|
||||||
|
<Space>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
日志保留期
|
||||||
|
</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: 150 }}
|
||||||
|
min={0}
|
||||||
|
max={3650}
|
||||||
|
value={retentionDays}
|
||||||
|
onChange={(value) => setRetentionDays(Number(value) || 0)}
|
||||||
|
suffix="天"
|
||||||
|
placeholder="0=永久"
|
||||||
|
/>
|
||||||
|
<Button type="primary" size="small" loading={savingRetention} onClick={() => void handleSaveRetention()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
|
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
|||||||
Reference in New Issue
Block a user