Files
lotteryLaravel/app/Services/Player/PlayerCreditService.php
kang 6b2ea39ea1
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
fix: 修复并发竞态与幂等性缺陷并优化子树查询
- 扩大玩家ID随机数位宽降低碰撞概率
- 授信额度变更加 lockForUpdate 防并发下调
- 结算冲正与入账利用唯一索引实现幂等闸门
- 失败登录计数加锁防丢失
- 代理子树查询改用子查询替代全表 pluck
- 修复返点默认值百分比单位转换
- 测试中全量 fake 广播事件防 CI 500
2026-06-18 09:38:24 +08:00

473 lines
15 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
namespace App\Services\Player;
use App\Models\Player;
use App\Support\AgentOverdueGuard;
use App\Support\CreditAmountScale;
use App\Support\PlayerFundingMode;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class PlayerCreditService
{
/**
* @param array{credit_limit?: int} $payload
*/
public function upsertAccount(Player $player, array $payload): void
{
$limit = max(0, (int) ($payload['credit_limit'] ?? 0));
$now = now();
// 整段包在事务中:并发下调额(先 SELECT used_credit 后 UPDATE若不加锁
// 两个请求都拿到旧 used_credit 并各自通过 below_player_used 校验,导致额度被收缩到比已用更小。
// lockForUpdate 保证同时只有一个请求能修改该玩家授信记录。
DB::transaction(function () use ($player, $limit, $now): void {
$row = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->lockForUpdate()
->first();
if ($row !== null) {
$usedTotal = (int) $row->used_credit + (int) $row->frozen_credit;
if ($limit < $usedTotal) {
throw ValidationException::withMessages([
'credit_limit' => ['below_player_used'],
]);
}
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'credit_limit' => $limit,
'updated_at' => $now,
]);
return;
}
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => $limit,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
});
}
/** 可用授信(主货币整数,与后台「授信额度」一致)。 */
public function availableCredit(Player $player): int
{
$row = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
if ($row === null) {
return 0;
}
return max(0, (int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit);
}
/** 可用授信(最小货币单位,供玩家端钱包/下注与钱包余额 API 对齐)。 */
public function availableCreditMinor(Player $player, ?string $currencyCode = null): int
{
$currency = $currencyCode ?? (string) $player->default_currency;
return CreditAmountScale::majorToMinor($this->availableCredit($player), $currency);
}
/** 逾期/代理线门禁与可用额度预检(不占额)。 */
public function assertCreditPreflight(Player $player, int $amountMinor): void
{
if (! PlayerFundingMode::usesCredit($player) || $amountMinor <= 0) {
return;
}
$this->assertCreditGuards($player);
$currency = (string) $player->default_currency;
$majorNeeded = CreditAmountScale::minorToMajor($amountMinor, $currency);
if ($majorNeeded > $this->availableCredit($player)) {
throw ValidationException::withMessages([
'credit' => ['insufficient'],
]);
}
}
public function holdForBet(Player $player, int $amountMinor): void
{
if ($amountMinor <= 0) {
return;
}
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
$currency = (string) $player->default_currency;
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
$now = now();
$row = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->lockForUpdate()
->first();
if ($row === null) {
throw ValidationException::withMessages([
'credit' => ['insufficient'],
]);
}
$availableMajor = max(
0,
(int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit,
);
$availableMinor = CreditAmountScale::majorToMinor($availableMajor, $currency);
if ($amountMinor > $availableMinor) {
throw ValidationException::withMessages([
'credit' => ['insufficient'],
]);
}
$updated = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->whereRaw('credit_limit - used_credit - frozen_credit >= ?', [$majorDelta])
->update([
'used_credit' => DB::raw('used_credit + '.$majorDelta),
'updated_at' => $now,
]);
if ($updated !== 1) {
throw ValidationException::withMessages([
'credit' => ['insufficient'],
]);
}
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -$amountMinor,
'reason' => 'bet_hold',
'ref_type' => 'bet',
'ref_id' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
public function applySettledLoss(Player $player, int $amountMinor, int $ticketItemId): void
{
if ($amountMinor <= 0) {
return;
}
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
$currency = (string) $player->default_currency;
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
$now = now();
// 先写 credit_ledger以 (ref_type, ref_id) partial unique 索引为幂等闸门。
// 已存在同 (ticket_item, game_settlement_loss) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -$amountMinor,
'reason' => 'game_settlement_loss',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => $now,
'updated_at' => $now,
]);
} catch (QueryException $e) {
// 23505 (pgsql) / 23000/19 (sqlite) 唯一约束冲突即幂等成功,跳过余额更新。
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
// 余额变更在 lockForUpdate 保护下做条件 update。
$row = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->lockForUpdate()
->first();
if ($row === null) {
return;
}
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'used_credit' => (int) $row->used_credit + $majorDelta,
'updated_at' => $now,
]);
}
public function applySettledWin(Player $player, int $amountMinor, int $ticketItemId): void
{
if ($amountMinor <= 0) {
return;
}
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
$this->decreaseUsedCredit($player, $amountMinor);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'game_settlement_win',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function assertMayPlaceBet(Player $player, int $amountMinor): void
{
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
$this->assertCreditGuards($player);
$this->holdForBet($player, $amountMinor);
}
private function assertCreditGuards(Player $player): void
{
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
$overdue = DB::table('settlement_bills')
->where('owner_type', 'player')
->where('owner_id', $player->id)
->where('status', 'overdue')
->where('unpaid_amount', '>', 0)
->exists();
if ($overdue) {
throw ValidationException::withMessages([
'credit' => ['overdue'],
]);
}
$agentNodeId = (int) ($player->agent_node_id ?? 0);
if ($agentNodeId > 0) {
AgentOverdueGuard::assertAgentMayGrantCredit($agentNodeId);
AgentOverdueGuard::assertAgentLineMayPlaceBet($agentNodeId);
}
}
public function releaseBetHold(Player $player, int $amountMinor, int $ticketItemId): void
{
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$this->decreaseUsedCredit($player, $amountMinor);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'bet_hold_release',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function reverseBetHold(Player $player, int $amountMinor): void
{
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$this->decreaseUsedCredit($player, $amountMinor);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'bet_hold_release',
'ref_type' => 'bet',
'ref_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function reverseGameSettlement(Player $player, int $gameWinLossSigned, int $ticketItemId): void
{
if ($gameWinLossSigned === 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$now = now();
if ($gameWinLossSigned > 0) {
$amountMinor = $gameWinLossSigned;
$this->decreaseUsedCredit($player, $amountMinor);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'game_settlement_reversal',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => $now,
'updated_at' => $now,
]);
return;
}
$amountMinor = abs($gameWinLossSigned);
$currency = (string) $player->default_currency;
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'used_credit' => DB::raw('used_credit + '.$majorDelta),
'updated_at' => $now,
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -$amountMinor,
'reason' => 'game_settlement_reversal',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => $now,
'updated_at' => $now,
]);
}
public function releaseFromSettlement(Player $player, int $amountMinor, int $billId): void
{
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
// 幂等闸门credit_ledger (settlement_bill, settlement_confirm) 唯一;重复调用直接返回。
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'settlement_confirm',
'ref_type' => 'settlement_bill',
'ref_id' => $billId,
'created_at' => now(),
'updated_at' => now(),
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
$this->decreaseUsedCredit($player, $amountMinor);
}
public function applySettlementPayout(Player $player, int $amountMinor, int $billId): void
{
if ($amountMinor <= 0) {
return;
}
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
// 幂等闸门credit_ledger (settlement_bill, settlement_payout) 唯一;重复入账直接返回。
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'settlement_payout',
'ref_type' => 'settlement_bill',
'ref_id' => $billId,
'created_at' => now(),
'updated_at' => now(),
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
}
private function decreaseUsedCredit(Player $player, int $amountMinor): void
{
if ($amountMinor <= 0) {
return;
}
$playerId = (int) $player->id;
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency);
$row = DB::table('player_credit_accounts')
->where('player_id', $playerId)
->lockForUpdate()
->first();
if ($row === null) {
return;
}
$next = max(0, (int) $row->used_credit - $majorDelta);
DB::table('player_credit_accounts')
->where('player_id', $playerId)
->update([
'used_credit' => $next,
'updated_at' => now(),
]);
}
/**
* 识别数据库唯一约束冲突PostgreSQL SQLSTATE 23505SQLite SQLSTATE 23000/error code 19
* 兜底扫描消息中 unique 字样,兼容驱动返回的 SQLSTATE 缺失场景。
*/
private function isUniqueViolation(QueryException $e): bool
{
$sqlState = (string) $e->getCode();
$errorInfo = $e->errorInfo ?? null;
$driverCode = is_array($errorInfo) ? (int) ($errorInfo[1] ?? 0) : 0;
if ($sqlState === '23505') {
return true;
}
if ($sqlState === '23000' || $sqlState === '19') {
return true;
}
if ($driverCode === 19 || $driverCode === 1062) {
return true;
}
$message = $e->getMessage();
if (stripos($message, 'unique') !== false || stripos($message, 'UNIQUE constraint') !== false) {
return true;
}
return false;
}
}