, * bet_wins: list> * } * * @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}> */ $aggregateByUser = []; /** @var array */ $userOutcome = []; /** @var array */ $jackpotNotify = []; /** @var array}> */ $winByUser = []; /** @var list}> */ $userStreakEvents = []; /** @var list> */ $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); if ($userId > 0) { if (bccomp($win, '0', 2) > 0) { $userOutcome[$userId]['had_win'] = true; } if (bccomp($win, '0', 2) > 0 && StreakWinReward::isJackpotForStreakAtBet((int) ($bet['streak_at_bet'] ?? 0))) { $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) { $jackpotFlags = self::betWinJackpotFlagsForOrder($bet, $win, $needReview); 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> $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> */ 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> $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> $jackpotHits */ public static function publishJackpotHitsAfterCommit(array $jackpotHits, int $periodId, string $periodNo, int $resultNumber): void { if ($jackpotHits === [] || $periodId <= 0 || $resultNumber < 1) { return; } GameWebSocketEventBus::publish(self::TOPIC_JACKPOT_HIT, [ 'period_id' => $periodId, 'period_no' => $periodNo, 'result_number' => $resultNumber, 'hits' => $jackpotHits, 'server_time' => time(), ]); } /** * 结算提交后统一推送:user.streak / wallet.changed / bet.win / jackpot.hit(每期仅推一次)。 * * @param array{ * jackpot_hits?: list>, * bet_wins?: list>, * user_streak_events?: list}>, * wallet_events?: list>, * 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; } } } 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)); } $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : []; self::publishJackpotHitsAfterCommit($jackpotHits, $periodId, $periodNo, $resultNumber); } } $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> $primary * @param list> $secondary * @return list> */ private static function mergeBetWinPayloads(array $primary, array $secondary): array { /** @var array> $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 $userIds * @return array */ 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}|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 $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, ]; } }