user_cleanup_execute.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "strings"
  8. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  9. "github.com/google/uuid"
  10. "github.com/lib/pq"
  11. )
  12. type UserCleanupAuditContext struct {
  13. AuthMethod string
  14. ClientIP string
  15. RequestID string
  16. Method string
  17. Path string
  18. }
  19. func (s *UserCleanupService) Execute(ctx context.Context, actorID int64, previewID string, confirmed bool, expectedCount int, audit UserCleanupAuditContext) (out *UserCleanupResult, err error) {
  20. defer func() {
  21. var pgErr *pq.Error
  22. if errors.As(err, &pgErr) && pgErr.Code == "55P03" {
  23. err = ErrUserCleanupBusy
  24. }
  25. }()
  26. if !confirmed || expectedCount < 1 || expectedCount > UserCleanupBatchLimit {
  27. return nil, ErrUserCleanupConfirmation
  28. }
  29. if _, err := uuid.Parse(previewID); err != nil {
  30. return nil, ErrUserCleanupPreview
  31. }
  32. tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
  33. if err != nil {
  34. return nil, err
  35. }
  36. defer func() { _ = tx.Rollback() }()
  37. // A concurrent retry can briefly wait for the same preview's persisted result.
  38. if _, err := tx.ExecContext(ctx, `SET LOCAL lock_timeout = '15s'`); err != nil {
  39. return nil, err
  40. }
  41. actor, err := cleanupActor(ctx, tx, actorID)
  42. if err != nil {
  43. return nil, err
  44. }
  45. var ids pq.Int64Array
  46. var cached []byte
  47. var valid bool
  48. err = tx.QueryRowContext(ctx, `SELECT candidate_ids,result,expires_at>NOW() FROM user_cleanup_previews WHERE id=$1 AND actor_user_id=$2 FOR UPDATE`, previewID, actorID).Scan(&ids, &cached, &valid)
  49. if errors.Is(err, sql.ErrNoRows) {
  50. return nil, ErrUserCleanupPreview
  51. }
  52. if err != nil {
  53. return nil, err
  54. }
  55. if len(ids) != expectedCount {
  56. return nil, ErrUserCleanupConfirmation
  57. }
  58. if len(cached) > 0 {
  59. var result UserCleanupResult
  60. if err := json.Unmarshal(cached, &result); err != nil {
  61. return nil, err
  62. }
  63. result.Replayed = true
  64. return &result, nil
  65. }
  66. if !valid {
  67. return nil, ErrUserCleanupPreview
  68. }
  69. // Financial/account row contention fails promptly and leaves the preview retryable.
  70. if _, err := tx.ExecContext(ctx, `SET LOCAL lock_timeout = '3s'`); err != nil {
  71. return nil, err
  72. }
  73. // This separate statement is essential: any balance/usage transaction already in
  74. // progress commits before the next READ COMMITTED eligibility snapshot is taken.
  75. // FOR UPDATE also conflicts with FK KEY SHARE locks taken by new usage_log rows.
  76. rows, err := tx.QueryContext(ctx, `SELECT id FROM users WHERE id=ANY($1) ORDER BY id FOR UPDATE`, ids)
  77. if err != nil {
  78. return nil, err
  79. }
  80. for rows.Next() {
  81. var id int64
  82. if err := rows.Scan(&id); err != nil {
  83. _ = rows.Close()
  84. return nil, err
  85. }
  86. }
  87. err = rows.Err()
  88. _ = rows.Close()
  89. if err != nil {
  90. return nil, err
  91. }
  92. // API authentication touches last_used_at before forwarding, including long
  93. // requests whose usage log is not yet written. Lock those rows before rechecking.
  94. keysByUser, err := cleanupLockKeys(ctx, tx, ids)
  95. if err != nil {
  96. return nil, err
  97. }
  98. candidates, err := cleanupCandidates(ctx, tx, ids)
  99. if err != nil {
  100. return nil, err
  101. }
  102. result := &UserCleanupResult{PreviewID: previewID, UserIDs: make([]int64, 0, len(candidates)), SkippedCount: len(ids) - len(candidates)}
  103. for _, candidate := range candidates {
  104. result.UserIDs = append(result.UserIDs, candidate.ID)
  105. if candidate.ZeroBalance {
  106. result.ZeroBalance++
  107. } else {
  108. result.NegativeBalance++
  109. }
  110. }
  111. result.DeletedCount = len(result.UserIDs)
  112. var cacheKeys []string
  113. for _, id := range result.UserIDs {
  114. cacheKeys = append(cacheKeys, keysByUser[id]...)
  115. }
  116. if len(result.UserIDs) > 0 {
  117. if err := cleanupArchiveIdentities(ctx, tx, result.UserIDs, previewID); err != nil {
  118. return nil, err
  119. }
  120. // Same credential-free tombstone semantics as APIKeyRepository.DeleteWithAudit.
  121. // Existing migration 184 additionally enqueues durable auth cache invalidations.
  122. if _, err := tx.ExecContext(ctx, `UPDATE api_keys SET key='__deleted__cleanup__'||id::text||'__'||$2,deleted_at=NOW(),updated_at=NOW() WHERE user_id=ANY($1) AND deleted_at IS NULL`, pq.Array(result.UserIDs), previewID); err != nil {
  123. return nil, err
  124. }
  125. if _, err := tx.ExecContext(ctx, `UPDATE users SET deleted_at=NOW(),updated_at=NOW(),status='disabled' WHERE id=ANY($1) AND deleted_at IS NULL`, pq.Array(result.UserIDs)); err != nil {
  126. return nil, err
  127. }
  128. }
  129. if err := tx.QueryRowContext(ctx, `SELECT NOW()`).Scan(&result.CompletedAt); err != nil {
  130. return nil, err
  131. }
  132. data, err := json.Marshal(result)
  133. if err != nil {
  134. return nil, err
  135. }
  136. if err := cleanupWriteAudit(ctx, tx, actor, "admin.users.cleanup", audit, map[string]any{"preview_id": previewID, "deleted_count": result.DeletedCount, "zero_balance": result.ZeroBalance, "negative_balance": result.NegativeBalance, "skipped_count": result.SkippedCount, "user_ids": result.UserIDs}); err != nil {
  137. return nil, err
  138. }
  139. if _, err := tx.ExecContext(ctx, `UPDATE user_cleanup_previews SET completed_at=NOW(),result=$2::jsonb WHERE id=$1`, previewID, string(data)); err != nil {
  140. return nil, err
  141. }
  142. if err := tx.Commit(); err != nil {
  143. return nil, err
  144. }
  145. if s.invalidator != nil {
  146. for _, key := range cacheKeys {
  147. s.invalidator.InvalidateAuthCacheByKey(ctx, key)
  148. }
  149. for _, id := range result.UserIDs {
  150. s.invalidator.InvalidateAuthCacheByUserID(ctx, id)
  151. }
  152. }
  153. return result, nil
  154. }
  155. func cleanupLockKeys(ctx context.Context, tx *sql.Tx, ids []int64) (map[int64][]string, error) {
  156. rows, err := tx.QueryContext(ctx, `SELECT user_id,key FROM api_keys WHERE user_id=ANY($1) ORDER BY id FOR UPDATE`, pq.Array(ids))
  157. if err != nil {
  158. return nil, err
  159. }
  160. defer func() { _ = rows.Close() }()
  161. keys := make(map[int64][]string)
  162. for rows.Next() {
  163. var id int64
  164. var key string
  165. if err := rows.Scan(&id, &key); err != nil {
  166. return nil, err
  167. }
  168. // Keys are only held in memory for invalidation, never in audit/snapshots.
  169. keys[id] = append(keys[id], key)
  170. }
  171. return keys, rows.Err()
  172. }
  173. func (s *UserCleanupService) GetGuard(ctx context.Context, actorID, userID int64) (*UserCleanupGuard, error) {
  174. if _, err := cleanupActor(ctx, s.db, actorID); err != nil {
  175. return nil, err
  176. }
  177. return cleanupReadGuard(ctx, s.db, userID)
  178. }
  179. func cleanupReadGuard(ctx context.Context, q userCleanupQuerier, userID int64) (*UserCleanupGuard, error) {
  180. out := &UserCleanupGuard{UserID: userID}
  181. err := q.QueryRowContext(ctx, `SELECT COALESCE(g.is_system,FALSE),COALESCE(g.is_protected,FALSE),u.role<>'user' FROM users u LEFT JOIN user_cleanup_guards g ON g.user_id=u.id WHERE u.id=$1 AND u.deleted_at IS NULL`, userID).Scan(&out.IsSystem, &out.IsProtected, &out.RoleProtected)
  182. if errors.Is(err, sql.ErrNoRows) {
  183. return nil, ErrUserNotFound
  184. }
  185. return out, err
  186. }
  187. func (s *UserCleanupService) UpdateGuard(ctx context.Context, actorID, userID int64, protected bool, system *bool, audit UserCleanupAuditContext) (*UserCleanupGuard, error) {
  188. tx, err := s.db.BeginTx(ctx, nil)
  189. if err != nil {
  190. return nil, err
  191. }
  192. defer func() { _ = tx.Rollback() }()
  193. actor, err := cleanupActor(ctx, tx, actorID)
  194. if err != nil {
  195. return nil, err
  196. }
  197. var role string
  198. if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&role); err != nil {
  199. if errors.Is(err, sql.ErrNoRows) {
  200. return nil, ErrUserNotFound
  201. }
  202. return nil, err
  203. }
  204. if role != "user" {
  205. return nil, infraerrors.Forbidden("USER_CLEANUP_ROLE_PROTECTED", "管理与系统角色已自动受保护")
  206. }
  207. before, err := cleanupReadGuard(ctx, tx, userID)
  208. if err != nil {
  209. return nil, err
  210. }
  211. nextSystem := before.IsSystem
  212. if system != nil && *system != before.IsSystem {
  213. if actor.Role != "super_admin" {
  214. return nil, infraerrors.Forbidden("USER_CLEANUP_SYSTEM_GUARD", "Only a super administrator can change system account protection")
  215. }
  216. nextSystem = *system
  217. }
  218. if _, err := tx.ExecContext(ctx, `INSERT INTO user_cleanup_guards(user_id,is_system,is_protected,updated_by) VALUES ($1,$2,$3,$4) ON CONFLICT(user_id) DO UPDATE SET is_system=EXCLUDED.is_system,is_protected=EXCLUDED.is_protected,updated_by=EXCLUDED.updated_by,updated_at=NOW()`, userID, nextSystem, protected, actor.ID); err != nil {
  219. return nil, err
  220. }
  221. if err := cleanupWriteAudit(ctx, tx, actor, "admin.users.cleanup_guard.update", audit, map[string]any{"user_id": userID, "before": before, "is_system": nextSystem, "is_protected": protected}); err != nil {
  222. return nil, err
  223. }
  224. if err := tx.Commit(); err != nil {
  225. return nil, err
  226. }
  227. return &UserCleanupGuard{UserID: userID, IsSystem: nextSystem, IsProtected: protected}, nil
  228. }
  229. func cleanupWriteAudit(ctx context.Context, tx *sql.Tx, actor *UserCleanupActor, action string, audit UserCleanupAuditContext, extra map[string]any) error {
  230. data, err := json.Marshal(extra)
  231. if err != nil {
  232. return err
  233. }
  234. _, err = tx.ExecContext(ctx, `INSERT INTO audit_logs(actor_user_id,actor_email,actor_role,auth_method,action,method,path,request_id,client_ip,status_code,extra) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,200,$10::jsonb)`, actor.ID, actor.Email, actor.Role, cleanupLimit(audit.AuthMethod, 32), action, cleanupLimit(audit.Method, 16), cleanupLimit(audit.Path, 512), cleanupLimit(audit.RequestID, 64), cleanupLimit(audit.ClientIP, 64), string(data))
  235. return err
  236. }
  237. func cleanupLimit(value string, limit int) string {
  238. value = strings.TrimSpace(value)
  239. if len(value) > limit {
  240. return value[:limit]
  241. }
  242. return value
  243. }