1.修复自动创建下一期bug
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user