// 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} }