SimulationBetService.php 10 KB

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