SimulationRunner.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. <?php
  2. namespace App\Services\Simulation;
  3. use App\Models\LhcOrder;
  4. use Illuminate\Support\Facades\DB;
  5. use Throwable;
  6. /**
  7. * 自动任务执行器,由 simulation:run 命令周期调用。
  8. * 每次只推进一个动作并写一条事件;游标保存在运行记录上,暂停、停止、进程重启都能从原处继续,不会重复执行。
  9. */
  10. final class SimulationRunner
  11. {
  12. private const WAIT_SECONDS = 10; // 等待彩票开奖时的重试间隔
  13. private const MAX_WAITS = 180; // 最多等 30 分钟
  14. private const MAX_FAILURE_STREAK = 10; // 连续失败这么多次就把整个运行标为失败
  15. /** 推进所有到期的运行,返回本次执行的动作数。 */
  16. public static function tick(): int
  17. {
  18. if (!SimulationSettings::enabled()) return 0;
  19. try {
  20. SimulationTaskService::scheduleNext();
  21. } catch (Throwable $e) {
  22. // 持续运行这一轮没能启动(例如默认参数超过上限);不影响已有任务,下次调度再试
  23. \Illuminate\Support\Facades\Log::warning('simulation_schedule_failed', ['error' => $e->getMessage()]);
  24. }
  25. try {
  26. SimulationBetService::sweepSettledLottery();
  27. } catch (Throwable $e) {
  28. \Illuminate\Support\Facades\Log::warning('simulation_lottery_sweep_failed', ['error' => $e->getMessage()]);
  29. }
  30. $settings = SimulationSettings::all();
  31. $slots = (int) $settings['simulation_max_concurrency'] - DB::table('simulation_task_runs')->where('status', 'running')->count();
  32. if ($slots > 0) {
  33. $queued = DB::table('simulation_task_runs')->where('status', 'queued')->orderBy('id')->limit($slots)->pluck('id');
  34. foreach ($queued as $id) {
  35. DB::table('simulation_task_runs')->where('id', $id)->where('status', 'queued')
  36. ->update(['status' => 'running', 'started_at' => now(), 'next_run_at' => now(), 'updated_at' => now()]);
  37. }
  38. }
  39. $budget = max(1, intdiv((int) $settings['simulation_rate_per_minute'], 30));
  40. $done = 0;
  41. $due = DB::table('simulation_task_runs')->where('status', 'running')->where('next_run_at', '<=', now())->orderBy('next_run_at')->pluck('id');
  42. foreach ($due as $id) {
  43. for ($i = 0; $i < $budget; $i++) {
  44. [$acted, $continue] = self::advance((int) $id);
  45. $done += $acted ? 1 : 0;
  46. if (!$continue) break;
  47. }
  48. }
  49. return $done;
  50. }
  51. /** @return array{0:bool,1:bool} [是否执行了动作, 本轮是否可以继续推进这个运行] */
  52. private static function advance(int $id): array
  53. {
  54. return DB::transaction(function () use ($id) {
  55. $run = DB::table('simulation_task_runs')->where('id', $id)->lockForUpdate()->first();
  56. if (!$run || $run->status !== 'running' || (string) $run->next_run_at > now()->toDateTimeString()) return [false, false];
  57. $steps = json_decode($run->steps, true);
  58. $members = json_decode($run->member_ids, true) ?: [];
  59. $cursor = json_decode((string) $run->cursor, true) + ['step' => 0, 'member' => 0, 'seq' => 1, 'waited' => 0, 'streak' => 0];
  60. if ($cursor['step'] >= count($steps)) {
  61. DB::table('simulation_task_runs')->where('id', $id)->update(['status' => 'completed', 'finished_at' => now(), 'updated_at' => now()]);
  62. return [false, false];
  63. }
  64. $step = $steps[$cursor['step']];
  65. $memberId = $step['type'] === 'register' ? '' : (string) ($members[$cursor['member']] ?? '');
  66. $event = ['run_id' => $id, 'member_id' => $memberId, 'step_index' => $cursor['step'], 'seq' => $cursor['seq'], 'action' => $step['type'],
  67. 'status' => 'ok', 'ref_type' => null, 'ref_id' => null, 'amount' => $step['amount'] ?? null, 'message' => '', 'created_at' => now()];
  68. try {
  69. if ($step['type'] !== 'register' && $memberId === '') throw new \RuntimeException('没有可用的模拟会员(注册步骤可能失败)');
  70. $result = DB::transaction(fn () => self::perform($step, $memberId));
  71. if ($result === null) { // 彩票尚未开奖,稍后再看
  72. if (++$cursor['waited'] <= self::MAX_WAITS) {
  73. DB::table('simulation_task_runs')->where('id', $id)->update(['cursor' => json_encode($cursor),
  74. 'next_run_at' => now()->addSeconds(self::WAIT_SECONDS), 'updated_at' => now()]);
  75. return [false, false];
  76. }
  77. throw new \RuntimeException('等待开奖结算超时');
  78. }
  79. $event = array_merge($event, $result);
  80. if ($step['type'] === 'register') {
  81. $members[] = $event['member_id'];
  82. $event['seq'] = $cursor['member'] + 1; // 注册没有会员,用序号保证事件唯一
  83. }
  84. $cursor['streak'] = 0;
  85. } catch (Throwable $e) {
  86. $event['status'] = 'failed';
  87. $event['message'] = mb_substr($e->getMessage(), 0, 500);
  88. if ($step['type'] === 'register') $event['seq'] = $cursor['member'] + 1;
  89. $cursor['streak']++;
  90. }
  91. DB::table('simulation_task_events')->insertOrIgnore($event);
  92. $cursor['waited'] = 0;
  93. if (++$cursor['seq'] > $step['count']) {
  94. $cursor['seq'] = 1;
  95. if (++$cursor['member'] >= (int) $run->member_count) { $cursor['member'] = 0; $cursor['step']++; }
  96. }
  97. $update = ['cursor' => json_encode($cursor), 'member_ids' => json_encode($members), 'updated_at' => now(),
  98. 'next_run_at' => now()->addSeconds((int) $step['interval']),
  99. ($event['status'] === 'ok' ? 'completed' : 'failed') => DB::raw(($event['status'] === 'ok' ? 'completed' : 'failed') . ' + 1')];
  100. if ($event['status'] === 'failed') $update['last_error'] = $event['message'];
  101. $finished = $cursor['step'] >= count($steps);
  102. if ($cursor['streak'] >= self::MAX_FAILURE_STREAK) $update += ['status' => 'failed', 'finished_at' => now()];
  103. elseif ($finished) $update += ['status' => 'completed', 'finished_at' => now()];
  104. DB::table('simulation_task_runs')->where('id', $id)->update($update);
  105. return [true, !isset($update['status']) && (int) $step['interval'] === 0];
  106. });
  107. }
  108. /** 执行一个动作。返回要写进事件的字段;返回 null 表示还要等(彩票未开奖)。 */
  109. private static function perform(array $step, string $memberId): ?array
  110. {
  111. switch ($step['type']) {
  112. case 'register':
  113. $created = SimulationMemberService::create(1)[0];
  114. return ['member_id' => $created['member_id'], 'ref_type' => 'users', 'ref_id' => (string) $created['id'], 'message' => '注册 ' . $created['account']];
  115. case 'login':
  116. $login = SimulationMemberService::login($memberId, $step['platform'] ?? 'H5');
  117. return ['ref_type' => 'user_login', 'ref_id' => (string) $login['id'], 'message' => '登录成功'];
  118. case 'logout':
  119. return ['message' => '退出 ' . SimulationMemberService::logout($memberId) . ' 个会话'];
  120. case 'recharge':
  121. $order = SimulationFundService::recharge($memberId, $step['amount'], 'third', 'success');
  122. return ['ref_type' => 'payment_orders', 'ref_id' => (string) $order->id, 'message' => '充值成功'];
  123. case 'withdraw':
  124. $order = SimulationFundService::withdrawApply($memberId, $step['amount']);
  125. SimulationFundService::withdrawAudit((int) $order->id, true);
  126. return ['ref_type' => 'payment_orders', 'ref_id' => (string) $order->id, 'message' => '提现成功'];
  127. case 'bet':
  128. $category = $step['category'] ?? 'lottery';
  129. $note = '';
  130. if ($category === 'mixed') {
  131. // 混合:体育、彩票、第三方游戏各占约三分之一;彩票当前没有可下注的期号时改下另外两种
  132. $category = ['sport', 'lottery', 'third'][mt_rand(0, 2)];
  133. if ($category === 'lottery' && !SimulationBetService::lotteryOpen()) {
  134. $category = ['sport', 'third'][mt_rand(0, 1)];
  135. $note = '(彩票当前无可下注期号,改下其他分类)';
  136. }
  137. }
  138. if ($category === 'third') {
  139. $order = SimulationBetService::third($memberId, $step['amount']);
  140. return ['ref_type' => 'third_game_orders', 'ref_id' => (string) $order->id, 'message' => "第三方游戏 {$order->game_name} 输赢 " . (float) $order->settled_amount . $note];
  141. }
  142. if ($category === 'sport') {
  143. $order = SimulationBetService::sport($memberId, $step['amount'], number_format(mt_rand(150, 250) / 100, 2, '.', ''));
  144. return ['ref_type' => 'sport_order', 'ref_id' => (string) $order->id, 'message' => '体育下注 赔率 ' . (float) $order->odd . $note];
  145. }
  146. $order = SimulationBetService::lotteryRandom($memberId, $step['amount']);
  147. $name = SimulationBetService::LOTTERY_NAMES[(int) $order->type] ?? '彩票';
  148. return ['ref_type' => 'lhc_order', 'ref_id' => (string) $order->id, 'message' => "彩票下注 {$name} {$order->issue} 期"];
  149. case 'settle':
  150. $pending = LhcOrder::where('member_id', $memberId)->where('bet_source', LhcOrder::SOURCE_SIMULATION)
  151. ->where('lottery_status', LhcOrder::STATUS_STAY)->count();
  152. $user = SimulationSettings::member($memberId);
  153. $sport = SimulationBetService::sportQuery()->where('sport_order.user_id', (string) $user->user_id)
  154. ->where('sport_order.settlement_status', 0)->where('sport_order.status', 1)->pluck('sport_order.id');
  155. foreach ($sport as $orderId) {
  156. $mode = $step['settle_mode'] ?? 'random';
  157. SimulationBetService::settleSport((int) $orderId, $mode === 'random' ? ['win', 'lose', 'draw', 'half'][mt_rand(0, 3)] : $mode);
  158. }
  159. if ($pending > 0) return null;
  160. return ['amount' => null, 'message' => '体育结算 ' . count($sport) . ' 单,彩票已全部开奖'];
  161. }
  162. throw new \RuntimeException('未知步骤:' . $step['type']);
  163. }
  164. }