feat(reports): 企业合规报表(后端聚合 + CSV 导出 + 前端页面) (#82)

新增合规报表:ReportService 逐任务聚合备份合规证据(成功率/最近成功/SLA 判定/加密/受保护量)+ JSON/CSV API;前端新增 /reports 页面(汇总卡片+明细表+CSV 导出)。后端 go test、前端 tsc+vite、端到端路由验证均通过。
This commit is contained in:
Wu Qing
2026-05-27 08:14:56 +08:00
committed by GitHub
parent 74e29a0753
commit a0d1e66199
10 changed files with 695 additions and 0 deletions

View File

@@ -104,6 +104,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
restoreLogHub := backup.NewLogHub()
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
reportService := service.NewReportService(backupTaskRepo, backupRecordRepo)
settingsService := service.NewSettingsService(systemConfigRepo)
// Audit
@@ -268,6 +269,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
ApiKeyService: apiKeyService,
NotificationService: notificationService,
DashboardService: dashboardService,
ReportService: reportService,
SettingsService: settingsService,
NodeService: nodeService,
AgentService: agentService,

View File

@@ -0,0 +1,95 @@
package http
import (
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type ReportHandler struct {
service *service.ReportService
}
func NewReportHandler(reportService *service.ReportService) *ReportHandler {
return &ReportHandler{service: reportService}
}
func reportDays(c *gin.Context) int {
days := 30
if v := strings.TrimSpace(c.Query("days")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
days = parsed
}
}
return days
}
// Compliance 返回 JSON 合规报表(按任务的备份合规证据 + 汇总)。
func (h *ReportHandler) Compliance(c *gin.Context) {
payload, err := h.service.ComplianceReport(c.Request.Context(), reportDays(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
// ComplianceCSV 把合规报表导出为 CSV供审计归档。带 UTF-8 BOM 以便 Excel 正确识别中文。
func (h *ReportHandler) ComplianceCSV(c *gin.Context) {
report, err := h.service.ComplianceReport(c.Request.Context(), reportDays(c))
if err != nil {
response.Error(c, err)
return
}
filename := fmt.Sprintf("backupx-compliance-%s.csv", report.GeneratedAt.Format("20060102-150405"))
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", "attachment; filename="+filename)
_, _ = c.Writer.WriteString("\ufeff") // UTF-8 BOM
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{
"任务ID", "任务名", "类型", "启用", "节点", "加密", "保留天数", "SLA(RPO小时)",
"周期内运行", "成功", "失败", "成功率", "最近状态", "最近运行(UTC)", "最近成功(UTC)", "保护字节数", "合规判定",
})
for _, row := range report.Tasks {
_ = w.Write([]string{
strconv.FormatUint(uint64(row.TaskID), 10),
row.TaskName,
row.Type,
boolCN(row.Enabled),
row.NodeName,
boolCN(row.Encrypted),
strconv.Itoa(row.RetentionDays),
strconv.Itoa(row.SLAHoursRPO),
strconv.Itoa(row.TotalRuns),
strconv.Itoa(row.Successes),
strconv.Itoa(row.Failures),
fmt.Sprintf("%.2f%%", row.SuccessRate*100),
row.LastStatus,
fmtTimePtr(row.LastRunAt),
fmtTimePtr(row.LastSuccessAt),
strconv.FormatInt(row.ProtectedBytes, 10),
row.Risk,
})
}
w.Flush()
}
func boolCN(b bool) string {
if b {
return "是"
}
return "否"
}
func fmtTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format("2006-01-02 15:04:05")
}

View File

@@ -41,6 +41,7 @@ type RouterDependencies struct {
ApiKeyService *service.ApiKeyService
NotificationService *service.NotificationService
DashboardService *service.DashboardService
ReportService *service.ReportService
SettingsService *service.SettingsService
NodeService *service.NodeService
AgentService *service.AgentService
@@ -211,6 +212,15 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
// 基于备份记录的验证入口:与 restore 对称
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
}
// 企业合规报表:按任务的可导出备份合规证据(区别于 Dashboard 的实时聚合视图)。
if deps.ReportService != nil {
reportHandler := NewReportHandler(deps.ReportService)
reports := api.Group("/reports")
reports.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
reports.GET("/compliance", reportHandler.Compliance)
reports.GET("/compliance/export", reportHandler.ComplianceCSV)
}
dashboard := api.Group("/dashboard")
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
dashboard.GET("/stats", dashboardHandler.Stats)

View File

@@ -0,0 +1,199 @@
package service
import (
"context"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
// ReportService 生成企业合规报表。区别于 Dashboard 的实时聚合视图,
// 本服务产出「按任务、可导出、可归档」的时间点合规证据(供 SOC2 / ISO27001 等审计)。
type ReportService struct {
tasks repository.BackupTaskRepository
records repository.BackupRecordRepository
}
func NewReportService(tasks repository.BackupTaskRepository, records repository.BackupRecordRepository) *ReportService {
return &ReportService{tasks: tasks, records: records}
}
// ComplianceTaskRow 单个备份任务的合规明细行。
type ComplianceTaskRow struct {
TaskID uint `json:"taskId"`
TaskName string `json:"taskName"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
NodeName string `json:"nodeName"`
CronExpr string `json:"cronExpr"`
Encrypted bool `json:"encrypted"`
RetentionDays int `json:"retentionDays"`
SLAHoursRPO int `json:"slaHoursRpo"`
TotalRuns int `json:"totalRuns"`
Successes int `json:"successes"`
Failures int `json:"failures"`
SuccessRate float64 `json:"successRate"`
LastStatus string `json:"lastStatus"`
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
ProtectedBytes int64 `json:"protectedBytes"`
Compliant bool `json:"compliant"`
Risk string `json:"risk"` // ok | at_risk | not_applicable
}
// ComplianceSummary 报表汇总。
type ComplianceSummary struct {
TotalTasks int `json:"totalTasks"`
EnabledTasks int `json:"enabledTasks"`
CompliantTasks int `json:"compliantTasks"`
AtRiskTasks int `json:"atRiskTasks"`
EncryptedTasks int `json:"encryptedTasks"`
OverallSuccessRate float64 `json:"overallSuccessRate"`
TotalProtectedB int64 `json:"totalProtectedBytes"`
}
// ComplianceReport 完整合规报表。
type ComplianceReport struct {
GeneratedAt time.Time `json:"generatedAt"`
RangeDays int `json:"rangeDays"`
Summary ComplianceSummary `json:"summary"`
Tasks []ComplianceTaskRow `json:"tasks"`
}
const (
reportMinDays = 1
reportMaxDays = 365
)
// ComplianceReport 生成最近 days 天的合规报表。
func (s *ReportService) ComplianceReport(ctx context.Context, days int) (*ComplianceReport, error) {
if days < reportMinDays || days > reportMaxDays {
return nil, apperror.BadRequest("REPORT_RANGE_INVALID",
"统计天数需在 1-365 之间", nil)
}
tasks, err := s.tasks.List(ctx, repository.BackupTaskListOptions{})
if err != nil {
return nil, apperror.Internal("REPORT_TASKS_FAILED", "无法获取任务列表", err)
}
now := time.Now().UTC()
since := now.AddDate(0, 0, -days)
report := &ComplianceReport{GeneratedAt: now, RangeDays: days, Tasks: make([]ComplianceTaskRow, 0, len(tasks))}
var totalRuns, totalSuccess int
for i := range tasks {
task := tasks[i]
records, err := s.records.ListByTask(ctx, task.ID)
if err != nil {
return nil, apperror.Internal("REPORT_RECORDS_FAILED", "无法获取任务备份记录", err)
}
row := s.buildTaskRow(&task, records, now, since)
report.Tasks = append(report.Tasks, row)
report.Summary.TotalTasks++
if task.Enabled {
report.Summary.EnabledTasks++
}
if task.Encrypt {
report.Summary.EncryptedTasks++
}
switch row.Risk {
case "ok":
report.Summary.CompliantTasks++
case "at_risk":
report.Summary.AtRiskTasks++
}
report.Summary.TotalProtectedB += row.ProtectedBytes
totalRuns += row.TotalRuns
totalSuccess += row.Successes
}
if totalRuns > 0 {
report.Summary.OverallSuccessRate = roundRate(float64(totalSuccess) / float64(totalRuns))
}
return report, nil
}
func (s *ReportService) buildTaskRow(task *model.BackupTask, records []model.BackupRecord, now, since time.Time) ComplianceTaskRow {
row := ComplianceTaskRow{
TaskID: task.ID,
TaskName: task.Name,
Type: task.Type,
Enabled: task.Enabled,
NodeName: task.Node.Name,
CronExpr: task.CronExpr,
Encrypted: task.Encrypt,
RetentionDays: task.RetentionDays,
SLAHoursRPO: task.SLAHoursRPO,
LastStatus: "none",
}
var lastRun, lastSuccess *model.BackupRecord
for i := range records {
rec := &records[i]
// 统计窗口内的运行情况(按 StartedAt 落在 [since, now])。
if !rec.StartedAt.Before(since) {
row.TotalRuns++
switch rec.Status {
case model.BackupRecordStatusSuccess:
row.Successes++
case model.BackupRecordStatusFailed:
row.Failures++
}
}
// 最近一次运行 / 最近一次成功(不限窗口,反映当前保护态)。
if lastRun == nil || rec.StartedAt.After(lastRun.StartedAt) {
lastRun = rec
}
if rec.Status == model.BackupRecordStatusSuccess {
if lastSuccess == nil || rec.StartedAt.After(lastSuccess.StartedAt) {
lastSuccess = rec
}
}
}
if row.TotalRuns > 0 {
row.SuccessRate = roundRate(float64(row.Successes) / float64(row.TotalRuns))
}
if lastRun != nil {
row.LastStatus = lastRun.Status
started := lastRun.StartedAt
row.LastRunAt = &started
}
if lastSuccess != nil {
when := lastSuccess.StartedAt
if lastSuccess.CompletedAt != nil {
when = *lastSuccess.CompletedAt
}
row.LastSuccessAt = &when
row.ProtectedBytes = lastSuccess.FileSize
}
row.Compliant, row.Risk = evaluateCompliance(task, lastSuccess, now)
return row
}
// evaluateCompliance 判定任务合规性:
// - 禁用任务not_applicable不计入合规/风险)。
// - 从未成功at_risk。
// - 配置了 SLARPO 小时):最近成功在 RPO 内为 ok否则 at_risk。
// - 未配置 SLA只要存在成功备份即视为 ok。
func evaluateCompliance(task *model.BackupTask, lastSuccess *model.BackupRecord, now time.Time) (bool, string) {
if !task.Enabled {
return false, "not_applicable"
}
if lastSuccess == nil {
return false, "at_risk"
}
if task.SLAHoursRPO > 0 {
when := lastSuccess.StartedAt
if lastSuccess.CompletedAt != nil {
when = *lastSuccess.CompletedAt
}
if now.Sub(when).Hours() > float64(task.SLAHoursRPO) {
return false, "at_risk"
}
}
return true, "ok"
}
func roundRate(v float64) float64 {
return float64(int(v*10000+0.5)) / 10000
}

View File

@@ -0,0 +1,136 @@
package service
import (
"context"
"os"
"path/filepath"
"testing"
"backupx/server/internal/backup"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
storageRclone "backupx/server/internal/storage/rclone"
)
func newReportTestHarness(t *testing.T) (*ReportService, *BackupExecutionService) {
t.Helper()
baseDir := t.TempDir()
sourceDir := filepath.Join(baseDir, "data")
storeDir := filepath.Join(baseDir, "store")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "f.txt"), []byte("report-data"), 0o644); err != nil {
t.Fatal(err)
}
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatal(err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
if err != nil {
t.Fatal(err)
}
cipher := codec.NewConfigCipher("report-secret")
targets := repository.NewStorageTargetRepository(db)
tasks := repository.NewBackupTaskRepository(db)
records := repository.NewBackupRecordRepository(db)
cfg, err := cipher.EncryptJSON(map[string]any{"basePath": storeDir})
if err != nil {
t.Fatal(err)
}
if err := targets.Create(context.Background(), &model.StorageTarget{Name: "s", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: cfg, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatal(err)
}
task := &model.BackupTask{Name: "rep-task", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
if err := tasks.Create(context.Background(), task); err != nil {
t.Fatal(err)
}
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, backup.NewLogHub(), nil, cipher, nil, baseDir, 2, 10, "")
return NewReportService(tasks, records), execution
}
func findRow(rows []ComplianceTaskRow, taskID uint) *ComplianceTaskRow {
for i := range rows {
if rows[i].TaskID == taskID {
return &rows[i]
}
}
return nil
}
func TestComplianceReport_ReflectsBackupOutcome(t *testing.T) {
report, execution := newReportTestHarness(t)
ctx := context.Background()
// 备份前:任务启用但从未成功 → at_risk。
before, err := report.ComplianceReport(ctx, 30)
if err != nil {
t.Fatalf("ComplianceReport: %v", err)
}
row := findRow(before.Tasks, 1)
if row == nil {
t.Fatal("task row missing before backup")
}
if row.Risk != "at_risk" || row.Compliant {
t.Fatalf("expected at_risk before any success, got risk=%s compliant=%v", row.Risk, row.Compliant)
}
if before.Summary.AtRiskTasks != 1 || before.Summary.CompliantTasks != 0 {
t.Fatalf("unexpected summary before: %+v", before.Summary)
}
// 跑一次成功备份。
bd, err := execution.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
if bd.Status != "success" {
t.Fatalf("backup not success: %s", bd.Status)
}
// 备份后:合规、成功率 1.0、保护字节数 > 0。
after, err := report.ComplianceReport(ctx, 30)
if err != nil {
t.Fatalf("ComplianceReport after: %v", err)
}
row = findRow(after.Tasks, 1)
if row == nil {
t.Fatal("task row missing after backup")
}
if !row.Compliant || row.Risk != "ok" {
t.Fatalf("expected ok/compliant after success, got risk=%s compliant=%v", row.Risk, row.Compliant)
}
if row.TotalRuns != 1 || row.Successes != 1 || row.Failures != 0 {
t.Fatalf("unexpected counts: runs=%d ok=%d fail=%d", row.TotalRuns, row.Successes, row.Failures)
}
if row.SuccessRate != 1 {
t.Fatalf("expected success rate 1.0, got %v", row.SuccessRate)
}
if row.LastStatus != "success" || row.LastSuccessAt == nil || row.ProtectedBytes <= 0 {
t.Fatalf("unexpected last/protected: status=%s lastSuccess=%v bytes=%d", row.LastStatus, row.LastSuccessAt, row.ProtectedBytes)
}
if after.Summary.CompliantTasks != 1 || after.Summary.AtRiskTasks != 0 || after.Summary.OverallSuccessRate != 1 {
t.Fatalf("unexpected summary after: %+v", after.Summary)
}
if after.Summary.TotalProtectedB <= 0 {
t.Fatalf("expected protected bytes > 0, got %d", after.Summary.TotalProtectedB)
}
}
func TestComplianceReport_RejectsInvalidRange(t *testing.T) {
report, _ := newReportTestHarness(t)
ctx := context.Background()
if _, err := report.ComplianceReport(ctx, 0); err == nil {
t.Fatal("expected error for days=0")
}
if _, err := report.ComplianceReport(ctx, 9999); err == nil {
t.Fatal("expected error for days>365")
}
}