user_cleanup.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "time"
  9. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  10. "github.com/google/uuid"
  11. "github.com/lib/pq"
  12. )
  13. const UserCleanupBatchLimit = 500
  14. var (
  15. ErrUserCleanupForbidden = infraerrors.Forbidden("USER_CLEANUP_FORBIDDEN", "Only an active administrator can manage user cleanup")
  16. ErrUserCleanupPreview = infraerrors.Conflict("USER_CLEANUP_PREVIEW_EXPIRED", "清理预览不存在或已过期,请重新预览")
  17. ErrUserCleanupConfirmation = infraerrors.BadRequest("USER_CLEANUP_CONFIRMATION_REQUIRED", "请确认本次预览中的用户数量后再清理")
  18. ErrUserCleanupBusy = infraerrors.Conflict("USER_CLEANUP_BUSY", "清理预览或用户数据正在处理,请保留本次预览稍后重试")
  19. )
  20. type UserCleanupActor struct {
  21. ID int64
  22. Email string
  23. Role string
  24. }
  25. type UserCleanupCandidate struct {
  26. ID int64 `json:"id"`
  27. Email string `json:"email"`
  28. Username string `json:"username"`
  29. Balance string `json:"balance"`
  30. LastUsedAt time.Time `json:"last_used_at"`
  31. CreatedAt time.Time `json:"created_at"`
  32. ZeroBalance bool `json:"zero_balance"`
  33. }
  34. type UserCleanupSummary struct {
  35. Total int `json:"total"`
  36. ZeroBalance int `json:"zero_balance"`
  37. NegativeBalance int `json:"negative_balance"`
  38. ServerTime time.Time `json:"server_time"`
  39. Cutoff time.Time `json:"cutoff"`
  40. BatchLimit int `json:"batch_limit"`
  41. }
  42. type UserCleanupPreview struct {
  43. UserCleanupSummary
  44. PreviewID string `json:"preview_id"`
  45. ExpiresAt time.Time `json:"expires_at"`
  46. Candidates []UserCleanupCandidate `json:"candidates"`
  47. }
  48. type UserCleanupResult struct {
  49. PreviewID string `json:"preview_id"`
  50. DeletedCount int `json:"deleted_count"`
  51. ZeroBalance int `json:"zero_balance"`
  52. NegativeBalance int `json:"negative_balance"`
  53. SkippedCount int `json:"skipped_count"`
  54. UserIDs []int64 `json:"user_ids"`
  55. CompletedAt time.Time `json:"completed_at"`
  56. Replayed bool `json:"replayed"`
  57. }
  58. type UserCleanupGuard struct {
  59. UserID int64 `json:"user_id"`
  60. IsSystem bool `json:"is_system"`
  61. IsProtected bool `json:"is_protected"`
  62. RoleProtected bool `json:"role_protected"`
  63. }
  64. type UserCleanupService struct {
  65. db *sql.DB
  66. invalidator APIKeyAuthCacheInvalidator
  67. }
  68. func NewUserCleanupService(db *sql.DB, invalidator APIKeyAuthCacheInvalidator) *UserCleanupService {
  69. return &UserCleanupService{db: db, invalidator: invalidator}
  70. }
  71. type userCleanupQuerier interface {
  72. QueryContext(context.Context, string, ...any) (*sql.Rows, error)
  73. QueryRowContext(context.Context, string, ...any) *sql.Row
  74. }
  75. func cleanupActor(ctx context.Context, q userCleanupQuerier, id int64) (*UserCleanupActor, error) {
  76. actor := &UserCleanupActor{ID: id}
  77. if id <= 0 {
  78. return nil, ErrUserCleanupForbidden
  79. }
  80. err := q.QueryRowContext(ctx, `SELECT email, role FROM users WHERE id=$1 AND deleted_at IS NULL AND status='active' AND role IN ('admin','super_admin') FOR SHARE`, id).Scan(&actor.Email, &actor.Role)
  81. if errors.Is(err, sql.ErrNoRows) {
  82. return nil, ErrUserCleanupForbidden
  83. }
  84. if err != nil {
  85. return nil, err
  86. }
  87. return actor, nil
  88. }
  89. func (s *UserCleanupService) Summary(ctx context.Context, actorID int64) (*UserCleanupSummary, error) {
  90. if _, err := cleanupActor(ctx, s.db, actorID); err != nil {
  91. return nil, err
  92. }
  93. return cleanupSummary(ctx, s.db)
  94. }
  95. func cleanupSummary(ctx context.Context, q userCleanupQuerier) (*UserCleanupSummary, error) {
  96. out := &UserCleanupSummary{BatchLimit: UserCleanupBatchLimit}
  97. err := q.QueryRowContext(ctx, `SELECT COUNT(*), COUNT(*) FILTER (WHERE u.balance=0), COUNT(*) FILTER (WHERE u.balance<0), NOW(), NOW()-INTERVAL '24 hours' FROM users u `+userCleanupPredicate).Scan(&out.Total, &out.ZeroBalance, &out.NegativeBalance, &out.ServerTime, &out.Cutoff)
  98. return out, err
  99. }
  100. func (s *UserCleanupService) Preview(ctx context.Context, actorID int64) (*UserCleanupPreview, error) {
  101. tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
  102. if err != nil {
  103. return nil, err
  104. }
  105. defer func() { _ = tx.Rollback() }()
  106. if _, err := cleanupActor(ctx, tx, actorID); err != nil {
  107. return nil, err
  108. }
  109. summary, err := cleanupSummary(ctx, tx)
  110. if err != nil {
  111. return nil, err
  112. }
  113. candidates, err := cleanupCandidates(ctx, tx, nil)
  114. if err != nil {
  115. return nil, err
  116. }
  117. out := &UserCleanupPreview{UserCleanupSummary: *summary, PreviewID: uuid.NewString(), Candidates: candidates}
  118. ids := make([]int64, 0, len(candidates))
  119. for _, candidate := range candidates {
  120. ids = append(ids, candidate.ID)
  121. }
  122. snapshot, err := json.Marshal(candidates)
  123. if err != nil {
  124. return nil, err
  125. }
  126. err = tx.QueryRowContext(ctx, `INSERT INTO user_cleanup_previews (id,actor_user_id,candidate_ids,candidate_snapshot) VALUES ($1,$2,$3,$4::jsonb) RETURNING expires_at`, out.PreviewID, actorID, pq.Array(ids), string(snapshot)).Scan(&out.ExpiresAt)
  127. if err != nil {
  128. return nil, err
  129. }
  130. if err := tx.Commit(); err != nil {
  131. return nil, err
  132. }
  133. return out, nil
  134. }
  135. func cleanupCandidates(ctx context.Context, q userCleanupQuerier, ids []int64) ([]UserCleanupCandidate, error) {
  136. query := `SELECT u.id, u.email, u.username, u.balance::text, (SELECT MAX(created_at) FROM usage_logs WHERE user_id=u.id), u.created_at, u.balance=0 FROM users u ` + userCleanupPredicate
  137. args := []any{}
  138. if ids != nil {
  139. query += ` AND u.id=ANY($1)`
  140. args = append(args, pq.Array(ids))
  141. }
  142. query += fmt.Sprintf(` ORDER BY u.id LIMIT %d`, UserCleanupBatchLimit)
  143. rows, err := q.QueryContext(ctx, query, args...)
  144. if err != nil {
  145. return nil, err
  146. }
  147. defer func() { _ = rows.Close() }()
  148. out := make([]UserCleanupCandidate, 0)
  149. for rows.Next() {
  150. var candidate UserCleanupCandidate
  151. if err := rows.Scan(&candidate.ID, &candidate.Email, &candidate.Username, &candidate.Balance, &candidate.LastUsedAt, &candidate.CreatedAt, &candidate.ZeroBalance); err != nil {
  152. return nil, err
  153. }
  154. out = append(out, candidate)
  155. }
  156. return out, rows.Err()
  157. }
  158. // Usage is the same persisted usage_logs.MAX(created_at) displayed by user management.
  159. // Missing history is never evidence of inactivity, including users whose logs were purged.
  160. // Recent registration/login/activity and unsettled assets add conservative protections.
  161. const userCleanupPredicate = `
  162. WHERE u.deleted_at IS NULL AND u.role='user'
  163. AND u.balance<=0 AND u.frozen_balance=0
  164. AND u.created_at < NOW()-INTERVAL '24 hours'
  165. AND (u.last_login_at IS NULL OR u.last_login_at < NOW()-INTERVAL '24 hours')
  166. AND (u.last_active_at IS NULL OR u.last_active_at < NOW()-INTERVAL '24 hours')
  167. AND (SELECT MAX(created_at) FROM usage_logs WHERE user_id=u.id) < NOW()-INTERVAL '24 hours'
  168. AND NOT EXISTS (SELECT 1 FROM api_keys k WHERE k.user_id=u.id AND k.last_used_at>=NOW()-INTERVAL '24 hours')
  169. AND NOT EXISTS (SELECT 1 FROM user_cleanup_guards g WHERE g.user_id=u.id AND (g.is_system OR g.is_protected))
  170. AND NOT EXISTS (SELECT 1 FROM user_subscriptions s WHERE s.user_id=u.id AND s.expires_at>NOW() AND s.status IN ('active','suspended'))
  171. AND NOT EXISTS (SELECT 1 FROM payment_orders p WHERE p.user_id=u.id AND ((p.status='PENDING' AND p.expires_at>NOW()) OR p.status IN ('PAID','RECHARGING','REFUNDING','REFUND_PENDING','REFUND_REQUESTED','REFUND_FAILED')))
  172. AND NOT EXISTS (SELECT 1 FROM batch_image_jobs b WHERE b.user_id=u.id AND (b.status NOT IN ('completed','failed','cancelled','output_deleted') OR (b.hold_amount>0 AND b.settled_at IS NULL)))
  173. `