Files
2026-06-22 08:55:57 +08:00

165 lines
4.7 KiB
Go

// reader.go 提供审计日志的只读查询路径(spec §11 步骤4)。
//
// audit.Recorder 是 write-only 门面;Reader 是与之对称的 read 门面,供 admin
// 审计查看器(GET /api/v1/admin/audit-logs)与保留期清理 runner 使用。pgReader
// 复用 auditsqlc 生成的 List/Count/Delete 查询,并在 pgtype <-> 领域类型间转换。
package audit
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
auditsqlc "github.com/yan1h/agent-coding-workflow/internal/audit/sqlc"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// LogEntry 是一条已落库审计记录的只读领域视图。区别于写入用的 Entry:含 ID/
// 时间戳,IP 以字符串形式返回。
type LogEntry struct {
ID int64
UserID *uuid.UUID
Action string
TargetType string
TargetID string
IP string
Metadata map[string]any
CreatedAt time.Time
}
// AuditFilter 是 List/Count 的可选过滤条件。零值字段表示不过滤。Limit<=0 时由
// Reader 兜底为默认页大小;Limit 上限由 Reader 钳制。
type AuditFilter struct {
UserID *uuid.UUID
Action string // 精确匹配
ActionPrefix string // 前缀匹配(搜索用)
TargetType string
TargetID string
From *time.Time
To *time.Time
Limit int
Offset int
}
const (
// DefaultAuditPageSize 是未指定 Limit 时的默认页大小。
DefaultAuditPageSize = 50
// MaxAuditPageSize 是单页返回的硬上限,避免一次拉全表。
MaxAuditPageSize = 500
)
// Reader 是审计读取的对外契约:分页列表 + 总数 + 保留期清理。
type Reader interface {
List(ctx context.Context, f AuditFilter) ([]LogEntry, int, error)
PurgeBefore(ctx context.Context, before time.Time) (int64, error)
}
type pgReader struct {
q *auditsqlc.Queries
}
// NewPostgresReader 用现有 pgxpool 构造 Reader 实现。
func NewPostgresReader(pool *pgxpool.Pool) Reader {
return &pgReader{q: auditsqlc.New(pool)}
}
// List 返回符合过滤条件的审计记录页 + 满足条件的总数(用于分页)。
func (r *pgReader) List(ctx context.Context, f AuditFilter) ([]LogEntry, int, error) {
limit := f.Limit
if limit <= 0 {
limit = DefaultAuditPageSize
}
if limit > MaxAuditPageSize {
limit = MaxAuditPageSize
}
offset := f.Offset
if offset < 0 {
offset = 0
}
rows, err := r.q.ListAuditLog(ctx, auditsqlc.ListAuditLogParams{
Limit: int32(limit),
Offset: int32(offset),
UserID: pgUUIDFromPtr(f.UserID),
Action: ptr(f.Action),
ActionPrefix: ptr(f.ActionPrefix),
TargetType: ptr(f.TargetType),
TargetID: ptr(f.TargetID),
FromTs: tsFromPtr(f.From),
ToTs: tsFromPtr(f.To),
})
if err != nil {
return nil, 0, errs.Wrap(err, errs.CodeInternal, "list audit logs")
}
total, err := r.q.CountAuditLog(ctx, auditsqlc.CountAuditLogParams{
UserID: pgUUIDFromPtr(f.UserID),
Action: ptr(f.Action),
ActionPrefix: ptr(f.ActionPrefix),
TargetType: ptr(f.TargetType),
TargetID: ptr(f.TargetID),
FromTs: tsFromPtr(f.From),
ToTs: tsFromPtr(f.To),
})
if err != nil {
return nil, 0, errs.Wrap(err, errs.CodeInternal, "count audit logs")
}
out := make([]LogEntry, 0, len(rows))
for _, row := range rows {
out = append(out, rowToEntry(row))
}
return out, int(total), nil
}
// PurgeBefore 删除 created_at < before 的全部审计记录,返回删除行数。保留期 runner 调用。
func (r *pgReader) PurgeBefore(ctx context.Context, before time.Time) (int64, error) {
n, err := r.q.DeleteAuditLogsBefore(ctx, toPgTimestamptz(before))
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "purge audit logs")
}
return n, nil
}
func rowToEntry(row auditsqlc.AuditLog) LogEntry {
e := LogEntry{
ID: row.ID,
Action: row.Action,
CreatedAt: row.CreatedAt.Time,
}
if row.UserID.Valid {
u := uuid.UUID(row.UserID.Bytes)
e.UserID = &u
}
if row.TargetType != nil {
e.TargetType = *row.TargetType
}
if row.TargetID != nil {
e.TargetID = *row.TargetID
}
if row.Ip != nil {
e.IP = row.Ip.String()
}
if len(row.Metadata) > 0 {
_ = json.Unmarshal(row.Metadata, &e.Metadata)
}
return e
}
// tsFromPtr 把 *time.Time 转为 pgtype.Timestamptz;nil 映射为 NULL(不过滤)。
func tsFromPtr(t *time.Time) pgtype.Timestamptz {
if t == nil {
return pgtype.Timestamptz{Valid: false}
}
return pgtype.Timestamptz{Time: *t, Valid: true}
}
// toPgTimestamptz 把非空 time.Time 转为有效 Timestamptz。
func toPgTimestamptz(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: t, Valid: true}
}