From f807ce10e6ab9e98ceba56fe19904f84cb18b10a Mon Sep 17 00:00:00 2001 From: Wu Qing <3184394176@qq.com> Date: Wed, 27 May 2026 08:37:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit):=20=E5=AE=A1=E8=AE=A1=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=BF=9D=E7=95=99=E6=9C=9F=E6=B8=85=E7=90=86=EF=BC=88?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86=20+=20?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E9=A1=B5=E9=85=8D=E7=BD=AE=EF=BC=89=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计日志新增可配置保留期:AuditLogRepository.DeleteBefore + AuditService 保留期监控(每 6h 读取 audit_retention_days,0/缺省=永久保留);审计页新增管理员保留天数配置控件。后端 go test、前端 tsc+vite 通过。 --- server/internal/app/app.go | 2 + .../repository/audit_log_repository.go | 7 ++ .../internal/service/audit_retention_test.go | 86 +++++++++++++++++++ server/internal/service/audit_service.go | 54 ++++++++++++ .../service/audit_service_webhook_test.go | 4 + server/internal/service/settings_service.go | 3 + web/src/pages/audit/AuditLogsPage.tsx | 54 +++++++++++- 7 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 server/internal/service/audit_retention_test.go diff --git a/server/internal/app/app.go b/server/internal/app/app.go index e35e4bc..2795778 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -114,6 +114,8 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, schedulerService.SetAuditRecorder(auditService) // 审计日志外输:启动时用当前 settings 初始化 webhook,后续前端修改立即生效 settingsService.SetAuditWebhookConfigurer(ctx, auditService) + // 审计日志保留期清理:每 6h 读取 audit_retention_days 设置并清理超期日志(0/缺省=永久保留) + auditService.StartRetentionMonitor(ctx, systemConfigRepo, 6*time.Hour) // Database discovery(集群依赖在 agentService 创建后注入) databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor()) diff --git a/server/internal/repository/audit_log_repository.go b/server/internal/repository/audit_log_repository.go index 9ffbb31..5729cda 100644 --- a/server/internal/repository/audit_log_repository.go +++ b/server/internal/repository/audit_log_repository.go @@ -29,6 +29,8 @@ type AuditLogRepository interface { Create(ctx context.Context, log *model.AuditLog) error List(ctx context.Context, opts AuditLogListOptions) (*AuditLogListResult, 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 { @@ -43,6 +45,11 @@ func (r *gormAuditLogRepository) Create(_ context.Context, log *model.AuditLog) 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) { query := r.buildQuery(opts) var total int64 diff --git a/server/internal/service/audit_retention_test.go b/server/internal/service/audit_retention_test.go new file mode 100644 index 0000000..2dfad6a --- /dev/null +++ b/server/internal/service/audit_retention_test.go @@ -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()) + } +} diff --git a/server/internal/service/audit_service.go b/server/internal/service/audit_service.go index 52a3f46..1cf1745 100644 --- a/server/internal/service/audit_service.go +++ b/server/internal/service/audit_service.go @@ -10,6 +10,7 @@ import ( "fmt" "log" "net/http" + "strconv" "strings" "sync" "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 与签名密钥。 // - url 为空字符串时禁用转发 // - secret 非空时对 payload 计算 HMAC-SHA256,作为 X-BackupX-Signature header diff --git a/server/internal/service/audit_service_webhook_test.go b/server/internal/service/audit_service_webhook_test.go index 5896151..97e6f81 100644 --- a/server/internal/service/audit_service_webhook_test.go +++ b/server/internal/service/audit_service_webhook_test.go @@ -46,6 +46,10 @@ func (r *fakeAuditRepo) ListAll(context.Context, repository.AuditLogListOptions) return nil, nil } +func (r *fakeAuditRepo) DeleteBefore(context.Context, time.Time) (int64, error) { + return 0, nil +} + func TestAuditService_WebhookDeliversSignedPayload(t *testing.T) { var hits atomic.Int32 var got struct { diff --git a/server/internal/service/settings_service.go b/server/internal/service/settings_service.go index 154709e..1feaa5c 100644 --- a/server/internal/service/settings_service.go +++ b/server/internal/service/settings_service.go @@ -47,6 +47,8 @@ const ( SettingKeyBandwidthLimit = "bandwidth_limit" SettingKeyAuditWebhookURL = "audit_webhook_url" SettingKeyAuditWebhookSecret = "audit_webhook_secret" + // SettingKeyAuditRetentionDays 审计日志保留天数(0/缺省=永久保留)。 + SettingKeyAuditRetentionDays = "audit_retention_days" ) var settingsKeys = []string{ @@ -57,6 +59,7 @@ var settingsKeys = []string{ SettingKeyBandwidthLimit, SettingKeyAuditWebhookURL, SettingKeyAuditWebhookSecret, + SettingKeyAuditRetentionDays, } func (s *SettingsService) GetAll(ctx context.Context) (map[string]string, error) { diff --git a/web/src/pages/audit/AuditLogsPage.tsx b/web/src/pages/audit/AuditLogsPage.tsx index b82c8b4..aeabf0b 100644 --- a/web/src/pages/audit/AuditLogsPage.tsx +++ b/web/src/pages/audit/AuditLogsPage.tsx @@ -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 { useCallback, useEffect, useState } from 'react' 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 { formatDateTime } from '../../utils/format' import { resolveErrorMessage } from '../../utils/error' @@ -112,6 +115,9 @@ export function AuditLogsPage() { const [dateRange, setDateRange] = useState(null) const [page, setPage] = useState(1) 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) => { setLoading(true) @@ -139,6 +145,31 @@ export function AuditLogsPage() { void fetchData(page) }, [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() { setExporting(true) try { @@ -171,6 +202,27 @@ export function AuditLogsPage() { style={{ paddingBottom: 0 }} title="审计日志" subTitle="记录系统中所有关键操作,保障数据操作链可溯源。支持高级筛选与 CSV 导出(最多 10000 行)。" + extra={ + admin ? ( + + + 日志保留期 + + setRetentionDays(Number(value) || 0)} + suffix="天" + placeholder="0=永久" + /> + + + ) : undefined + } /> {error ? {error} : null}