[表里的会员列, 对应的 users 列] */ private const TABLES = [ 'balance_logs' => ['member_id', 'member_id'], 'payment_orders' => ['member_id', 'member_id'], 'withdraws' => ['member_id', 'member_id'], 'recharges' => ['member_id', 'member_id'], 'wallet_freeze_records' => ['member_id', 'member_id'], 'wager_records' => ['member_id', 'member_id'], 'lhc_order_item' => ['member_id', 'member_id'], 'lhc_order' => ['member_id', 'member_id'], 'sport_game_order' => ['member_id', 'member_id'], 'jisu_game_order' => ['member_id', 'member_id'], 'bets' => ['member_id', 'member_id'], 'backflow' => ['member_id', 'member_id'], 'rebates' => ['member_id', 'member_id'], 'activity_user' => ['member_id', 'member_id'], 'sport_order' => ['user_id', 'user_id'], 'user_login' => ['user_id', 'user_id'], 'user_session' => ['user_id', 'user_id'], 'yuebao_item' => ['user_id', 'user_id'], 'share_bet_facts' => ['user_id', 'id'], 'third_game_orders' => ['user_id', 'id'], 'reporting_orders' => ['user_id', 'id'], 'reporting_events' => ['user_id', 'id'], 'reporting_contexts' => ['user_id', 'id'], 'reporting_activity' => ['user_id', 'id'], 'member_daily_reports' => ['user_id', 'id'], 'wallets' => ['member_id', 'member_id'], ]; private static function scoped(string $table) { [$column, $userColumn] = self::TABLES[$table]; return DB::table($table)->whereIn($column, MemberAudience::simulatedMembers($userColumn)); } private static function tables(): array { return array_values(array_filter(array_keys(self::TABLES), fn ($table) => Schema::hasTable($table) && Schema::hasColumn($table, self::TABLES[$table][0]))); } private static function assertSafe(): void { if (!MemberAudience::enabled()) throw new Exception('用户类型字段尚未迁移', HttpStatus::CUSTOM_ERROR); if (DB::table('simulation_task_runs')->whereIn('status', ['queued', 'running', 'paused'])->exists()) { throw new Exception('还有未结束的自动任务,请先停止', HttpStatus::CUSTOM_ERROR); } // 模拟会员一定是 from = 2 且没有代理;出现别的情况说明 user_type 被误标,拒绝批量删除 $odd = DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED) ->where(fn ($q) => $q->where('from', '<>', '2')->orWhereNull('from')->orWhereNotNull('agent_id'))->count(); if ($odd > 0) throw new Exception("有 {$odd} 个标记为模拟的会员不符合模拟会员特征(来源或代理异常),已中止,请人工核查", HttpStatus::CUSTOM_ERROR); } /** 预览将删除的行数,并发放一次性确认口令(10 分钟有效)。 */ public static function preview(): array { self::assertSafe(); $counts = ['users' => DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->count()]; foreach (self::tables() as $table) $counts[$table] = self::scoped($table)->count(); $counts['simulation_task_runs'] = DB::table('simulation_task_runs')->count(); $counts['simulation_task_events'] = DB::table('simulation_task_events')->count(); $token = bin2hex(random_bytes(16)); Cache::put(self::TOKEN_KEY, $token, 600); return ['counts' => $counts, 'confirm_token' => $token, 'confirm_text' => self::CONFIRM_TEXT]; } public static function reset(string $token, string $text): array { if ($text !== self::CONFIRM_TEXT) throw new Exception('确认短语不正确', HttpStatus::CUSTOM_ERROR); $expected = Cache::pull(self::TOKEN_KEY); if (!$expected || !hash_equals((string) $expected, $token)) throw new Exception('确认口令已失效,请重新预览', HttpStatus::CUSTOM_ERROR); self::assertSafe(); $deleted = []; DB::transaction(function () use (&$deleted) { foreach (self::tables() as $table) $deleted[$table] = self::scoped($table)->delete(); $deleted['simulation_task_events'] = DB::table('simulation_task_events')->delete(); $deleted['simulation_task_runs'] = DB::table('simulation_task_runs')->delete(); $deleted['users'] = DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->where('from', '2')->whereNull('agent_id')->delete(); }); return $deleted; } }