1078 lines
42 KiB
PHP
1078 lines
42 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service;
|
||
|
||
use app\common\library\game\StreakWinReward;
|
||
use support\Log;
|
||
use support\Redis;
|
||
use support\think\Db;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 开奖后结算注单:写入 win_amount、status=已结算;中奖时入账并记 user_wallet_record(biz_type=payout)。
|
||
* 连胜赔率来自 game_config.streak_win_reward;结算后更新 user.current_streak(未中奖则连胜归 0)。
|
||
*/
|
||
final class GameBetSettleService
|
||
{
|
||
public const PLAY_STATUS_PENDING_DRAW = 1;
|
||
public const PLAY_STATUS_SETTLED = 2;
|
||
public const PLAY_STATUS_REFUNDED = 3;
|
||
public const PLAY_STATUS_RETURNED = 4;
|
||
public const PLAY_STATUS_PENDING_REVIEW = 5;
|
||
|
||
public const CONFIG_KEY_JACKPOT_MAX_AMOUNT = 'jackpot_max_amount';
|
||
|
||
/** 本期中奖(含小奖/大奖档):移动端弹窗/通知统一入口,以 is_jackpot 区分 */
|
||
public const TOPIC_BET_WIN = 'bet.win';
|
||
|
||
public const TOPIC_JACKPOT_HIT = 'jackpot.hit';
|
||
|
||
/** 每期结算推送去重(避免事务重试 / recover 重复推 user.streak、wallet.changed) */
|
||
private const SETTLE_NOTIFY_DEDUP_PREFIX = 'dfw:v1:settle:notify:';
|
||
|
||
/** 每期每用户 bet.win 去重(与 streak/wallet 分离,避免整期 dedup 吞掉中奖推送) */
|
||
private const BET_WIN_NOTIFY_DEDUP_PREFIX = 'dfw:v1:ws:betwin:';
|
||
|
||
/** jackpot.hit 去重(独立于 settleNotify,避免 recover/补偿路径漏广播) */
|
||
private const JACKPOT_HIT_DEDUP_PREFIX = 'dfw:v1:ws:jackpot_hit:';
|
||
|
||
/**
|
||
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。
|
||
*
|
||
* @return array{
|
||
* jackpot_hits: list<array{user_id: int, nickname: string, period_no: string, total_win: string, result_number: int}>,
|
||
* bet_wins: list<array<string, mixed>>
|
||
* }
|
||
*
|
||
* @throws Throwable
|
||
*/
|
||
public static function settleBetsForDraw(int $recordId, int $resultNumber): array
|
||
{
|
||
if ($recordId <= 0 || $resultNumber < 1) {
|
||
return [
|
||
'jackpot_hits' => [],
|
||
'bet_wins' => [],
|
||
'user_streak_events' => [],
|
||
'wallet_events' => [],
|
||
'settled_order_count' => 0,
|
||
];
|
||
}
|
||
|
||
$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')
|
||
->where('period_id', $recordId)
|
||
->where('status', self::PLAY_STATUS_PENDING_DRAW)
|
||
->order('id', 'asc')
|
||
->select()
|
||
->toArray();
|
||
|
||
/** @var array<int, array{period_no: string, total_win: string, balance_after: string, orders: list<array{order_no: string, win_amount: string, hit: bool}>}> */
|
||
$aggregateByUser = [];
|
||
|
||
/** @var array<int, array{streak_at: int, had_win: bool}> */
|
||
$userOutcome = [];
|
||
|
||
/** @var array<int, true> */
|
||
$jackpotNotify = [];
|
||
|
||
/** @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 = [];
|
||
|
||
/** @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) {
|
||
$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;
|
||
}
|
||
|
||
$userId = (int) ($bet['user_id'] ?? 0);
|
||
if ($userId > 0 && !isset($userOutcome[$userId])) {
|
||
$userOutcome[$userId] = [
|
||
'streak_at' => (int) ($bet['streak_at_bet'] ?? 0),
|
||
'had_win' => false,
|
||
];
|
||
}
|
||
|
||
$win = self::computeWinAmount($bet, $resultNumber);
|
||
$jackpot = '0.00';
|
||
$needReview = self::shouldRequireJackpotReview($win, $jackpotMaxAmount);
|
||
$nextStatus = $needReview ? self::PLAY_STATUS_PENDING_REVIEW : self::PLAY_STATUS_SETTLED;
|
||
|
||
$affected = Db::name('bet_order')
|
||
->where('id', $betId)
|
||
->where('status', self::PLAY_STATUS_PENDING_DRAW)
|
||
->update([
|
||
'win_amount' => $win,
|
||
'jackpot_extra_amount' => $jackpot,
|
||
'status' => $nextStatus,
|
||
'update_time' => $now,
|
||
]);
|
||
|
||
if ($affected === 0) {
|
||
continue;
|
||
}
|
||
|
||
$settledOrderCount++;
|
||
|
||
self::creditUserBetFlow($bet, $now);
|
||
|
||
$jackpotFlags = ['is_jackpot' => false, 'payout_pending_review' => false];
|
||
if (bccomp($win, '0', 2) > 0) {
|
||
$jackpotFlags = self::betWinJackpotFlagsForOrder($bet, $win, $needReview);
|
||
}
|
||
|
||
if ($userId > 0) {
|
||
if (bccomp($win, '0', 2) > 0) {
|
||
$userOutcome[$userId]['had_win'] = true;
|
||
}
|
||
// 规则统一:只要 bet.win 判定为中大奖(is_jackpot=true),就必须广播 jackpot.hit 给所有订阅者
|
||
if (bccomp($win, '0', 2) > 0 && ($jackpotFlags['is_jackpot'] ?? false) === true) {
|
||
$jackpotNotify[$userId] = true;
|
||
}
|
||
}
|
||
|
||
if ($userId <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
|
||
if (!$needReview && bccomp($win, '0', 2) > 0) {
|
||
$paid = self::creditUserPayout($bet, $betId, $win, $now, null, '压注派彩', $resultNumber, false);
|
||
if (is_array($paid)) {
|
||
$balanceAfter = (string) ($paid['balance_after'] ?? $balanceAfter);
|
||
$walletPayload = $paid['wallet_payload'] ?? null;
|
||
if (is_array($walletPayload)) {
|
||
$walletEvents[] = $walletPayload;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (bccomp($win, '0', 2) > 0) {
|
||
if (!isset($winByUser[$userId])) {
|
||
$winByUser[$userId] = [
|
||
'user_id' => $userId,
|
||
'period_id' => $recordId,
|
||
'period_no' => (string) ($bet['period_no'] ?? ''),
|
||
'result_number' => $resultNumber,
|
||
'total_win' => '0.00',
|
||
'balance_after' => $balanceAfter,
|
||
'is_jackpot' => false,
|
||
'payout_pending_review' => false,
|
||
'bets' => [],
|
||
];
|
||
}
|
||
if ($jackpotFlags['is_jackpot']) {
|
||
$winByUser[$userId]['is_jackpot'] = true;
|
||
}
|
||
if ($jackpotFlags['payout_pending_review']) {
|
||
$winByUser[$userId]['payout_pending_review'] = true;
|
||
}
|
||
$winByUser[$userId]['total_win'] = bcadd($winByUser[$userId]['total_win'], $win, 2);
|
||
$winByUser[$userId]['balance_after'] = $balanceAfter;
|
||
$winByUser[$userId]['bets'][] = [
|
||
'bet_id' => $betId,
|
||
'win_amount' => $win,
|
||
];
|
||
}
|
||
|
||
$periodNo = (string) ($bet['period_no'] ?? '');
|
||
if (!isset($aggregateByUser[$userId])) {
|
||
$aggregateByUser[$userId] = [
|
||
'period_no' => $periodNo,
|
||
'total_win' => '0.00',
|
||
'balance_after' => $balanceAfter,
|
||
'orders' => [],
|
||
];
|
||
}
|
||
$aggregateByUser[$userId]['total_win'] = bcadd($aggregateByUser[$userId]['total_win'], $win, 2);
|
||
$aggregateByUser[$userId]['balance_after'] = $balanceAfter;
|
||
$aggregateByUser[$userId]['orders'][] = [
|
||
'order_no' => (string) $betId,
|
||
'win_amount' => $win,
|
||
'hit' => bccomp($win, '0', 2) > 0,
|
||
];
|
||
}
|
||
|
||
foreach ($userOutcome as $userId => $info) {
|
||
$streakAt = (int) ($info['streak_at'] ?? 0);
|
||
$hadWin = (bool) ($info['had_win'] ?? false);
|
||
if ($hadWin) {
|
||
$next = $streakAt + 1;
|
||
if ($next > 10) {
|
||
$next = 10;
|
||
}
|
||
} else {
|
||
$next = 0;
|
||
}
|
||
Db::name('user')->where('id', $userId)->update([
|
||
'current_streak' => $next,
|
||
'update_time' => $now,
|
||
]);
|
||
GameHotDataCoordinator::afterUserCommitted($userId);
|
||
$periodNo = isset($aggregateByUser[$userId]['period_no']) ? (string) $aggregateByUser[$userId]['period_no'] : '';
|
||
$userStreakEvents[] = [
|
||
'user_id' => $userId,
|
||
'current_streak' => $next,
|
||
'extra' => [
|
||
'is_win' => $hadWin,
|
||
'period_id' => $recordId,
|
||
'period_no' => $periodNo,
|
||
'result_number' => $resultNumber,
|
||
'settled_at' => $now,
|
||
],
|
||
];
|
||
}
|
||
|
||
// 兜底:若已判定本期中奖(is_win=true),但聚合中奖事件意外缺失,补一条 bet.win,保证客户端可感知中奖。
|
||
foreach ($userOutcome as $userId => $info) {
|
||
$hadWin = (bool) ($info['had_win'] ?? false);
|
||
if (!$hadWin || isset($winByUser[$userId])) {
|
||
continue;
|
||
}
|
||
$agg = $aggregateByUser[$userId] ?? null;
|
||
if (!is_array($agg) || bccomp((string) ($agg['total_win'] ?? '0.00'), '0', 2) <= 0) {
|
||
continue;
|
||
}
|
||
$isJackpotTier = isset($jackpotNotify[$userId]);
|
||
$winByUser[$userId] = [
|
||
'user_id' => $userId,
|
||
'period_id' => $recordId,
|
||
'period_no' => (string) ($agg['period_no'] ?? ''),
|
||
'result_number' => $resultNumber,
|
||
'total_win' => (string) ($agg['total_win'] ?? '0.00'),
|
||
'balance_after' => (string) ($agg['balance_after'] ?? '0'),
|
||
'is_jackpot' => $isJackpotTier,
|
||
'payout_pending_review' => false,
|
||
'bets' => [],
|
||
];
|
||
Log::warning('bet.win fallback emitted for settled winner', [
|
||
'user_id' => $userId,
|
||
'period_id' => $recordId,
|
||
'period_no' => (string) ($agg['period_no'] ?? ''),
|
||
'is_jackpot' => $isJackpotTier,
|
||
]);
|
||
}
|
||
|
||
$jackpotHits = [];
|
||
$hitUserIds = [];
|
||
foreach ($jackpotNotify as $uid => $_) {
|
||
if (!isset($aggregateByUser[$uid])) {
|
||
continue;
|
||
}
|
||
$agg = $aggregateByUser[$uid];
|
||
if (bccomp($agg['total_win'], '0', 2) <= 0) {
|
||
continue;
|
||
}
|
||
$hitUserIds[] = (int) $uid;
|
||
}
|
||
$userNameMap = self::loadUserDisplayNames($hitUserIds);
|
||
foreach ($hitUserIds as $uid) {
|
||
$agg = $aggregateByUser[$uid];
|
||
$jackpotHits[] = [
|
||
'user_id' => $uid,
|
||
'nickname' => $userNameMap[$uid] ?? ('用户' . $uid),
|
||
'period_no' => (string) ($agg['period_no'] ?? ''),
|
||
'total_win' => (string) $agg['total_win'],
|
||
'result_number' => $resultNumber,
|
||
];
|
||
}
|
||
|
||
$betWins = array_values($winByUser);
|
||
|
||
return [
|
||
'jackpot_hits' => $jackpotHits,
|
||
'bet_wins' => $betWins,
|
||
'user_streak_events' => $userStreakEvents,
|
||
'wallet_events' => $walletEvents,
|
||
'settled_order_count' => $settledOrderCount,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 事务提交后推送本期中奖(bet.win,小奖/大奖统一;data.is_jackpot 区分档位)。
|
||
*
|
||
* @param list<array<string, mixed>> $betWins
|
||
*/
|
||
public static function publishBetWinsAfterCommit(array $betWins, int $periodId = 0): void
|
||
{
|
||
$now = time();
|
||
foreach ($betWins as $payload) {
|
||
if (!is_array($payload)) {
|
||
continue;
|
||
}
|
||
$userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||
if ($userId === false || $userId <= 0) {
|
||
continue;
|
||
}
|
||
if ($periodId > 0 && self::hasBetWinNotifyMarked($periodId, $userId)) {
|
||
continue;
|
||
}
|
||
$isJackpot = !empty($payload['is_jackpot']);
|
||
$payoutPendingReview = !empty($payload['payout_pending_review']);
|
||
$data = GameWebSocketPayloadHelper::mergeUserStreakInto(array_merge($payload, [
|
||
'is_jackpot' => $isJackpot,
|
||
'is_win' => true,
|
||
'payout_pending_review' => $payoutPendingReview,
|
||
'server_time' => $now,
|
||
]), $userId);
|
||
$ok = GameWebSocketEventBus::publish(self::TOPIC_BET_WIN, $data);
|
||
if (!$ok) {
|
||
Log::warning('bet.win publish failed (will retry next round)', [
|
||
'period_id' => $periodId,
|
||
'user_id' => $userId,
|
||
'total_win' => $payload['total_win'] ?? '',
|
||
]);
|
||
continue;
|
||
}
|
||
if ($periodId > 0) {
|
||
self::markBetWinNotifyOnce($periodId, $userId);
|
||
}
|
||
Log::info('bet.win published', [
|
||
'period_id' => $periodId,
|
||
'user_id' => $userId,
|
||
'total_win' => $payload['total_win'] ?? '',
|
||
'is_jackpot' => $isJackpot,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从库内已结算中奖注单重建 bet.win 载荷(结算内存聚合缺失或推送被 dedup 拦截时的补偿)。
|
||
*
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
public static function buildBetWinPayloadsFromSettledOrders(int $periodId, int $resultNumber): array
|
||
{
|
||
if ($periodId <= 0 || $resultNumber < 1) {
|
||
return [];
|
||
}
|
||
$rows = Db::name('bet_order')
|
||
->where('period_id', $periodId)
|
||
->whereIn('status', [self::PLAY_STATUS_SETTLED, self::PLAY_STATUS_PENDING_REVIEW])
|
||
->order('id', 'asc')
|
||
->select()
|
||
->toArray();
|
||
|
||
/** @var array<int, array<string, mixed>> $winByUser */
|
||
$winByUser = [];
|
||
foreach ($rows as $bet) {
|
||
if (!is_array($bet)) {
|
||
continue;
|
||
}
|
||
$win = bcadd((string) ($bet['win_amount'] ?? '0'), '0', 2);
|
||
if (bccomp($win, '0', 2) <= 0) {
|
||
continue;
|
||
}
|
||
$userId = filter_var($bet['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||
if ($userId === false || $userId <= 0) {
|
||
continue;
|
||
}
|
||
$orderStatus = filter_var($bet['status'] ?? 0, FILTER_VALIDATE_INT);
|
||
$needReview = $orderStatus === self::PLAY_STATUS_PENDING_REVIEW;
|
||
$jackpotFlags = self::betWinJackpotFlagsForOrder($bet, $win, $needReview);
|
||
if (!isset($winByUser[$userId])) {
|
||
$coin = Db::name('user')->where('id', $userId)->value('coin');
|
||
$winByUser[$userId] = [
|
||
'user_id' => $userId,
|
||
'period_id' => $periodId,
|
||
'period_no' => (string) ($bet['period_no'] ?? ''),
|
||
'result_number' => $resultNumber,
|
||
'total_win' => '0.00',
|
||
'balance_after' => (string) ($coin ?? '0'),
|
||
'is_jackpot' => false,
|
||
'payout_pending_review' => false,
|
||
'bets' => [],
|
||
];
|
||
}
|
||
if ($jackpotFlags['is_jackpot']) {
|
||
$winByUser[$userId]['is_jackpot'] = true;
|
||
}
|
||
if ($jackpotFlags['payout_pending_review']) {
|
||
$winByUser[$userId]['payout_pending_review'] = true;
|
||
}
|
||
$winByUser[$userId]['total_win'] = bcadd((string) $winByUser[$userId]['total_win'], $win, 2);
|
||
$betId = filter_var($bet['id'] ?? 0, FILTER_VALIDATE_INT);
|
||
$winByUser[$userId]['bets'][] = [
|
||
'bet_id' => $betId === false ? 0 : $betId,
|
||
'win_amount' => $win,
|
||
];
|
||
}
|
||
|
||
return array_values($winByUser);
|
||
}
|
||
|
||
/**
|
||
* 大奖档命中时额外推送公共频道 jackpot.hit(全站公告;个人中奖通知仍以 bet.win 为准,不可替代)。
|
||
*
|
||
* @param list<array<string, mixed>> $jackpotHits
|
||
*/
|
||
public static function publishJackpotHitsAfterCommit(array $jackpotHits, int $periodId, string $periodNo, int $resultNumber): void
|
||
{
|
||
if ($jackpotHits === [] || $periodId <= 0 || $resultNumber < 1) {
|
||
return;
|
||
}
|
||
$dedupKey = self::JACKPOT_HIT_DEDUP_PREFIX . $periodId;
|
||
try {
|
||
$already = Redis::get($dedupKey);
|
||
if ($already !== false && $already !== null && $already !== '') {
|
||
return;
|
||
}
|
||
} catch (Throwable) {
|
||
// ignore
|
||
}
|
||
|
||
$ok = GameWebSocketEventBus::publish(self::TOPIC_JACKPOT_HIT, [
|
||
'period_id' => $periodId,
|
||
'period_no' => $periodNo,
|
||
'result_number' => $resultNumber,
|
||
'hits' => $jackpotHits,
|
||
'server_time' => time(),
|
||
]);
|
||
if ($ok) {
|
||
try {
|
||
Redis::setEx($dedupKey, 86400, '1');
|
||
} catch (Throwable) {
|
||
}
|
||
Log::channel('ws')->info('jackpot.hit published', [
|
||
'period_id' => $periodId,
|
||
'period_no' => $periodNo,
|
||
'result_number' => $resultNumber,
|
||
'hit_count' => count($jackpotHits),
|
||
]);
|
||
} else {
|
||
Log::channel('ws')->warning('jackpot.hit publish failed', [
|
||
'period_id' => $periodId,
|
||
'period_no' => $periodNo,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 结算提交后统一推送:user.streak / wallet.changed / bet.win / jackpot.hit(每期仅推一次)。
|
||
*
|
||
* @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
|
||
{
|
||
$settledCount = filter_var($settleOut['settled_order_count'] ?? 0, FILTER_VALIDATE_INT);
|
||
$betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : [];
|
||
$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 ($betWins === [] && !$hasStreak && !$hasWallet) {
|
||
if ($periodId <= 0 || $resultNumber < 1) {
|
||
return;
|
||
}
|
||
$betWins = self::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber);
|
||
if ($betWins === []) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// jackpot.hit 独立去重:即使 settleNotify 已被占位(recover/补偿路径)也允许补广播一次
|
||
$jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : [];
|
||
self::publishJackpotHitsAfterCommit($jackpotHits, $periodId, $periodNo, $resultNumber);
|
||
|
||
if (($settledCount !== false && $settledCount > 0) || $hasStreak || $hasWallet) {
|
||
if (self::markSettlementNotifyOnce($periodId)) {
|
||
$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)) {
|
||
continue;
|
||
}
|
||
$userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||
if ($userId === false || $userId <= 0) {
|
||
continue;
|
||
}
|
||
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($payload, $userId));
|
||
}
|
||
}
|
||
}
|
||
|
||
$effectiveBetWins = $periodId > 0 && $resultNumber > 0
|
||
? self::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber)
|
||
: [];
|
||
if ($effectiveBetWins === []) {
|
||
$effectiveBetWins = $betWins;
|
||
} else {
|
||
$effectiveBetWins = self::mergeBetWinPayloads($betWins, $effectiveBetWins);
|
||
}
|
||
self::publishBetWinsAfterCommit($effectiveBetWins, $periodId);
|
||
if ($periodId > 0 && $resultNumber > 0) {
|
||
self::ensurePeriodBetWinNotifications($periodId, $resultNumber);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 开奖后兜底:库内已有中奖但 Redis 无 bet.win 去重键时补推(避免 streak/wallet 整期 dedup 或旧版逻辑漏推)。
|
||
*/
|
||
public static function ensurePeriodBetWinNotifications(int $periodId, int $resultNumber): void
|
||
{
|
||
if ($periodId <= 0 || $resultNumber < 1) {
|
||
return;
|
||
}
|
||
$payloads = self::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber);
|
||
if ($payloads === []) {
|
||
return;
|
||
}
|
||
$missing = [];
|
||
foreach ($payloads as $payload) {
|
||
if (!is_array($payload)) {
|
||
continue;
|
||
}
|
||
$userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||
if ($userId === false || $userId <= 0) {
|
||
continue;
|
||
}
|
||
if (self::hasBetWinNotifyMarked($periodId, $userId)) {
|
||
continue;
|
||
}
|
||
$missing[] = $payload;
|
||
}
|
||
if ($missing !== []) {
|
||
Log::warning('bet.win ensurePeriodBetWinNotifications republish', [
|
||
'period_id' => $periodId,
|
||
'result_number' => $resultNumber,
|
||
'user_ids' => array_map(static fn (array $p): int => (int) ($p['user_id'] ?? 0), $missing),
|
||
]);
|
||
self::publishBetWinsAfterCommit($missing, $periodId);
|
||
}
|
||
}
|
||
|
||
private static function hasBetWinNotifyMarked(int $periodId, int $userId): bool
|
||
{
|
||
if ($periodId <= 0 || $userId <= 0) {
|
||
return false;
|
||
}
|
||
$key = self::BET_WIN_NOTIFY_DEDUP_PREFIX . $periodId . ':' . $userId;
|
||
try {
|
||
$existing = Redis::get($key);
|
||
|
||
return $existing !== false && $existing !== null && $existing !== '';
|
||
} catch (Throwable) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static function markBetWinNotifyOnce(int $periodId, int $userId): void
|
||
{
|
||
if ($periodId <= 0 || $userId <= 0) {
|
||
return;
|
||
}
|
||
$key = self::BET_WIN_NOTIFY_DEDUP_PREFIX . $periodId . ':' . $userId;
|
||
try {
|
||
Redis::setEx($key, 86400, '1');
|
||
} catch (Throwable) {
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string, mixed>> $primary
|
||
* @param list<array<string, mixed>> $secondary
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
private static function mergeBetWinPayloads(array $primary, array $secondary): array
|
||
{
|
||
/** @var array<int, array<string, mixed>> $byUser */
|
||
$byUser = [];
|
||
foreach (array_merge($primary, $secondary) as $payload) {
|
||
if (!is_array($payload)) {
|
||
continue;
|
||
}
|
||
$userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||
if ($userId === false || $userId <= 0) {
|
||
continue;
|
||
}
|
||
if (!isset($byUser[$userId])) {
|
||
$byUser[$userId] = $payload;
|
||
continue;
|
||
}
|
||
if (!empty($payload['is_jackpot'])) {
|
||
$byUser[$userId]['is_jackpot'] = true;
|
||
}
|
||
if (!empty($payload['payout_pending_review'])) {
|
||
$byUser[$userId]['payout_pending_review'] = true;
|
||
}
|
||
}
|
||
|
||
return array_values($byUser);
|
||
}
|
||
|
||
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;仍空则返回空串(调用方自行兜底)。
|
||
*
|
||
* @param list<int> $userIds
|
||
* @return array<int, string>
|
||
*/
|
||
private static function loadUserDisplayNames(array $userIds): array
|
||
{
|
||
$userIds = array_values(array_unique(array_filter($userIds, static fn ($v): bool => $v > 0)));
|
||
if ($userIds === []) {
|
||
return [];
|
||
}
|
||
$rows = Db::name('user')
|
||
->whereIn('id', $userIds)
|
||
->field(['id', 'nickname', 'username'])
|
||
->select()
|
||
->toArray();
|
||
$out = [];
|
||
foreach ($rows as $row) {
|
||
$uid = isset($row['id']) && is_numeric($row['id']) ? (int) $row['id'] : 0;
|
||
if ($uid <= 0) {
|
||
continue;
|
||
}
|
||
$nickname = isset($row['nickname']) && is_string($row['nickname']) ? trim($row['nickname']) : '';
|
||
if ($nickname !== '') {
|
||
$out[$uid] = $nickname;
|
||
continue;
|
||
}
|
||
$username = isset($row['username']) && is_string($row['username']) ? trim($row['username']) : '';
|
||
$out[$uid] = $username;
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 大奖审核通过后派彩(幂等):仅当 play_record.status=待审核 且 win_amount>=阈值时执行。
|
||
*
|
||
* @return array{ok: bool, msg: string, balance_after?: string}
|
||
*/
|
||
public static function approveJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark = ''): array
|
||
{
|
||
if ($playRecordId <= 0) {
|
||
return ['ok' => false, 'msg' => __('Parameter error')];
|
||
}
|
||
// 兼容:bet_order 可能是 VIEW,且 * 列表会固化;审核字段始终以 game_play_record 为准
|
||
$row = Db::name('game_play_record')->where('id', $playRecordId)->find();
|
||
if (!is_array($row)) {
|
||
$row = Db::name('bet_order')->where('id', $playRecordId)->find();
|
||
}
|
||
if (!is_array($row)) {
|
||
return ['ok' => false, 'msg' => __('Record not found')];
|
||
}
|
||
$status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
|
||
if ($status !== self::PLAY_STATUS_PENDING_REVIEW) {
|
||
return ['ok' => false, 'msg' => __('This record does not require review')];
|
||
}
|
||
$winAmount = bcadd((string) ($row['win_amount'] ?? '0'), '0', 2);
|
||
$threshold = self::jackpotMaxAmount();
|
||
if (!self::shouldRequireJackpotReview($winAmount, $threshold)) {
|
||
return ['ok' => false, 'msg' => __('This record does not meet jackpot review threshold')];
|
||
}
|
||
$userId = isset($row['user_id']) && is_numeric($row['user_id']) ? (int) $row['user_id'] : 0;
|
||
if ($userId <= 0) {
|
||
return ['ok' => false, 'msg' => __('Order is missing user info')];
|
||
}
|
||
$now = time();
|
||
$balanceAfter = null;
|
||
if (bccomp($winAmount, '0', 2) > 0) {
|
||
$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);
|
||
if ($reviewRemark === '') {
|
||
$reviewRemark = 'approved';
|
||
}
|
||
$update = [
|
||
'status' => self::PLAY_STATUS_SETTLED,
|
||
'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null,
|
||
'review_time' => $now,
|
||
'review_remark' => substr($reviewRemark, 0, 255),
|
||
'update_time' => $now,
|
||
];
|
||
// 优先写主表
|
||
Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
|
||
// 兼容写 view 场景(若存在且可写)
|
||
try {
|
||
Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
|
||
} catch (\Throwable) {
|
||
}
|
||
|
||
$periodId = filter_var($row['period_id'] ?? 0, FILTER_VALIDATE_INT);
|
||
$periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '';
|
||
$resultNumber = filter_var(
|
||
Db::name('game_record')->where('id', $periodId)->value('result_number'),
|
||
FILTER_VALIDATE_INT
|
||
);
|
||
if ($periodId !== false && $periodId > 0 && $resultNumber !== false && $resultNumber > 0 && bccomp($winAmount, '0', 2) > 0) {
|
||
$jackpotFlags = self::betWinJackpotFlagsForOrder($row, $winAmount, false);
|
||
self::publishBetWinsAfterCommit([[
|
||
'user_id' => $userId,
|
||
'period_id' => $periodId,
|
||
'period_no' => $periodNo,
|
||
'result_number' => $resultNumber,
|
||
'total_win' => $winAmount,
|
||
'balance_after' => is_string($balanceAfter ?? null) ? $balanceAfter : (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'),
|
||
'is_jackpot' => $jackpotFlags['is_jackpot'],
|
||
'payout_pending_review' => false,
|
||
'bets' => [['bet_id' => $playRecordId, 'win_amount' => $winAmount]],
|
||
]], $periodId);
|
||
}
|
||
|
||
$out = ['ok' => true, 'msg' => __('Approved')];
|
||
if (is_string($balanceAfter)) {
|
||
$out['balance_after'] = $balanceAfter;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 大奖审核拒绝:仅当 status=待审核 才可操作;拒绝后不派彩,标记为已退回(status=4)。
|
||
*
|
||
* @return array{ok: bool, msg: string}
|
||
*/
|
||
public static function rejectJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark): array
|
||
{
|
||
if ($playRecordId <= 0) {
|
||
return ['ok' => false, 'msg' => __('Parameter error')];
|
||
}
|
||
$reason = trim($remark);
|
||
if ($reason === '') {
|
||
return ['ok' => false, 'msg' => __('Please provide reject reason')];
|
||
}
|
||
$row = Db::name('game_play_record')->where('id', $playRecordId)->find();
|
||
if (!is_array($row)) {
|
||
$row = Db::name('bet_order')->where('id', $playRecordId)->find();
|
||
}
|
||
if (!is_array($row)) {
|
||
return ['ok' => false, 'msg' => __('Record not found')];
|
||
}
|
||
$status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
|
||
if ($status !== self::PLAY_STATUS_PENDING_REVIEW) {
|
||
return ['ok' => false, 'msg' => __('This record does not require review')];
|
||
}
|
||
$now = time();
|
||
$update = [
|
||
'status' => self::PLAY_STATUS_RETURNED,
|
||
'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null,
|
||
'review_time' => $now,
|
||
'review_remark' => substr($reason, 0, 255),
|
||
'update_time' => $now,
|
||
];
|
||
Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
|
||
try {
|
||
Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
|
||
} catch (\Throwable) {
|
||
}
|
||
return ['ok' => true, 'msg' => __('Rejected')];
|
||
}
|
||
|
||
/**
|
||
* 补偿:库中已结束局次但注单仍为待开奖的,可重复调用(幂等)。
|
||
*/
|
||
public static function settlePendingForEndedRecords(): int
|
||
{
|
||
$rows = Db::name('game_record')
|
||
->where('status', 2)
|
||
->whereNotNull('result_number')
|
||
->field(['id', 'result_number'])
|
||
->order('id', 'asc')
|
||
->select()
|
||
->toArray();
|
||
|
||
$count = 0;
|
||
foreach ($rows as $row) {
|
||
$rid = (int) ($row['id'] ?? 0);
|
||
$rn = (int) ($row['result_number'] ?? 0);
|
||
if ($rid <= 0 || $rn < 1) {
|
||
continue;
|
||
}
|
||
$pending = Db::name('bet_order')
|
||
->where('period_id', $rid)
|
||
->where('status', self::PLAY_STATUS_PENDING_DRAW)
|
||
->count();
|
||
if ($pending === 0) {
|
||
continue;
|
||
}
|
||
Db::startTrans();
|
||
try {
|
||
$settleOut = self::settleBetsForDraw($rid, $rn);
|
||
Db::commit();
|
||
$periodNo = (string) Db::name('game_record')->where('id', $rid)->value('period_no');
|
||
self::publishSettlementWinsAfterCommit($settleOut, $rid, $periodNo, $rn);
|
||
$count++;
|
||
} catch (Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
return $count;
|
||
}
|
||
|
||
/**
|
||
* 应付派彩:开奖号码 ∈ pick_numbers 即中奖;整笔 total_amount × odds_factor(odds_factor 来自连胜奖励表对应档位)。
|
||
*/
|
||
public static function computeWinAmount(array $bet, int $resultNumber): string
|
||
{
|
||
$pickNumbers = $bet['pick_numbers'] ?? null;
|
||
if (is_string($pickNumbers)) {
|
||
$decoded = json_decode($pickNumbers, true);
|
||
$pickNumbers = is_array($decoded) ? $decoded : [];
|
||
}
|
||
if (!is_array($pickNumbers)) {
|
||
$pickNumbers = [];
|
||
}
|
||
if (!in_array($resultNumber, array_map('intval', $pickNumbers), true)) {
|
||
return '0.00';
|
||
}
|
||
$total = (string) ($bet['total_amount'] ?? '0');
|
||
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
||
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
|
||
|
||
return bcmul($total, $odds, 2);
|
||
}
|
||
|
||
/**
|
||
* 累加玩家打码量(流水):按本注单 total_amount 1:1 加到 user.bet_flow_coin。
|
||
*
|
||
* 幂等性由调用点保证:只有 bet_order 首次从 status=1 变更为 status=2(返回 $affected=1)
|
||
* 时才会调用本方法,重复结算不会触发。
|
||
*/
|
||
private static function creditUserBetFlow(array $bet, int $now): void
|
||
{
|
||
$userId = isset($bet['user_id']) && is_numeric($bet['user_id']) ? intval($bet['user_id']) : 0;
|
||
if ($userId <= 0) {
|
||
return;
|
||
}
|
||
$totalRaw = $bet['total_amount'] ?? '0';
|
||
$total = is_string($totalRaw) ? trim($totalRaw) : (is_numeric($totalRaw) ? strval($totalRaw) : '0');
|
||
if ($total === '' || !is_numeric($total)) {
|
||
return;
|
||
}
|
||
$flow = bcadd($total, '0', 2);
|
||
if (bccomp($flow, '0', 2) <= 0) {
|
||
return;
|
||
}
|
||
Db::name('user')
|
||
->where('id', $userId)
|
||
->update([
|
||
'bet_flow_coin' => Db::raw('bet_flow_coin + ' . $flow),
|
||
'update_time' => $now,
|
||
]);
|
||
GameHotDataCoordinator::afterUserCommitted($userId);
|
||
}
|
||
|
||
/**
|
||
* @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,
|
||
bool $emitWalletEvent = true
|
||
): ?array {
|
||
$userId = (int) ($bet['user_id'] ?? 0);
|
||
if ($userId <= 0) {
|
||
return null;
|
||
}
|
||
|
||
$idem = 'payout_bet_' . $betId;
|
||
if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) {
|
||
$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 [
|
||
'balance_after' => $balanceAfter,
|
||
'wallet_payload' => $walletPayload,
|
||
];
|
||
}
|
||
|
||
$user = Db::name('user')->where('id', $userId)->find();
|
||
if (!$user) {
|
||
return null;
|
||
}
|
||
|
||
$before = (string) ($user['coin'] ?? '0');
|
||
$after = bcadd($before, $winAmount, 2);
|
||
|
||
Db::name('user_wallet_record')->insert([
|
||
'user_id' => $userId,
|
||
'channel_id' => $bet['channel_id'] ?? null,
|
||
'biz_type' => 'payout',
|
||
'direction' => 1,
|
||
'amount' => $winAmount,
|
||
'balance_before' => $before,
|
||
'balance_after' => $after,
|
||
'ref_type' => 'bet_order',
|
||
'ref_id' => $betId,
|
||
'idempotency_key' => $idem,
|
||
'operator_admin_id' => $operatorAdminId,
|
||
'remark' => $remark !== '' ? $remark : '压注派彩',
|
||
'create_time' => $now,
|
||
]);
|
||
|
||
Db::name('user')->where('id', $userId)->update([
|
||
'coin' => $after,
|
||
'update_time' => $now,
|
||
]);
|
||
GameHotDataCoordinator::afterUserCommitted($userId);
|
||
$walletPayload = [
|
||
'user_id' => $userId,
|
||
'balance_after' => $after,
|
||
'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;
|
||
}
|
||
if ($emitWalletEvent) {
|
||
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($walletPayload, $userId));
|
||
}
|
||
|
||
return [
|
||
'balance_after' => $after,
|
||
'wallet_payload' => $walletPayload,
|
||
];
|
||
}
|
||
|
||
private static function jackpotMaxAmount(): string
|
||
{
|
||
// 结算属于高频长驻进程逻辑:为避免 GameHotDataRedis::$gcLocal 进程内静态缓存导致阈值更新不生效,
|
||
// 这里直接读库拿最新值(本方法在 settleBetsForDraw 中仅调用一次)。
|
||
$row = Db::name('game_config')->where('config_key', self::CONFIG_KEY_JACKPOT_MAX_AMOUNT)->find();
|
||
if (!is_array($row)) {
|
||
return '0.00';
|
||
}
|
||
$raw = $row['config_value'] ?? null;
|
||
if ($raw === null || $raw === '') {
|
||
return '0.00';
|
||
}
|
||
$v = is_string($raw) ? trim($raw) : (is_numeric($raw) ? strval($raw) : '');
|
||
if ($v === '' || !is_numeric($v)) {
|
||
return '0.00';
|
||
}
|
||
$normalized = bcadd($v, '0', 2);
|
||
if (bccomp($normalized, '0', 2) <= 0) {
|
||
return '0.00';
|
||
}
|
||
return $normalized;
|
||
}
|
||
|
||
private static function shouldRequireJackpotReview(string $winAmount, string $threshold): bool
|
||
{
|
||
if (bccomp($threshold, '0', 2) <= 0) {
|
||
return false;
|
||
}
|
||
return bccomp($winAmount, $threshold, 2) >= 0;
|
||
}
|
||
|
||
/**
|
||
* bet.win 展示用大奖标记:连胜大奖档 或 触发后台大奖审核阈值,均视为「中大奖」并走 bet.win 通知。
|
||
*
|
||
* @param array<string, mixed> $bet
|
||
* @return array{is_jackpot: bool, payout_pending_review: bool}
|
||
*/
|
||
private static function betWinJackpotFlagsForOrder(array $bet, string $winAmount, bool $needReview): array
|
||
{
|
||
$streakJackpot = StreakWinReward::isJackpotForStreakAtBet((int) ($bet['streak_at_bet'] ?? 0));
|
||
$amountJackpot = self::shouldRequireJackpotReview($winAmount, self::jackpotMaxAmount());
|
||
|
||
return [
|
||
'is_jackpot' => $streakJackpot || $amountJackpot || $needReview,
|
||
'payout_pending_review' => $needReview,
|
||
];
|
||
}
|
||
}
|