audit_log_repo.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "fmt"
  7. "strings"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/lib/pq"
  11. )
  12. // auditLogRepository 审计日志仓储(raw SQL,append-only)。
  13. // 刻意不实现单条删除:审计日志只允许追加、按保留期批量清理、以及带 2FA 的全量清空。
  14. type auditLogRepository struct {
  15. db *sql.DB
  16. }
  17. // NewAuditLogRepository 创建审计日志仓储。
  18. func NewAuditLogRepository(db *sql.DB) service.AuditLogRepository {
  19. return &auditLogRepository{db: db}
  20. }
  21. const auditLogInsertColumns = `created_at, actor_user_id, actor_email, actor_role, auth_method,
  22. credential_masked, action, method, path, request_id, client_ip, user_agent,
  23. request_body, status_code, latency_ms, extra`
  24. func auditLogInsertValues(log *service.AuditLog) []any {
  25. createdAt := log.CreatedAt
  26. if createdAt.IsZero() {
  27. createdAt = time.Now().UTC()
  28. }
  29. extraJSON := "{}"
  30. if len(log.Extra) > 0 {
  31. if encoded, err := json.Marshal(log.Extra); err == nil {
  32. extraJSON = string(encoded)
  33. }
  34. }
  35. return []any{
  36. createdAt.UTC(),
  37. nullInt64Ptr(log.ActorUserID),
  38. truncateString(log.ActorEmail, 255),
  39. truncateString(log.ActorRole, 32),
  40. truncateString(log.AuthMethod, 32),
  41. truncateString(log.CredentialMasked, 160),
  42. truncateString(log.Action, 128),
  43. truncateString(log.Method, 16),
  44. truncateString(log.Path, 512),
  45. truncateString(log.RequestID, 64),
  46. truncateString(log.ClientIP, 64),
  47. truncateString(log.UserAgent, 512),
  48. log.RequestBody,
  49. log.StatusCode,
  50. log.LatencyMs,
  51. extraJSON,
  52. }
  53. }
  54. func (r *auditLogRepository) BatchInsert(ctx context.Context, logs []*service.AuditLog) (int64, error) {
  55. if r == nil || r.db == nil {
  56. return 0, fmt.Errorf("nil audit log repository")
  57. }
  58. if len(logs) == 0 {
  59. return 0, nil
  60. }
  61. tx, err := r.db.BeginTx(ctx, nil)
  62. if err != nil {
  63. return 0, err
  64. }
  65. stmt, err := tx.PrepareContext(ctx, pq.CopyIn(
  66. "audit_logs",
  67. "created_at", "actor_user_id", "actor_email", "actor_role", "auth_method",
  68. "credential_masked", "action", "method", "path", "request_id", "client_ip", "user_agent",
  69. "request_body", "status_code", "latency_ms", "extra",
  70. ))
  71. if err != nil {
  72. _ = tx.Rollback()
  73. return 0, err
  74. }
  75. var inserted int64
  76. for _, log := range logs {
  77. if log == nil {
  78. continue
  79. }
  80. if _, err := stmt.ExecContext(ctx, auditLogInsertValues(log)...); err != nil {
  81. _ = stmt.Close()
  82. _ = tx.Rollback()
  83. return inserted, err
  84. }
  85. inserted++
  86. }
  87. if _, err := stmt.ExecContext(ctx); err != nil {
  88. _ = stmt.Close()
  89. _ = tx.Rollback()
  90. return inserted, err
  91. }
  92. if err := stmt.Close(); err != nil {
  93. _ = tx.Rollback()
  94. return inserted, err
  95. }
  96. if err := tx.Commit(); err != nil {
  97. return inserted, err
  98. }
  99. return inserted, nil
  100. }
  101. func (r *auditLogRepository) Insert(ctx context.Context, log *service.AuditLog) error {
  102. if r == nil || r.db == nil {
  103. return fmt.Errorf("nil audit log repository")
  104. }
  105. if log == nil {
  106. return fmt.Errorf("nil audit log")
  107. }
  108. query := `INSERT INTO audit_logs (` + auditLogInsertColumns + `)
  109. VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`
  110. _, err := r.db.ExecContext(ctx, query, auditLogInsertValues(log)...)
  111. return err
  112. }
  113. func buildAuditLogsWhere(filter *service.AuditLogFilter) (string, []any) {
  114. clauses := make([]string, 0, 10)
  115. args := make([]any, 0, 10)
  116. clauses = append(clauses, "1=1")
  117. if filter.StartTime != nil {
  118. args = append(args, filter.StartTime.UTC())
  119. clauses = append(clauses, "l.created_at >= $"+itoa(len(args)))
  120. }
  121. if filter.EndTime != nil {
  122. args = append(args, filter.EndTime.UTC())
  123. clauses = append(clauses, "l.created_at <= $"+itoa(len(args)))
  124. }
  125. if filter.ActorUserID != nil {
  126. args = append(args, *filter.ActorUserID)
  127. clauses = append(clauses, "l.actor_user_id = $"+itoa(len(args)))
  128. }
  129. if v := strings.TrimSpace(filter.ActorEmail); v != "" {
  130. args = append(args, "%"+escapeLikePattern(v)+"%")
  131. clauses = append(clauses, "l.actor_email ILIKE $"+itoa(len(args)))
  132. }
  133. if v := strings.TrimSpace(filter.AuthMethod); v != "" {
  134. args = append(args, v)
  135. clauses = append(clauses, "l.auth_method = $"+itoa(len(args)))
  136. }
  137. if v := strings.TrimSpace(filter.Action); v != "" {
  138. args = append(args, "%"+escapeLikePattern(v)+"%")
  139. clauses = append(clauses, "l.action ILIKE $"+itoa(len(args)))
  140. }
  141. if v := strings.TrimSpace(filter.Method); v != "" {
  142. args = append(args, strings.ToUpper(v))
  143. clauses = append(clauses, "l.method = $"+itoa(len(args)))
  144. }
  145. if v := strings.TrimSpace(filter.ClientIP); v != "" {
  146. args = append(args, v)
  147. clauses = append(clauses, "l.client_ip = $"+itoa(len(args)))
  148. }
  149. if filter.Success != nil {
  150. if *filter.Success {
  151. clauses = append(clauses, "l.status_code < 400")
  152. } else {
  153. clauses = append(clauses, "l.status_code >= 400")
  154. }
  155. }
  156. if v := strings.TrimSpace(filter.Query); v != "" {
  157. args = append(args, "%"+escapeLikePattern(v)+"%")
  158. idx := itoa(len(args))
  159. clauses = append(clauses, "(l.path ILIKE $"+idx+" OR l.action ILIKE $"+idx+" OR l.actor_email ILIKE $"+idx+")")
  160. }
  161. return "WHERE " + strings.Join(clauses, " AND "), args
  162. }
  163. const auditLogSelectColumns = `
  164. l.id,
  165. l.created_at,
  166. l.actor_user_id,
  167. COALESCE(l.actor_email, ''),
  168. COALESCE(l.actor_role, ''),
  169. COALESCE(l.auth_method, ''),
  170. COALESCE(l.credential_masked, ''),
  171. COALESCE(l.action, ''),
  172. COALESCE(l.method, ''),
  173. COALESCE(l.path, ''),
  174. COALESCE(l.request_id, ''),
  175. COALESCE(l.client_ip, ''),
  176. COALESCE(l.user_agent, ''),
  177. COALESCE(l.request_body, ''),
  178. l.status_code,
  179. l.latency_ms,
  180. COALESCE(l.extra::text, '{}')`
  181. func scanAuditLogRow(scan func(dest ...any) error) (*service.AuditLog, error) {
  182. item := &service.AuditLog{}
  183. var actorUserID sql.NullInt64
  184. var extraRaw string
  185. if err := scan(
  186. &item.ID,
  187. &item.CreatedAt,
  188. &actorUserID,
  189. &item.ActorEmail,
  190. &item.ActorRole,
  191. &item.AuthMethod,
  192. &item.CredentialMasked,
  193. &item.Action,
  194. &item.Method,
  195. &item.Path,
  196. &item.RequestID,
  197. &item.ClientIP,
  198. &item.UserAgent,
  199. &item.RequestBody,
  200. &item.StatusCode,
  201. &item.LatencyMs,
  202. &extraRaw,
  203. ); err != nil {
  204. return nil, err
  205. }
  206. if actorUserID.Valid {
  207. v := actorUserID.Int64
  208. item.ActorUserID = &v
  209. }
  210. extraRaw = strings.TrimSpace(extraRaw)
  211. if extraRaw != "" && extraRaw != "null" && extraRaw != "{}" {
  212. extra := make(map[string]any)
  213. if err := json.Unmarshal([]byte(extraRaw), &extra); err == nil {
  214. item.Extra = extra
  215. }
  216. }
  217. return item, nil
  218. }
  219. func (r *auditLogRepository) List(ctx context.Context, filter *service.AuditLogFilter) (*service.AuditLogList, error) {
  220. if r == nil || r.db == nil {
  221. return nil, fmt.Errorf("nil audit log repository")
  222. }
  223. if filter == nil {
  224. filter = &service.AuditLogFilter{}
  225. }
  226. page := filter.Page
  227. if page <= 0 {
  228. page = 1
  229. }
  230. pageSize := filter.PageSize
  231. if pageSize <= 0 {
  232. pageSize = 50
  233. }
  234. if pageSize > 200 {
  235. pageSize = 200
  236. }
  237. where, args := buildAuditLogsWhere(filter)
  238. countSQL := "SELECT COUNT(*) FROM audit_logs l " + where
  239. var total int
  240. if err := r.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil {
  241. return nil, err
  242. }
  243. offset := (page - 1) * pageSize
  244. argsWithLimit := append(args, pageSize, offset)
  245. query := "SELECT" + auditLogSelectColumns + "\nFROM audit_logs l\n" + where + `
  246. ORDER BY l.created_at DESC, l.id DESC
  247. LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
  248. rows, err := r.db.QueryContext(ctx, query, argsWithLimit...)
  249. if err != nil {
  250. return nil, err
  251. }
  252. defer func() { _ = rows.Close() }()
  253. logs := make([]*service.AuditLog, 0, pageSize)
  254. for rows.Next() {
  255. item, err := scanAuditLogRow(rows.Scan)
  256. if err != nil {
  257. return nil, err
  258. }
  259. // 列表页不返回 body,降低载荷;详情接口返回完整记录。
  260. item.RequestBody = ""
  261. logs = append(logs, item)
  262. }
  263. if err := rows.Err(); err != nil {
  264. return nil, err
  265. }
  266. return &service.AuditLogList{
  267. Logs: logs,
  268. Total: total,
  269. Page: page,
  270. PageSize: pageSize,
  271. }, nil
  272. }
  273. func (r *auditLogRepository) GetByID(ctx context.Context, id int64) (*service.AuditLog, error) {
  274. if r == nil || r.db == nil {
  275. return nil, fmt.Errorf("nil audit log repository")
  276. }
  277. query := "SELECT" + auditLogSelectColumns + "\nFROM audit_logs l WHERE l.id = $1"
  278. row := r.db.QueryRowContext(ctx, query, id)
  279. item, err := scanAuditLogRow(row.Scan)
  280. if err != nil {
  281. if err == sql.ErrNoRows {
  282. return nil, service.ErrAuditLogNotFound
  283. }
  284. return nil, err
  285. }
  286. return item, nil
  287. }
  288. func (r *auditLogRepository) Count(ctx context.Context) (int64, error) {
  289. if r == nil || r.db == nil {
  290. return 0, fmt.Errorf("nil audit log repository")
  291. }
  292. var total int64
  293. if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM audit_logs").Scan(&total); err != nil {
  294. return 0, err
  295. }
  296. return total, nil
  297. }
  298. func (r *auditLogRepository) TruncateAll(ctx context.Context) error {
  299. if r == nil || r.db == nil {
  300. return fmt.Errorf("nil audit log repository")
  301. }
  302. _, err := r.db.ExecContext(ctx, "TRUNCATE TABLE audit_logs")
  303. return err
  304. }
  305. func (r *auditLogRepository) DeleteBefore(ctx context.Context, cutoff time.Time, batchSize int) (int64, error) {
  306. if r == nil || r.db == nil {
  307. return 0, fmt.Errorf("nil audit log repository")
  308. }
  309. if batchSize <= 0 {
  310. batchSize = 5000
  311. }
  312. res, err := r.db.ExecContext(ctx, `
  313. WITH batch AS (
  314. SELECT id FROM audit_logs WHERE created_at < $1 ORDER BY id LIMIT $2
  315. )
  316. DELETE FROM audit_logs WHERE id IN (SELECT id FROM batch)`, cutoff.UTC(), batchSize)
  317. if err != nil {
  318. return 0, err
  319. }
  320. return res.RowsAffected()
  321. }
  322. func nullInt64Ptr(v *int64) any {
  323. if v == nil || *v <= 0 {
  324. return nil
  325. }
  326. return *v
  327. }
  328. func truncateString(s string, max int) string {
  329. s = strings.TrimSpace(s)
  330. if len(s) <= max {
  331. return s
  332. }
  333. // 按字节截断可能切断多字节字符,按 rune 处理。
  334. runes := []rune(s)
  335. for len(string(runes)) > max && len(runes) > 0 {
  336. runes = runes[:len(runes)-1]
  337. }
  338. return string(runes)
  339. }