Files
usernode/internal/service/audit.go
T
cao.wangrenbo 7340a3dd48 feat(api): 审计查询支持按结果筛选
- AuditFilter 增加 Result 字段,Query 按 result 精确过滤
- GET /audit 接口解析 result 查询参数(success/failed)
2026-08-30 17:27:49 +08:00

224 lines
6.5 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"
"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 BOMExcel 直接打开不乱码。
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 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
}