mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-29 03:57:39 +08:00
新增合规报表:ReportService 逐任务聚合备份合规证据(成功率/最近成功/SLA 判定/加密/受保护量)+ JSON/CSV API;前端新增 /reports 页面(汇总卡片+明细表+CSV 导出)。后端 go test、前端 tsc+vite、端到端路由验证均通过。
200 lines
6.4 KiB
Go
200 lines
6.4 KiB
Go
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。
|
|
// - 配置了 SLA(RPO 小时):最近成功在 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
|
|
}
|