initializeMobile($request); if ($response !== null) { return $response; } $periodRow = GameHotDataRedis::gameRecordLatest(); $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(); return $this->mobileSuccess([ 'server_time' => $now, 'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE), 'period' => [ 'period_no' => (string) ($periodRow['period_no'] ?? ''), 'status' => $this->mapPeriodStatus($periodRow['status'] ?? null), 'countdown' => $countdown, 'lock_at' => $lockAt, 'open_at' => $openAt, ], 'bet_config' => [ 'pick_max_number_count' => $this->getPickMaxNumberCount(), 'chips' => ['1.00', '5.00', '10.00', '25.00', '50.00', '100.00'], 'min_bet_per_number' => $this->getConfigValue('min_bet_per_number', '0.0100'), 'max_bet_per_number' => $this->getConfigValue('max_bet_per_number', '10000.0000'), ], 'dictionary' => $items, 'user_snapshot' => [ 'coin' => $user->coin, 'current_streak' => $user->current_streak ?? 0, ], ]); } 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 = GameHotDataRedis::gameRecordLatest(); 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' => $periodRow['id'], '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 * 新语义与 place_bet 一致:bet_amount 作为“单注金额”。 */ public function betPlace(Request $request): Response { return $this->placeBet($request); } /** * 提交下注:入参为 period_no + numbers + single_bet_amount + idempotency_key。 * 兼容前端传参 bet_amount(作为 single_bet_amount 同义字段)。 */ 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', ''); $singleBetAmount = trim((string) ($request->post('single_bet_amount', $request->post('bet_amount', '')))); $idempotencyKey = trim((string) $request->post('idempotency_key', '')); if ($periodNo === '' || $singleBetAmount === '' || $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'); } $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', [ 'user_id' => $userId, 'period_no' => $period->period_no, 'numbers' => $numbers, 'single_bet_amount' => $singleAmount, 'numbers_count' => count($numbers), 'total_amount' => $totalAmount, 'balance_after' => $after, 'accepted_at' => time(), ]); GameWebSocketEventBus::publish('wallet.changed', [ 'user_id' => $userId, 'balance_after' => $after, 'biz_type' => 'bet', 'changed_at' => time(), ]); return $this->mobileSuccess([ 'order_no' => $orderNo, 'period_no' => $period->period_no, 'status' => 'accepted', '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', ''); $singleBetAmount = trim((string) ($request->post('single_bet_amount', $request->post('bet_amount', '')))); $rounds = $this->intValue($request->post('rounds', 1)); if ($periodNo === '' || $singleBetAmount === '' || $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, '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, '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 语义一致) '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'; } /** * 单注最多可选号码个数:`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; } private function intValue($value): int { $result = filter_var($value, FILTER_VALIDATE_INT); if ($result === false) { return 0; } return $result; } }