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

This commit is contained in:
2026-05-26 17:17:27 +08:00
parent 0122a0301f
commit 365e643072
2 changed files with 195 additions and 31 deletions

View File

@@ -6,6 +6,7 @@ namespace app\common\service;
use app\common\library\game\StreakWinReward; use app\common\library\game\StreakWinReward;
use support\Log; use support\Log;
use support\Redis;
use support\think\Db; use support\think\Db;
use Throwable; use Throwable;
@@ -28,6 +29,9 @@ final class GameBetSettleService
public const TOPIC_JACKPOT_HIT = 'jackpot.hit'; public const TOPIC_JACKPOT_HIT = 'jackpot.hit';
/** 每期结算推送去重(避免事务重试 / recover 重复推 user.streak、wallet.changed */
private const SETTLE_NOTIFY_DEDUP_PREFIX = 'dfw:v1:settle:notify:';
/** /**
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。 * 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。
* *
@@ -41,7 +45,13 @@ final class GameBetSettleService
public static function settleBetsForDraw(int $recordId, int $resultNumber): array public static function settleBetsForDraw(int $recordId, int $resultNumber): array
{ {
if ($recordId <= 0 || $resultNumber < 1) { if ($recordId <= 0 || $resultNumber < 1) {
return ['jackpot_hits' => [], 'bet_wins' => []]; return [
'jackpot_hits' => [],
'bet_wins' => [],
'user_streak_events' => [],
'wallet_events' => [],
'settled_order_count' => 0,
];
} }
$now = time(); $now = time();
@@ -65,6 +75,14 @@ final class GameBetSettleService
/** @var array<int, array{user_id: int, period_id: int, period_no: string, result_number: int, total_win: string, balance_after: string, is_jackpot: bool, bets: list<array{bet_id: int, win_amount: string}>}> */ /** @var array<int, array{user_id: int, period_id: int, period_no: string, result_number: int, total_win: string, balance_after: string, is_jackpot: bool, bets: list<array{bet_id: int, win_amount: string}>}> */
$winByUser = []; $winByUser = [];
/** @var list<array{user_id: int, current_streak: int, extra: array<string, mixed>}> */
$userStreakEvents = [];
/** @var list<array<string, mixed>> */
$walletEvents = [];
$settledOrderCount = 0;
foreach ($bets as $bet) { foreach ($bets as $bet) {
$betId = (int) ($bet['id'] ?? 0); $betId = (int) ($bet['id'] ?? 0);
if ($betId <= 0) { if ($betId <= 0) {
@@ -98,6 +116,8 @@ final class GameBetSettleService
continue; continue;
} }
$settledOrderCount++;
self::creditUserBetFlow($bet, $now); self::creditUserBetFlow($bet, $now);
if ($userId > 0) { if ($userId > 0) {
@@ -115,9 +135,13 @@ final class GameBetSettleService
$balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'); $balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
if (!$needReview && bccomp($win, '0', 2) > 0) { if (!$needReview && bccomp($win, '0', 2) > 0) {
$paid = self::creditUserPayout($bet, $betId, $win, $now, null, '压注派彩', $resultNumber); $paid = self::creditUserPayout($bet, $betId, $win, $now, null, '压注派彩', $resultNumber, false);
if ($paid !== null) { if (is_array($paid)) {
$balanceAfter = $paid; $balanceAfter = (string) ($paid['balance_after'] ?? $balanceAfter);
$walletPayload = $paid['wallet_payload'] ?? null;
if (is_array($walletPayload)) {
$walletEvents[] = $walletPayload;
}
} }
} }
@@ -182,14 +206,17 @@ final class GameBetSettleService
]); ]);
GameHotDataCoordinator::afterUserCommitted($userId); GameHotDataCoordinator::afterUserCommitted($userId);
$periodNo = isset($aggregateByUser[$userId]['period_no']) ? (string) $aggregateByUser[$userId]['period_no'] : ''; $periodNo = isset($aggregateByUser[$userId]['period_no']) ? (string) $aggregateByUser[$userId]['period_no'] : '';
GameWebSocketPayloadHelper::publishUserStreak($userId, $next, [ $userStreakEvents[] = [
// 明确标记本期结算结果,客户端可直接判断“当前用户是否中奖”。 'user_id' => $userId,
'is_win' => $hadWin, 'current_streak' => $next,
'period_id' => $recordId, 'extra' => [
'period_no' => $periodNo, 'is_win' => $hadWin,
'result_number' => $resultNumber, 'period_id' => $recordId,
'settled_at' => $now, 'period_no' => $periodNo,
]); 'result_number' => $resultNumber,
'settled_at' => $now,
],
];
} }
// 兜底若已判定本期中奖is_win=true但聚合中奖事件意外缺失补一条 bet.win保证客户端可感知中奖。 // 兜底若已判定本期中奖is_win=true但聚合中奖事件意外缺失补一条 bet.win保证客户端可感知中奖。
@@ -247,7 +274,13 @@ final class GameBetSettleService
$betWins = array_values($winByUser); $betWins = array_values($winByUser);
return ['jackpot_hits' => $jackpotHits, 'bet_wins' => $betWins]; return [
'jackpot_hits' => $jackpotHits,
'bet_wins' => $betWins,
'user_streak_events' => $userStreakEvents,
'wallet_events' => $walletEvents,
'settled_order_count' => $settledOrderCount,
];
} }
/** /**
@@ -295,18 +328,78 @@ final class GameBetSettleService
} }
/** /**
* 结算提交后统一推送:先 bet.win全员中奖 jackpot.hit仅大奖档)。 * 结算提交后统一推送:user.streak / wallet.changed / bet.win / jackpot.hit每期仅推一次)。
* *
* @param array{jackpot_hits?: list<array<string, mixed>>, bet_wins?: list<array<string, mixed>>} $settleOut * @param array{
* jackpot_hits?: list<array<string, mixed>>,
* bet_wins?: list<array<string, mixed>>,
* user_streak_events?: list<array{user_id: int, current_streak: int, extra?: array<string, mixed>}>,
* wallet_events?: list<array<string, mixed>>,
* settled_order_count?: int
* } $settleOut
*/ */
public static function publishSettlementWinsAfterCommit(array $settleOut, int $periodId, string $periodNo, int $resultNumber): void public static function publishSettlementWinsAfterCommit(array $settleOut, int $periodId, string $periodNo, int $resultNumber): void
{ {
$settledCount = filter_var($settleOut['settled_order_count'] ?? 0, FILTER_VALIDATE_INT);
$betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : []; $betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : [];
$hasWins = $betWins !== [];
$hasStreak = is_array($settleOut['user_streak_events'] ?? null) && $settleOut['user_streak_events'] !== [];
$hasWallet = is_array($settleOut['wallet_events'] ?? null) && $settleOut['wallet_events'] !== [];
if ($settledCount === false || $settledCount <= 0) {
if (!$hasWins && !$hasStreak && !$hasWallet) {
return;
}
}
if (!self::markSettlementNotifyOnce($periodId)) {
return;
}
$streakEvents = is_array($settleOut['user_streak_events'] ?? null) ? $settleOut['user_streak_events'] : [];
foreach ($streakEvents as $row) {
if (!is_array($row)) {
continue;
}
$userId = filter_var($row['user_id'] ?? 0, FILTER_VALIDATE_INT);
if ($userId === false || $userId <= 0) {
continue;
}
$streak = filter_var($row['current_streak'] ?? 0, FILTER_VALIDATE_INT);
$extra = is_array($row['extra'] ?? null) ? $row['extra'] : [];
GameWebSocketPayloadHelper::publishUserStreak($userId, $streak === false ? 0 : $streak, $extra);
}
$walletEvents = is_array($settleOut['wallet_events'] ?? null) ? $settleOut['wallet_events'] : [];
foreach ($walletEvents as $payload) {
if (!is_array($payload) || empty($payload['user_id'])) {
continue;
}
$userId = filter_var($payload['user_id'], FILTER_VALIDATE_INT);
if ($userId === false || $userId <= 0) {
continue;
}
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($payload, $userId));
}
$jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : []; $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : [];
self::publishBetWinsAfterCommit($betWins); self::publishBetWinsAfterCommit($betWins);
self::publishJackpotHitsAfterCommit($jackpotHits, $periodId, $periodNo, $resultNumber); self::publishJackpotHitsAfterCommit($jackpotHits, $periodId, $periodNo, $resultNumber);
} }
private static function markSettlementNotifyOnce(int $periodId): bool
{
if ($periodId <= 0) {
return false;
}
$key = self::SETTLE_NOTIFY_DEDUP_PREFIX . $periodId;
try {
$ok = Redis::set($key, '1', ['nx', 'ex' => 86400]);
return $ok === true || $ok === 'OK';
} catch (Throwable) {
return true;
}
}
/** /**
* 批量读取用户展示名nickname 优先;空则 fallback 到 username仍空则返回空串调用方自行兜底 * 批量读取用户展示名nickname 优先;空则 fallback 到 username仍空则返回空串调用方自行兜底
* *
@@ -376,7 +469,19 @@ final class GameBetSettleService
$now = time(); $now = time();
$balanceAfter = null; $balanceAfter = null;
if (bccomp($winAmount, '0', 2) > 0) { if (bccomp($winAmount, '0', 2) > 0) {
$balanceAfter = self::creditUserPayout($row, $playRecordId, $winAmount, $now, $operatorAdminId > 0 ? $operatorAdminId : null, '大奖审核通过派彩'); $paid = self::creditUserPayout(
$row,
$playRecordId,
$winAmount,
$now,
$operatorAdminId > 0 ? $operatorAdminId : null,
'大奖审核通过派彩',
null,
true
);
if (is_array($paid)) {
$balanceAfter = (string) ($paid['balance_after'] ?? '0');
}
} }
$reviewRemark = trim($remark); $reviewRemark = trim($remark);
if ($reviewRemark === '') { if ($reviewRemark === '') {
@@ -474,8 +579,10 @@ final class GameBetSettleService
} }
Db::startTrans(); Db::startTrans();
try { try {
self::settleBetsForDraw($rid, $rn); $settleOut = self::settleBetsForDraw($rid, $rn);
Db::commit(); Db::commit();
$periodNo = (string) Db::name('game_record')->where('id', $rid)->value('period_no');
self::publishSettlementWinsAfterCommit($settleOut, $rid, $periodNo, $rn);
$count++; $count++;
} catch (Throwable $e) { } catch (Throwable $e) {
Db::rollback(); Db::rollback();
@@ -540,10 +647,18 @@ final class GameBetSettleService
} }
/** /**
* @return string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null * @return array{balance_after: string, wallet_payload: array<string, mixed>}|null
*/ */
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now, ?int $operatorAdminId, string $remark, ?int $resultNumber = null): ?string private static function creditUserPayout(
{ array $bet,
int $betId,
string $winAmount,
int $now,
?int $operatorAdminId,
string $remark,
?int $resultNumber = null,
bool $emitWalletEvent = true
): ?array {
$userId = (int) ($bet['user_id'] ?? 0); $userId = (int) ($bet['user_id'] ?? 0);
if ($userId <= 0) { if ($userId <= 0) {
return null; return null;
@@ -552,8 +667,25 @@ final class GameBetSettleService
$idem = 'payout_bet_' . $betId; $idem = 'payout_bet_' . $betId;
if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) { if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) {
$coin = Db::name('user')->where('id', $userId)->value('coin'); $coin = Db::name('user')->where('id', $userId)->value('coin');
$balanceAfter = (string) ($coin ?? '0');
$walletPayload = [
'user_id' => $userId,
'balance_after' => $balanceAfter,
'biz_type' => 'payout',
'ref_id' => $betId,
'amount' => $winAmount,
'period_no' => (string) ($bet['period_no'] ?? ''),
'period_id' => isset($bet['period_id']) && is_numeric($bet['period_id']) ? (int) $bet['period_id'] : 0,
'changed_at' => $now,
];
if ($resultNumber !== null && $resultNumber > 0) {
$walletPayload['result_number'] = $resultNumber;
}
return (string) ($coin ?? '0'); return [
'balance_after' => $balanceAfter,
'wallet_payload' => $walletPayload,
];
} }
$user = Db::name('user')->where('id', $userId)->find(); $user = Db::name('user')->where('id', $userId)->find();
@@ -598,9 +730,14 @@ final class GameBetSettleService
if ($resultNumber !== null && $resultNumber > 0) { if ($resultNumber !== null && $resultNumber > 0) {
$walletPayload['result_number'] = $resultNumber; $walletPayload['result_number'] = $resultNumber;
} }
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($walletPayload, $userId)); if ($emitWalletEvent) {
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($walletPayload, $userId));
}
return $after; return [
'balance_after' => $after,
'wallet_payload' => $walletPayload,
];
} }
private static function jackpotMaxAmount(): string private static function jackpotMaxAmount(): string

