20) throw new Exception('任务步骤不能超过 20 个', HttpStatus::CUSTOM_ERROR); $out = []; foreach ($steps as $step) { $type = (string) ($step['type'] ?? ''); if (!in_array($type, self::STEP_TYPES, true)) throw new Exception('任务步骤类型不正确:' . $type, HttpStatus::CUSTOM_ERROR); $row = ['type' => $type, 'count' => in_array($type, self::ONCE, true) ? 1 : min(50, max(1, (int) ($step['count'] ?? 1))), 'interval' => min(3600, max(0, (int) ($step['interval'] ?? 2)))]; if (in_array($type, ['recharge', 'bet', 'withdraw'], true)) $row['amount'] = SimulationSettings::assertAmount($step['amount'] ?? 100); if ($type === 'bet') $row['category'] = in_array($step['category'] ?? '', self::BET_CATEGORIES, true) ? $step['category'] : 'mixed'; if ($type === 'settle') $row['settle_mode'] = in_array($step['settle_mode'] ?? 'random', ['random', 'win', 'lose'], true) ? ($step['settle_mode'] ?? 'random') : 'random'; if (in_array($type, ['register', 'login'], true)) $row['platform'] = substr((string) ($step['platform'] ?? 'H5'), 0, 16); $out[] = $row; } return $out; } public static function saveTemplate(array $input, ?int $adminId): int { $name = trim((string) ($input['name'] ?? '')); if ($name === '' || mb_strlen($name) > 100) throw new Exception('请填写模板名称(不超过 100 字)', HttpStatus::CUSTOM_ERROR); $row = ['name' => $name, 'steps' => json_encode(self::normalizeSteps($input['steps'] ?? null), JSON_UNESCAPED_UNICODE), 'target_mode' => ($input['target_mode'] ?? 'new') === 'existing' ? 'existing' : 'new', 'member_count' => self::memberCount($input['member_count'] ?? 1), 'remark' => mb_substr((string) ($input['remark'] ?? ''), 0, 255), 'updated_at' => now()]; if (!empty($input['id'])) { if (!DB::table('simulation_tasks')->where('id', (int) $input['id'])->update($row)) throw new Exception('模板不存在', HttpStatus::CUSTOM_ERROR); return (int) $input['id']; } return (int) DB::table('simulation_tasks')->insertGetId($row + ['created_by' => $adminId, 'created_at' => now()]); } private static function memberCount($count): int { $count = (int) $count; $max = (int) SimulationSettings::get('simulation_max_members_per_task'); if ($count < 1 || $count > $max) throw new Exception("任务会员数应在 1 到 {$max} 之间", HttpStatus::CUSTOM_ERROR); return $count; } /** 新建一次运行并排队。来自模板(task_id)或直接给步骤;existing 模式要给 member_ids。 */ public static function start(array $input, ?int $adminId): int { SimulationSettings::assertEnabled(); $task = !empty($input['task_id']) ? DB::table('simulation_tasks')->where('id', (int) $input['task_id'])->first() : null; if (!empty($input['task_id']) && !$task) throw new Exception('模板不存在', HttpStatus::CUSTOM_ERROR); $steps = self::normalizeSteps($input['steps'] ?? ($task ? json_decode($task->steps, true) : null)); $memberIds = array_values(array_unique(array_map('strval', (array) ($input['member_ids'] ?? [])))); $registers = in_array('register', array_column($steps, 'type'), true); if ($registers) { if ($steps[0]['type'] !== 'register' || count(array_keys(array_column($steps, 'type'), 'register')) > 1) { throw new Exception('“注册”只能作为任务的第一个步骤,且只能有一个', HttpStatus::CUSTOM_ERROR); } $memberIds = []; $count = self::memberCount($input['member_count'] ?? ($task->member_count ?? 1)); } else { if ($memberIds === []) throw new Exception('任务没有“注册”步骤时,请选择要使用的模拟会员', HttpStatus::CUSTOM_ERROR); $count = self::memberCount(count($memberIds)); $valid = DB::table('users')->whereIn('member_id', $memberIds)->where('user_type', MemberAudience::TYPE_SIMULATED)->count(); if ($valid !== count($memberIds)) throw new Exception('所选会员中包含非模拟会员', HttpStatus::CUSTOM_ERROR); } $total = 0; foreach ($steps as $step) $total += $step['count'] * $count; $name = trim((string) ($input['name'] ?? '')) ?: ($task->name ?? '临时任务'); return (int) DB::table('simulation_task_runs')->insertGetId(['task_id' => $task->id ?? null, 'name' => mb_substr($name, 0, 100), 'steps' => json_encode($steps, JSON_UNESCAPED_UNICODE), 'member_ids' => json_encode($memberIds), 'member_count' => $count, 'status' => 'queued', 'total' => $total, 'cursor' => json_encode(['step' => 0, 'member' => 0, 'seq' => 1, 'waited' => 0]), 'next_run_at' => now(), 'created_by' => $adminId, 'created_at' => now(), 'updated_at' => now()]); } /** * 一键启动:按“模拟设置”里的默认任务参数,跑一遍完整流程(注册 → 登录 → 充值 → 下注 → 结算 → 提现 → 退出)。 * 提现金额设为 0 时不提现。 */ public static function quickStart(?int $adminId, bool $scheduled = false): int { $s = SimulationSettings::all(); $interval = (int) $s['simulation_task_interval']; $max = (float) $s['simulation_max_amount']; foreach (['simulation_task_recharge' => '充值金额', 'simulation_task_bet_amount' => '下注金额', 'simulation_task_withdraw' => '提现金额'] as $key => $label) { if ((float) $s[$key] > $max) throw new Exception("默认任务的{$label}超过了单次金额上限 {$s['simulation_max_amount']},请在模拟设置中调整", HttpStatus::CUSTOM_ERROR); } if ((int) $s['simulation_task_members'] > (int) $s['simulation_max_members_per_task']) { throw new Exception('默认任务的会员数超过了单任务会员数上限,请在模拟设置中调整', HttpStatus::CUSTOM_ERROR); } $steps = [ ['type' => 'register', 'interval' => $interval], ['type' => 'login', 'interval' => $interval], ['type' => 'recharge', 'amount' => $s['simulation_task_recharge'], 'interval' => $interval], ['type' => 'bet', 'category' => $s['simulation_task_bet_category'], 'amount' => $s['simulation_task_bet_amount'], 'count' => (int) $s['simulation_task_bet_count'], 'interval' => $interval], ['type' => 'settle', 'settle_mode' => 'random', 'interval' => $interval], ]; if ((float) $s['simulation_task_withdraw'] > 0) $steps[] = ['type' => 'withdraw', 'amount' => $s['simulation_task_withdraw'], 'interval' => $interval]; $steps[] = ['type' => 'logout', 'interval' => $interval]; $count = (int) $s['simulation_task_members']; $input = ['name' => ($scheduled ? self::SCHEDULED_PREFIX : '一键任务') . ' ' . now()->format('m-d H:i:s'), 'steps' => $steps, 'member_count' => $count]; // 持续运行时会员总数到上限后不再注册,改为随机抽已有的模拟会员继续跑,否则跑不满整个区间 $existing = DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->count(); if ($scheduled && $existing + $count > (int) $s['simulation_max_members_total']) { $input['member_ids'] = DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->where('is_banned', 0) ->inRandomOrder()->limit($count)->pluck('member_id')->all(); $input['steps'] = array_values(array_filter($steps, fn ($step) => $step['type'] !== 'register')); } return self::start($input, $adminId); } public const SCHEDULED_PREFIX = '持续任务'; /** * 持续运行:开关打开且今天在设置的日期区间内时,上一轮结束并隔够间隔后自动启动下一轮。由执行器每次调度时调用。 * @return int|null 新启动的运行编号;没到启动条件时为 null */ public static function scheduleNext(): ?int { $s = SimulationSettings::all(); if ($s['simulation_enabled'] !== '1' || $s['simulation_schedule_enabled'] !== '1') return null; $today = now()->toDateString(); if ($s['simulation_schedule_start'] === '' || $today < $s['simulation_schedule_start'] || $today > $s['simulation_schedule_end']) return null; $last = DB::table('simulation_task_runs')->where('name', 'like', self::SCHEDULED_PREFIX . ' %')->orderByDesc('id')->first(); if ($last) { if (in_array($last->status, ['queued', 'running', 'paused'], true)) return null; $since = $last->finished_at ?? $last->updated_at; if ($since && now()->lt(\Carbon\Carbon::parse($since)->addMinutes((int) $s['simulation_schedule_gap_minutes']))) return null; } return self::quickStart(null, true); } /** 暂停 / 继续 / 停止;下一次调度时生效。 */ public static function setStatus(int $id, string $action): object { $run = DB::table('simulation_task_runs')->where('id', $id)->first(); if (!$run) throw new Exception('运行记录不存在', HttpStatus::CUSTOM_ERROR); $next = match (true) { $action === 'pause' && in_array($run->status, ['queued', 'running'], true) => 'paused', $action === 'resume' && $run->status === 'paused' => 'running', $action === 'stop' && in_array($run->status, ['queued', 'running', 'paused'], true) => 'stopped', default => throw new Exception('当前状态不能执行该操作', HttpStatus::CUSTOM_ERROR), }; $update = ['status' => $next, 'updated_at' => now()]; if ($next === 'running') { SimulationSettings::assertEnabled(); $update['next_run_at'] = now(); } if ($next === 'stopped') $update['finished_at'] = now(); DB::table('simulation_task_runs')->where('id', $id)->where('status', $run->status)->update($update); return DB::table('simulation_task_runs')->where('id', $id)->first(); } public static function present(object $run): array { $row = (array) $run; foreach (['steps', 'member_ids', 'cursor'] as $key) $row[$key] = json_decode((string) ($row[$key] ?? 'null'), true); $row['progress'] = $run->total > 0 ? (int) floor(($run->completed + $run->failed) * 100 / $run->total) : 0; return $row; } }