| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- package service
- import (
- "context"
- "database/sql"
- "encoding/json"
- "errors"
- "strings"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/google/uuid"
- "github.com/lib/pq"
- )
- type UserCleanupAuditContext struct {
- AuthMethod string
- ClientIP string
- RequestID string
- Method string
- Path string
- }
- func (s *UserCleanupService) Execute(ctx context.Context, actorID int64, previewID string, confirmed bool, expectedCount int, audit UserCleanupAuditContext) (out *UserCleanupResult, err error) {
- defer func() {
- var pgErr *pq.Error
- if errors.As(err, &pgErr) && pgErr.Code == "55P03" {
- err = ErrUserCleanupBusy
- }
- }()
- if !confirmed || expectedCount < 1 || expectedCount > UserCleanupBatchLimit {
- return nil, ErrUserCleanupConfirmation
- }
- if _, err := uuid.Parse(previewID); err != nil {
- return nil, ErrUserCleanupPreview
- }
- tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
- if err != nil {
- return nil, err
- }
- defer func() { _ = tx.Rollback() }()
- // A concurrent retry can briefly wait for the same preview's persisted result.
- if _, err := tx.ExecContext(ctx, `SET LOCAL lock_timeout = '15s'`); err != nil {
- return nil, err
- }
- actor, err := cleanupActor(ctx, tx, actorID)
- if err != nil {
- return nil, err
- }
- var ids pq.Int64Array
- var cached []byte
- var valid bool
- 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)
- if errors.Is(err, sql.ErrNoRows) {
- return nil, ErrUserCleanupPreview
- }
- if err != nil {
- return nil, err
- }
- if len(ids) != expectedCount {
- return nil, ErrUserCleanupConfirmation
- }
- if len(cached) > 0 {
- var result UserCleanupResult
- if err := json.Unmarshal(cached, &result); err != nil {
- return nil, err
- }
- result.Replayed = true
- return &result, nil
- }
- if !valid {
- return nil, ErrUserCleanupPreview
- }
- // Financial/account row contention fails promptly and leaves the preview retryable.
- if _, err := tx.ExecContext(ctx, `SET LOCAL lock_timeout = '3s'`); err != nil {
- return nil, err
- }
- // This separate statement is essential: any balance/usage transaction already in
- // progress commits before the next READ COMMITTED eligibility snapshot is taken.
- // FOR UPDATE also conflicts with FK KEY SHARE locks taken by new usage_log rows.
- rows, err := tx.QueryContext(ctx, `SELECT id FROM users WHERE id=ANY($1) ORDER BY id FOR UPDATE`, ids)
- if err != nil {
- return nil, err
- }
- for rows.Next() {
- var id int64
- if err := rows.Scan(&id); err != nil {
- _ = rows.Close()
- return nil, err
- }
- }
- err = rows.Err()
- _ = rows.Close()
- if err != nil {
- return nil, err
- }
- // API authentication touches last_used_at before forwarding, including long
- // requests whose usage log is not yet written. Lock those rows before rechecking.
- keysByUser, err := cleanupLockKeys(ctx, tx, ids)
- if err != nil {
- return nil, err
- }
- candidates, err := cleanupCandidates(ctx, tx, ids)
- if err != nil {
- return nil, err
- }
- result := &UserCleanupResult{PreviewID: previewID, UserIDs: make([]int64, 0, len(candidates)), SkippedCount: len(ids) - len(candidates)}
- for _, candidate := range candidates {
- result.UserIDs = append(result.UserIDs, candidate.ID)
- if candidate.ZeroBalance {
- result.ZeroBalance++
- } else {
- result.NegativeBalance++
- }
- }
- result.DeletedCount = len(result.UserIDs)
- var cacheKeys []string
- for _, id := range result.UserIDs {
- cacheKeys = append(cacheKeys, keysByUser[id]...)
- }
- if len(result.UserIDs) > 0 {
- if err := cleanupArchiveIdentities(ctx, tx, result.UserIDs, previewID); err != nil {
- return nil, err
- }
- // Same credential-free tombstone semantics as APIKeyRepository.DeleteWithAudit.
- // Existing migration 184 additionally enqueues durable auth cache invalidations.
- 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 {
- return nil, err
- }
- 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 {
- return nil, err
- }
- }
- if err := tx.QueryRowContext(ctx, `SELECT NOW()`).Scan(&result.CompletedAt); err != nil {
- return nil, err
- }
- data, err := json.Marshal(result)
- if err != nil {
- return nil, err
- }
- 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 {
- return nil, err
- }
- if _, err := tx.ExecContext(ctx, `UPDATE user_cleanup_previews SET completed_at=NOW(),result=$2::jsonb WHERE id=$1`, previewID, string(data)); err != nil {
- return nil, err
- }
- if err := tx.Commit(); err != nil {
- return nil, err
- }
- if s.invalidator != nil {
- for _, key := range cacheKeys {
- s.invalidator.InvalidateAuthCacheByKey(ctx, key)
- }
- for _, id := range result.UserIDs {
- s.invalidator.InvalidateAuthCacheByUserID(ctx, id)
- }
- }
- return result, nil
- }
- func cleanupLockKeys(ctx context.Context, tx *sql.Tx, ids []int64) (map[int64][]string, error) {
- 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))
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- keys := make(map[int64][]string)
- for rows.Next() {
- var id int64
- var key string
- if err := rows.Scan(&id, &key); err != nil {
- return nil, err
- }
- // Keys are only held in memory for invalidation, never in audit/snapshots.
- keys[id] = append(keys[id], key)
- }
- return keys, rows.Err()
- }
- func (s *UserCleanupService) GetGuard(ctx context.Context, actorID, userID int64) (*UserCleanupGuard, error) {
- if _, err := cleanupActor(ctx, s.db, actorID); err != nil {
- return nil, err
- }
- return cleanupReadGuard(ctx, s.db, userID)
- }
- func cleanupReadGuard(ctx context.Context, q userCleanupQuerier, userID int64) (*UserCleanupGuard, error) {
- out := &UserCleanupGuard{UserID: userID}
- 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)
- if errors.Is(err, sql.ErrNoRows) {
- return nil, ErrUserNotFound
- }
- return out, err
- }
- func (s *UserCleanupService) UpdateGuard(ctx context.Context, actorID, userID int64, protected bool, system *bool, audit UserCleanupAuditContext) (*UserCleanupGuard, error) {
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return nil, err
- }
- defer func() { _ = tx.Rollback() }()
- actor, err := cleanupActor(ctx, tx, actorID)
- if err != nil {
- return nil, err
- }
- var role string
- if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&role); err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return nil, ErrUserNotFound
- }
- return nil, err
- }
- if role != "user" {
- return nil, infraerrors.Forbidden("USER_CLEANUP_ROLE_PROTECTED", "管理与系统角色已自动受保护")
- }
- before, err := cleanupReadGuard(ctx, tx, userID)
- if err != nil {
- return nil, err
- }
- nextSystem := before.IsSystem
- if system != nil && *system != before.IsSystem {
- if actor.Role != "super_admin" {
- return nil, infraerrors.Forbidden("USER_CLEANUP_SYSTEM_GUARD", "Only a super administrator can change system account protection")
- }
- nextSystem = *system
- }
- 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 {
- return nil, err
- }
- 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 {
- return nil, err
- }
- if err := tx.Commit(); err != nil {
- return nil, err
- }
- return &UserCleanupGuard{UserID: userID, IsSystem: nextSystem, IsProtected: protected}, nil
- }
- func cleanupWriteAudit(ctx context.Context, tx *sql.Tx, actor *UserCleanupActor, action string, audit UserCleanupAuditContext, extra map[string]any) error {
- data, err := json.Marshal(extra)
- if err != nil {
- return err
- }
- _, 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))
- return err
- }
- func cleanupLimit(value string, limit int) string {
- value = strings.TrimSpace(value)
- if len(value) > limit {
- return value[:limit]
- }
- return value
- }
|