Files
webman-buildadmin/app/common/service/GameBetSettleService.php

1034 lines
40 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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_recordbiz_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:';
/**
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 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);
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<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);
GameWebSocketEventBus::publish(self::TOPIC_BET_WIN, $data);
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;
}
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<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;
}
}
}
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<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_factorodds_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,
];
}
}