} * * @throws Throwable */ public static function settleBetsForDraw(int $recordId, int $resultNumber): array { if ($recordId <= 0 || $resultNumber < 1) { return ['jackpot_hits' => []]; } $now = time(); $bets = Db::name('bet_order') ->where('period_id', $recordId) ->where('status', 1) ->order('id', 'asc') ->select() ->toArray(); /** @var array}> */ $aggregateByUser = []; /** @var array */ $userOutcome = []; /** @var array */ $jackpotNotify = []; foreach ($bets as $bet) { $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'; $affected = Db::name('bet_order') ->where('id', $betId) ->where('status', 1) ->update([ 'win_amount' => $win, 'jackpot_extra_amount' => $jackpot, 'status' => 2, 'update_time' => $now, ]); if ($affected === 0) { continue; } 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 (bccomp($win, '0', 2) > 0) { $paid = self::creditUserPayout($bet, $betId, $win, $now); if ($paid !== null) { $balanceAfter = $paid; } } $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); } $jackpotHits = []; foreach ($jackpotNotify as $uid => $_) { if (!isset($aggregateByUser[$uid])) { continue; } $agg = $aggregateByUser[$uid]; if (bccomp($agg['total_win'], '0', 2) <= 0) { continue; } $jackpotHits[] = [ 'user_id' => (int) $uid, 'period_no' => (string) ($agg['period_no'] ?? ''), 'total_win' => (string) $agg['total_win'], 'result_number' => $resultNumber, ]; } return ['jackpot_hits' => $jackpotHits]; } /** * 补偿:库中已结束局次但注单仍为待开奖的,可重复调用(幂等)。 */ 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', 1) ->count(); if ($pending === 0) { continue; } Db::startTrans(); try { self::settleBetsForDraw($rid, $rn); Db::commit(); $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 string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null */ private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): ?string { $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'); return (string) ($coin ?? '0'); } $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' => null, 'remark' => '压注派彩', 'create_time' => $now, ]); Db::name('user')->where('id', $userId)->update([ 'coin' => $after, 'update_time' => $now, ]); GameHotDataCoordinator::afterUserCommitted($userId); GameWebSocketEventBus::publish('wallet.changed', [ 'user_id' => $userId, 'balance_after' => $after, 'biz_type' => 'payout', 'ref_id' => $betId, 'changed_at' => $now, ]); return $after; } }