1.修复自动创建下一期bug

This commit is contained in:
2026-05-26 18:30:19 +08:00
parent bdd66f7bd9
commit bb5ef82d49
7 changed files with 169 additions and 69 deletions

View File

@@ -25,7 +25,6 @@ class Live extends Backend
{ {
$recordIdRaw = $this->request ? $this->request->get('record_id') : null; $recordIdRaw = $this->request ? $this->request->get('record_id') : null;
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null; $recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
GameLiveService::recoverLiveRoundState();
return $this->success('', GameLiveService::buildSnapshot($recordId)); return $this->success('', GameLiveService::buildSnapshot($recordId));
} }
@@ -38,7 +37,6 @@ class Live extends Backend
} }
$recordIdRaw = $request->get('record_id'); $recordIdRaw = $request->get('record_id');
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null; $recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
GameLiveService::recoverLiveRoundState();
return $this->success('', GameLiveService::buildSnapshot($recordId)); return $this->success('', GameLiveService::buildSnapshot($recordId));
} }

View File

@@ -11,6 +11,9 @@ final class WebSocketConfigHelper
public static function wsUrl(?Request $request = null): string public static function wsUrl(?Request $request = null): string
{ {
$url = trim((string) env('H5_WEBSOCKET_URL', '')); $url = trim((string) env('H5_WEBSOCKET_URL', ''));
if ($url !== '' && $request !== null && self::isLoopbackWsUrl($url) && !self::isLoopbackRequestHost($request)) {
$url = '';
}
if ($url !== '') { if ($url !== '') {
return $url; return $url;
} }
@@ -36,5 +39,30 @@ final class WebSocketConfigHelper
return 'ws://127.0.0.1:3131/ws/'; return 'ws://127.0.0.1:3131/ws/';
} }
private static function isLoopbackWsUrl(string $url): bool
{
$host = parse_url($url, PHP_URL_HOST);
if (!is_string($host) || $host === '') {
return false;
}
$host = strtolower($host);
return in_array($host, ['127.0.0.1', 'localhost', '::1'], true);
}
private static function isLoopbackRequestHost(Request $request): bool
{
$host = strtolower(trim((string) $request->host(true)));
if ($host === '') {
$host = strtolower(trim((string) $request->header('host', '')));
}
if ($host === '') {
return false;
}
$hostOnly = preg_split('/:/', $host)[0] ?? $host;
return in_array($hostOnly, ['127.0.0.1', 'localhost', '::1'], true);
}
} }

View File

