| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- package repository
- import (
- "context"
- "database/sql"
- "encoding/json"
- "fmt"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/lib/pq"
- )
- // auditLogRepository 审计日志仓储(raw SQL,append-only)。
- // 刻意不实现单条删除:审计日志只允许追加、按保留期批量清理、以及带 2FA 的全量清空。
- type auditLogRepository struct {
- db *sql.DB
- }
- // NewAuditLogRepository 创建审计日志仓储。
- func NewAuditLogRepository(db *sql.DB) service.AuditLogRepository {
- return &auditLogRepository{db: db}
- }
- const auditLogInsertColumns = `created_at, actor_user_id, actor_email, actor_role, auth_method,
- credential_masked, action, method, path, request_id, client_ip, user_agent,
- request_body, status_code, latency_ms, extra`
- func auditLogInsertValues(log *service.AuditLog) []any {
- createdAt := log.CreatedAt
- if createdAt.IsZero() {
- createdAt = time.Now().UTC()
- }
- extraJSON := "{}"
- if len(log.Extra) > 0 {
- if encoded, err := json.Marshal(log.Extra); err == nil {
- extraJSON = string(encoded)
- }
- }
- return []any{
- createdAt.UTC(),
- nullInt64Ptr(log.ActorUserID),
- truncateString(log.ActorEmail, 255),
- truncateString(log.ActorRole, 32),
- truncateString(log.AuthMethod, 32),
- truncateString(log.CredentialMasked, 160),
- truncateString(log.Action, 128),
- truncateString(log.Method, 16),
- truncateString(log.Path, 512),
- truncateString(log.RequestID, 64),
- truncateString(log.ClientIP, 64),
- truncateString(log.UserAgent, 512),
- log.RequestBody,
- log.StatusCode,
- log.LatencyMs,
- extraJSON,
- }
- }
- func (r *auditLogRepository) BatchInsert(ctx context.Context, logs []*service.AuditLog) (int64, error) {
- if r == nil || r.db == nil {
- return 0, fmt.Errorf("nil audit log repository")
- }
- if len(logs) == 0 {
- return 0, nil
- }
- tx, err := r.db.BeginTx(ctx, nil)
- if err != nil {
- return 0, err
- }
- stmt, err := tx.PrepareContext(ctx, pq.CopyIn(
- "audit_logs",
- "created_at", "actor_user_id", "actor_email", "actor_role", "auth_method",
- "credential_masked", "action", "method", "path", "request_id", "client_ip", "user_agent",
- "request_body", "status_code", "latency_ms", "extra",
- ))
- if err != nil {
- _ = tx.Rollback()
- return 0, err
- }
- var inserted int64
- for _, log := range logs {
- if log == nil {
- continue
- }
- if _, err := stmt.ExecContext(ctx, auditLogInsertValues(log)...); err != nil {
- _ = stmt.Close()
- _ = tx.Rollback()
- return inserted, err
- }
- inserted++
- }
- if _, err := stmt.ExecContext(ctx); err != nil {
- _ = stmt.Close()
- _ = tx.Rollback()
- return inserted, err
- }
- if err := stmt.Close(); err != nil {
- _ = tx.Rollback()
- return inserted, err
- }
- if err := tx.Commit(); err != nil {
- return inserted, err
- }
- return inserted, nil
- }
- func (r *auditLogRepository) Insert(ctx context.Context, log *service.AuditLog) error {
- if r == nil || r.db == nil {
- return fmt.Errorf("nil audit log repository")
- }
- if log == nil {
- return fmt.Errorf("nil audit log")
- }
- query := `INSERT INTO audit_logs (` + auditLogInsertColumns + `)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`
- _, err := r.db.ExecContext(ctx, query, auditLogInsertValues(log)...)
- return err
- }
- func buildAuditLogsWhere(filter *service.AuditLogFilter) (string, []any) {
- clauses := make([]string, 0, 10)
- args := make([]any, 0, 10)
- clauses = append(clauses, "1=1")
- if filter.StartTime != nil {
- args = append(args, filter.StartTime.UTC())
- clauses = append(clauses, "l.created_at >= $"+itoa(len(args)))
- }
- if filter.EndTime != nil {
- args = append(args, filter.EndTime.UTC())
- clauses = append(clauses, "l.created_at <= $"+itoa(len(args)))
- }
- if filter.ActorUserID != nil {
- args = append(args, *filter.ActorUserID)
- clauses = append(clauses, "l.actor_user_id = $"+itoa(len(args)))
- }
- if v := strings.TrimSpace(filter.ActorEmail); v != "" {
- args = append(args, "%"+escapeLikePattern(v)+"%")
- clauses = append(clauses, "l.actor_email ILIKE $"+itoa(len(args)))
- }
- if v := strings.TrimSpace(filter.AuthMethod); v != "" {
- args = append(args, v)
- clauses = append(clauses, "l.auth_method = $"+itoa(len(args)))
- }
- if v := strings.TrimSpace(filter.Action); v != "" {
- args = append(args, "%"+escapeLikePattern(v)+"%")
- clauses = append(clauses, "l.action ILIKE $"+itoa(len(args)))
- }
- if v := strings.TrimSpace(filter.Method); v != "" {
- args = append(args, strings.ToUpper(v))
- clauses = append(clauses, "l.method = $"+itoa(len(args)))
- }
- if v := strings.TrimSpace(filter.ClientIP); v != "" {
- args = append(args, v)
- clauses = append(clauses, "l.client_ip = $"+itoa(len(args)))
- }
- if filter.Success != nil {
- if *filter.Success {
- clauses = append(clauses, "l.status_code < 400")
- } else {
- clauses = append(clauses, "l.status_code >= 400")
- }
- }
- if v := strings.TrimSpace(filter.Query); v != "" {
- args = append(args, "%"+escapeLikePattern(v)+"%")
- idx := itoa(len(args))
- clauses = append(clauses, "(l.path ILIKE $"+idx+" OR l.action ILIKE $"+idx+" OR l.actor_email ILIKE $"+idx+")")
- }
- return "WHERE " + strings.Join(clauses, " AND "), args
- }
- const auditLogSelectColumns = `
- l.id,
- l.created_at,
- l.actor_user_id,
- COALESCE(l.actor_email, ''),
- COALESCE(l.actor_role, ''),
- COALESCE(l.auth_method, ''),
- COALESCE(l.credential_masked, ''),
- COALESCE(l.action, ''),
- COALESCE(l.method, ''),
- COALESCE(l.path, ''),
- COALESCE(l.request_id, ''),
- COALESCE(l.client_ip, ''),
- COALESCE(l.user_agent, ''),
- COALESCE(l.request_body, ''),
- l.status_code,
- l.latency_ms,
- COALESCE(l.extra::text, '{}')`
- func scanAuditLogRow(scan func(dest ...any) error) (*service.AuditLog, error) {
- item := &service.AuditLog{}
- var actorUserID sql.NullInt64
- var extraRaw string
- if err := scan(
- &item.ID,
- &item.CreatedAt,
- &actorUserID,
- &item.ActorEmail,
- &item.ActorRole,
- &item.AuthMethod,
- &item.CredentialMasked,
- &item.Action,
- &item.Method,
- &item.Path,
- &item.RequestID,
- &item.ClientIP,
- &item.UserAgent,
- &item.RequestBody,
- &item.StatusCode,
- &item.LatencyMs,
- &extraRaw,
- ); err != nil {
- return nil, err
- }
- if actorUserID.Valid {
- v := actorUserID.Int64
- item.ActorUserID = &v
- }
- extraRaw = strings.TrimSpace(extraRaw)
- if extraRaw != "" && extraRaw != "null" && extraRaw != "{}" {
- extra := make(map[string]any)
- if err := json.Unmarshal([]byte(extraRaw), &extra); err == nil {
- item.Extra = extra
- }
- }
- return item, nil
- }
- func (r *auditLogRepository) List(ctx context.Context, filter *service.AuditLogFilter) (*service.AuditLogList, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil audit log repository")
- }
- if filter == nil {
- filter = &service.AuditLogFilter{}
- }
- page := filter.Page
- if page <= 0 {
- page = 1
- }
- pageSize := filter.PageSize
- if pageSize <= 0 {
- pageSize = 50
- }
- if pageSize > 200 {
- pageSize = 200
- }
- where, args := buildAuditLogsWhere(filter)
- countSQL := "SELECT COUNT(*) FROM audit_logs l " + where
- var total int
- if err := r.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil {
- return nil, err
- }
- offset := (page - 1) * pageSize
- argsWithLimit := append(args, pageSize, offset)
- query := "SELECT" + auditLogSelectColumns + "\nFROM audit_logs l\n" + where + `
- ORDER BY l.created_at DESC, l.id DESC
- LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
- rows, err := r.db.QueryContext(ctx, query, argsWithLimit...)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- logs := make([]*service.AuditLog, 0, pageSize)
- for rows.Next() {
- item, err := scanAuditLogRow(rows.Scan)
- if err != nil {
- return nil, err
- }
- // 列表页不返回 body,降低载荷;详情接口返回完整记录。
- item.RequestBody = ""
- logs = append(logs, item)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return &service.AuditLogList{
- Logs: logs,
- Total: total,
- Page: page,
- PageSize: pageSize,
- }, nil
- }
- func (r *auditLogRepository) GetByID(ctx context.Context, id int64) (*service.AuditLog, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil audit log repository")
- }
- query := "SELECT" + auditLogSelectColumns + "\nFROM audit_logs l WHERE l.id = $1"
- row := r.db.QueryRowContext(ctx, query, id)
- item, err := scanAuditLogRow(row.Scan)
- if err != nil {
- if err == sql.ErrNoRows {
- return nil, service.ErrAuditLogNotFound
- }
- return nil, err
- }
- return item, nil
- }
- func (r *auditLogRepository) Count(ctx context.Context) (int64, error) {
- if r == nil || r.db == nil {
- return 0, fmt.Errorf("nil audit log repository")
- }
- var total int64
- if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM audit_logs").Scan(&total); err != nil {
- return 0, err
- }
- return total, nil
- }
- func (r *auditLogRepository) TruncateAll(ctx context.Context) error {
- if r == nil || r.db == nil {
- return fmt.Errorf("nil audit log repository")
- }
- _, err := r.db.ExecContext(ctx, "TRUNCATE TABLE audit_logs")
- return err
- }
- func (r *auditLogRepository) DeleteBefore(ctx context.Context, cutoff time.Time, batchSize int) (int64, error) {
- if r == nil || r.db == nil {
- return 0, fmt.Errorf("nil audit log repository")
- }
- if batchSize <= 0 {
- batchSize = 5000
- }
- res, err := r.db.ExecContext(ctx, `
- WITH batch AS (
- SELECT id FROM audit_logs WHERE created_at < $1 ORDER BY id LIMIT $2
- )
- DELETE FROM audit_logs WHERE id IN (SELECT id FROM batch)`, cutoff.UTC(), batchSize)
- if err != nil {
- return 0, err
- }
- return res.RowsAffected()
- }
- func nullInt64Ptr(v *int64) any {
- if v == nil || *v <= 0 {
- return nil
- }
- return *v
- }
- func truncateString(s string, max int) string {
- s = strings.TrimSpace(s)
- if len(s) <= max {
- return s
- }
- // 按字节截断可能切断多字节字符,按 rune 处理。
- runes := []rune(s)
- for len(string(runes)) > max && len(runes) > 0 {
- runes = runes[:len(runes)-1]
- }
- return string(runes)
- }
|