diff --git a/app/common/service/GameHotDataLock.php b/app/common/service/GameHotDataLock.php
index 266860a..85a3668 100644
--- a/app/common/service/GameHotDataLock.php
+++ b/app/common/service/GameHotDataLock.php
@@ -68,6 +68,23 @@ final class GameHotDataLock
}
}
+ /**
+ * 管理员强制操作前释放可能残留的互斥锁(仅删 key,不校验 token)。
+ */
+ public static function forceRelease(string $type, string $resourceKey): void
+ {
+ if ($resourceKey === '') {
+ return;
+ }
+ try {
+ $client = Redis::connection()->client();
+ if (is_object($client) && method_exists($client, 'del')) {
+ $client->del(self::lockKey($type, $resourceKey));
+ }
+ } catch (Throwable) {
+ }
+ }
+
public static function release(string $type, string $resourceKey, ?string $token, bool $redisLock): void
{
if ($resourceKey === '' || !$redisLock || $token === null || $token === '') {
diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php
index e15cf2d..9759320 100644
--- a/app/common/service/GameLiveService.php
+++ b/app/common/service/GameLiveService.php
@@ -572,10 +572,10 @@ final class GameLiveService
return ['ok' => false, 'msg' => __('Failed to save scheduled draw number; please try again')];
}
GameHotDataCoordinator::afterGameRecordCommitted($rid);
- self::publishSnapshot(null);
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
}
+ self::publishSnapshot(null);
return [
'ok' => true,
@@ -610,6 +610,11 @@ final class GameLiveService
if (!$lock['acquired']) {
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
}
+
+ /** @var null|callable(): void */
+ $notifyAfterLock = null;
+ $result = ['ok' => false, 'msg' => __('Game live: settlement error')];
+
try {
self::ensureAiLocked($rid);
@@ -620,102 +625,122 @@ final class GameLiveService
$payoutUntil = 0;
$periodNo = '';
$now = time();
+ $drawCommitted = false;
Db::startTrans();
try {
$record = self::loadRecordRowFromDb($rid, true);
if (!$record) {
Db::rollback();
- return ['ok' => false, 'msg' => __('No active game in progress')];
+ $result = ['ok' => false, 'msg' => __('No active game in progress')];
+ } else {
+ $st = (int) ($record['status'] ?? -1);
+ $existingResult = filter_var($record['result_number'] ?? 0, FILTER_VALIDATE_INT);
+ if ($existingResult !== false && $existingResult >= 1 && $existingResult <= self::DRAW_NUMBER_MAX && $st >= 2) {
+ Db::commit();
+ $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
+ GameHotDataCoordinator::afterGameRecordCommitted($rid);
+ $notifyAfterLock = static function () use ($rid): void {
+ self::publishSnapshot($rid);
+ };
+ $result = [
+ 'ok' => true,
+ 'msg' => __('Draw completed; paying out'),
+ 'result_number' => $existingResult,
+ 'estimated_loss' => '0.00',
+ 'payout_until' => (int) ($record['payout_until'] ?? 0),
+ ];
+ } elseif (!in_array($st, [0, 1], true)) {
+ Db::rollback();
+ $result = ['ok' => false, 'msg' => __('Current game status does not allow drawing')];
+ } else {
+ $elapsedLocked = max(0, $now - (int) ($record['period_start_at'] ?? $now));
+ if ($elapsedLocked < $betSeconds || $elapsedLocked < $periodSeconds) {
+ Db::rollback();
+ $result = ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')];
+ } else {
+ [$finalNumber, $drawMode] = self::resolveFinalDrawNumber($record, $manualNumber);
+ $bets = Db::name('bet_order')->where('period_id', $rid)->select()->toArray();
+ $finalLoss = self::estimateLossForNumber($bets, $finalNumber);
+ $payoutUntil = $now + self::getPayoutGraceSeconds();
+ $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
+
+ Db::name('game_record')->where('id', $rid)->update([
+ 'status' => 3,
+ 'result_number' => $finalNumber,
+ 'draw_mode' => $drawMode,
+ 'pending_draw_number' => null,
+ 'payout_until' => $payoutUntil,
+ 'update_time' => $now,
+ ]);
+ $settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber);
+ Db::commit();
+ $drawCommitted = true;
+ }
+ }
}
-
- $st = (int) ($record['status'] ?? -1);
- $existingResult = filter_var($record['result_number'] ?? 0, FILTER_VALIDATE_INT);
- if ($existingResult !== false && $existingResult >= 1 && $existingResult <= self::DRAW_NUMBER_MAX && $st >= 2) {
- Db::commit();
- $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
- GameHotDataCoordinator::afterGameRecordCommitted($rid);
- self::publishSnapshot($rid);
-
- return [
- 'ok' => true,
- 'msg' => __('Draw completed; paying out'),
- 'result_number' => $existingResult,
- 'estimated_loss' => '0.00',
- 'payout_until' => (int) ($record['payout_until'] ?? 0),
- ];
- }
-
- if (!in_array($st, [0, 1], true)) {
- Db::rollback();
- return ['ok' => false, 'msg' => __('Current game status does not allow drawing')];
- }
-
- $elapsedLocked = max(0, $now - (int) ($record['period_start_at'] ?? $now));
- if ($elapsedLocked < $betSeconds || $elapsedLocked < $periodSeconds) {
- Db::rollback();
- return ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')];
- }
-
- [$finalNumber, $drawMode] = self::resolveFinalDrawNumber($record, $manualNumber);
- $bets = Db::name('bet_order')->where('period_id', $rid)->select()->toArray();
- $finalLoss = self::estimateLossForNumber($bets, $finalNumber);
- $payoutUntil = $now + self::getPayoutGraceSeconds();
- $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
-
- Db::name('game_record')->where('id', $rid)->update([
- 'status' => 3,
- 'result_number' => $finalNumber,
- 'draw_mode' => $drawMode,
- 'pending_draw_number' => null,
- 'payout_until' => $payoutUntil,
- 'update_time' => $now,
- ]);
- $settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber);
- Db::commit();
} catch (Throwable $e) {
Db::rollback();
- return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()];
+ $result = ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()];
}
- GameBetSettleService::publishSettlementWinsAfterCommit(
- $settleOut,
- $rid,
- $periodNo,
- $finalNumber
- );
+ if ($drawCommitted) {
+ GameBetSettleService::publishSettlementWinsAfterCommit(
+ $settleOut,
+ $rid,
+ $periodNo,
+ $finalNumber
+ );
- GameHotDataCoordinator::afterGameRecordCommitted($rid);
+ GameHotDataCoordinator::afterGameRecordCommitted($rid);
- try {
- GameRecordStatService::refreshForRecordId($rid);
- } catch (Throwable) {
+ try {
+ GameRecordStatService::refreshForRecordId($rid);
+ } catch (Throwable) {
+ }
+
+ $notifyAfterLock = static function () use (
+ $rid,
+ $periodNo,
+ $finalNumber,
+ $drawMode,
+ $payoutUntil,
+ $now,
+ $settleOut
+ ): void {
+ self::publishPublicPeriodOpened($periodNo, $finalNumber, $drawMode, $now);
+ self::publishPublicPeriodPayout($rid, $periodNo, $finalNumber, $payoutUntil, $now);
+ $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : [];
+ GameWebSocketEventBus::publish('admin.live.opened', [
+ 'period_id' => $rid,
+ 'period_no' => $periodNo,
+ 'result_number' => $finalNumber,
+ 'draw_mode' => $drawMode,
+ 'payout_until' => $payoutUntil,
+ 'jackpot_hits' => $jackpotHits,
+ 'server_time' => $now,
+ ]);
+ self::publishSnapshot(null);
+ };
+
+ $result = [
+ 'ok' => true,
+ 'msg' => __('Draw completed; paying out'),
+ 'result_number' => $finalNumber,
+ 'draw_mode' => $drawMode,
+ 'estimated_loss' => $finalLoss,
+ 'payout_until' => $payoutUntil,
+ ];
}
- self::publishPublicPeriodOpened($periodNo, $finalNumber, $drawMode, $now);
- self::publishPublicPeriodPayout($rid, $periodNo, $finalNumber, $payoutUntil, $now);
- $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : [];
- GameWebSocketEventBus::publish('admin.live.opened', [
- 'period_id' => $rid,
- 'period_no' => $periodNo,
- 'result_number' => $finalNumber,
- 'draw_mode' => $drawMode,
- 'payout_until' => $payoutUntil,
- 'jackpot_hits' => $jackpotHits,
- 'server_time' => $now,
- ]);
- self::publishSnapshot(null);
-
- return [
- 'ok' => true,
- 'msg' => __('Draw completed; paying out'),
- 'result_number' => $finalNumber,
- 'draw_mode' => $drawMode,
- 'estimated_loss' => $finalLoss,
- 'payout_until' => $payoutUntil,
- ];
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
}
+
+ if ($notifyAfterLock !== null) {
+ $notifyAfterLock();
+ }
+
+ return $result;
}
/**
@@ -739,6 +764,10 @@ final class GameLiveService
return;
}
+
+ /** @var null|callable(): void */
+ $notifyAfterLock = null;
+
try {
$periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '';
$resultNumber = filter_var($row['result_number'] ?? 0, FILTER_VALIDATE_INT);
@@ -747,6 +776,7 @@ final class GameLiveService
}
$newPeriodNo = null;
$now = time();
+ $finalized = false;
Db::startTrans();
try {
$affected = Db::name('game_record')
@@ -759,16 +789,19 @@ final class GameLiveService
]);
if ($affected < 1) {
Db::rollback();
-
- return;
+ } else {
+ Db::commit();
+ $finalized = true;
}
- Db::commit();
} catch (Throwable $e) {
Db::rollback();
Log::warning('finalizePayoutGrace failed: ' . $e->getMessage(), ['record_id' => $id]);
+ }
+ if (!$finalized) {
return;
}
+
if (GameRecordService::isAutoCreateEnabled()) {
try {
$newPeriodNo = GameRecordService::createNextRecordAfterDraw();
@@ -781,27 +814,41 @@ final class GameLiveService
}
GameHotDataCoordinator::afterGameRecordCommitted($id);
GameRecordStatService::refreshForRecordId($id);
- self::publishPublicPeriodFinished($id, $periodNo, $resultNumber);
- GameWebSocketEventBus::publish('admin.live.finalized', [
- 'period_id' => $id,
- 'period_no' => $periodNo,
- 'result_number' => $resultNumber,
- 'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
- 'maintenance_ui' => !GameRecordService::isLiveRuntimeEnabled()
- && !GameRecordService::hasActiveRecord(),
- 'server_time' => $now,
- ]);
- self::publishSnapshot(null);
- if (!GameRecordService::isLiveRuntimeEnabled()) {
- self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize();
- GameHotDataRedis::gameRecordRefreshAggregateCaches();
+
+ $runtimeEnabled = GameRecordService::isLiveRuntimeEnabled();
+ $notifyAfterLock = static function () use (
+ $id,
+ $periodNo,
+ $resultNumber,
+ $now,
+ $runtimeEnabled,
+ $newPeriodNo
+ ): void {
+ self::publishPublicPeriodFinished($id, $periodNo, $resultNumber);
+ GameWebSocketEventBus::publish('admin.live.finalized', [
+ 'period_id' => $id,
+ 'period_no' => $periodNo,
+ 'result_number' => $resultNumber,
+ 'runtime_enabled' => $runtimeEnabled,
+ 'maintenance_ui' => !$runtimeEnabled && !GameRecordService::hasActiveRecord(),
+ 'server_time' => $now,
+ ]);
self::publishSnapshot(null);
- } elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') {
- self::publishImmediateBettingTickAfterFinalize();
- }
+ if (!$runtimeEnabled) {
+ self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize();
+ GameHotDataRedis::gameRecordRefreshAggregateCaches();
+ self::publishSnapshot(null);
+ } elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') {
+ self::publishImmediateBettingTickAfterFinalize();
+ }
+ };
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']);
}
+
+ if ($notifyAfterLock !== null) {
+ $notifyAfterLock();
+ }
}
public static function tickAutoDraw(): void
@@ -919,7 +966,7 @@ final class GameLiveService
if (!in_array($st, [0, 1], true)) {
return ['ok' => false, 'msg' => __('Current period cannot be voided')];
}
- $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000);
+ $lock = self::acquireRecordLockForAdminMutation((string) $recordId, 3000);
if (!$lock['acquired']) {
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
}
@@ -1592,6 +1639,26 @@ final class GameLiveService
return $max;
}
+ /**
+ * 管理员作废等操作:等待锁失败时清除残留锁再试一次,避免 ticker/开奖持锁导致无法作废。
+ *
+ * @return array{acquired: bool, token: ?string, redis_lock: bool}
+ */
+ private static function acquireRecordLockForAdminMutation(string $recordId, int $waitMs): array
+ {
+ if ($recordId === '') {
+ return ['acquired' => false, 'token' => null, 'redis_lock' => false];
+ }
+ $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, $waitMs);
+ if ($lock['acquired']) {
+ return $lock;
+ }
+ GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, $recordId);
+ Log::warning('admin record lock force-released before void', ['record_id' => $recordId]);
+
+ return GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, 800);
+ }
+
/**
* 封盘至本期完全结束前均展示赔付预估(含已开奖/派彩中),供后台实时对局页保留表格数据。
*/
diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue
index 3f7fc4d..c35ae14 100644
--- a/web/src/views/backend/game/live/index.vue
+++ b/web/src/views/backend/game/live/index.vue
@@ -37,7 +37,6 @@
>
{{ t('game.live.void_btn') }}
- {{ t('Refresh') }}
@@ -277,12 +276,7 @@ const serverSkewSeconds = ref(0)
/** 每秒递增,驱动派彩剩余秒本地刷新 */
const clockTick = ref(0)
let clockTimer: number | null = null
-let payoutStuckRefreshTimer: number | null = null
-let drawStuckRefreshTimer: number | null = null
-let drawStuckSeconds = 0
-let payoutPhaseStuckSeconds = 0
let fallbackPollTimer: number | null = null
-let betStreamRefreshTimer: number | null = null
/** 合并并发 snapshot 请求,避免 axios 重复请求取消导致控制台报错 */
let snapshotLoadPromise: Promise | null = null
@@ -350,16 +344,23 @@ function handleWsPayload(raw: unknown): void {
snapshot.result_number = opened.result_number
calcResultNumber.value = opened.result_number
}
- void loadSnapshot({ force: true })
+ if (typeof opened.payout_until === 'number' && opened.payout_until > 0 && snapshot.record) {
+ snapshot.record.payout_until = opened.payout_until
+ snapshot.is_payout_phase = true
+ }
return
}
if (event === 'admin.live.finalized' && parsed.data && typeof parsed.data === 'object') {
const fin = parsed.data as anyObj
+ snapshot.is_payout_phase = false
+ snapshot.payout_remaining_seconds = 0
if (toBool(fin.maintenance_ui) === true) {
- snapshot.is_payout_phase = false
- snapshot.payout_remaining_seconds = 0
+ snapshot.maintenance_ui = true
+ snapshot.record = null
+ }
+ if (toBool(fin.runtime_enabled) !== null) {
+ snapshot.runtime_enabled = toBool(fin.runtime_enabled) === true
}
- void loadSnapshot({ force: true })
return
}
if (event === 'period.payout' && parsed.data && typeof parsed.data === 'object') {
@@ -393,30 +394,7 @@ function handleWsPayload(raw: unknown): void {
}
if (event === 'period.tick' && parsed.data && typeof parsed.data === 'object') {
handlePeriodTickEvent(parsed.data as anyObj)
- return
}
- if (event === 'bet.accepted' && parsed.data && typeof parsed.data === 'object') {
- const betData = parsed.data as anyObj
- const periodNo = typeof betData.period_no === 'string' ? betData.period_no : ''
- const currentNo = typeof snapshot.record?.period_no === 'string' ? snapshot.record.period_no : ''
- if (periodNo !== '' && periodNo === currentNo) {
- scheduleBetStreamRefresh()
- } else if (!wsConnected.value) {
- void loadSnapshot({ force: true })
- }
- return
- }
-}
-
-/** 有新下注时防抖拉取快照,补全 WS 每秒快照之间的下注列表 */
-function scheduleBetStreamRefresh(): void {
- if (betStreamRefreshTimer !== null) {
- window.clearTimeout(betStreamRefreshTimer)
- }
- betStreamRefreshTimer = window.setTimeout(() => {
- betStreamRefreshTimer = null
- void loadSnapshot({ force: true })
- }, 600)
}
/** 用 period.tick 轻量字段刷新倒计时(不触发 HTTP,避免与 WS 每秒 snapshot 冲突) */
@@ -457,18 +435,9 @@ function handlePeriodTickEvent(periodData: anyObj): void {
if (runtimeOff && (status === 'betting' || status === 'locked')) {
return
}
- if (status === 'betting' && periodNo !== '' && periodNo !== currentNo) {
- void loadSnapshot({ force: true })
- return
- }
if (status === 'finished') {
snapshot.is_payout_phase = false
snapshot.payout_remaining_seconds = 0
- void loadSnapshot({ force: true })
- return
- }
- if (currentNo === '' && periodNo !== '' && !runtimeOff) {
- void loadSnapshot({ force: true })
}
}
@@ -1015,96 +984,17 @@ const countdownParts = computed(() => {
return { bet, draw, payout }
})
-/** 派彩倒计时从 >0 变为 0 时主动拉 HTTP 快照 */
-watch(payoutRemainingLive, (remain, prev) => {
- if (!snapshot.is_payout_phase || remain !== 0) {
- return
- }
- if (prev !== null && prev !== undefined && prev > 0) {
- schedulePayoutEndRefresh(400)
- }
-})
-
-function schedulePayoutEndRefresh(delayMs: number): void {
- if (payoutStuckRefreshTimer !== null) {
- window.clearTimeout(payoutStuckRefreshTimer)
- }
- payoutStuckRefreshTimer = window.setTimeout(() => {
- payoutStuckRefreshTimer = null
- if (!snapshot.is_payout_phase) {
- return
- }
- void loadSnapshot({ force: true })
- }, delayMs)
-}
-
-/** 下注/开奖倒计时均已归零但仍未进入派彩(常见于 live ticker 未跑或开奖锁竞争失败) */
-function isPrePayoutDrawStuck(): boolean {
- if (snapshot.is_payout_phase || !snapshot.can_calculate) {
- return false
- }
- const bet = snapshot.bet_remaining_seconds ?? 0
- const draw = snapshot.remaining_seconds ?? 0
- if (bet > 0 || draw > 0) {
- return false
- }
- const st = numberValue(snapshot.record?.status)
- return st === 0 || st === 1
-}
-
-/** 派彩倒计时已为 0 但 is_payout_phase 仍为 true(关服排水时常见) */
-function tickPayoutPhaseStuckRecovery(): void {
- if (!snapshot.is_payout_phase) {
- payoutPhaseStuckSeconds = 0
- return
- }
- const remain = payoutRemainingLive.value
- if (remain === null || remain > 0) {
- payoutPhaseStuckSeconds = 0
- return
- }
- payoutPhaseStuckSeconds++
- if (payoutPhaseStuckSeconds >= 4) {
- payoutPhaseStuckSeconds = 0
- void loadSnapshot({ force: true })
- }
-}
-
-function tickPrePayoutDrawStuckRecovery(): void {
- if (!isPrePayoutDrawStuck()) {
- drawStuckSeconds = 0
- if (drawStuckRefreshTimer !== null) {
- window.clearTimeout(drawStuckRefreshTimer)
- drawStuckRefreshTimer = null
- }
- return
- }
- drawStuckSeconds++
- if (drawStuckSeconds < 8 || drawStuckRefreshTimer !== null) {
- return
- }
- drawStuckRefreshTimer = window.setTimeout(() => {
- drawStuckRefreshTimer = null
- drawStuckSeconds = 0
- if (isPrePayoutDrawStuck()) {
- void loadSnapshot({ force: true })
- }
- }, 300)
-}
-
onMounted(async () => {
updateIsMobile()
window.addEventListener('resize', updateIsMobile)
clockTimer = window.setInterval(() => {
clockTick.value++
- tickPrePayoutDrawStuckRecovery()
- tickPayoutPhaseStuckRecovery()
}, 1000)
fallbackPollTimer = window.setInterval(() => {
if (!wsConnected.value) {
void loadSnapshot({ force: true })
}
- }, 3000)
+ }, 15000)
await loadSnapshot({ force: true })
await reloadWsConfig()
connectWs()
@@ -1121,18 +1011,6 @@ onUnmounted(() => {
window.clearInterval(fallbackPollTimer)
fallbackPollTimer = null
}
- if (payoutStuckRefreshTimer !== null) {
- window.clearTimeout(payoutStuckRefreshTimer)
- payoutStuckRefreshTimer = null
- }
- if (drawStuckRefreshTimer !== null) {
- window.clearTimeout(drawStuckRefreshTimer)
- drawStuckRefreshTimer = null
- }
- if (betStreamRefreshTimer !== null) {
- window.clearTimeout(betStreamRefreshTimer)
- betStreamRefreshTimer = null
- }
})