Files
webman-buildadmin/app/api/controller/Game.php
zhenhui a033bf47b6 1.优化game_config
2.备份MySQL数据库
2026-04-28 15:50:16 +08:00

498 lines
18 KiB
PHP
Raw 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\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 support\think\Db;
use Webman\Http\Request;
use support\Response;
class Game extends MobileBase
{
protected array $noNeedLogin = ['dictionaryList'];
public function lobbyInit(Request $request): Response
{
$response = $this->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::isLiveRuntimeEnabled(),
'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::isLiveRuntimeEnabled(),
'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::isLiveRuntimeEnabled()) {
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<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';
}
/**
* 单注最多可选号码个数:`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;
}
}