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 }