329 lines
11 KiB
PHP
329 lines
11 KiB
PHP
<?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_record(biz_type=payout)。
|
||
* 连胜赔率来自 game_config.streak_win_reward;结算后更新 user.current_streak(未中奖则连胜归 0)。
|
||
*/
|
||
final class GameBetSettleService
|
||
{
|
||
/**
|
||
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 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();
|
||
$bets = Db::name('bet_order')
|
||
->where('period_id', $recordId)
|
||
->where('status', 1)
|
||
->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.0000';
|
||
|
||
$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', 4) > 0) {
|
||
$userOutcome[$userId]['had_win'] = true;
|
||
}
|
||
if (bccomp($win, '0', 4) > 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', 4) > 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.0000',
|
||
'balance_after' => $balanceAfter,
|
||
'orders' => [],
|
||
];
|
||
}
|
||
$aggregateByUser[$userId]['total_win'] = bcadd($aggregateByUser[$userId]['total_win'], $win, 4);
|
||
$aggregateByUser[$userId]['balance_after'] = $balanceAfter;
|
||
$aggregateByUser[$userId]['orders'][] = [
|
||
'order_no' => (string) $betId,
|
||
'win_amount' => $win,
|
||
'hit' => bccomp($win, '0', 4) > 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);
|
||
}
|
||
|
||
foreach ($aggregateByUser as $userId => $agg) {
|
||
$hitOrderCount = 0;
|
||
foreach ($agg['orders'] as $o) {
|
||
if (($o['hit'] ?? false) === true) {
|
||
$hitOrderCount++;
|
||
}
|
||
}
|
||
UserPushService::publish((int) $userId, UserPushService::EVT_BET_SETTLED, [
|
||
'period_no' => $agg['period_no'],
|
||
'result_number' => $resultNumber,
|
||
'total_win_amount' => $agg['total_win'],
|
||
'order_count' => count($agg['orders']),
|
||
'hit_order_count' => $hitOrderCount,
|
||
'balance_after' => $agg['balance_after'],
|
||
]);
|
||
|
||
if (bccomp($agg['total_win'], '0', 4) > 0) {
|
||
UserPushService::publish((int) $userId, UserPushService::EVT_WALLET_CHANGED, [
|
||
'reason' => 'payout',
|
||
'ref_type' => 'game_period',
|
||
'ref_id' => (string) $recordId,
|
||
'delta' => $agg['total_win'],
|
||
'balance_after' => $agg['balance_after'],
|
||
]);
|
||
}
|
||
}
|
||
|
||
$jackpotHits = [];
|
||
foreach ($jackpotNotify as $uid => $_) {
|
||
if (!isset($aggregateByUser[$uid])) {
|
||
continue;
|
||
}
|
||
$agg = $aggregateByUser[$uid];
|
||
if (bccomp($agg['total_win'], '0', 4) <= 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', 4)
|
||
->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 {
|
||
$out = self::settleBetsForDraw($rid, $rn);
|
||
Db::commit();
|
||
JackpotPushService::publishHits($out['jackpot_hits'] ?? []);
|
||
$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.0000';
|
||
}
|
||
$total = (string) ($bet['total_amount'] ?? '0');
|
||
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
||
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
|
||
|
||
return bcmul($total, $odds, 4);
|
||
}
|
||
|
||
/**
|
||
* 累加玩家打码量(流水):按本注单 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', 4);
|
||
if (bccomp($flow, '0', 4) <= 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, 4);
|
||
|
||
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);
|
||
|
||
return $after;
|
||
}
|
||
}
|