|
|
|
|
@@ -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,22 +1189,65 @@ final class GameLiveService
|
|
|
|
|
->select()
|
|
|
|
|
->toArray();
|
|
|
|
|
foreach ($bets as $bet) {
|
|
|
|
|
$single = self::refundSinglePendingBet($bet, $now);
|
|
|
|
|
if ($single === null) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($single['user_id'] > 0) {
|
|
|
|
|
$userIdSet[$single['user_id']] = true;
|
|
|
|
|
}
|
|
|
|
|
$orderCount++;
|
|
|
|
|
$totalAmount = bcadd($totalAmount, $single['amount'], 2);
|
|
|
|
|
$orderIds[] = $single['bet_id'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$out = [];
|
|
|
|
|
foreach (array_keys($userIdSet) as $uid) {
|
|
|
|
|
$out[] = (int) $uid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'user_ids' => $out,
|
|
|
|
|
'order_count' => $orderCount,
|
|
|
|
|
'total_amount' => $totalAmount,
|
|
|
|
|
'order_ids' => $orderIds,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @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;
|
|
|
|
|
if ($betId <= 0) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$attempts = 0;
|
|
|
|
|
while ($attempts < 4) {
|
|
|
|
|
$attempts++;
|
|
|
|
|
Db::startTrans();
|
|
|
|
|
try {
|
|
|
|
|
if ($userId <= 0 || bccomp($total, '0', 2) <= 0) {
|
|
|
|
|
Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
|
|
|
|
|
$bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
|
|
|
|
|
'status' => 3,
|
|
|
|
|
'update_time' => $now,
|
|
|
|
|
]);
|
|
|
|
|
$orderCount++;
|
|
|
|
|
$orderIds[] = $betId;
|
|
|
|
|
continue;
|
|
|
|
|
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([
|
|
|
|
|
@@ -1127,7 +1262,9 @@ final class GameLiveService
|
|
|
|
|
'update_time' => $now,
|
|
|
|
|
]);
|
|
|
|
|
if ($bo !== 1) {
|
|
|
|
|
throw new \RuntimeException((string) __('Bet order state changed; please retry'));
|
|
|
|
|
Db::rollback();
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
$channelIdRaw = $bet['channel_id'] ?? null;
|
|
|
|
|
$channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT);
|
|
|
|
|
@@ -1146,28 +1283,27 @@ final class GameLiveService
|
|
|
|
|
'remark' => (string) __('Period void refund'),
|
|
|
|
|
'create_time' => $now,
|
|
|
|
|
]);
|
|
|
|
|
$userIdSet[$userId] = true;
|
|
|
|
|
$orderCount++;
|
|
|
|
|
$totalAmount = bcadd($totalAmount, $total, 2);
|
|
|
|
|
$orderIds[] = $betId;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$out = [];
|
|
|
|
|
foreach (array_keys($userIdSet) as $uid) {
|
|
|
|
|
$out[] = (int) $uid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'user_ids' => $out,
|
|
|
|
|
'order_count' => $orderCount,
|
|
|
|
|
'total_amount' => $totalAmount,
|
|
|
|
|
'order_ids' => $orderIds,
|
|
|
|
|
];
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|