You've already forked agentic-coding-workflow
165 lines
4.7 KiB
Go
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}
|
|
}
|