@@ -641,60 +641,103 @@ final class GameLiveService
$now = time(); $now = time();
$drawCommitted = false; $drawCommitted = false;
Db::startTrans(); $txResult = self::withShortInnodbLockWait(3, static function () use (
try { $rid,
$record = self::loadRecordRowFromDb($rid, true); $betSeconds,
if (!$record) { $periodSeconds,
Db::rollback(); $manualNumber,
$result = ['ok' => false, 'msg' => __('No active game in progress')]; $now
} else { ): array {
Db::startTrans();
try {
$record = self::loadRecordRowFromDb($rid, true);
if (!$record) {
Db::rollback();
return ['ok' => false, 'draw_committed' => false, 'result' => ['ok' => false, 'msg' => __('No active game in progress')]];
}
$st = (int) ($record['status'] ?? -1); $st = (int) ($record['status'] ?? -1);
$existingResult = filter_var($record['result_number'] ?? 0, FILTER_VALIDATE_INT); $existingResult = filter_var($record['result_number'] ?? 0, FILTER_VALIDATE_INT);
if ($existingResult !== false && $existingResult >= 1 && $existingResult <= self::DRAW_NUMBER_MAX && $st >= 2) { if ($existingResult !== false && $existingResult >= 1 && $existingResult <= self::DRAW_NUMBER_MAX && $st >= 2) {
Db::commit(); Db::commit();
$periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
$postLockWork = static function () use ($rid): void {
GameHotDataCoordinator::afterGameRecordCommitted($rid);
self::publishSnapshot($rid, false);
};
$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([ return [
'status' => 3, 'ok' => true,
'result_number' => $finalNumber, 'draw_committed' => false,
'draw_mode' => $drawMode, 'existing_result' => $existingResult,
'pending_draw_number' => null, 'record' => $record,
'payout_until' => $payoutUntil, ];
'update_time' => $now,
]);
Db::commit();
$drawCommitted = true;
}
} }
if (!in_array($st, [0, 1], true)) {
Db::rollback();
return ['ok' => false, 'draw_committed' => false, 'result' => ['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, 'draw_committed' => false, 'result' => ['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,
]);
Db::commit();
return [
'ok' => true,
'draw_committed' => true,
'final_number' => $finalNumber,
'draw_mode' => $drawMode,
'final_loss' => $finalLoss,
'payout_until' => $payoutUntil,
'period_no' => $periodNo,
];
} catch (Throwable $e) {
Db::rollback();
return [
'ok' => false,
'draw_committed' => false,
'result' => ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()],
];
} }
} catch (Throwable $e) { });
Db::rollback();
$result = ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()]; if (!($txResult['ok'] ?? false)) {
$result = is_array($txResult['result'] ?? null) ? $txResult['result'] : ['ok' => false, 'msg' => __('Game live: settlement error')];
} elseif (!empty($txResult['draw_committed'])) {
$drawCommitted = true;
$finalNumber = (int) ($txResult['final_number'] ?? 0);
$drawMode = (int) ($txResult['draw_mode'] ?? 0);
$finalLoss = is_string($txResult['final_loss'] ?? null) ? $txResult['final_loss'] : '0.00';
$payoutUntil = (int) ($txResult['payout_until'] ?? 0);
$periodNo = is_string($txResult['period_no'] ?? null) ? (string) $txResult['period_no'] : '';
} else {
$existingResult = (int) ($txResult['existing_result'] ?? 0);
$periodNo = is_string($txResult['record']['period_no'] ?? null) ? (string) $txResult['record']['period_no'] : '';
$postLockWork = static function () use ($rid): void {
GameHotDataCoordinator::afterGameRecordCommitted($rid);
self::publishSnapshot($rid, false);
};
$result = [
'ok' => true,
'msg' => __('Draw completed; paying out'),
'result_number' => $existingResult,
'estimated_loss' => '0.00',
'payout_until' => (int) ($txResult['record']['payout_until'] ?? 0),
];
} }
if ($drawCommitted) { if ($drawCommitted) {
@@ -882,34 +925,33 @@ final class GameLiveService
return; return;
} }
$rid = (int) $record['id']; $rid = (int) $record['id'];
if (GameHotDataRedis::isStaleOpenPeriodRecord($record, $periodSeconds)) { $st = (int) ($record['status'] ?? 0);
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid); $abnormalAfter = $periodSeconds + self::getPayoutGraceSeconds() + self::STARTUP_RECOVER_GRACE_SECONDS;
if ($elapsed > $abnormalAfter) {
$resultNumber = isset($record['result_number']) ? (int) $record['result_number'] : 0;
if ($resultNumber <= 0) {
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid);
self::markAbnormalAndRefundOnStartup($rid, $st);
}
return;
} }
$out = self::drawResult($rid, null); $out = self::drawResult($rid, null);
if ($out['ok'] ?? false) { if ($out['ok'] ?? false) {
return; return;
} }
$msg = is_string($out['msg'] ?? null) ? (string) $out['msg'] : ''; $msg = is_string($out['msg'] ?? null) ? (string) $out['msg'] : '';
if (!str_contains($msg, 'Another operation is in progress')) { if (
!str_contains($msg, 'Another operation is in progress')
&& !str_contains($msg, 'Lock wait timeout')
&& !str_contains($msg, '1205')
) {
Log::warning('tickAutoDraw: drawResult failed', [ Log::warning('tickAutoDraw: drawResult failed', [
'record_id' => $rid, 'record_id' => $rid,
'period_no' => $record['period_no'] ?? '', 'period_no' => $record['period_no'] ?? '',
'msg' => $msg, 'msg' => $msg,
]); ]);
return;
}
if (!GameHotDataRedis::isStaleOpenPeriodRecord($record, $periodSeconds)) {
return;
}
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid);
$retry = self::drawResult($rid, null);
if (!($retry['ok'] ?? false)) {
Log::warning('tickAutoDraw: drawResult failed after lock force-release', [
'record_id' => $rid,
'period_no' => $record['period_no'] ?? '',
'msg' => is_string($retry['msg'] ?? null) ? $retry['msg'] : '',
]);
} }
} }
@@ -1871,4 +1913,28 @@ final class GameLiveService
} }
return $parsed; return $parsed;
} }
/**
* 开奖事务使用较短行锁等待,避免 HTTP/定时任务被 InnoDB 默认 50s 锁等待拖死。
*
* @template T
* @param callable(): T $fn
* @return T
*/
private static function withShortInnodbLockWait(int $seconds, callable $fn): mixed
{
$seconds = max(1, min(50, $seconds));
try {
Db::execute('SET SESSION innodb_lock_wait_timeout = ' . $seconds);
} catch (Throwable) {
}
try {
return $fn();
} finally {
try {
Db::execute('SET SESSION innodb_lock_wait_timeout = 50');
} catch (Throwable) {
}
}
}
} }

