feat(audit): recorder with pg implementation and ip parsing

This commit is contained in:
2026-04-28 22:53:22 +08:00
parent 0b323b91ee
commit f42b88e57d
3 changed files with 129 additions and 9 deletions
+101
View File
@@ -0,0 +1,101 @@
// Package audit 提供登录、登出、关键管理动作等敏感事件的审计记录器。
// Recorder 接口与具体存储解耦,user 模块只依赖接口;NewPostgresRecorder
// 是默认实现,把每条 Entry 转成 audit_logs 行写入。
//
// IP 解析:调用方通常直接传 r.RemoteAddr(host:port 形式),Record 内部
// 用 net.SplitHostPort + netip.ParseAddr 拆解。解析失败时不阻断主流程,
// 只把 ip 列写为 NULL,避免审计写失败影响业务。
package audit
import (
"context"
"encoding/json"
"net"
"net/netip"
"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"
)
// Recorder 是审计记录的对外契约。Record 失败时返回 errs.AppError,
// 调用方决定是否吞错(user 模块通常吞错并仅记日志)。
type Recorder interface {
Record(ctx context.Context, e Entry) error
}
// Entry 描述一条待写入的审计事件。UserID 为 nil 表示无登录上下文(如系统
// 维护任务);Action 为 dot.notation 字符串("user.login" / "user.logout"
// 等),TargetType/TargetID 描述事件指向的对象。
type Entry struct {
UserID *uuid.UUID
Action string
TargetType string
TargetID string
IP string
Metadata map[string]any
}
type pgRecorder struct {
q *auditsqlc.Queries
}
// NewPostgresRecorder 用现有 pgxpool 构造 Recorder 实现。
func NewPostgresRecorder(pool *pgxpool.Pool) Recorder {
return &pgRecorder{q: auditsqlc.New(pool)}
}
// Record 把 Entry 落地为 audit_logs 一行。
func (r *pgRecorder) Record(ctx context.Context, e Entry) error {
var meta []byte
if e.Metadata != nil {
b, err := json.Marshal(e.Metadata)
if err != nil {
return errs.Wrap(err, errs.CodeInternal, "marshal audit metadata")
}
meta = b
}
var ip *netip.Addr
if e.IP != "" {
host, _, err := net.SplitHostPort(e.IP)
if err != nil {
host = e.IP
}
if a, err := netip.ParseAddr(host); err == nil {
ip = &a
}
}
if err := r.q.InsertAuditLog(ctx, auditsqlc.InsertAuditLogParams{
UserID: pgUUIDFromPtr(e.UserID),
Action: e.Action,
TargetType: ptr(e.TargetType),
TargetID: ptr(e.TargetID),
Ip: ip,
Metadata: meta,
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "insert audit")
}
return nil
}
// pgUUIDFromPtr 把可空的领域 *uuid.UUID 转为 sqlc 期望的 pgtype.UUID。
// nil 映射为 Valid=false(DB NULL),非 nil 映射为 Valid=true + Bytes。
func pgUUIDFromPtr(u *uuid.UUID) pgtype.UUID {
if u == nil {
return pgtype.UUID{Valid: false}
}
return pgtype.UUID{Bytes: *u, Valid: true}
}
// ptr 把空字符串映射为 nil,使数据库列写入 NULL;其它字符串原样取地址。
func ptr(s string) *string {
if s == "" {
return nil
}
return &s
}
+3 -2
View File
@@ -1,2 +1,3 @@
-- name: Ping :one -- name: InsertAuditLog :exec
SELECT 1::int AS ok; INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata)
VALUES ($1, $2, $3, $4, $5, $6);
+25 -7
View File
@@ -7,15 +7,33 @@ package auditsqlc
import ( import (
"context" "context"
"net/netip"
"github.com/jackc/pgx/v5/pgtype"
) )
const ping = `-- name: Ping :one const insertAuditLog = `-- name: InsertAuditLog :exec
SELECT 1::int AS ok INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
` `
func (q *Queries) Ping(ctx context.Context) (int32, error) { type InsertAuditLogParams struct {
row := q.db.QueryRow(ctx, ping) UserID pgtype.UUID `json:"user_id"`
var ok int32 Action string `json:"action"`
err := row.Scan(&ok) TargetType *string `json:"target_type"`
return ok, err TargetID *string `json:"target_id"`
Ip *netip.Addr `json:"ip"`
Metadata []byte `json:"metadata"`
}
func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error {
_, err := q.db.Exec(ctx, insertAuditLog,
arg.UserID,
arg.Action,
arg.TargetType,
arg.TargetID,
arg.Ip,
arg.Metadata,
)
return err
} }