| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- <?php
- namespace App\Services\Simulation;
- use App\Constants\HttpStatus;
- use App\Models\Config;
- use App\Models\LhcOrder;
- use App\Models\LhcOrderItem;
- use App\Models\Order;
- use App\Models\PcIssue;
- use App\Models\Wallet;
- use App\Services\BalanceLogService;
- use App\Services\IssueService;
- use App\Services\LotteryOdds;
- use App\Support\MemberAudience;
- use Exception;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Schema;
- /**
- * 模拟会员的注单,写真实注单表并产生真实账变。
- * 彩票:全部彩种,下在真实期号上。PC28(type 5 / 6)随本项目开奖由 BetService::betSettled 结算;六合彩、极速彩等由前台项目的结算任务按真实开奖结算。
- * 体育:sport_order,issue 用不存在于赛事表的合成值,所以前台的结算任务取不到它,只能在这里按给定结果结算。
- */
- final class SimulationBetService
- {
- public const SPORT_ISSUE_PREFIX = 'SIM';
- public const SPORT_RESULTS = ['win' => 1, 'lose' => 0, 'draw' => 2, 'half' => 3];
- /** lhc_order.type => 彩种名(与前台一致,也是流水类型的前缀)。5、6 是 PC28,由本项目开奖结算;其余由前台项目的结算任务按真实开奖结算。 */
- public const LOTTERY_NAMES = [1 => '新澳门六合彩', 2 => '香港六合彩', 3 => '澳门六合彩', 4 => '极速六合彩', 5 => '加拿大28', 6 => '极速28',
- 7 => '极速赛车', 8 => '极速时时彩', 9 => '极速飞艇', 10 => 'SG时时彩', 11 => 'SG飞艇', 12 => '极速快3', 13 => 'SG快3'];
- /** 要一次选多个号码才成立的玩法,模拟只下单号码注单,所以不提供。 */
- private const MULTI_NUMBER_PLAYS = ['连', '合肖', '不中', '过关', '中一', '二中', '三中', '特串'];
- private static function lotteryType(string $key): int
- {
- [$game, $type] = array_pad(explode(':', $key, 2), 2, null);
- return match ($game) { 'canada28' => 5, 'jisu28' => 6, 'lhc' => (int) $type, 'jisu' => (int) $type + 6 };
- }
- /** 全部彩种:PC28 只列当前开关启用的那一个。 */
- public static function lotteryGames(): array
- {
- $pc28 = self::currentIssue()[1];
- $games = [];
- foreach (LotteryOdds::gameKeys() as $key) {
- if (in_array($key, ['canada28', 'jisu28'], true) && $key !== $pc28) continue;
- $games[$key] = self::LOTTERY_NAMES[self::lotteryType($key)];
- }
- return $games;
- }
- /**
- * 某个彩种当前可下注的期号;封盘、没有期号或彩种表不存在时返回 null。封盘规则与前台下注一致。
- * @return array{type:int,issue:string,close:?string}|null
- */
- public static function openIssue(string $key): ?array
- {
- [$game, $sub] = array_pad(explode(':', $key, 2), 2, null);
- $type = self::lotteryType($key);
- $time = fn ($value) => $value === null || $value === '' ? null : (is_numeric($value) ? (int) $value : strtotime((string) $value));
- if ($game === 'canada28' || $game === 'jisu28') {
- [, $current, $issue] = self::currentIssue();
- if ($current !== $key || !$issue || (string) $issue->end_time < date('Y-m-d H:i:s', time() + 30)) return null;
- return ['type' => $type, 'issue' => (string) $issue->issue_no, 'close' => (string) $issue->end_time];
- }
- if ($game === 'lhc') {
- if (!Schema::hasTable('lhc_lottery')) return null;
- // 新澳门、澳门六合彩共用新澳门的期号,下的是下一期;每天 21:30-21:40 封盘
- $official = in_array($type, [1, 3], true);
- $lottery = DB::table('lhc_lottery')->where('type', $official ? 1 : $type)->orderByDesc('issue')->first();
- if (!$lottery || (int) $lottery->status === 2) return null;
- if ($official) {
- if (date('H:i') >= '21:30' && date('H:i') <= '21:40') return null;
- return ['type' => $type, 'issue' => (string) ((int) $lottery->issue + 1), 'close' => null];
- }
- $next = $time($lottery->next_open_time);
- if ($next !== null && time() >= $next) return null;
- $issue = $type === 2 ? $lottery->next_issue : $lottery->issue;
- return $issue ? ['type' => $type, 'issue' => (string) $issue, 'close' => $next ? date('Y-m-d H:i:s', $next) : null] : null;
- }
- if (!Schema::hasTable('jisu_lottery')) return null;
- $lottery = DB::table('jisu_lottery')->where('type', (int) $sub)->orderByDesc('issue')->first();
- $open = $lottery ? $time($lottery->open_time) : null;
- $close = $open === null ? null : $open - (in_array((int) $sub, [1, 2, 3, 6], true) ? 15 : 30);
- if (!$lottery || (int) $lottery->status === 2 || $close === null || time() >= $close) return null;
- return ['type' => $type, 'issue' => (string) $lottery->issue, 'close' => date('Y-m-d H:i:s', $close)];
- }
- /** 某个彩种可下注的玩法:已配置赔率、赔率大于 0、单号码即可成注。PC28 的 id 为空,按 keywords 下注。 */
- public static function lotteryRules(string $key): array
- {
- $pc28 = in_array($key, ['canada28', 'jisu28'], true);
- try {
- $rows = DB::transaction(fn () => LotteryOdds::pricedCatalog($key, (int) LotteryOdds::template($key)->id));
- } catch (\Throwable $e) {
- return []; // 该彩种没有赔率模板或玩法表
- }
- $rules = [];
- foreach ($rows as $row) {
- if (!is_numeric($row->odds) || $row->odds <= 0) continue;
- $label = $pc28 ? (string) $row->keywords : trim(($row->game ?? '') . ' ' . ($row->gameplay ?? '') . ' ' . ($row->number ?? ''));
- if (!$pc28) foreach (self::MULTI_NUMBER_PLAYS as $word) if (str_contains($label, $word)) continue 2;
- $rules[] = ['id' => $pc28 ? null : (int) $row->id, 'keywords' => $label, 'odds' => $row->odds, 'mininum' => $row->mininum, 'maxinum' => $row->maxinum];
- }
- return $rules;
- }
- /** 下注表单用:各彩种及其当前期号;带 $game 时再给出该彩种的玩法。 */
- public static function options(?string $game = null): array
- {
- $games = [];
- foreach (self::lotteryGames() as $key => $name) {
- $open = self::openIssue($key);
- $games[] = ['key' => $key, 'name' => $name, 'type' => self::lotteryType($key), 'issue' => $open['issue'] ?? null, 'close' => $open['close'] ?? null, 'open' => $open !== null];
- }
- $game = $game !== null && isset(self::lotteryGames()[$game]) ? $game : null;
- return ['lottery' => ['games' => $games, 'game' => $game, 'rules' => $game ? self::lotteryRules($game) : []],
- 'sport' => ['results' => array_keys(self::SPORT_RESULTS)], 'third' => ['games' => self::THIRD_GAMES]];
- }
- private static function currentIssue(): array
- {
- $switch = (int) (Config::where('field', 'pc28_switch')->value('val') ?? 0);
- $issue = $switch === 1
- ? PcIssue::where('status', PcIssue::STATUS_BETTING)->orderByDesc('id')->first()
- : IssueService::model()::where('status', IssueService::model()::STATUS_BETTING)->orderByDesc('id')->first();
- return $switch === 1 ? [6, 'jisu28', $issue] : [5, 'canada28', $issue];
- }
- /**
- * 彩票下注,支持全部彩种,下在真实期号上,不发任何群消息或私聊。
- * PC28($game 为空或 canada28 / jisu28)按 $keywords 下注,随本项目开奖结算;
- * 其他彩种按玩法编号 $itemId 下注,注单格式与前台一致,由前台项目的结算任务按真实开奖结算。
- */
- public static function lottery($memberId, string $keywords, $amount, ?string $game = null, ?int $itemId = null): LhcOrder
- {
- SimulationSettings::assertEnabled();
- $user = SimulationSettings::member($memberId);
- $amount = SimulationSettings::assertAmount($amount);
- $key = $game ?: self::currentIssue()[1];
- if (!isset(self::lotteryGames()[$key])) throw new Exception('彩种不可用', HttpStatus::CUSTOM_ERROR);
- $pc28 = in_array($key, ['canada28', 'jisu28'], true);
- $open = self::openIssue($key);
- if (!$open) throw new Exception(self::LOTTERY_NAMES[self::lotteryType($key)] . ' 当前封盘或暂无可下注期数', HttpStatus::CUSTOM_ERROR);
- return DB::transaction(function () use ($user, $keywords, $amount, $key, $pc28, $open, $itemId) {
- $template = LotteryOdds::template($key);
- if ($pc28) {
- $rule = LotteryOdds::pc28Price($template, $keywords);
- $item = ['number' => $keywords];
- $fields = [];
- } else {
- $rule = LotteryOdds::pricedCatalog($key, (int) $template->id)->firstWhere('id', (int) $itemId);
- $item = ['number_id' => (int) $itemId, 'number' => (string) ($rule->number ?? '')];
- $fields = ['game' => (string) ($rule->game ?? ''), 'gameplay' => (string) ($rule->gameplay ?? '')];
- }
- if (!$rule) throw new Exception('玩法未配置:' . ($pc28 ? $keywords : $itemId), HttpStatus::CUSTOM_ERROR);
- if (!is_numeric($rule->odds) || $rule->odds <= 0) throw new Exception('该玩法赔率为 0,禁止投注', HttpStatus::CUSTOM_ERROR);
- if ($amount < $rule->mininum) throw new Exception('低于该玩法最小金额 ' . $rule->mininum, HttpStatus::CUSTOM_ERROR);
- if ($amount > $rule->maxinum) throw new Exception('超过该玩法最大金额 ' . $rule->maxinum, HttpStatus::CUSTOM_ERROR);
- $wallet = Wallet::where('member_id', $user->member_id)->lockForUpdate()->first();
- if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
- $before = (string) $wallet->available_balance;
- if (bccomp($before, $amount, 10) < 0) throw new Exception('余额不足', HttpStatus::CUSTOM_ERROR);
- $ordernum = date('YmdHis') . str_pad((string) mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
- LhcOrderItem::create(['ordernum' => $ordernum, 'member_id' => $user->member_id] + $item);
- $order = LhcOrder::create(['type' => $open['type'], 'bet_source' => LhcOrder::SOURCE_SIMULATION, 'issue' => $open['issue'], 'ordernum' => $ordernum,
- 'member_id' => $user->member_id, 'odds' => $rule->odds, 'amount' => $amount, 'total_amount' => $amount]
- + $fields + LotteryOdds::orderFields($template));
- $after = bcsub($before, $amount, 10);
- $wallet->available_balance = $after;
- $wallet->save();
- // 流水类型与真实下注一致:PC28 记“投注”,其他彩种记“彩种名 + 投注”
- $changeType = $pc28 ? '投注' : self::LOTTERY_NAMES[$open['type']] . '投注';
- BalanceLogService::addLog($user->member_id, bcsub('0', $amount, 2), $before, $after, $changeType, $order->id, '模拟投注');
- return $order;
- });
- }
- /** 随机挑一个当前可下注的彩种和玩法下一注;没有任何彩种可下时抛错。PC28 优先下大小单双。 */
- public static function lotteryRandom($memberId, $amount): LhcOrder
- {
- $keys = array_keys(self::lotteryGames());
- shuffle($keys);
- foreach ($keys as $key) {
- if (!self::openIssue($key)) continue;
- $rules = array_values(array_filter(self::lotteryRules($key), fn ($rule) => $amount >= $rule['mininum'] && $amount <= $rule['maxinum']));
- if (!$rules) continue;
- if (in_array($key, ['canada28', 'jisu28'], true)) {
- $basic = array_values(array_filter($rules, fn ($rule) => in_array($rule['keywords'], ['大', '小', '单', '双'], true)));
- $rules = $basic ?: $rules;
- }
- $rule = $rules[mt_rand(0, count($rules) - 1)];
- return self::lottery($memberId, (string) $rule['keywords'], $amount, $key, $rule['id']);
- }
- throw new Exception('当前没有可下注的彩种(都在封盘,或下注金额不在任何玩法的限额内)', HttpStatus::CUSTOM_ERROR);
- }
- /** 当前是否有任何彩种可下注(混合下注时用来决定要不要下彩票)。 */
- public static function lotteryOpen(): bool
- {
- foreach (array_keys(self::lotteryGames()) as $key) if (self::openIssue($key)) return true;
- return false;
- }
- /**
- * 非 PC28 彩种由前台项目结算,它不会给模拟会员登记报表用的结算时间;这里把已结算的模拟注单补登记(可重复执行)。
- * @return int 本次补登记的注单数
- */
- public static function sweepSettledLottery(): int
- {
- if (!Schema::hasTable('reporting_contexts')) return 0;
- $ids = DB::table('lhc_order')->where('bet_source', LhcOrder::SOURCE_SIMULATION)->whereNotIn('type', [5, 6])->where('lottery_status', '<>', 0)
- ->whereNotIn('id', DB::table('reporting_contexts')->where('source', 'lhc_order')->whereNotNull('succeeded_at')->selectRaw('CAST(source_id AS UNSIGNED)'))
- ->orderByDesc('id')->limit(200)->pluck('id');
- foreach ($ids as $id) \App\Services\Reporting\ReportContext::lotterySettled((int) $id);
- return count($ids);
- }
- /** 生成一张体育注单并扣款。 */
- public static function sport($memberId, $amount, $odd, string $title = '', string $selection = ''): Order
- {
- SimulationSettings::assertEnabled();
- $user = SimulationSettings::member($memberId);
- $amount = SimulationSettings::assertAmount($amount);
- if (!is_numeric($odd) || $odd <= 1 || $odd > 100) throw new Exception('赔率应大于 1 且不超过 100', HttpStatus::CUSTOM_ERROR);
- $title = $title !== '' ? $title : '模拟主队 VS 模拟客队';
- $selection = $selection !== '' ? $selection : '主胜';
- return DB::transaction(function () use ($user, $amount, $odd, $title, $selection) {
- $wallet = Wallet::where('member_id', $user->member_id)->lockForUpdate()->first();
- if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
- $before = (string) $wallet->available_balance;
- if (bccomp($before, $amount, 10) < 0) throw new Exception('余额不足', HttpStatus::CUSTOM_ERROR);
- $serial = date('YmdHis') . str_pad((string) mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
- $order = (new Order())->forceFill(['user_id' => (string) $user->user_id, 'order_id' => 'S' . $serial, 'issue' => self::SPORT_ISSUE_PREFIX . $serial,
- 'detail' => json_encode(['simulation' => true, 'title' => $title, 'selection' => $selection], JSON_UNESCAPED_UNICODE),
- 'amount' => $amount, 'cny_rate' => 1, 'is_faker' => 0, 'status' => 1, 'pay_status' => 1, 'pay_time' => time(),
- 'odd_name' => $title, 'odd_value' => $selection, 'odd' => $odd, 'is_roll' => 0, 'remark' => '模拟注单']);
- $order->save();
- $after = bcsub($before, $amount, 10);
- $wallet->available_balance = $after;
- $wallet->save();
- BalanceLogService::addLog($user->member_id, bcsub('0', $amount, 2), $before, $after, '体彩投注', $order->id, '模拟投注');
- return $order;
- });
- }
- public const THIRD_GAMES = [1 => '模拟视讯', 2 => '模拟老虎机', 3 => '模拟彩票', 4 => '模拟体育', 5 => '模拟电竞', 6 => '模拟捕猎', 7 => '模拟棋牌'];
- public const THIRD_PLATFORM = 'SIM';
- /**
- * 第三方游戏的模拟注单:只在本地生成一张已完成的注单,不向任何三方平台发请求。
- * 结果不指定时随机(约 45% 赢,赢 0.5~1.5 倍下注额;其余全输)。输赢净额以“资产转移”流水进出钱包。
- * @param string|null $result win|lose,null 为随机
- */
- public static function third($memberId, $amount, ?int $gameType = null, ?string $result = null): \App\Models\ThirdGameOrder
- {
- SimulationSettings::assertEnabled();
- $user = SimulationSettings::member($memberId);
- $amount = SimulationSettings::assertAmount($amount);
- $gameType = $gameType ?: mt_rand(1, 7);
- if (!isset(self::THIRD_GAMES[$gameType])) throw new Exception('游戏分类不正确', HttpStatus::CUSTOM_ERROR);
- if ($result !== null && !in_array($result, ['win', 'lose'], true)) throw new Exception('结果只能是赢或输', HttpStatus::CUSTOM_ERROR);
- $win = $result === null ? mt_rand(1, 100) <= 45 : $result === 'win';
- $profit = $win ? bcmul($amount, number_format(mt_rand(50, 150) / 100, 2, '.', ''), 2) : bcsub('0', $amount, 2);
- return DB::transaction(function () use ($user, $amount, $gameType, $profit) {
- $wallet = Wallet::where('member_id', $user->member_id)->lockForUpdate()->first();
- if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
- $before = (string) $wallet->available_balance;
- if (bccomp($before, $amount, 10) < 0) throw new Exception('余额不足', HttpStatus::CUSTOM_ERROR);
- $playerId = trim((string) config('third_game.player_prefix', 'u')) . str_pad((string) $user->id, 4, '0', STR_PAD_LEFT);
- $gameOrderId = 'SIM' . date('YmdHis') . str_pad((string) mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
- $now = now();
- $order = \App\Models\ThirdGameOrder::create(['order_key' => hash('sha256', self::THIRD_PLATFORM . '|' . $playerId . '|' . $gameOrderId),
- 'user_id' => $user->id, 'member_id' => (string) $user->member_id, 'username' => (string) ($user->username ?? ''),
- 'first_name' => (string) ($user->first_name ?? ''), 'player_id' => $playerId, 'platform' => self::THIRD_PLATFORM,
- 'currency' => (string) config('third_game.currency', 'CNY'), 'game_type' => $gameType, 'game_name' => self::THIRD_GAMES[$gameType],
- 'round_no' => $gameOrderId, 'table_no' => '', 'seat' => '', 'bet_amount' => $amount, 'valid_amount' => $amount, 'settled_amount' => $profit,
- 'bet_content' => '模拟注单', 'status' => 1, 'game_order_id' => $gameOrderId, 'bet_time' => $now, 'last_update_time' => $now]);
- $after = bcadd($before, $profit, 10);
- $wallet->available_balance = $after;
- $wallet->save();
- BalanceLogService::addLog($user->member_id, $profit, $before, $after, '资产转移', $order->id, '三方游戏模拟结算:' . self::THIRD_GAMES[$gameType]);
- return $order;
- });
- }
- /** 只允许操作模拟会员自己的、合成期号的体育注单。 */
- private static function sportOrder(int $id): Order
- {
- $order = Order::where('id', $id)->lockForUpdate()->first();
- if (!$order || !str_starts_with((string) $order->issue, self::SPORT_ISSUE_PREFIX)
- || !DB::table('users')->where('user_id', (string) $order->user_id)->where('user_type', MemberAudience::TYPE_SIMULATED)->exists()) {
- throw new Exception('不是模拟体育注单', HttpStatus::CUSTOM_ERROR);
- }
- if ((int) $order->settlement_status !== 0 || (int) $order->status !== 1) throw new Exception('注单已结算或已取消', HttpStatus::CUSTOM_ERROR);
- return $order;
- }
- /** 按给定结果结算:赢=本金×赔率,和局退本金,输半退一半,输不退。金额口径与前台结算一致。 */
- public static function settleSport(int $id, string $result): Order
- {
- SimulationSettings::assertEnabled();
- if (!isset(self::SPORT_RESULTS[$result])) throw new Exception('结算结果不正确', HttpStatus::CUSTOM_ERROR);
- return DB::transaction(function () use ($id, $result) {
- $order = self::sportOrder($id);
- $amount = (string) $order->amount;
- $win = '0'; $return = '0'; $profit = bcsub('0', $amount, 2); $changeType = null;
- if ($result === 'win') { $win = bcmul($amount, (string) $order->odd, 2); $profit = bcsub($win, $amount, 2); $changeType = '体彩中奖'; }
- if ($result === 'draw') { $return = $amount; $profit = '0'; $changeType = '体彩和局退款'; }
- if ($result === 'half') { $return = bcdiv($amount, '2', 2); $profit = bcsub($return, $amount, 2); $changeType = '体彩输半退款'; }
- $order->forceFill(['is_win' => self::SPORT_RESULTS[$result], 'win_amount' => $win, 'return_amount' => $return, 'profit_and_loss' => $profit,
- 'settlement_status' => 1, 'settlement_time' => time(), 'game_result' => $result])->save();
- $credit = bcadd($win, $return, 2);
- if ($changeType !== null && bccomp($credit, '0', 2) > 0) self::credit((string) $order->user_id, $credit, $changeType, (int) $order->id);
- return $order;
- });
- }
- public static function cancelSport(int $id, string $remark = ''): Order
- {
- SimulationSettings::assertEnabled();
- return DB::transaction(function () use ($id, $remark) {
- $order = self::sportOrder($id);
- $order->forceFill(['status' => 2, 'return_amount' => $order->amount, 'profit_and_loss' => 0, 'settlement_status' => 1,
- 'settlement_time' => time(), 'remark' => $remark !== '' ? $remark : '模拟注单撤销'])->save();
- self::credit((string) $order->user_id, (string) $order->amount, '体彩退款', (int) $order->id);
- return $order;
- });
- }
- private static function credit(string $userId, string $amount, string $changeType, int $orderId): void
- {
- $memberId = DB::table('users')->where('user_id', $userId)->value('member_id');
- $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
- if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
- $before = (string) $wallet->available_balance;
- $after = bcadd($before, $amount, 10);
- $wallet->available_balance = $after;
- $wallet->save();
- BalanceLogService::addLog($memberId, $amount, $before, $after, $changeType, $orderId, '模拟结算');
- }
- /** 模拟体育注单查询(合成期号没有赛事,不能复用正式列表的赛事关联)。 */
- public static function sportQuery()
- {
- return DB::table('sport_order')->join('users', 'users.user_id', '=', 'sport_order.user_id')
- ->where('users.user_type', MemberAudience::TYPE_SIMULATED);
- }
- }
|