| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146 |
- <?php
- namespace App\Services\Simulation;
- use App\Models\LhcOrder;
- use Illuminate\Support\Facades\DB;
- use Throwable;
- /**
- * 自动任务执行器,由 simulation:run 命令周期调用。
- * 每次只推进一个动作并写一条事件;游标保存在运行记录上,暂停、停止、进程重启都能从原处继续,不会重复执行。
- */
- final class SimulationRunner
- {
- private const WAIT_SECONDS = 10; // 等待彩票开奖时的重试间隔
- private const MAX_WAITS = 180; // 最多等 30 分钟
- private const MAX_FAILURE_STREAK = 10; // 连续失败这么多次就把整个运行标为失败
- /** 推进所有到期的运行,返回本次执行的动作数。 */
- public static function tick(): int
- {
- if (!SimulationSettings::enabled()) return 0;
- $settings = SimulationSettings::all();
- $slots = (int) $settings['simulation_max_concurrency'] - DB::table('simulation_task_runs')->where('status', 'running')->count();
- if ($slots > 0) {
- $queued = DB::table('simulation_task_runs')->where('status', 'queued')->orderBy('id')->limit($slots)->pluck('id');
- foreach ($queued as $id) {
- DB::table('simulation_task_runs')->where('id', $id)->where('status', 'queued')
- ->update(['status' => 'running', 'started_at' => now(), 'next_run_at' => now(), 'updated_at' => now()]);
- }
- }
- $budget = max(1, intdiv((int) $settings['simulation_rate_per_minute'], 30));
- $done = 0;
- $due = DB::table('simulation_task_runs')->where('status', 'running')->where('next_run_at', '<=', now())->orderBy('next_run_at')->pluck('id');
- foreach ($due as $id) {
- for ($i = 0; $i < $budget; $i++) {
- [$acted, $continue] = self::advance((int) $id);
- $done += $acted ? 1 : 0;
- if (!$continue) break;
- }
- }
- return $done;
- }
- /** @return array{0:bool,1:bool} [是否执行了动作, 本轮是否可以继续推进这个运行] */
- private static function advance(int $id): array
- {
- return DB::transaction(function () use ($id) {
- $run = DB::table('simulation_task_runs')->where('id', $id)->lockForUpdate()->first();
- if (!$run || $run->status !== 'running' || (string) $run->next_run_at > now()->toDateTimeString()) return [false, false];
- $steps = json_decode($run->steps, true);
- $members = json_decode($run->member_ids, true) ?: [];
- $cursor = json_decode((string) $run->cursor, true) + ['step' => 0, 'member' => 0, 'seq' => 1, 'waited' => 0, 'streak' => 0];
- if ($cursor['step'] >= count($steps)) {
- DB::table('simulation_task_runs')->where('id', $id)->update(['status' => 'completed', 'finished_at' => now(), 'updated_at' => now()]);
- return [false, false];
- }
- $step = $steps[$cursor['step']];
- $memberId = $step['type'] === 'register' ? '' : (string) ($members[$cursor['member']] ?? '');
- $event = ['run_id' => $id, 'member_id' => $memberId, 'step_index' => $cursor['step'], 'seq' => $cursor['seq'], 'action' => $step['type'],
- 'status' => 'ok', 'ref_type' => null, 'ref_id' => null, 'amount' => $step['amount'] ?? null, 'message' => '', 'created_at' => now()];
- try {
- if ($step['type'] !== 'register' && $memberId === '') throw new \RuntimeException('没有可用的模拟会员(注册步骤可能失败)');
- $result = DB::transaction(fn () => self::perform($step, $memberId));
- if ($result === null) { // 彩票尚未开奖,稍后再看
- if (++$cursor['waited'] <= self::MAX_WAITS) {
- DB::table('simulation_task_runs')->where('id', $id)->update(['cursor' => json_encode($cursor),
- 'next_run_at' => now()->addSeconds(self::WAIT_SECONDS), 'updated_at' => now()]);
- return [false, false];
- }
- throw new \RuntimeException('等待开奖结算超时');
- }
- $event = array_merge($event, $result);
- if ($step['type'] === 'register') {
- $members[] = $event['member_id'];
- $event['seq'] = $cursor['member'] + 1; // 注册没有会员,用序号保证事件唯一
- }
- $cursor['streak'] = 0;
- } catch (Throwable $e) {
- $event['status'] = 'failed';
- $event['message'] = mb_substr($e->getMessage(), 0, 500);
- if ($step['type'] === 'register') $event['seq'] = $cursor['member'] + 1;
- $cursor['streak']++;
- }
- DB::table('simulation_task_events')->insertOrIgnore($event);
- $cursor['waited'] = 0;
- if (++$cursor['seq'] > $step['count']) {
- $cursor['seq'] = 1;
- if (++$cursor['member'] >= (int) $run->member_count) { $cursor['member'] = 0; $cursor['step']++; }
- }
- $update = ['cursor' => json_encode($cursor), 'member_ids' => json_encode($members), 'updated_at' => now(),
- 'next_run_at' => now()->addSeconds((int) $step['interval']),
- ($event['status'] === 'ok' ? 'completed' : 'failed') => DB::raw(($event['status'] === 'ok' ? 'completed' : 'failed') . ' + 1')];
- if ($event['status'] === 'failed') $update['last_error'] = $event['message'];
- $finished = $cursor['step'] >= count($steps);
- if ($cursor['streak'] >= self::MAX_FAILURE_STREAK) $update += ['status' => 'failed', 'finished_at' => now()];
- elseif ($finished) $update += ['status' => 'completed', 'finished_at' => now()];
- DB::table('simulation_task_runs')->where('id', $id)->update($update);
- return [true, !isset($update['status']) && (int) $step['interval'] === 0];
- });
- }
- /** 执行一个动作。返回要写进事件的字段;返回 null 表示还要等(彩票未开奖)。 */
- private static function perform(array $step, string $memberId): ?array
- {
- switch ($step['type']) {
- case 'register':
- $created = SimulationMemberService::create(1)[0];
- return ['member_id' => $created['member_id'], 'ref_type' => 'users', 'ref_id' => (string) $created['id'], 'message' => '注册 ' . $created['account']];
- case 'login':
- $login = SimulationMemberService::login($memberId, $step['platform'] ?? 'H5');
- return ['ref_type' => 'user_login', 'ref_id' => (string) $login['id'], 'message' => '登录成功'];
- case 'logout':
- return ['message' => '退出 ' . SimulationMemberService::logout($memberId) . ' 个会话'];
- case 'recharge':
- $order = SimulationFundService::recharge($memberId, $step['amount'], 'third', 'success');
- return ['ref_type' => 'payment_orders', 'ref_id' => (string) $order->id, 'message' => '充值成功'];
- case 'withdraw':
- $order = SimulationFundService::withdrawApply($memberId, $step['amount']);
- SimulationFundService::withdrawAudit((int) $order->id, true);
- return ['ref_type' => 'payment_orders', 'ref_id' => (string) $order->id, 'message' => '提现成功'];
- case 'bet':
- if (($step['category'] ?? 'lottery') === 'sport') {
- $order = SimulationBetService::sport($memberId, $step['amount'], number_format(mt_rand(150, 250) / 100, 2, '.', ''));
- return ['ref_type' => 'sport_order', 'ref_id' => (string) $order->id, 'message' => '体育下注 赔率 ' . (float) $order->odd];
- }
- $keywords = ['大', '小', '单', '双'][mt_rand(0, 3)];
- $order = SimulationBetService::lottery($memberId, $keywords, $step['amount']);
- return ['ref_type' => 'lhc_order', 'ref_id' => (string) $order->id, 'message' => "彩票下注 {$order->issue} 期 {$keywords}"];
- case 'settle':
- $pending = LhcOrder::where('member_id', $memberId)->where('bet_source', LhcOrder::SOURCE_SIMULATION)
- ->where('lottery_status', LhcOrder::STATUS_STAY)->count();
- $user = SimulationSettings::member($memberId);
- $sport = SimulationBetService::sportQuery()->where('sport_order.user_id', (string) $user->user_id)
- ->where('sport_order.settlement_status', 0)->where('sport_order.status', 1)->pluck('sport_order.id');
- foreach ($sport as $orderId) {
- $mode = $step['settle_mode'] ?? 'random';
- SimulationBetService::settleSport((int) $orderId, $mode === 'random' ? ['win', 'lose', 'draw', 'half'][mt_rand(0, 3)] : $mode);
- }
- if ($pending > 0) return null;
- return ['amount' => null, 'message' => '体育结算 ' . count($sport) . ' 单,彩票已全部开奖'];
- }
- throw new \RuntimeException('未知步骤:' . $step['type']);
- }
- }
|