feat(M4): 生命周期 + 审计 — 到期锁定/回收 cron、审计查询/CSV 导出/每日归档、settings 动态策略

This commit is contained in:
2026-08-30 10:17:22 +08:00
parent 1ea18490e0
commit c0e2ff975a
18 changed files with 1088 additions and 75 deletions
+153 -10
View File
@@ -2,9 +2,13 @@ package service
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"time"
"gorm.io/gorm"
@@ -13,14 +17,15 @@ import (
)
// AuditService 审计服务:append-only 记录(业务代码仅允许 INSERT),
// 保留策略与 CSV 导出归档在 M4 完成,这里给出接口与最小实现
// 查询/CSV 导出/每日归档(PLAN F6
type AuditService struct {
db *gorm.DB
db *gorm.DB
log *slog.Logger
}
// NewAuditService 创建审计服务。
func NewAuditService(db *gorm.DB) *AuditService {
return &AuditService{db: db}
func NewAuditService(db *gorm.DB, log *slog.Logger) *AuditService {
return &AuditService{db: db, log: log}
}
// Record 记录一条管理操作审计。detail 为任意结构体,入库前 JSON 序列化。
@@ -42,10 +47,148 @@ func (s *AuditService) Record(ctx context.Context, actorID uint, actorName, acti
return s.db.WithContext(ctx).Create(&entry).Error
}
// ExportCSV 导出审计为 CSV。M0 骨架:返回占位错误,M4 实现手动导出 + 每日归档
// AuditFilter 审计查询条件
type AuditFilter struct {
ActorName string // 操作者模糊匹配
Action string // 动作精确匹配
ResourceType string // 资源类型(user / ssh_key / approval ...
ResourceID string // 资源 ID
Since *time.Time // 起始时间(含)
Until *time.Time // 结束时间(含)
Page int
PageSize int
}
// Query 分页查询审计日志(最新在前)。
func (s *AuditService) Query(ctx context.Context, f AuditFilter) ([]model.AuditLog, int64, error) {
q := s.db.WithContext(ctx).Model(&model.AuditLog{})
if f.ActorName != "" {
q = q.Where("actor_name LIKE ?", "%"+f.ActorName+"%")
}
if f.Action != "" {
q = q.Where("action = ?", f.Action)
}
if f.ResourceType != "" {
q = q.Where("resource_type = ?", f.ResourceType)
}
if f.ResourceID != "" {
q = q.Where("resource_id = ?", f.ResourceID)
}
if f.Since != nil {
q = q.Where("created_at >= ?", *f.Since)
}
if f.Until != nil {
q = q.Where("created_at <= ?", *f.Until)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
page, size := f.Page, f.PageSize
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 200 {
size = 200
}
var rows []model.AuditLog
if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
// ExportCSV 导出审计为 CSV(手动导出接口)。since/until 为空则导出全量。
// 输出带 UTF-8 BOMExcel 直接打开不乱码。
func (s *AuditService) ExportCSV(ctx context.Context, w io.Writer, since, until *time.Time) error {
_ = w
_ = since
_ = until
return errors.New("service: 审计 CSV 导出将在 M4 实现")
q := s.db.WithContext(ctx).Model(&model.AuditLog{})
if since != nil {
q = q.Where("created_at >= ?", *since)
}
if until != nil {
q = q.Where("created_at <= ?", *until)
}
var rows []model.AuditLog
if err := q.Order("id ASC").Find(&rows).Error; err != nil {
return err
}
return writeAuditCSV(w, rows)
}
// Archive 每日归档:将 before 之前的审计记录导出到 dir 后删除(PLAN F6
// "归档后可安全清理")。dir 为空时跳过并返回 0(防止未配置目录就丢审计)。
// 写文件失败时不删除任何记录(归档成功是清理的前提)。
func (s *AuditService) Archive(ctx context.Context, dir string, before time.Time) (int, error) {
if dir == "" {
s.log.Warn("audit: archive_dir 未配置,跳过归档与清理(防止丢审计)")
return 0, nil
}
var rows []model.AuditLog
if err := s.db.WithContext(ctx).Where("created_at < ?", before).Order("id ASC").Find(&rows).Error; err != nil {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return 0, err
}
// 按归档执行日期分文件,同日追加
name := "audit-" + time.Now().Format("2006-01-02") + ".csv"
f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
if err != nil {
return 0, err
}
if err := writeAuditCSV(f, rows); err != nil {
f.Close()
return 0, err
}
if err := f.Close(); err != nil {
return 0, err
}
// 归档成功后清理(append-only 约束:清理是运维动作,走 cron 每日任务)
if err := s.db.WithContext(ctx).Where("created_at < ?", before).Delete(&model.AuditLog{}).Error; err != nil {
return 0, err
}
s.log.Info("audit: archived", "file", name, "count", len(rows), "before", before.Format(time.RFC3339))
return len(rows), nil
}
// writeAuditCSV 以固定列序写审计记录(ExportCSV 与 Archive 共用)。
func writeAuditCSV(w io.Writer, rows []model.AuditLog) error {
// UTF-8 BOMExcel 识别中文
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return err
}
cw := csv.NewWriter(w)
header := []string{"id", "created_at", "actor_id", "actor_name", "action", "resource_type", "resource_id", "detail", "ip", "result"}
if err := cw.Write(header); err != nil {
return err
}
for _, r := range rows {
rec := []string{
strconv.FormatUint(uint64(r.ID), 10),
r.CreatedAt.Format(time.RFC3339),
strconv.FormatUint(uint64(r.ActorID), 10),
r.ActorName,
r.Action,
r.ResourceType,
r.ResourceID,
r.Detail,
r.IP,
r.Result,
}
if err := cw.Write(rec); err != nil {
return err
}
}
cw.Flush()
if err := cw.Error(); err != nil {
return err
}
// 追加模式归档时,BOM 会在文件中部重复,无害(解析器按首列内容处理)
return nil
}