Files
BackupX/server/internal/service/report_service.go
Wu Qing a0d1e66199 feat(reports): 企业合规报表(后端聚合 + CSV 导出 + 前端页面) (#82)
新增合规报表:ReportService 逐任务聚合备份合规证据(成功率/最近成功/SLA 判定/加密/受保护量)+ JSON/CSV API;前端新增 /reports 页面(汇总卡片+明细表+CSV 导出)。后端 go test、前端 tsc+vite、端到端路由验证均通过。
2026-05-27 08:14:56 +08:00

200 lines
6.4 KiB
Go
Raw 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 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
}