feat(M4): 生命周期 + 审计 — 到期锁定/回收 cron、审计查询/CSV 导出/每日归档、settings 动态策略
This commit is contained in:
+153
-10
@@ -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 BOM,Excel 直接打开不乱码。
|
||||
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 BOM:Excel 识别中文
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user