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();
$jackpotMaxAmount = self::jackpotMaxAmount();
$bets = Db::name('bet_order')
@@ -84,6 +98,14 @@ final class GameBetSettleService
$settledOrderCount = 0;
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);
if ($betId <= 0) {
continue;

View File

@@ -15,6 +15,9 @@ use Throwable;
final class GameLiveService
{
/** @var array<int, true> 防止同进程内 recover→draw 重入导致 Redis 自锁 */
private static array $drawingRecordIds = [];
private const CHANNEL = 'game-live';
private const EVENT = 'bet-updated';
@@ -612,15 +615,20 @@ final class GameLiveService
}
$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']) {
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
}
/** @var null|callable(): void */
$notifyAfterLock = null;
$postLockWork = null;
$result = ['ok' => false, 'msg' => __('Game live: settlement error')];
self::$drawingRecordIds[$rid] = true;
try {
self::ensureAiLocked($rid);
@@ -645,7 +653,7 @@ final class GameLiveService
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'] : '';
$notifyAfterLock = static function () use ($rid): void {
$postLockWork = static function () use ($rid): void {
GameHotDataCoordinator::afterGameRecordCommitted($rid);
self::publishSnapshot($rid, false);
};
@@ -679,7 +687,6 @@ final class GameLiveService
'payout_until' => $payoutUntil,
'update_time' => $now,
]);
$settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber);
Db::commit();
$drawCommitted = true;
}
@@ -691,15 +698,24 @@ final class GameLiveService
}
if ($drawCommitted) {
$notifyAfterLock = static function () use (
$postLockWork = static function () use (
$rid,
$periodNo,
$finalNumber,
$drawMode,
$payoutUntil,
$now,
$settleOut
$now
): 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(
$settleOut,
$rid,
@@ -736,11 +752,12 @@ final class GameLiveService
];
}
} finally {
unset(self::$drawingRecordIds[$rid]);
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
}
if ($notifyAfterLock !== null) {
$notifyAfterLock();
if ($postLockWork !== null) {
$postLockWork();
}
return $result;
@@ -865,12 +882,33 @@ final class GameLiveService
return;
}
$rid = (int) $record['id'];
if (GameHotDataRedis::isStaleOpenPeriodRecord($record, $periodSeconds)) {
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid);
}
$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', [
'record_id' => $rid,
'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')];
}
$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')];
}
$lock = self::acquireRecordLockForAdminMutation((string) $recordId, 1500);
$lock = self::acquireRecordLockForAdminMutation((string) $recordId, 8000);
if (!$lock['acquired']) {
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' => []];
try {
$now = time();
Db::startTrans();
try {
$refund = self::refundPendingBetsSummaryForPeriodLocked($recordId, $now);
$refundedUserIds = $refund['user_ids'];
Db::name('game_record')->where('id', $recordId)->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()];
$marked = self::markPeriodVoidedInDb($recordId, $reason, $now);
if (!($marked['ok'] ?? false)) {
$errMsg = $marked['msg'] ?? null;
return ['ok' => false, 'msg' => is_string($errMsg) ? $errMsg : __('Void failed')];
}
$refund = self::refundPendingBetsSummaryForPeriod($recordId, $now);
$refundedUserIds = $refund['user_ids'];
} catch (Throwable $e) {
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()];
} finally {
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')];
}
$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')];
}
$rid = (int) $record['id'];
@@ -1081,10 +1112,71 @@ final class GameLiveService
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>}
*/
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 = [];
$orderCount = 0;
@@ -1097,59 +1189,16 @@ final class GameLiveService
->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) {
$single = self::refundSinglePendingBet($bet, $now);
if ($single === null) {
continue;
}
if ($userId <= 0 || bccomp($total, '0', 2) <= 0) {
Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
'status' => 3,
'update_time' => $now,
]);
$orderCount++;
$orderIds[] = $betId;
continue;
if ($single['user_id'] > 0) {
$userIdSet[$single['user_id']] = true;
}
$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++;
$totalAmount = bcadd($totalAmount, $total, 2);
$orderIds[] = $betId;
$totalAmount = bcadd($totalAmount, $single['amount'], 2);
$orderIds[] = $single['bet_id'];
}
$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
{
if ($runRecovery) {
if ($runRecovery && empty(self::$drawingRecordIds)) {
self::recoverLiveRoundState();
}
$snapshot = self::buildSnapshot($recordId);
@@ -1669,7 +1805,6 @@ final class GameLiveService
if ($recordId === '') {
return ['acquired' => false, 'token' => null, 'redis_lock' => false];
}
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, $recordId);
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, $waitMs);
if ($lock['acquired']) {
return $lock;
@@ -1677,7 +1812,7 @@ final class GameLiveService
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, $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);
}
/**