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

454 lines
17 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';
/**
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。
*
* @return array{jackpot_hits: list<array{user_id: int, period_no: string, total_win: string, result_number: int}>}
*
* @throws Throwable
*/
public static function settleBetsForDraw(int $recordId, int $resultNumber): array
{
if ($recordId <= 0 || $resultNumber < 1) {
return ['jackpot_hits' => []];
}
$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 = [];
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, '压注派彩');
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);
GameWebSocketPayloadHelper::publishUserStreak($userId, $next);
}
$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];
}
/**
* 大奖审核通过后派彩(幂等):仅当 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): ?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);
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto([
'user_id' => $userId,
'balance_after' => $after,
'biz_type' => 'payout',
'ref_id' => $betId,
'changed_at' => $now,
], $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;
}
}