Files
webman-buildadmin/app/api/controller/Game.php

640 lines
24 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\library\game\BetChips;
use app\common\library\game\GamePeriodNo;
use app\common\library\game\StreakWinReward;
use app\common\library\game\ZiHuaDictionary;
use app\common\model\BetOrder;
use app\common\model\GameRecord;
use app\common\model\UserWalletRecord;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataRedis;
use app\common\service\GameRecordService;
use app\common\service\GameWebSocketEventBus;
use app\common\service\GameWebSocketPayloadHelper;
use support\think\Db;
use Webman\Http\Request;
use support\Response;
class Game extends MobileBase
{
protected array $noNeedLogin = ['dictionaryList', 'periodHistory'];
public function lobbyInit(Request $request): Response
{
$response = $this->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' => GamePeriodNo::toDisplay(
(string) ($periodRow['period_no'] ?? ''),
$periodRow ? $this->intValue($periodRow['id'] ?? 0) : 0
),
'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,
]);
}
/**
* 获取最近开奖记录(默认最新 30 条),期号为纯数字。
*/
public function periodHistory(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$limit = $this->intValue($request->input('limit', 30));
if ($limit < 1) {
$limit = 30;
}
if ($limit > 100) {
$limit = 100;
}
$list = GameRecord::whereNotNull('result_number')->order('id', 'desc')->limit($limit)->select();
$rows = [];
foreach ($list as $item) {
$periodId = $this->intValue($item->id ?? 0);
$rows[] = [
'period_no' => GamePeriodNo::toDisplay((string) ($item->period_no ?? ''), $periodId),
'result_number' => $item->result_number,
'open_time' => $item->update_time,
];
}
return $this->mobileSuccess(['list' => $rows]);
}
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' => GamePeriodNo::toDisplay(
(string) ($periodRow['period_no'] ?? ''),
$this->intValue($periodRow['id'] ?? 0)
),
'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_id16单注金额取自后台 game_config 的 bet_chips。
*/
public function betPlace(Request $request): Response
{
return $this->placeBet($request);
}
/**
* 提交下注:入参为 period_no + numbers + bet_id16对应 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 && ctype_digit($periodNo)) {
$periodIdLookup = filter_var($periodNo, FILTER_VALIDATE_INT);
if ($periodIdLookup !== false && $periodIdLookup > 0) {
$period = GameRecord::find($periodIdLookup);
}
}
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));
$periodIdForDisplay = filter_var($period->id ?? null, FILTER_VALIDATE_INT);
if ($periodIdForDisplay === false) {
$periodIdForDisplay = 0;
}
return $this->mobileSuccess([
'order_no' => $orderNo,
'period_no' => GamePeriodNo::toDisplay((string) ($period->period_no ?? ''), $periodIdForDisplay),
'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,
]);
$periodIds = [];
foreach ($paginate->items() as $item) {
$periodIdValue = filter_var($item->period_id ?? null, FILTER_VALIDATE_INT);
if ($periodIdValue !== false && $periodIdValue > 0) {
$periodIds[] = $periodIdValue;
}
}
$periodIds = array_values(array_unique($periodIds));
$resultNumberByPeriodId = [];
if ($periodIds !== []) {
$periodRows = Db::name('game_record')
->whereIn('id', $periodIds)
->field(['id', 'result_number'])
->select()
->toArray();
foreach ($periodRows as $row) {
if (!is_array($row)) {
continue;
}
$pid = filter_var($row['id'] ?? null, FILTER_VALIDATE_INT);
if ($pid === false || $pid <= 0) {
continue;
}
$rn = filter_var($row['result_number'] ?? null, FILTER_VALIDATE_INT);
$resultNumberByPeriodId[$pid] = ($rn === false ? null : $rn);
}
}
$rows = [];
foreach ($paginate->items() as $item) {
$periodIdValue = filter_var($item->period_id ?? null, FILTER_VALIDATE_INT);
if ($periodIdValue === false) {
$periodIdValue = 0;
}
$rows[] = [
'order_no' => (string) $item->id,
'period_no' => GamePeriodNo::toDisplay((string) ($item->period_no ?? ''), $periodIdValue),
'numbers' => $item->pick_numbers ?? [],
// 整笔压注金额(本笔总扣款)
'bet_amount' => $item->total_amount,
'total_amount' => $item->total_amount,
'result_number' => $periodIdValue > 0 ? ($resultNumberByPeriodId[$periodIdValue] ?? null) : 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<int>
*/
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<string, mixed>|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;
}
}