diff --git a/app/admin/controller/game/Live.php b/app/admin/controller/game/Live.php index 31b9e6d..8a2198e 100644 --- a/app/admin/controller/game/Live.php +++ b/app/admin/controller/game/Live.php @@ -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)); + } } diff --git a/app/api/controller/Game.php b/app/api/controller/Game.php index 458a4c9..c7b9e48 100644 --- a/app/api/controller/Game.php +++ b/app/api/controller/Game.php @@ -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'; } diff --git a/app/common/lang/en/game_live.php b/app/common/lang/en/game_live.php index 8d6fdda..de3e9fd 100644 --- a/app/common/lang/en/game_live.php +++ b/app/common/lang/en/game_live.php @@ -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', ]; diff --git a/app/common/lang/zh-cn/game_live.php b/app/common/lang/zh-cn/game_live.php index 7757c99..11ffd2b 100644 --- a/app/common/lang/zh-cn/game_live.php +++ b/app/common/lang/zh-cn/game_live.php @@ -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' => '期次作废退款', ]; diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index c8407d0..9bf1ea2 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -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 + */ + 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'; } diff --git a/app/common/service/GameRecordService.php b/app/common/service/GameRecordService.php index 3ad2a4e..b7bce30 100644 --- a/app/common/service/GameRecordService.php +++ b/app/common/service/GameRecordService.php @@ -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(); diff --git a/web/src/lang/backend/en/game/live.ts b/web/src/lang/backend/en/game/live.ts index 9a39db1..e8db46f 100644 --- a/web/src/lang/backend/en/game/live.ts +++ b/web/src/lang/backend/en/game/live.ts @@ -26,4 +26,17 @@ export default { pick_numbers: 'Pick numbers', total_amount: 'Total bet amount', streak_at_bet: 'Streak at bet', + runtime_switch: 'Game runtime', + countdown_maintenance: 'Maintenance', + runtime_draining_banner: + 'Game stopped: the current round will run through draw, settlement and payout. Full maintenance UI appears after payout completes.', + runtime_maintenance_banner: + 'Maintenance: player betting is disabled. Turn runtime on to resume; a new round is created when idle.', + runtime_off_tip: 'When turning runtime on with no active round, a new period is created immediately.', + void_btn: 'Void round', + void_dialog_title: 'Void current round', + void_reason_label: 'Reason', + void_reason_placeholder: 'Enter the reason (stored on the record; pending bets will be refunded).', + void_submit: 'Confirm void', + void_reason_too_short: 'Reason must be at least 2 characters', } diff --git a/web/src/lang/backend/zh-cn/game/live.ts b/web/src/lang/backend/zh-cn/game/live.ts index 5bd5ddf..403ec85 100644 --- a/web/src/lang/backend/zh-cn/game/live.ts +++ b/web/src/lang/backend/zh-cn/game/live.ts @@ -26,4 +26,16 @@ export default { pick_numbers: '压注号码', total_amount: '压注总额', streak_at_bet: '下注时连胜', + runtime_switch: '游戏运行', + countdown_maintenance: '维护中', + runtime_draining_banner: + '已关闭游戏:当前局将正常进行至开奖、结算并完成派彩;全部结束后进入维护模式(倒计时与操作区将切换为维护中)。', + runtime_maintenance_banner: '维护中:玩家端已禁止下注。请开启「游戏运行」恢复;若无进行中的局将自动创建新一期。', + runtime_off_tip: '开启「游戏运行」后,若无进行中的局将立即创建新一期。', + void_btn: '作废本局', + void_dialog_title: '作废本局', + void_reason_label: '作废原因', + void_reason_placeholder: '请填写本期作废原因(将写入对局记录并退款待开奖注单)', + void_submit: '确认作废', + void_reason_too_short: '作废原因至少 2 个字符', } diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index 72b2d5d..3cd508a 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -2,6 +2,33 @@
+ + + +
+
+ {{ t('game.live.runtime_switch') }} + + {{ t('game.live.runtime_off_tip') }} +
+
@@ -21,7 +48,10 @@
{{ t('game.live.countdown') }}
-
+
+ {{ t('game.live.countdown_maintenance') }} +
+
{{ t('game.live.bet_countdown') }} {{ countdownParts.bet }}s @@ -49,8 +79,17 @@
-
@@ -102,12 +151,45 @@ + + + + + + + + +