initializeMobile($request); if ($response !== null) { return $response; } $periodRow = $this->resolveMobilePeriodRow(); $now = time(); $startAt = $periodRow ? $this->intValue($periodRow['period_start_at'] ?? 0) : $now; $lockAt = $startAt + 20; $openAt = $startAt + 22; $countdown = $periodRow ? max(0, ($startAt + 30) - $now) : 0; $dictionaryConfig = GameHotDataRedis::gameConfigRow(ZiHuaDictionary::CONFIG_KEY) ?? []; $dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig['config_value'] ?? null); $items = []; foreach ($dictionaryItems as $row) { $items[] = [ 'number' => $row['no'], 'name' => $row['name'], 'category' => $row['category'], 'icon' => '', ]; } $user = $this->auth->getUser(); $currentStreakRaw = $user->current_streak ?? 0; $currentStreakParsed = filter_var($currentStreakRaw, FILTER_VALIDATE_INT); $currentStreak = $currentStreakParsed === false ? 0 : $currentStreakParsed; return $this->mobileSuccess([ 'server_time' => $now, 'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE), 'period' => [ 'period_id' => $periodRow ? $this->intValue($periodRow['id'] ?? 0) : 0, 'period_no' => (string) ($periodRow['period_no'] ?? ''), 'status' => $this->mapPeriodStatus($periodRow['status'] ?? null), 'countdown' => $countdown, 'lock_at' => $lockAt, 'open_at' => $openAt, ], 'bet_config' => array_merge( [ 'pick_max_number_count' => $this->getPickMaxNumberCount(), 'min_bet_per_number' => $this->getConfigValue('min_bet_per_number', '0.0100'), 'max_bet_per_number' => $this->getConfigValue('max_bet_per_number', '10000.0000'), ], BetChips::lobbyChipsPayload() ), 'dictionary' => $items, 'user_snapshot' => array_merge( [ 'coin' => $user->coin, ], StreakWinReward::playerBetOddsForCurrentStreak($currentStreak) ), ]); } public function dictionaryList(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $dictionaryConfig = GameHotDataRedis::gameConfigRow(ZiHuaDictionary::CONFIG_KEY) ?? []; $dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig['config_value'] ?? null); $items = []; foreach ($dictionaryItems as $row) { $items[] = [ 'number' => $row['no'], 'name' => $row['name'], 'category' => $row['category'], 'icon' => '', ]; } return $this->mobileSuccess([ 'version' => (string) ($dictionaryConfig['update_time'] ?? '1'), 'items' => $items, ]); } public function periodCurrent(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $periodRow = $this->resolveMobilePeriodRow(); if (!$periodRow) { return $this->mobileError(2002, 'Game period does not exist'); } $now = time(); $startAt = $this->intValue($periodRow['period_start_at'] ?? 0); return $this->mobileSuccess([ 'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE), 'period_id' => $this->intValue($periodRow['id'] ?? 0), 'period_no' => $periodRow['period_no'], 'status' => $this->mapPeriodStatus($periodRow['status'] ?? null), 'countdown' => max(0, ($startAt + 30) - $now), 'bet_close_in' => max(0, ($startAt + 20) - $now), 'result_number' => $periodRow['result_number'] ?? null, ]); } /** * 兼容旧路由:/api/game/betPlace * 与 placeBet 一致:须传筹码标识 bet_id(1–6),单注金额取自后台 game_config 的 bet_chips。 */ public function betPlace(Request $request): Response { return $this->placeBet($request); } /** * 提交下注:入参为 period_no + numbers + bet_id(1–6,对应 lobbyInit.bet_config.chips 的键)+ idempotency_key。 */ public function placeBet(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $periodNo = trim((string) $request->post('period_no', '')); $numbersRaw = $request->post('numbers', ''); $idempotencyKey = trim((string) $request->post('idempotency_key', '')); $chipPick = $this->resolveBetChipFromRequest($request); if (isset($chipPick['error'])) { return $chipPick['error']; } $betChipId = $chipPick['bet_id']; $singleBetAmount = $chipPick['amount']; if ($periodNo === '' || $idempotencyKey === '') { return $this->mobileError(1001, 'Missing parameters'); } if (!is_numeric($singleBetAmount) || bccomp($singleBetAmount, '0', 2) <= 0) { return $this->mobileError(1003, 'Invalid parameter value'); } $numbers = $this->parseBetNumbersFromRequest($numbersRaw); if ($numbers === []) { return $this->mobileError(1003, 'Invalid parameter value'); } $maxSelect = $this->getPickMaxNumberCount(); if (count($numbers) > $maxSelect) { return $this->mobileError(1003, 'Invalid parameter value'); } $singleAmount = bcadd($singleBetAmount, '0', 2); $minPer = trim((string) $this->getConfigValue('min_bet_per_number', '0.0100')); $maxPer = trim((string) $this->getConfigValue('max_bet_per_number', '10000.0000')); if (!is_numeric($minPer) || bccomp($minPer, '0', 4) <= 0) { $minPer = '0.0100'; } if (!is_numeric($maxPer) || bccomp($maxPer, $minPer, 4) < 0) { $maxPer = '10000.0000'; } if (bccomp($singleAmount, $minPer, 4) < 0 || bccomp($singleAmount, $maxPer, 4) > 0) { return $this->mobileError(1003, 'Bet amount out of range'); } $numberCount = (string) count($numbers); $totalAmount = bcmul($singleAmount, $numberCount, 2); if (!GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE)) { return $this->mobileError(3001, 'Game is paused'); } $period = GameRecord::where('period_no', $periodNo)->find(); if (!$period) { return $this->mobileError(2002, 'Game period does not exist'); } if ($this->intValue($period->status) !== 0) { return $this->mobileError(3002, 'Betting is closed'); } $activeRow = GameHotDataRedis::gameRecordActive(); if ($activeRow !== null) { $activeNo = trim((string) ($activeRow['period_no'] ?? '')); $activeId = $this->intValue($activeRow['id'] ?? 0); if ($activeNo !== '' && ($periodNo !== $activeNo || $this->intValue($period->id) !== $activeId)) { return $this->mobileError(3004, 'Not the current period; please refresh period_no'); } } $userIdRaw = $this->auth->id ?? null; $userId = filter_var($userIdRaw, FILTER_VALIDATE_INT); if ($userId === false || $userId <= 0) { return $this->mobileError(1001, 'Missing parameters'); } $user = $this->auth->getUser(); if (bccomp((string) $user->coin, $totalAmount, 2) < 0) { return $this->mobileError(2001, 'Insufficient balance'); } $exists = BetOrder::where('idempotency_key', $idempotencyKey)->find(); if ($exists) { return $this->mobileError(3003, 'Duplicate request'); } $lock = GameHotDataRedis::userAdminMutationLockTry($userId); if (!$lock['acquired']) { return $this->mobileError(5000, (string) __('This user is being operated by another admin (wallet/concurrent save); please try again later')); } try { $coinRow = Db::name('user')->where('id', $userId)->field(['coin', 'channel_id', 'current_streak'])->find(); if (!$coinRow) { return $this->mobileError(5000, 'System is busy, please try again later'); } $before = (string) ($coinRow['coin'] ?? '0'); if (bccomp($before, $totalAmount, 2) < 0) { return $this->mobileError(2001, 'Insufficient balance'); } $existsLocked = BetOrder::where('idempotency_key', $idempotencyKey)->find(); if ($existsLocked) { return $this->mobileError(3003, 'Duplicate request'); } $channelIdRaw = $coinRow['channel_id'] ?? null; $channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT); if ($channelId === false) { $channelId = null; } $now = time(); $after = bcsub($before, $totalAmount, 2); $orderNo = 'BO' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6); $streakAtBet = (int) ($coinRow['current_streak'] ?? 0); Db::startTrans(); try { $affected = Db::name('user')->where('id', $userId)->where('coin', $before)->update([ 'coin' => $after, 'update_time' => $now, ]); if ($affected !== 1) { Db::rollback(); return $this->mobileError(5000, (string) __('Debit failed: user balance changed by another request; please refresh and retry')); } UserWalletRecord::create([ 'user_id' => $userId, 'channel_id' => $channelId, 'biz_type' => 'bet', 'direction' => 2, 'amount' => $totalAmount, 'balance_before' => $before, 'balance_after' => $after, 'ref_type' => 'bet_order', 'remark' => '移动端下注', 'create_time' => $now, ]); BetOrder::create([ 'period_id' => $period->id, 'period_no' => $period->period_no, 'user_id' => $userId, 'channel_id' => $channelId, 'pick_numbers' => $numbers, 'total_amount' => $totalAmount, 'streak_at_bet' => $streakAtBet, 'is_auto' => 0, 'status' => 1, 'idempotency_key' => $idempotencyKey, 'create_time' => $now, 'update_time' => $now, ]); Db::commit(); } catch (\Throwable $e) { Db::rollback(); return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]); } GameHotDataCoordinator::afterUserCommitted($userId); GameWebSocketEventBus::publish('bet.accepted', GameWebSocketPayloadHelper::mergeUserStreakInto([ 'user_id' => $userId, 'period_no' => $period->period_no, 'numbers' => $numbers, 'bet_id' => $betChipId, 'single_bet_amount' => $singleAmount, 'numbers_count' => count($numbers), 'total_amount' => $totalAmount, 'balance_after' => $after, 'accepted_at' => time(), ], $userId, $streakAtBet)); GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto([ 'user_id' => $userId, 'balance_after' => $after, 'biz_type' => 'bet', 'changed_at' => time(), ], $userId, $streakAtBet)); return $this->mobileSuccess([ 'order_no' => $orderNo, 'period_no' => $period->period_no, 'status' => 'accepted', 'bet_id' => $betChipId, 'single_bet_amount' => $singleAmount, 'numbers_count' => count($numbers), 'locked_balance' => '0.00', 'balance_after' => $after, 'current_streak' => $streakAtBet, ]); } finally { GameHotDataRedis::userAdminMutationLockRelease($userId, $lock['token'], $lock['redis_lock']); } } /** * 自动托管(无推送模式):先落托管配置,实际执行仍由客户端轮询 current_status 驱动。 */ public function autoSpin(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $action = trim((string) $request->post('action', 'start')); if ($action === 'stop') { return $this->mobileSuccess([ 'status' => 'stopped', 'auto_mode' => false, ]); } $periodNo = trim((string) $request->post('period_no', '')); $numbersRaw = $request->post('numbers', ''); $rounds = $this->intValue($request->post('rounds', 1)); $chipPick = $this->resolveBetChipFromRequest($request); if (isset($chipPick['error'])) { return $chipPick['error']; } $singleBetAmount = $chipPick['amount']; if ($periodNo === '' || $rounds < 1) { return $this->mobileError(1001, 'Missing parameters'); } if (!is_numeric($singleBetAmount) || bccomp($singleBetAmount, '0', 2) <= 0) { return $this->mobileError(1003, 'Invalid parameter value'); } $numbers = $this->parseBetNumbersFromRequest($numbersRaw); if ($numbers === []) { return $this->mobileError(1003, 'Invalid parameter value'); } $userIdValue = filter_var($this->auth->id ?? null, FILTER_VALIDATE_INT); if ($userIdValue === false) { $userIdValue = 0; } GameWebSocketEventBus::publish('auto.spin.progress', [ 'user_id' => $userIdValue, 'period_no' => $periodNo, 'bet_id' => $chipPick['bet_id'], 'rounds' => $rounds, 'remaining_rounds' => $rounds, 'completed_rounds' => 0, 'status' => 'scheduled', 'server_time' => time(), ]); return $this->mobileSuccess([ 'status' => 'scheduled', 'auto_mode' => true, 'period_no' => $periodNo, 'numbers' => $numbers, 'bet_id' => $chipPick['bet_id'], 'single_bet_amount' => bcadd($singleBetAmount, '0', 2), 'rounds' => $rounds, 'remaining_rounds' => $rounds, ]); } public function betMyOrders(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $page = $this->intValue($request->input('page', 1)); $pageSize = $this->intValue($request->input('page_size', 20)); $paginate = BetOrder::where('user_id', $this->auth->id)->order('id', 'desc')->paginate([ 'page' => $page, 'list_rows' => $pageSize, ]); $rows = []; foreach ($paginate->items() as $item) { $rows[] = [ 'order_no' => (string) $item->id, 'period_no' => $item->period_no, 'numbers' => $item->pick_numbers ?? [], // 整笔压注金额(本笔总扣款) 'bet_amount' => $item->total_amount, 'total_amount' => $item->total_amount, 'result_number' => null, 'win_amount' => $item->win_amount, 'status' => (string) $item->status, 'create_time' => $item->create_time, ]; } return $this->mobileSuccess([ 'list' => $rows, 'pagination' => [ 'page' => $paginate->currentPage(), 'page_size' => $paginate->listRows(), 'total' => $paginate->total(), ], ]); } /** * 下注号码:`numbers` 为逗号分隔字符串(如 `1,8,16`);兼容旧版 JSON 数组。 * * @return list */ private function parseBetNumbersFromRequest($numbersRaw): array { if (is_array($numbersRaw)) { $out = []; foreach ($numbersRaw as $v) { $n = filter_var($v, FILTER_VALIDATE_INT); if ($n === false || $n < 1 || $n > 36) { return []; } $out[] = $n; } $out = array_values(array_unique($out)); sort($out); return $out; } $raw = trim((string) $numbersRaw); if ($raw === '') { return []; } $parts = preg_split('/\s*,\s*/', $raw); $out = []; foreach ($parts as $p) { if ($p === '') { continue; } $n = filter_var($p, FILTER_VALIDATE_INT); if ($n === false || $n < 1 || $n > 36) { return []; } $out[] = $n; } $out = array_values(array_unique($out)); sort($out); return $out; } private function mapPeriodStatus($status): string { if ($this->intValue($status) === 0) { return 'betting'; } if ($this->intValue($status) === 1) { return 'locked'; } if ($this->intValue($status) === 2 || $this->intValue($status) === 3) { return 'settling'; } if ($this->intValue($status) === 5) { return 'void'; } return 'finished'; } /** * @return array{bet_id: int, amount: string}|array{error: Response} */ private function resolveBetChipFromRequest(Request $request): array { $betIdRaw = $request->post('bet_id', ''); if ($betIdRaw === '' || $betIdRaw === null) { return ['error' => $this->mobileError(1001, 'Missing parameters')]; } $betId = filter_var($betIdRaw, FILTER_VALIDATE_INT); if ($betId === false || $betId < 1 || $betId > 6) { return ['error' => $this->mobileError(1003, 'Invalid parameter value')]; } $resolved = BetChips::resolveFromHotData(); $amount = BetChips::amountForBetId($betId, $resolved['map']); if ($amount === null) { return ['error' => $this->mobileError(1003, 'Invalid parameter value')]; } return ['bet_id' => $betId, 'amount' => $amount]; } /** * 单注最多可选号码个数:`game_config.config_key = pick_max_number_count` */ private function getPickMaxNumberCount(): int { $v = $this->intValue($this->getConfigValue('pick_max_number_count', '10')); if ($v < 1) { return 1; } if ($v > 36) { return 36; } return $v; } private function getConfigValue(string $key, string $default): string { $row = GameHotDataRedis::gameConfigRow($key); if ($row === null) { return $default; } $value = $row['config_value'] ?? null; if ($value === null || $value === '') { return $default; } return (string) $value; } /** * 移动端展示的当前期:优先进行中局(id 最大且 status∈0..3),避免 gameRecordLatest 指向已结束新局而客户端仍用旧 period_no 下注。 * * @return array|null */ private function resolveMobilePeriodRow(): ?array { return GameHotDataRedis::gameRecordActive() ?? GameHotDataRedis::gameRecordLatest(); } private function intValue($value): int { $result = filter_var($value, FILTER_VALIDATE_INT); if ($result === false) { return 0; } return $result; } }