|
|
@@ -15,10 +15,11 @@ use App\Services\LotteryOdds;
|
|
|
use App\Support\MemberAudience;
|
|
|
use Exception;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
+use Illuminate\Support\Facades\Schema;
|
|
|
|
|
|
/**
|
|
|
* 模拟会员的注单,写真实注单表并产生真实账变。
|
|
|
- * 彩票:PC28(加拿大 type 5 / 极速 type 6,跟随当前 pc28_switch),下在真实期号上,随真实开奖由 BetService::betSettled 结算。
|
|
|
+ * 彩票:全部彩种,下在真实期号上。PC28(type 5 / 6)随本项目开奖由 BetService::betSettled 结算;六合彩、极速彩等由前台项目的结算任务按真实开奖结算。
|
|
|
* 体育:sport_order,issue 用不存在于赛事表的合成值,所以前台的结算任务取不到它,只能在这里按给定结果结算。
|
|
|
*/
|
|
|
final class SimulationBetService
|
|
|
@@ -26,16 +27,98 @@ final class SimulationBetService
|
|
|
public const SPORT_ISSUE_PREFIX = 'SIM';
|
|
|
public const SPORT_RESULTS = ['win' => 1, 'lose' => 0, 'draw' => 2, 'half' => 3];
|
|
|
|
|
|
- /** 当前可下注的 PC28 期号与可用玩法。 */
|
|
|
- public static function options(): array
|
|
|
+ /** 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
|
|
|
{
|
|
|
- [$type, $key, $issue] = self::currentIssue();
|
|
|
- $rules = DB::transaction(fn () => LotteryOdds::pricedCatalog($key, LotteryOdds::template($key)->id)
|
|
|
- ->filter(fn ($rule) => $rule->odds > 0)
|
|
|
- ->map(fn ($rule) => ['keywords' => $rule->keywords, 'odds' => $rule->odds,
|
|
|
- 'mininum' => $rule->mininum, 'maxinum' => $rule->maxinum])->values());
|
|
|
- return ['lottery' => ['type' => $type, 'issue' => $issue ? $issue->issue_no : null, 'end_time' => $issue ? (string) $issue->end_time : null, 'rules' => $rules],
|
|
|
- 'sport' => ['results' => array_keys(self::SPORT_RESULTS)]];
|
|
|
+ [$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
|
|
|
@@ -47,20 +130,34 @@ final class SimulationBetService
|
|
|
return $switch === 1 ? [6, 'jisu28', $issue] : [5, 'canada28', $issue];
|
|
|
}
|
|
|
|
|
|
- /** PC28 下注,校验与 BetService::singleNote 一致,但不发任何群消息或私聊。 */
|
|
|
- public static function lottery($memberId, string $keywords, $amount): LhcOrder
|
|
|
+ /**
|
|
|
+ * 彩票下注,支持全部彩种,下在真实期号上,不发任何群消息或私聊。
|
|
|
+ * 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);
|
|
|
- [$type, $key, $issue] = self::currentIssue();
|
|
|
- if (!$issue) throw new Exception('暂无可下注期数', HttpStatus::CUSTOM_ERROR);
|
|
|
- if ((string) $issue->end_time < date('Y-m-d H:i:s', time() + 30)) throw new Exception('封盘中,本次下注无效', HttpStatus::CUSTOM_ERROR);
|
|
|
- return DB::transaction(function () use ($user, $keywords, $amount, $key, $type, $issue) {
|
|
|
+ $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);
|
|
|
- $rule = LotteryOdds::pc28Price($template, $keywords);
|
|
|
- if (!$rule) throw new Exception('玩法未配置:' . $keywords, HttpStatus::CUSTOM_ERROR);
|
|
|
- if ($rule->odds <= 0) throw new Exception('该玩法赔率为 0,禁止投注', HttpStatus::CUSTOM_ERROR);
|
|
|
+ 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();
|
|
|
@@ -68,18 +165,60 @@ final class SimulationBetService
|
|
|
$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, 'number' => $keywords]);
|
|
|
- $order = LhcOrder::create(['type' => $type, 'bet_source' => LhcOrder::SOURCE_SIMULATION, 'issue' => $issue->issue_no, 'ordernum' => $ordernum,
|
|
|
+ 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]
|
|
|
- + LotteryOdds::orderFields($template));
|
|
|
+ + $fields + LotteryOdds::orderFields($template));
|
|
|
$after = bcsub($before, $amount, 10);
|
|
|
$wallet->available_balance = $after;
|
|
|
$wallet->save();
|
|
|
- BalanceLogService::addLog($user->member_id, bcsub('0', $amount, 2), $before, $after, '投注', $order->id, '模拟投注');
|
|
|
+ // 流水类型与真实下注一致: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
|
|
|
{
|
|
|
@@ -108,6 +247,46 @@ final class SimulationBetService
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+ 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
|
|
|
{
|