SimulationBetService.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. <?php
  2. namespace App\Services\Simulation;
  3. use App\Constants\HttpStatus;
  4. use App\Models\Config;
  5. use App\Models\LhcOrder;
  6. use App\Models\LhcOrderItem;
  7. use App\Models\Order;
  8. use App\Models\PcIssue;
  9. use App\Models\Wallet;
  10. use App\Services\BalanceLogService;
  11. use App\Services\GameplayRuleService;
  12. use App\Services\IssueService;
  13. use App\Support\MemberAudience;
  14. use Exception;
  15. use Illuminate\Support\Facades\DB;
  16. /**
  17. * 模拟会员的注单,写真实注单表并产生真实账变。
  18. * 彩票:PC28(加拿大 type 5 / 极速 type 6,跟随当前 pc28_switch),下在真实期号上,随真实开奖由 BetService::betSettled 结算。
  19. * 体育:sport_order,issue 用不存在于赛事表的合成值,所以前台的结算任务取不到它,只能在这里按给定结果结算。
  20. */
  21. final class SimulationBetService
  22. {
  23. public const SPORT_ISSUE_PREFIX = 'SIM';
  24. public const SPORT_RESULTS = ['win' => 1, 'lose' => 0, 'draw' => 2, 'half' => 3];
  25. /** 当前可下注的 PC28 期号与可用玩法。 */
  26. public static function options(): array
  27. {
  28. [$type, $issue] = self::currentIssue();
  29. $rules = GameplayRuleService::model()::query()->where('odds', '>', 0)->get(['keywords', 'odds', 'mininum', 'maxinum']);
  30. return ['lottery' => ['type' => $type, 'issue' => $issue ? $issue->issue_no : null, 'end_time' => $issue ? (string) $issue->end_time : null, 'rules' => $rules],
  31. 'sport' => ['results' => array_keys(self::SPORT_RESULTS)]];
  32. }
  33. private static function currentIssue(): array
  34. {
  35. $switch = (int) (Config::where('field', 'pc28_switch')->value('val') ?? 0);
  36. $issue = $switch === 1
  37. ? PcIssue::where('status', PcIssue::STATUS_BETTING)->orderByDesc('id')->first()
  38. : IssueService::model()::where('status', IssueService::model()::STATUS_BETTING)->orderByDesc('id')->first();
  39. return [$switch === 1 ? 6 : 5, $issue];
  40. }
  41. /** PC28 下注,校验与 BetService::singleNote 一致,但不发任何群消息或私聊。 */
  42. public static function lottery($memberId, string $keywords, $amount): LhcOrder
  43. {
  44. SimulationSettings::assertEnabled();
  45. $user = SimulationSettings::member($memberId);
  46. $amount = SimulationSettings::assertAmount($amount);
  47. $rule = GameplayRuleService::getGameplayRules($keywords);
  48. if (!$rule) throw new Exception('玩法未配置:' . $keywords, HttpStatus::CUSTOM_ERROR);
  49. if ($rule['odds'] <= 0) throw new Exception('该玩法赔率为 0,禁止投注', HttpStatus::CUSTOM_ERROR);
  50. [$type, $issue] = self::currentIssue();
  51. if (!$issue) throw new Exception('暂无可下注期数', HttpStatus::CUSTOM_ERROR);
  52. if ((string) $issue->end_time < date('Y-m-d H:i:s', time() + 30)) throw new Exception('封盘中,本次下注无效', HttpStatus::CUSTOM_ERROR);
  53. if ($amount < $rule['mininum']) throw new Exception('低于该玩法最小金额 ' . $rule['mininum'], HttpStatus::CUSTOM_ERROR);
  54. if ($amount > $rule['maxinum']) throw new Exception('超过该玩法最大金额 ' . $rule['maxinum'], HttpStatus::CUSTOM_ERROR);
  55. return DB::transaction(function () use ($user, $keywords, $amount, $rule, $type, $issue) {
  56. $wallet = Wallet::where('member_id', $user->member_id)->lockForUpdate()->first();
  57. if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
  58. $before = (string) $wallet->available_balance;
  59. if (bccomp($before, $amount, 10) < 0) throw new Exception('余额不足', HttpStatus::CUSTOM_ERROR);
  60. $ordernum = date('YmdHis') . str_pad((string) mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
  61. LhcOrderItem::create(['ordernum' => $ordernum, 'member_id' => $user->member_id, 'number' => $keywords]);
  62. $order = LhcOrder::create(['type' => $type, 'bet_source' => LhcOrder::SOURCE_SIMULATION, 'issue' => $issue->issue_no, 'ordernum' => $ordernum,
  63. 'member_id' => $user->member_id, 'odds' => $rule['odds'], 'amount' => $amount, 'total_amount' => $amount]);
  64. $after = bcsub($before, $amount, 10);
  65. $wallet->available_balance = $after;
  66. $wallet->save();
  67. BalanceLogService::addLog($user->member_id, bcsub('0', $amount, 2), $before, $after, '投注', $order->id, '模拟投注');
  68. return $order;
  69. });
  70. }
  71. /** 生成一张体育注单并扣款。 */
  72. public static function sport($memberId, $amount, $odd, string $title = '', string $selection = ''): Order
  73. {
  74. SimulationSettings::assertEnabled();
  75. $user = SimulationSettings::member($memberId);
  76. $amount = SimulationSettings::assertAmount($amount);
  77. if (!is_numeric($odd) || $odd <= 1 || $odd > 100) throw new Exception('赔率应大于 1 且不超过 100', HttpStatus::CUSTOM_ERROR);
  78. $title = $title !== '' ? $title : '模拟主队 VS 模拟客队';
  79. $selection = $selection !== '' ? $selection : '主胜';
  80. return DB::transaction(function () use ($user, $amount, $odd, $title, $selection) {
  81. $wallet = Wallet::where('member_id', $user->member_id)->lockForUpdate()->first();
  82. if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
  83. $before = (string) $wallet->available_balance;
  84. if (bccomp($before, $amount, 10) < 0) throw new Exception('余额不足', HttpStatus::CUSTOM_ERROR);
  85. $serial = date('YmdHis') . str_pad((string) mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
  86. $order = (new Order())->forceFill(['user_id' => (string) $user->user_id, 'order_id' => 'S' . $serial, 'issue' => self::SPORT_ISSUE_PREFIX . $serial,
  87. 'detail' => json_encode(['simulation' => true, 'title' => $title, 'selection' => $selection], JSON_UNESCAPED_UNICODE),
  88. 'amount' => $amount, 'cny_rate' => 1, 'is_faker' => 0, 'status' => 1, 'pay_status' => 1, 'pay_time' => time(),
  89. 'odd_name' => $title, 'odd_value' => $selection, 'odd' => $odd, 'is_roll' => 0, 'remark' => '模拟注单']);
  90. $order->save();
  91. $after = bcsub($before, $amount, 10);
  92. $wallet->available_balance = $after;
  93. $wallet->save();
  94. BalanceLogService::addLog($user->member_id, bcsub('0', $amount, 2), $before, $after, '体彩投注', $order->id, '模拟投注');
  95. return $order;
  96. });
  97. }
  98. /** 只允许操作模拟会员自己的、合成期号的体育注单。 */
  99. private static function sportOrder(int $id): Order
  100. {
  101. $order = Order::where('id', $id)->lockForUpdate()->first();
  102. if (!$order || !str_starts_with((string) $order->issue, self::SPORT_ISSUE_PREFIX)
  103. || !DB::table('users')->where('user_id', (string) $order->user_id)->where('user_type', MemberAudience::TYPE_SIMULATED)->exists()) {
  104. throw new Exception('不是模拟体育注单', HttpStatus::CUSTOM_ERROR);
  105. }
  106. if ((int) $order->settlement_status !== 0 || (int) $order->status !== 1) throw new Exception('注单已结算或已取消', HttpStatus::CUSTOM_ERROR);
  107. return $order;
  108. }
  109. /** 按给定结果结算:赢=本金×赔率,和局退本金,输半退一半,输不退。金额口径与前台结算一致。 */
  110. public static function settleSport(int $id, string $result): Order
  111. {
  112. SimulationSettings::assertEnabled();
  113. if (!isset(self::SPORT_RESULTS[$result])) throw new Exception('结算结果不正确', HttpStatus::CUSTOM_ERROR);
  114. return DB::transaction(function () use ($id, $result) {
  115. $order = self::sportOrder($id);
  116. $amount = (string) $order->amount;
  117. $win = '0'; $return = '0'; $profit = bcsub('0', $amount, 2); $changeType = null;
  118. if ($result === 'win') { $win = bcmul($amount, (string) $order->odd, 2); $profit = bcsub($win, $amount, 2); $changeType = '体彩中奖'; }
  119. if ($result === 'draw') { $return = $amount; $profit = '0'; $changeType = '体彩和局退款'; }
  120. if ($result === 'half') { $return = bcdiv($amount, '2', 2); $profit = bcsub($return, $amount, 2); $changeType = '体彩输半退款'; }
  121. $order->forceFill(['is_win' => self::SPORT_RESULTS[$result], 'win_amount' => $win, 'return_amount' => $return, 'profit_and_loss' => $profit,
  122. 'settlement_status' => 1, 'settlement_time' => time(), 'game_result' => $result])->save();
  123. $credit = bcadd($win, $return, 2);
  124. if ($changeType !== null && bccomp($credit, '0', 2) > 0) self::credit((string) $order->user_id, $credit, $changeType, (int) $order->id);
  125. return $order;
  126. });
  127. }
  128. public static function cancelSport(int $id, string $remark = ''): Order
  129. {
  130. SimulationSettings::assertEnabled();
  131. return DB::transaction(function () use ($id, $remark) {
  132. $order = self::sportOrder($id);
  133. $order->forceFill(['status' => 2, 'return_amount' => $order->amount, 'profit_and_loss' => 0, 'settlement_status' => 1,
  134. 'settlement_time' => time(), 'remark' => $remark !== '' ? $remark : '模拟注单撤销'])->save();
  135. self::credit((string) $order->user_id, (string) $order->amount, '体彩退款', (int) $order->id);
  136. return $order;
  137. });
  138. }
  139. private static function credit(string $userId, string $amount, string $changeType, int $orderId): void
  140. {
  141. $memberId = DB::table('users')->where('user_id', $userId)->value('member_id');
  142. $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
  143. if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
  144. $before = (string) $wallet->available_balance;
  145. $after = bcadd($before, $amount, 10);
  146. $wallet->available_balance = $after;
  147. $wallet->save();
  148. BalanceLogService::addLog($memberId, $amount, $before, $after, $changeType, $orderId, '模拟结算');
  149. }
  150. /** 模拟体育注单查询(合成期号没有赛事,不能复用正式列表的赛事关联)。 */
  151. public static function sportQuery()
  152. {
  153. return DB::table('sport_order')->join('users', 'users.user_id', '=', 'sport_order.user_id')
  154. ->where('users.user_type', MemberAudience::TYPE_SIMULATED);
  155. }
  156. }