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

This commit is contained in:
2026-05-26 18:08:57 +08:00
parent 6c4e935b6c
commit bdd66f7bd9
4 changed files with 240 additions and 84 deletions

View File

@@ -54,6 +54,20 @@ final class GameBetSettleService
]; ];
} }
$periodStatus = filter_var(
Db::name('game_record')->where('id', $recordId)->value('status'),
FILTER_VALIDATE_INT
);
if ($periodStatus === 5) {
return [
'jackpot_hits' => [],
'bet_wins' => [],
'user_streak_events' => [],
'wallet_events' => [],
'settled_order_count' => 0,
];
}
$now = time(); $now = time();
$jackpotMaxAmount = self::jackpotMaxAmount(); $jackpotMaxAmount = self::jackpotMaxAmount();
$bets = Db::name('bet_order') $bets = Db::name('bet_order')
@@ -84,6 +98,14 @@ final class GameBetSettleService
$settledOrderCount = 0; $settledOrderCount = 0;
foreach ($bets as $bet) { foreach ($bets as $bet) {
$periodStatusNow = filter_var(
Db::name('game_record')->where('id', $recordId)->value('status'),
FILTER_VALIDATE_INT
);
if ($periodStatusNow === 5) {
break;
}
$betId = (int) ($bet['id'] ?? 0); $betId = (int) ($bet['id'] ?? 0);
if ($betId <= 0) { if ($betId <= 0) {
continue; continue;

View File

@@ -15,6 +15,9 @@ use Throwable;
final class GameLiveService final class GameLiveService
{ {
/** @var array<int, true> 防止同进程内 recover→draw 重入导致 Redis 自锁 */
private static array $drawingRecordIds = [];
private const CHANNEL = 'game-live'; private const CHANNEL = 'game-live';
private const EVENT = 'bet-updated'; private const EVENT = 'bet-updated';
@@ -612,15 +615,20 @@ final class GameLiveService
} }
$rid = (int) $record['id']; $rid = (int) $record['id'];
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 2000); if (isset(self::$drawingRecordIds[$rid])) {
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
}
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 500);
if (!$lock['acquired']) { if (!$lock['acquired']) {
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
} }
/** @var null|callable(): void */ /** @var null|callable(): void */
$notifyAfterLock = null; $postLockWork = null;
$result = ['ok' => false, 'msg' => __('Game live: settlement error')]; $result = ['ok' => false, 'msg' => __('Game live: settlement error')];
self::$drawingRecordIds[$rid] = true;
try { try {
self::ensureAiLocked($rid); self::ensureAiLocked($rid);
@@ -645,7 +653,7 @@ final class GameLiveService
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'] : ''; $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
$notifyAfterLock = static function () use ($rid): void { $postLockWork = static function () use ($rid): void {
GameHotDataCoordinator::afterGameRecordCommitted($rid); GameHotDataCoordinator::afterGameRecordCommitted($rid);
self::publishSnapshot($rid, false); self::publishSnapshot($rid, false);
}; };
@@ -679,7 +687,6 @@ final class GameLiveService
'payout_until' => $payoutUntil, 'payout_until' => $payoutUntil,
'update_time' => $now, 'update_time' => $now,
]); ]);
$settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber);
Db::commit(); Db::commit();
$drawCommitted = true; $drawCommitted = true;
} }
@@ -691,15 +698,24 @@ final class GameLiveService
} }
if ($drawCommitted) { if ($drawCommitted) {
$notifyAfterLock = static function () use ( $postLockWork = static function () use (
$rid, $rid,
$periodNo, $periodNo,
$finalNumber, $finalNumber,
$drawMode, $drawMode,
$payoutUntil, $payoutUntil,
$now, $now
$settleOut
): void { ): void {
try {
$settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber);
} catch (Throwable $e) {
Log::warning('drawResult settle after lock failed', [
'record_id' => $rid,
'error' => $e->getMessage(),
]);
return;
}
GameBetSettleService::publishSettlementWinsAfterCommit( GameBetSettleService::publishSettlementWinsAfterCommit(
$settleOut, $settleOut,
$rid, $rid,
@@ -736,11 +752,12 @@ final class GameLiveService
]; ];
} }
} finally { } finally {
unset(self::$drawingRecordIds[$rid]);
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
} }
if ($notifyAfterLock !== null) { if ($postLockWork !== null) {
$notifyAfterLock(); $postLockWork();
} }
return $result; return $result;
@@ -865,12 +882,33 @@ final class GameLiveService
return; return;
} }
$rid = (int) $record['id']; $rid = (int) $record['id'];
if (GameHotDataRedis::isStaleOpenPeriodRecord($record, $periodSeconds)) {
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid);
}
$out = self::drawResult($rid, null); $out = self::drawResult($rid, null);
if (!($out['ok'] ?? false)) { if ($out['ok'] ?? false) {
return;
}
$msg = is_string($out['msg'] ?? null) ? (string) $out['msg'] : '';
if (!str_contains($msg, 'Another operation is in progress')) {
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' => $out['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'] : '',
]); ]);
} }
} }
@@ -965,10 +1003,10 @@ final class GameLiveService
return ['ok' => false, 'msg' => __('No active game in progress')]; return ['ok' => false, 'msg' => __('No active game in progress')];
} }
$st = (int) ($record['status'] ?? -1); $st = (int) ($record['status'] ?? -1);
if (!in_array($st, [0, 1], true)) { if (!in_array($st, [0, 1, 3], true)) {
return ['ok' => false, 'msg' => __('Current period cannot be voided')]; return ['ok' => false, 'msg' => __('Current period cannot be voided')];
} }
$lock = self::acquireRecordLockForAdminMutation((string) $recordId, 1500); $lock = self::acquireRecordLockForAdminMutation((string) $recordId, 8000);
if (!$lock['acquired']) { if (!$lock['acquired']) {
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
} }
@@ -976,23 +1014,16 @@ final class GameLiveService
$refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []]; $refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []];
try { try {
$now = time(); $now = time();
Db::startTrans(); $marked = self::markPeriodVoidedInDb($recordId, $reason, $now);
try { if (!($marked['ok'] ?? false)) {
$refund = self::refundPendingBetsSummaryForPeriodLocked($recordId, $now); $errMsg = $marked['msg'] ?? null;
$refundedUserIds = $refund['user_ids'];
Db::name('game_record')->where('id', $recordId)->update([ return ['ok' => false, 'msg' => is_string($errMsg) ? $errMsg : __('Void failed')];
'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()];
} }
$refund = self::refundPendingBetsSummaryForPeriod($recordId, $now);
$refundedUserIds = $refund['user_ids'];
} catch (Throwable $e) {
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()];
} finally { } finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']); GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']);
} }
@@ -1037,7 +1068,7 @@ final class GameLiveService
return ['ok' => false, 'msg' => __('No active game in progress')]; return ['ok' => false, 'msg' => __('No active game in progress')];
} }
$st = (int) $record['status']; $st = (int) $record['status'];
if (!in_array($st, [0, 1], true)) { if (!in_array($st, [0, 1, 3], true)) {
return ['ok' => false, 'msg' => __('Current period cannot be voided')]; return ['ok' => false, 'msg' => __('Current period cannot be voided')];
} }
$rid = (int) $record['id']; $rid = (int) $record['id'];
@@ -1081,10 +1112,71 @@ final class GameLiveService
return $summary['user_ids']; return $summary['user_ids'];
} }
/**
* 先将本期标记为作废(短事务、尽快释放 game_record 行锁),再逐笔退款。
*
* @return array{ok: bool, msg?: string}
*/
private static function markPeriodVoidedInDb(int $recordId, string $reason, int $now): array
{
$attempts = 0;
while ($attempts < 4) {
$attempts++;
Db::startTrans();
try {
$row = self::loadRecordRowFromDb($recordId, true);
if (!$row) {
Db::rollback();
return ['ok' => false, 'msg' => __('No active game in progress')];
}
$st = (int) ($row['status'] ?? -1);
if (!in_array($st, [0, 1, 3], true)) {
Db::rollback();
return ['ok' => false, 'msg' => __('Current period cannot be voided')];
}
Db::name('game_record')->where('id', $recordId)->update([
'status' => 5,
'void_reason' => $reason,
'pending_draw_number' => null,
'payout_until' => null,
'ai_locked_number' => $st === 3 ? ($row['ai_locked_number'] ?? null) : null,
'update_time' => $now,
]);
Db::commit();
return ['ok' => true];
} catch (Throwable $e) {
Db::rollback();
$msg = $e->getMessage();
if ($attempts < 4 && str_contains($msg, '1205')) {
usleep(200_000);
continue;
}
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $msg];
}
}
return ['ok' => false, 'msg' => __('Void failed') . ': lock wait timeout'];
}
/** /**
* @return array{user_ids:list<int>,order_count:int,total_amount:string,order_ids:list<int>} * @return array{user_ids:list<int>,order_count:int,total_amount:string,order_ids:list<int>}
*/ */
private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array
{
return self::refundPendingBetsSummaryForPeriod($periodId, $now);
}
/**
* 逐笔退款(每笔独立短事务),避免与开奖结算共用一个长事务抢 InnoDB 行锁。
*
* @return array{user_ids:list<int>,order_count:int,total_amount:string,order_ids:list<int>}
*/
private static function refundPendingBetsSummaryForPeriod(int $periodId, int $now): array
{ {
$userIdSet = []; $userIdSet = [];
$orderCount = 0; $orderCount = 0;
@@ -1097,59 +1189,16 @@ final class GameLiveService
->select() ->select()
->toArray(); ->toArray();
foreach ($bets as $bet) { foreach ($bets as $bet) {
$betId = (int) ($bet['id'] ?? 0); $single = self::refundSinglePendingBet($bet, $now);
$userId = (int) ($bet['user_id'] ?? 0); if ($single === null) {
$totalRaw = $bet['total_amount'] ?? '0';
$total = is_string($totalRaw) ? $totalRaw : (string) $totalRaw;
if ($betId <= 0) {
continue; continue;
} }
if ($userId <= 0 || bccomp($total, '0', 2) <= 0) { if ($single['user_id'] > 0) {
Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([ $userIdSet[$single['user_id']] = true;
'status' => 3,
'update_time' => $now,
]);
$orderCount++;
$orderIds[] = $betId;
continue;
} }
$before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
$after = bcadd($before, $total, 2);
$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' => 'void_refund',
'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;
$orderCount++; $orderCount++;
$totalAmount = bcadd($totalAmount, $total, 2); $totalAmount = bcadd($totalAmount, $single['amount'], 2);
$orderIds[] = $betId; $orderIds[] = $single['bet_id'];
} }
$out = []; $out = [];
@@ -1165,9 +1214,96 @@ final class GameLiveService
]; ];
} }
/**
* @param array<string, mixed> $bet
* @return array{user_id: int, bet_id: int, amount: string}|null
*/
private static function refundSinglePendingBet(array $bet, int $now): ?array
{
$betId = (int) ($bet['id'] ?? 0);
if ($betId <= 0) {
return null;
}
$userId = (int) ($bet['user_id'] ?? 0);
$totalRaw = $bet['total_amount'] ?? '0';
$total = is_string($totalRaw) ? $totalRaw : (string) $totalRaw;
$attempts = 0;
while ($attempts < 4) {
$attempts++;
Db::startTrans();
try {
if ($userId <= 0 || bccomp($total, '0', 2) <= 0) {
$bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
'status' => 3,
'update_time' => $now,
]);
if ($bo !== 1) {
Db::rollback();
return null;
}
Db::commit();
return ['user_id' => 0, 'bet_id' => $betId, 'amount' => '0.00'];
}
$before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
$after = bcadd($before, $total, 2);
$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) {
Db::rollback();
return null;
}
$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' => 'void_refund',
'direction' => 1,
'amount' => $total,
'balance_before' => $before,
'balance_after' => $after,
'ref_type' => 'bet_order',
'remark' => (string) __('Period void refund'),
'create_time' => $now,
]);
Db::commit();
return ['user_id' => $userId, 'bet_id' => $betId, 'amount' => $total];
} catch (Throwable $e) {
Db::rollback();
$msg = $e->getMessage();
if ($attempts < 4 && str_contains($msg, '1205')) {
usleep(200_000);
continue;
}
throw $e;
}
}
throw new \RuntimeException((string) __('Void failed') . ': lock wait timeout');
}
public static function publishSnapshot(?int $recordId = null, bool $runRecovery = true): void public static function publishSnapshot(?int $recordId = null, bool $runRecovery = true): void
{ {
if ($runRecovery) { if ($runRecovery && empty(self::$drawingRecordIds)) {
self::recoverLiveRoundState(); self::recoverLiveRoundState();
} }
$snapshot = self::buildSnapshot($recordId); $snapshot = self::buildSnapshot($recordId);
@@ -1669,7 +1805,6 @@ final class GameLiveService
if ($recordId === '') { if ($recordId === '') {
return ['acquired' => false, 'token' => null, 'redis_lock' => false]; return ['acquired' => false, 'token' => null, 'redis_lock' => false];
} }
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, $recordId);
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, $waitMs); $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, $waitMs);
if ($lock['acquired']) { if ($lock['acquired']) {
return $lock; return $lock;
@@ -1677,7 +1812,7 @@ final class GameLiveService
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, $recordId); GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, $recordId);
Log::warning('admin record lock force-released before void retry', ['record_id' => $recordId]); Log::warning('admin record lock force-released before void retry', ['record_id' => $recordId]);
return GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_RECORD, $recordId); return GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, 2000);
} }
/** /**

View File

@@ -85,7 +85,6 @@ class GameWebSocketServer
if (!$hasAdminSubscriber) { if (!$hasAdminSubscriber) {
return; return;
} }
GameLiveService::recoverLiveRoundState();
$snapshot = GameLiveService::buildSnapshot(null); $snapshot = GameLiveService::buildSnapshot(null);
$payload = json_encode([ $payload = json_encode([
'event' => 'admin.live.snapshot', 'event' => 'admin.live.snapshot',

View File

@@ -668,7 +668,7 @@ const canVoidPeriod = computed(() => {
return false return false
} }
const s = Number(r.status) const s = Number(r.status)
return s === 0 || s === 1 return s === 0 || s === 1 || s === 3
}) })
/** 派彩结束后的完整维护态:操作区除顶部开关外全部锁定 */ /** 派彩结束后的完整维护态:操作区除顶部开关外全部锁定 */