- AuditFilter 增加 Result 字段,Query 按 result 精确过滤 - GET /audit 接口解析 result 查询参数(success/failed)
224 lines
6.5 KiB
Go
224 lines
6.5 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/csv"
|
||
"encoding/json"
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
|
||
"ws_usernode/internal/model"
|
||
)
|
||
|
||
// AuditService 审计服务:append-only 记录(业务代码仅允许 INSERT),
|
||
// 查询/CSV 导出/每日归档(PLAN F6)。
|
||
type AuditService struct {
|
||
db *gorm.DB
|
||
log *slog.Logger
|
||
}
|
||
|
||
// NewAuditService 创建审计服务。
|
||
func NewAuditService(db *gorm.DB, log *slog.Logger) *AuditService {
|
||
return &AuditService{db: db, log: log}
|
||
}
|
||
|
||
// Record 记录一条管理操作审计。detail 为任意结构体,入库前 JSON 序列化。
|
||
func (s *AuditService) Record(ctx context.Context, actorID uint, actorName, action, resourceType, resourceID string, detail any, ip, result string) error {
|
||
b, err := json.Marshal(detail)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
entry := model.AuditLog{
|
||
ActorID: actorID,
|
||
ActorName: actorName,
|
||
Action: action,
|
||
ResourceType: resourceType,
|
||
ResourceID: resourceID,
|
||
Detail: string(b),
|
||
IP: ip,
|
||
Result: result,
|
||
}
|
||
return s.db.WithContext(ctx).Create(&entry).Error
|
||
}
|
||
|
||
// AuditFilter 审计查询条件。
|
||
type AuditFilter struct {
|
||
ActorName string // 操作者模糊匹配
|
||
Action string // 动作精确匹配
|
||
ResourceType string // 资源类型(user / ssh_key / approval ...)
|
||
ResourceID string // 资源 ID
|
||
Result string // 结果(success / failed)
|
||
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.Result != "" {
|
||
q = q.Where("result = ?", f.Result)
|
||
}
|
||
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
|
||
}
|
||
|
||
// QueryByUser 查询与指定用户相关的审计记录(个人中心"我的审计痕迹",PLAN §8)。
|
||
// 匹配该用户作为操作者(actor_id 对用户会话)或作为操作对象(resource_type=user 且 resource_id 为数字 ID)。
|
||
func (s *AuditService) QueryByUser(ctx context.Context, userID uint, page, pageSize int) ([]model.AuditLog, int64, error) {
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 {
|
||
pageSize = 20
|
||
}
|
||
if pageSize > 200 {
|
||
pageSize = 200
|
||
}
|
||
q := s.db.WithContext(ctx).Model(&model.AuditLog{}).
|
||
Where("actor_id = ? OR (resource_type = ? AND resource_id = ?)", userID, "user", strconv.FormatUint(uint64(userID), 10))
|
||
var total int64
|
||
if err := q.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
var rows []model.AuditLog
|
||
if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).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 {
|
||
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
|
||
}
|