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

599 lines
23 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\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';
/**
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 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' => []];
}
$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 = [];
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';
$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;
}
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);
if ($paid !== null) {
$balanceAfter = $paid;
}
}
if (bccomp($win, '0', 2) > 0) {
$streakAtBet = (int) ($bet['streak_at_bet'] ?? 0);
$isJackpotTier = StreakWinReward::isJackpotForStreakAtBet($streakAtBet);
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,
'bets' => [],
];
}
if ($isJackpotTier) {
$winByUser[$userId]['is_jackpot'] = 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);
GameWebSocketPayloadHelper::publishUserStreak($userId, $next);
}
$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];
}
/**
* 事务提交后推送本期中奖bet.win小奖/大奖统一data.is_jackpot 区分档位)。
*
* @param list<array<string, mixed>> $betWins
*/
public static function publishBetWinsAfterCommit(array $betWins): void
{
$now = time();
foreach ($betWins as $payload) {
if (!is_array($payload) || empty($payload['user_id'])) {
continue;
}
$userId = filter_var($payload['user_id'], FILTER_VALIDATE_INT);
if ($userId === false || $userId <= 0) {
continue;
}
$isJackpot = !empty($payload['is_jackpot']);
$data = array_merge($payload, [
'is_jackpot' => $isJackpot,
'server_time' => $now,
]);
GameWebSocketEventBus::publish(self::TOPIC_BET_WIN, $data);
}
}
/**
* 大奖档命中时额外推送公共频道 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(),
]);
}
/**
* 结算提交后统一推送:先 bet.win全员中奖再 jackpot.hit仅大奖档
*
* @param array{jackpot_hits?: list<array<string, mixed>>, bet_wins?: list<array<string, mixed>>} $settleOut
*/
public static function publishSettlementWinsAfterCommit(array $settleOut, int $periodId, string $periodNo, int $resultNumber): void
{
$betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : [];
$jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : [];
self::publishBetWinsAfterCommit($betWins);
self::publishJackpotHitsAfterCommit($jackpotHits, $periodId, $periodNo, $resultNumber);
}
/**
* 批量读取用户展示名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) {
$balanceAfter = self::creditUserPayout($row, $playRecordId, $winAmount, $now, $operatorAdminId > 0 ? $operatorAdminId : null, '大奖审核通过派彩');
}
$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) {
}
$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 {
self::settleBetsForDraw($rid, $rn);
Db::commit();
$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 string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null
*/
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now, ?int $operatorAdminId, string $remark, ?int $resultNumber = null): ?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' => $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;
}
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($walletPayload, $userId));
return $after;
}
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;
}
}