| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174 |
- <?php
- namespace App\Services\Simulation;
- use App\Constants\HttpStatus;
- use App\Support\MemberAudience;
- use Exception;
- use Illuminate\Support\Facades\DB;
- /**
- * 自动任务:模板(simulation_tasks)和运行(simulation_task_runs)。
- * 一次运行按步骤顺序执行,每一步对每个模拟会员做 count 次;实际执行在 SimulationRunner。
- */
- final class SimulationTaskService
- {
- public const STEP_TYPES = ['register', 'login', 'recharge', 'bet', 'settle', 'withdraw', 'logout'];
- /** 每个会员只做一次的步骤,count 不生效。 */
- public const ONCE = ['register', 'login', 'logout', 'settle'];
- public static function normalizeSteps($steps): array
- {
- if (!is_array($steps) || $steps === []) throw new Exception('请至少添加一个任务步骤', HttpStatus::CUSTOM_ERROR);
- if (count($steps) > 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'] = ($step['category'] ?? 'lottery') === 'sport' ? 'sport' : 'lottery';
- 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;
- }
- }
|