SimulationRunner.php 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  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. $settings = SimulationSettings::all();
  20. $slots = (int) $settings['simulation_max_concurrency'] - DB::table('simulation_task_runs')->where('status', 'running')->count();
  21. if ($slots > 0) {
  22. $queued = DB::table('simulation_task_runs')->where('status', 'queued')->orderBy('id')->limit($slots)->pluck('id');
  23. foreach ($queued as $id) {
  24. DB::table('simulation_task_runs')->where('id', $id)->where('status', 'queued')
  25. ->update(['status' => 'running', 'started_at' => now(), 'next_run_at' => now(), 'updated_at' => now()]);
  26. }
  27. }
  28. $budget = max(1, intdiv((int) $settings['simulation_rate_per_minute'], 30));
  29. $done = 0;
  30. $due = DB::table('simulation_task_runs')->where('status', 'running')->where('next_run_at', '<=', now())->orderBy('next_run_at')->pluck('id');
  31. foreach ($due as $id) {
  32. for ($i = 0; $i < $budget; $i++) {
  33. [$acted, $continue] = self::advance((int) $id);
  34. $done += $acted ? 1 : 0;
  35. if (!$continue) break;
  36. }
  37. }
  38. return $done;
  39. }
  40. /** @return array{0:bool,1:bool} [是否执行了动作, 本轮是否可以继续推进这个运行] */
  41. private static function advance(int $id): array
  42. {
  43. return DB::transaction(function () use ($id) {
  44. $run = DB::table('simulation_task_runs')->where('id', $id)->lockForUpdate()->first();
  45. if (!$run || $run->status !== 'running' || (string) $run->next_run_at > now()->toDateTimeString()) return [false, false];
  46. $steps = json_decode($run->steps, true);
  47. $members = json_decode($run->member_ids, true) ?: [];
  48. $cursor = json_decode((string) $run->cursor, true) + ['step' => 0, 'member' => 0, 'seq' => 1, 'waited' => 0, 'streak' => 0];
  49. if ($cursor['step'] >= count($steps)) {
  50. DB::table('simulation_task_runs')->where('id', $id)->update(['status' => 'completed', 'finished_at' => now(), 'updated_at' => now()]);
  51. return [false, false];
  52. }
  53. $step = $steps[$cursor['step']];
  54. $memberId = $step['type'] === 'register' ? '' : (string) ($members[$cursor['member']] ?? '');
  55. $event = ['run_id' => $id, 'member_id' => $memberId, 'step_index' => $cursor['step'], 'seq' => $cursor['seq'], 'action' => $step['type'],
  56. 'status' => 'ok', 'ref_type' => null, 'ref_id' => null, 'amount' => $step['amount'] ?? null, 'message' => '', 'created_at' => now()];
  57. try {
  58. if ($step['type'] !== 'register' && $memberId === '') throw new \RuntimeException('没有可用的模拟会员(注册步骤可能失败)');
  59. $result = DB::transaction(fn () => self::perform($step, $memberId));
  60. if ($result === null) { // 彩票尚未开奖,稍后再看
  61. if (++$cursor['waited'] <= self::MAX_WAITS) {
  62. DB::table('simulation_task_runs')->where('id', $id)->update(['cursor' => json_encode($cursor),
  63. 'next_run_at' => now()->addSeconds(self::WAIT_SECONDS), 'updated_at' => now()]);
  64. return [false, false];
  65. }
  66. throw new \RuntimeException('等待开奖结算超时');
  67. }
  68. $event = array_merge($event, $result);
  69. if ($step['type'] === 'register') {
  70. $members[] = $event['member_id'];
  71. $event['seq'] = $cursor['member'] + 1; // 注册没有会员,用序号保证事件唯一
  72. }
  73. $cursor['streak'] = 0;
  74. } catch (Throwable $e) {
  75. $event['status'] = 'failed';
  76. $event['message'] = mb_substr($e->getMessage(), 0, 500);
  77. if ($step['type'] === 'register') $event['seq'] = $cursor['member'] + 1;
  78. $cursor['streak']++;
  79. }
  80. DB::table('simulation_task_events')->insertOrIgnore($event);
  81. $cursor['waited'] = 0;
  82. if (++$cursor['seq'] > $step['count']) {
  83. $cursor['seq'] = 1;
  84. if (++$cursor['member'] >= (int) $run->member_count) { $cursor['member'] = 0; $cursor['step']++; }
  85. }
  86. $update = ['cursor' => json_encode($cursor), 'member_ids' => json_encode($members), 'updated_at' => now(),
  87. 'next_run_at' => now()->addSeconds((int) $step['interval']),
  88. ($event['status'] === 'ok' ? 'completed' : 'failed') => DB::raw(($event['status'] === 'ok' ? 'completed' : 'failed') . ' + 1')];
  89. if ($event['status'] === 'failed') $update['last_error'] = $event['message'];
  90. $finished = $cursor['step'] >= count($steps);
  91. if ($cursor['streak'] >= self::MAX_FAILURE_STREAK) $update += ['status' => 'failed', 'finished_at' => now()];
  92. elseif ($finished) $update += ['status' => 'completed', 'finished_at' => now()];
  93. DB::table('simulation_task_runs')->where('id', $id)->update($update);
  94. return [true, !isset($update['status']) && (int) $step['interval'] === 0];
  95. });
  96. }
  97. /** 执行一个动作。返回要写进事件的字段;返回 null 表示还要等(彩票未开奖)。 */
  98. private static function perform(array $step, string $memberId): ?array
  99. {
  100. switch ($step['type']) {
  101. case 'register':
  102. $created = SimulationMemberService::create(1)[0];
  103. return ['member_id' => $created['member_id'], 'ref_type' => 'users', 'ref_id' => (string) $created['id'], 'message' => '注册 ' . $created['account']];
  104. case 'login':
  105. $login = SimulationMemberService::login($memberId, $step['platform'] ?? 'H5');
  106. return ['ref_type' => 'user_login', 'ref_id' => (string) $login['id'], 'message' => '登录成功'];
  107. case 'logout':
  108. return ['message' => '退出 ' . SimulationMemberService::logout($memberId) . ' 个会话'];
  109. case 'recharge':
  110. $order = SimulationFundService::recharge($memberId, $step['amount'], 'third', 'success');
  111. return ['ref_type' => 'payment_orders', 'ref_id' => (string) $order->id, 'message' => '充值成功'];
  112. case 'withdraw':
  113. $order = SimulationFundService::withdrawApply($memberId, $step['amount']);
  114. SimulationFundService::withdrawAudit((int) $order->id, true);
  115. return ['ref_type' => 'payment_orders', 'ref_id' => (string) $order->id, 'message' => '提现成功'];
  116. case 'bet':
  117. if (($step['category'] ?? 'lottery') === 'sport') {
  118. $order = SimulationBetService::sport($memberId, $step['amount'], number_format(mt_rand(150, 250) / 100, 2, '.', ''));
  119. return ['ref_type' => 'sport_order', 'ref_id' => (string) $order->id, 'message' => '体育下注 赔率 ' . (float) $order->odd];
  120. }
  121. $keywords = ['大', '小', '单', '双'][mt_rand(0, 3)];
  122. $order = SimulationBetService::lottery($memberId, $keywords, $step['amount']);
  123. return ['ref_type' => 'lhc_order', 'ref_id' => (string) $order->id, 'message' => "彩票下注 {$order->issue} 期 {$keywords}"];
  124. case 'settle':
  125. $pending = LhcOrder::where('member_id', $memberId)->where('bet_source', LhcOrder::SOURCE_SIMULATION)
  126. ->where('lottery_status', LhcOrder::STATUS_STAY)->count();
  127. $user = SimulationSettings::member($memberId);
  128. $sport = SimulationBetService::sportQuery()->where('sport_order.user_id', (string) $user->user_id)
  129. ->where('sport_order.settlement_status', 0)->where('sport_order.status', 1)->pluck('sport_order.id');
  130. foreach ($sport as $orderId) {
  131. $mode = $step['settle_mode'] ?? 'random';
  132. SimulationBetService::settleSport((int) $orderId, $mode === 'random' ? ['win', 'lose', 'draw', 'half'][mt_rand(0, 3)] : $mode);
  133. }
  134. if ($pending > 0) return null;
  135. return ['amount' => null, 'message' => '体育结算 ' . count($sport) . ' 单,彩票已全部开奖'];
  136. }
  137. throw new \RuntimeException('未知步骤:' . $step['type']);
  138. }
  139. }