优化游戏实时对局页面
This commit is contained in:
@@ -5,6 +5,7 @@ namespace app\admin\controller\game;
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\admin\PushChannelConfigHelper;
|
||||
use app\common\service\GameLiveService;
|
||||
use app\common\service\GameRecordService;
|
||||
use support\Response;
|
||||
use Webman\Http\Request as WebmanRequest;
|
||||
|
||||
@@ -102,4 +103,50 @@ class Live extends Backend
|
||||
$okMsg = $res['msg'] ?? '';
|
||||
return $this->success(is_string($okMsg) ? $okMsg : '', $res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 游戏运行开关:关闭时禁止下注、派彩结束后不自动开新期,但当局仍自动开奖并结算;重新开启且无进行中局时立即创建新一期。
|
||||
*/
|
||||
public function runtime(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$raw = $request->post('enabled');
|
||||
$enabled = $raw === true || $raw === '1' || $raw === 1;
|
||||
GameRecordService::setLiveRuntimeEnabled($enabled);
|
||||
if ($enabled) {
|
||||
GameRecordService::bootstrapPeriodWhenRuntimeEnabled();
|
||||
}
|
||||
return $this->success('', GameLiveService::buildSnapshot(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 作废当前对局(下注/封盘阶段),填写原因后退款待开奖注单并关闭运行开关。
|
||||
*/
|
||||
public function voidPeriod(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$recordIdRaw = $request->post('record_id');
|
||||
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
|
||||
$voidReason = $request->post('void_reason');
|
||||
$reasonStr = is_string($voidReason) ? $voidReason : '';
|
||||
$res = GameLiveService::voidCurrentPeriod($recordId, $reasonStr);
|
||||
if (!($res['ok'] ?? false)) {
|
||||
$errMsg = $res['msg'] ?? null;
|
||||
return $this->error(is_string($errMsg) ? $errMsg : __('Void failed'));
|
||||
}
|
||||
$okMsg = $res['msg'] ?? '';
|
||||
return $this->success(is_string($okMsg) ? $okMsg : '', GameLiveService::buildSnapshot(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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\UserPushService;
|
||||
use support\think\Db;
|
||||
use Webman\Http\Request;
|
||||
@@ -48,6 +49,7 @@ class Game extends MobileBase
|
||||
$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),
|
||||
@@ -126,6 +128,7 @@ class Game extends MobileBase
|
||||
$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),
|
||||
@@ -168,6 +171,9 @@ class Game extends MobileBase
|
||||
return $this->mobileError(1003, 'Invalid parameter value');
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -382,6 +388,9 @@ class Game extends MobileBase
|
||||
if ($this->intValue($status) === 2 || $this->intValue($status) === 3) {
|
||||
return 'settling';
|
||||
}
|
||||
if ($this->intValue($status) === 5) {
|
||||
return 'void';
|
||||
}
|
||||
return 'finished';
|
||||
}
|
||||
|
||||
|
||||
@@ -21,4 +21,14 @@ return [
|
||||
'Calculation failed' => 'Calculation failed',
|
||||
'Please enter the draw number' => 'Please enter the draw number',
|
||||
'Schedule failed' => 'Schedule failed',
|
||||
'Game runtime is paused' => 'Game runtime is paused',
|
||||
'Void reason is required' => 'Void reason is required',
|
||||
'Void reason is too long' => 'Void reason is too long',
|
||||
'Void reason is too short' => 'Void reason is too short',
|
||||
'Current period cannot be voided' => 'This period cannot be voided (only during betting or locked phase)',
|
||||
'Void failed' => 'Void failed',
|
||||
'Period voided' => 'Period voided',
|
||||
'Concurrent balance update; please retry' => 'Concurrent balance update; please retry',
|
||||
'Bet order state changed; please retry' => 'Bet order state changed; please retry',
|
||||
'Period void refund' => 'Period void refund',
|
||||
];
|
||||
|
||||
@@ -21,4 +21,14 @@ return [
|
||||
'Calculation failed' => '计算失败',
|
||||
'Please enter the draw number' => '请填写开奖号码',
|
||||
'Schedule failed' => '预约失败',
|
||||
'Game runtime is paused' => '游戏已暂停(运行开关关闭)',
|
||||
'Void reason is required' => '请填写作废原因',
|
||||
'Void reason is too long' => '作废原因过长',
|
||||
'Void reason is too short' => '作废原因过短',
|
||||
'Current period cannot be voided' => '当前期次不可作废(仅下注或封盘阶段可作废)',
|
||||
'Void failed' => '作废失败',
|
||||
'Period voided' => '本期已作废',
|
||||
'Concurrent balance update; please retry' => '余额并发变更,请重试',
|
||||
'Bet order state changed; please retry' => '注单状态已变更,请重试',
|
||||
'Period void refund' => '期次作废退款',
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\library\game\StreakWinReward;
|
||||
use app\common\model\UserWalletRecord;
|
||||
use app\common\service\GameHotDataCoordinator;
|
||||
use app\common\service\GameHotDataLock;
|
||||
use support\think\Db;
|
||||
@@ -98,6 +99,11 @@ final class GameLiveService
|
||||
&& $elapsed >= $betSeconds
|
||||
&& $elapsed < $periodSeconds;
|
||||
|
||||
$runtimeEnabled = GameRecordService::isLiveRuntimeEnabled();
|
||||
$hasActiveRound = GameRecordService::hasActiveRecord();
|
||||
/** 关服且已无进行中局:派彩结束后的「完整维护」态(仅此时展示维护中 UI) */
|
||||
$maintenanceUi = !$runtimeEnabled && !$hasActiveRound;
|
||||
|
||||
return [
|
||||
'record' => $record,
|
||||
'bets' => array_map(static function (array $row): array {
|
||||
@@ -123,6 +129,9 @@ final class GameLiveService
|
||||
'bet_remaining_seconds' => $betRemaining,
|
||||
'payout_remaining_seconds' => $payoutRemaining,
|
||||
'is_payout_phase' => $status === 3,
|
||||
'runtime_enabled' => $runtimeEnabled,
|
||||
'maintenance_ui' => $maintenanceUi,
|
||||
/** 关闭游戏(维护)时仍允许完成当局、计算与预约开奖;仅阻止新用户下注与结束后自动开新期 */
|
||||
'can_calculate' => $canCalculate,
|
||||
'can_draw' => $canScheduleDraw,
|
||||
'can_schedule_draw' => $canScheduleDraw,
|
||||
@@ -135,6 +144,10 @@ final class GameLiveService
|
||||
*/
|
||||
private static function emptySnapshotPayload(): array
|
||||
{
|
||||
$runtimeEnabled = GameRecordService::isLiveRuntimeEnabled();
|
||||
$hasActiveRound = GameRecordService::hasActiveRecord();
|
||||
$maintenanceUi = !$runtimeEnabled && !$hasActiveRound;
|
||||
|
||||
return [
|
||||
'record' => null,
|
||||
'bets' => [],
|
||||
@@ -150,6 +163,8 @@ final class GameLiveService
|
||||
'bet_remaining_seconds' => 0,
|
||||
'payout_remaining_seconds' => 0,
|
||||
'is_payout_phase' => false,
|
||||
'runtime_enabled' => $runtimeEnabled,
|
||||
'maintenance_ui' => $maintenanceUi,
|
||||
'can_calculate' => false,
|
||||
'can_draw' => false,
|
||||
'can_schedule_draw' => false,
|
||||
@@ -449,6 +464,151 @@ final class GameLiveService
|
||||
self::drawResult((int) $record['id'], null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作废当前期(仅 status 为下注/封盘):待开奖注单退款,本期置为已作废,并关闭运行开关。
|
||||
*
|
||||
* @return array{ok: bool, msg?: string, record?: array|null}
|
||||
*/
|
||||
public static function voidCurrentPeriod(?int $recordId, string $voidReason): array
|
||||
{
|
||||
$reason = trim($voidReason);
|
||||
if ($reason === '') {
|
||||
return ['ok' => false, 'msg' => __('Void reason is required')];
|
||||
}
|
||||
if (function_exists('mb_strlen')) {
|
||||
if (mb_strlen($reason) > 255) {
|
||||
return ['ok' => false, 'msg' => __('Void reason is too long')];
|
||||
}
|
||||
} elseif (strlen($reason) > 255) {
|
||||
return ['ok' => false, 'msg' => __('Void reason is too long')];
|
||||
}
|
||||
if (strlen($reason) < 2) {
|
||||
return ['ok' => false, 'msg' => __('Void reason is too short')];
|
||||
}
|
||||
|
||||
$record = self::resolveRecord($recordId);
|
||||
if (!$record) {
|
||||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||
}
|
||||
$st = (int) $record['status'];
|
||||
if (!in_array($st, [0, 1], true)) {
|
||||
return ['ok' => false, 'msg' => __('Current period cannot be voided')];
|
||||
}
|
||||
$rid = (int) $record['id'];
|
||||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 3000);
|
||||
if (!$lock['acquired']) {
|
||||
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
|
||||
}
|
||||
$refundedUserIds = [];
|
||||
try {
|
||||
$now = time();
|
||||
Db::startTrans();
|
||||
try {
|
||||
$refundedUserIds = self::refundPendingBetsForPeriodLocked($rid, $now);
|
||||
Db::name('game_record')->where('id', $rid)->update([
|
||||
'status' => 5,
|
||||
'void_reason' => $reason,
|
||||
'pending_draw_number' => null,
|
||||
'payout_until' => null,
|
||||
'ai_locked_number' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()];
|
||||
}
|
||||
GameRecordService::setLiveRuntimeEnabled(false);
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($rid);
|
||||
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_LIVE_RUNTIME);
|
||||
foreach ($refundedUserIds as $uid) {
|
||||
if ($uid > 0) {
|
||||
GameHotDataCoordinator::afterUserCommitted($uid);
|
||||
}
|
||||
}
|
||||
self::publishSnapshot(null);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'msg' => __('Period voided'),
|
||||
'record' => self::reloadRecord($rid),
|
||||
];
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array
|
||||
{
|
||||
$userIdSet = [];
|
||||
$bets = Db::name('bet_order')
|
||||
->where('period_id', $periodId)
|
||||
->where('status', 1)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($bets as $bet) {
|
||||
$betId = (int) ($bet['id'] ?? 0);
|
||||
$userId = (int) ($bet['user_id'] ?? 0);
|
||||
$totalRaw = $bet['total_amount'] ?? '0';
|
||||
$total = is_string($totalRaw) ? $totalRaw : (string) $totalRaw;
|
||||
if ($betId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($userId <= 0 || bccomp($total, '0', 4) <= 0) {
|
||||
Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
|
||||
'status' => 3,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
|
||||
$after = bcadd($before, $total, 4);
|
||||
$u = Db::name('user')->where('id', $userId)->where('coin', $before)->update([
|
||||
'coin' => $after,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($u !== 1) {
|
||||
throw new \RuntimeException((string) __('Concurrent balance update; please retry'));
|
||||
}
|
||||
$bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
|
||||
'status' => 3,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($bo !== 1) {
|
||||
throw new \RuntimeException((string) __('Bet order state changed; please retry'));
|
||||
}
|
||||
$channelIdRaw = $bet['channel_id'] ?? null;
|
||||
$channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT);
|
||||
if ($channelId === false) {
|
||||
$channelId = null;
|
||||
}
|
||||
UserWalletRecord::create([
|
||||
'user_id' => $userId,
|
||||
'channel_id' => $channelId,
|
||||
'biz_type' => 'bet_void',
|
||||
'direction' => 1,
|
||||
'amount' => $total,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'ref_type' => 'bet_order',
|
||||
'remark' => (string) __('Period void refund'),
|
||||
'create_time' => $now,
|
||||
]);
|
||||
$userIdSet[$userId] = true;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach (array_keys($userIdSet) as $uid) {
|
||||
$out[] = (int) $uid;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function publishSnapshot(?int $recordId = null): void
|
||||
{
|
||||
try {
|
||||
@@ -575,6 +735,9 @@ final class GameLiveService
|
||||
*/
|
||||
private static function mapPublicPeriodStatus(int $dbStatus, int $betCloseIn): string
|
||||
{
|
||||
if ($dbStatus === 5) {
|
||||
return 'void';
|
||||
}
|
||||
if ($dbStatus === 0) {
|
||||
return $betCloseIn > 0 ? 'betting' : 'locked';
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ final class GameRecordService
|
||||
|
||||
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
|
||||
|
||||
/** 后台「游戏实时对局」运行开关:0=暂停自动开奖与派彩后自动创建下一期;1=运行 */
|
||||
public const KEY_LIVE_RUNTIME = 'game_live_runtime_enabled';
|
||||
|
||||
private const ACTIVE_STATUSES = [0, 1, 2, 3];
|
||||
|
||||
public static function getConfigBool(string $key): bool
|
||||
@@ -50,6 +53,9 @@ final class GameRecordService
|
||||
|
||||
public static function tickAutoCreate(): void
|
||||
{
|
||||
if (!self::isLiveRuntimeEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
|
||||
return;
|
||||
}
|
||||
@@ -80,12 +86,55 @@ final class GameRecordService
|
||||
|
||||
public static function createNextRecordAfterDraw(): ?string
|
||||
{
|
||||
if (!self::isLiveRuntimeEnabled()) {
|
||||
return null;
|
||||
}
|
||||
if (self::hasActiveRecord()) {
|
||||
return null;
|
||||
}
|
||||
return self::createNextRecordRow();
|
||||
}
|
||||
|
||||
/**
|
||||
* 未配置键时视为开启(兼容旧库未跑迁移)。
|
||||
*/
|
||||
public static function isLiveRuntimeEnabled(): bool
|
||||
{
|
||||
$row = GameHotDataRedis::gameConfigRow(self::KEY_LIVE_RUNTIME);
|
||||
if ($row === null) {
|
||||
return true;
|
||||
}
|
||||
$v = $row['config_value'] ?? '';
|
||||
return $v === '1' || $v === 1;
|
||||
}
|
||||
|
||||
public static function setLiveRuntimeEnabled(bool $enabled): void
|
||||
{
|
||||
$now = time();
|
||||
$v = $enabled ? '1' : '0';
|
||||
self::upsertConfig(
|
||||
self::KEY_LIVE_RUNTIME,
|
||||
$v,
|
||||
'int',
|
||||
'后台「游戏实时对局」运行开关:0=维护(禁止下注、结束后不自动开新期,当局仍自动开奖并结算);1=运行',
|
||||
$now
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新开启游戏时:若无进行中/未结清对局,则立即创建新一期(与定时任务「无局时自动创建」语义一致,供开关打开时立刻开局)。
|
||||
*/
|
||||
public static function bootstrapPeriodWhenRuntimeEnabled(): void
|
||||
{
|
||||
if (self::hasActiveRecord()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
self::createNextRecordRow();
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function createNextRecordRow(): string
|
||||
{
|
||||
$periodNo = self::generatePeriodNo();
|
||||
|
||||
Reference in New Issue
Block a user