View File

@@ -99,12 +99,25 @@ final class GameLiveService
return; return;
} }
$pendingCount = (int) Db::name('bet_order')
->where('period_id', $recordId)
->where('status', GameBetSettleService::PLAY_STATUS_PENDING_DRAW)
->count();
$now = time(); $now = time();
$payoutUntil = isset($row['payout_until']) ? (int) $row['payout_until'] : 0; $payoutUntil = isset($row['payout_until']) ? (int) $row['payout_until'] : 0;
$settleOut = ['jackpot_hits' => [], 'bet_wins' => []]; $settleOut = [
'jackpot_hits' => [],
'bet_wins' => [],
'user_streak_events' => [],
'wallet_events' => [],
'settled_order_count' => 0,
];
Db::startTrans(); Db::startTrans();
try { try {
$settleOut = GameBetSettleService::settleBetsForDraw($recordId, $resultNumber); if ($pendingCount > 0) {
$settleOut = GameBetSettleService::settleBetsForDraw($recordId, $resultNumber);
}
if ($status === 2) { if ($status === 2) {
if ($payoutUntil <= 0) { if ($payoutUntil <= 0) {
$payoutUntil = $now + self::getPayoutGraceSeconds(); $payoutUntil = $now + self::getPayoutGraceSeconds();
@@ -743,12 +756,14 @@ final class GameLiveService
public static function tickAutoDraw(): void public static function tickAutoDraw(): void
{ {
if (!GameRecordService::isAutoCreateEnabled()) { if (!GameRecordService::isAutoCreateEnabled()) {
$openCount = (int) Db::name('game_record')->whereIn('status', [0, 1])->count(); $record = Db::name('game_record')
if ($openCount <= 0) { ->whereIn('status', [0, 1])
return; ->order('id', 'asc')
} ->find();
$record = is_array($record) ? $record : null;
} else {
$record = self::resolveRecordForAutoDraw();
} }
$record = self::resolveRecordForAutoDraw();
if (!$record || !in_array((int) $record['status'], [0, 1], true)) { if (!$record || !in_array((int) $record['status'], [0, 1], true)) {
return; return;
} }
@@ -804,7 +819,7 @@ final class GameLiveService
} }
$reason = (string) __('Open period closed after payout: game is in maintenance'); $reason = (string) __('Open period closed after payout: game is in maintenance');
$rows = Db::name('game_record') $rows = Db::name('game_record')
->whereIn('status', [0, 1]) ->whereIn('status', [0, 1, 2])
->order('id', 'asc') ->order('id', 'asc')
->select() ->select()
->toArray(); ->toArray();
@@ -813,6 +828,18 @@ final class GameLiveService
if ($rid === false || $rid <= 0) { if ($rid === false || $rid <= 0) {
continue; continue;
} }
$st = (int) ($row['status'] ?? -1);
if ($st === 2) {
Db::name('game_record')->where('id', $rid)->update([
'status' => 5,
'void_reason' => $reason,
'pending_draw_number' => null,
'payout_until' => null,
'update_time' => time(),
]);
GameHotDataCoordinator::afterGameRecordCommitted($rid);
continue;
}
self::voidOpenPeriodInternal($rid, $reason); self::voidOpenPeriodInternal($rid, $reason);
} }
GameHotDataRedis::gameRecordRefreshAggregateCaches(); GameHotDataRedis::gameRecordRefreshAggregateCaches();