View File

@@ -18,7 +18,8 @@ export default {
push_connected: 'Realtime connection established', push_connected: 'Realtime connection established',
push_disconnected: 'Polling mode enabled (push removed)', push_disconnected: 'Polling mode enabled (push removed)',
ws_connected: 'Connected to real-time match', ws_connected: 'Connected to real-time match',
ws_disconnected: 'Service unavailable, please check backend service', ws_disconnected: 'Realtime push disconnected, using polling',
snapshot_load_failed: 'Failed to load match snapshot, please retry',
ws_panel_title: 'Admin WebSocket (vs. mobile lightweight stream)', ws_panel_title: 'Admin WebSocket (vs. mobile lightweight stream)',
ws_reload_config: 'Load WS config', ws_reload_config: 'Load WS config',
ws_connect: 'Connect WS', ws_connect: 'Connect WS',

View File

@@ -18,7 +18,8 @@ export default {
push_connected: '实时连接已建立', push_connected: '实时连接已建立',
push_disconnected: '已切换为轮询模式(无推送)', push_disconnected: '已切换为轮询模式(无推送)',
ws_connected: '已连接实时对局', ws_connected: '已连接实时对局',
ws_disconnected: '服务异常,请检查服务端', ws_disconnected: '实时推送未连接,已使用轮询刷新',
snapshot_load_failed: '对局快照加载失败,请稍后重试',
ws_panel_title: '后台 WebSocket 连接(区别于前端轻量流)', ws_panel_title: '后台 WebSocket 连接(区别于前端轻量流)',
ws_reload_config: '加载WS配置', ws_reload_config: '加载WS配置',
ws_connect: '连接WS', ws_connect: '连接WS',

View File

@@ -825,6 +825,7 @@ async function loadSnapshot(options?: { force?: boolean }): Promise<void> {
const res = await createAxios({ const res = await createAxios({
url: '/admin/game.Live/snapshot', url: '/admin/game.Live/snapshot',
method: 'get', method: 'get',
timeout: 30 * 1000,
showCodeMessage: false, showCodeMessage: false,
showErrorMessage: false, showErrorMessage: false,
cancelDuplicateRequest: false, cancelDuplicateRequest: false,
@@ -996,7 +997,11 @@ onMounted(async () => {
void loadSnapshot({ force: true }) void loadSnapshot({ force: true })
} }
}, 15000) }, 15000)
await loadSnapshot({ force: true }) try {
await loadSnapshot({ force: true })
} catch {
ElMessage.warning(t('game.live.snapshot_load_failed'))
}
await reloadWsConfig() await reloadWsConfig()
connectWs() connectWs()
}) })

View File

@@ -33,6 +33,7 @@ const viteConfig = ({ mode }: ConfigEnv): UserConfig => {
'/admin': { target: 'http://localhost:7979', changeOrigin: true }, '/admin': { target: 'http://localhost:7979', changeOrigin: true },
'/install': { target: 'http://localhost:7979', changeOrigin: true }, '/install': { target: 'http://localhost:7979', changeOrigin: true },
'/plugin': { target: 'http://localhost:7979', changeOrigin: true }, '/plugin': { target: 'http://localhost:7979', changeOrigin: true },
'/ws': { target: 'http://localhost:3131', changeOrigin: true, ws: true },
}, },
}, },
build: { build: {