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); } }