Files
lotteryLaravel/app/Services/Player/PlayerCreditService.php
wchino 15bd997c4e
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
feat(core): harden sessions settlement and credit activity
2026-07-22 20:53:43 +08:00

694 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
namespace App\Services\Player;
use App\Models\Player;
use App\Support\AgentOverdueGuard;
use App\Support\CreditAmountScale;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use Illuminate\Validation\ValidationException;
use App\Services\Agent\AgentUsedCreditSyncService;
final class PlayerCreditService
{
public function __construct(
private readonly AgentUsedCreditSyncService $usedCreditSync,
) {}
/**
* @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, ?int $ticketOrderId = null): 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' => $ticketOrderId !== null && $ticketOrderId > 0 ? 'ticket_order' : 'bet',
'ref_id' => $ticketOrderId !== null && $ticketOrderId > 0 ? $ticketOrderId : null,
'created_at' => $now,
'updated_at' => $now,
]);
$this->syncAgentUsedCredit($player);
}
public function applySettledLoss(
Player $player,
int $amountMinor,
int $ticketItemId,
int $settlementVersion = 0,
): 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,
'settlement_version' => $settlementVersion,
'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,
]);
$this->syncAgentUsedCredit($player);
}
public function applySettledWin(
Player $player,
int $amountMinor,
int $ticketItemId,
int $settlementVersion = 0,
): void {
if ($amountMinor <= 0) {
return;
}
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
$applied = DB::transaction(function () use ($player, $amountMinor, $ticketItemId, $settlementVersion): bool {
$now = now();
$currency = (string) $player->default_currency;
$requestedMajor = CreditAmountScale::minorToMajor($amountMinor, $currency);
$account = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->lockForUpdate()
->first();
$appliedMajor = $account === null
? 0
: min((int) $account->used_credit, $requestedMajor);
$appliedMinor = CreditAmountScale::majorToMinor($appliedMajor, $currency);
// 流水记录实际释放的额度,而非可能超过 used_credit 的名义中奖额。
// 结算被驳回时必须按这个实际值恢复,否则会凭空增加玩家已用额度。
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $appliedMinor,
'reason' => 'game_settlement_win',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now,
'updated_at' => $now,
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return false;
}
throw $e;
}
if ($account !== null && $appliedMajor > 0) {
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'used_credit' => (int) $account->used_credit - $appliedMajor,
'updated_at' => $now,
]);
}
return true;
});
if ($applied) {
$this->syncAgentUsedCredit($player);
}
}
public function assertMayPlaceBet(Player $player, int $amountMinor, ?int $ticketOrderId = null): void
{
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
$this->assertCreditGuards($player);
$this->holdForBet($player, $amountMinor, $ticketOrderId);
}
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,
int $settlementVersion = 0,
): void {
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$now = now();
// 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。
// 已存在同 (ticket_item, bet_hold_release) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。
try {
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,
'settlement_version' => $settlementVersion,
'created_at' => $now,
'updated_at' => $now,
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
$this->decreaseUsedCredit($player, $amountMinor);
$this->syncAgentUsedCredit($player);
}
public function reverseBetHold(Player $player, int $amountMinor, int $ticketOrderId): void
{
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$now = now();
// 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。
// 已存在同 (ticket_order, bet_hold_release) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'bet_hold_release',
'ref_type' => 'ticket_order',
'ref_id' => $ticketOrderId,
'created_at' => $now,
'updated_at' => $now,
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
$this->decreaseUsedCredit($player, $amountMinor);
$this->syncAgentUsedCredit($player);
}
public function reverseGameSettlement(
Player $player,
int $gameWinLossSigned,
int $ticketItemId,
int $settlementVersion = 0,
): void {
if ($gameWinLossSigned === 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$now = now();
$amountMinor = abs($gameWinLossSigned);
if ($gameWinLossSigned < 0) {
// 中奖释放 used_credit 时可能受 0 下限截断;反转只能恢复当时实际释放的数额。
// 旧流水或人工修复数据可能不存在,保留名义值兜底以兼容历史记录。
$recordedAmount = DB::table('credit_ledger')
->where('owner_type', 'player')
->where('owner_id', $player->id)
->where('ref_type', 'ticket_item')
->where('ref_id', $ticketItemId)
->where('reason', 'game_settlement_win')
->where('settlement_version', $settlementVersion)
->value('amount');
if ($recordedAmount !== null) {
$amountMinor = abs((int) $recordedAmount);
}
}
// 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $gameWinLossSigned > 0 ? $amountMinor : -$amountMinor,
'reason' => 'game_settlement_reversal',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now,
'updated_at' => $now,
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
if ($gameWinLossSigned > 0) {
$this->decreaseUsedCredit($player, $amountMinor);
return;
}
$currency = (string) $player->default_currency;
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
$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,
]);
$this->syncAgentUsedCredit($player);
}
/** 驳回结算后恢复原注占额,使票回到 pending_draw 时额度状态与结算前一致。 */
public function restoreBetHoldAfterSettlementReversal(
Player $player,
int $amountMinor,
int $ticketItemId,
int $settlementVersion = 0,
): void {
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$now = now();
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -$amountMinor,
'reason' => 'bet_hold_restore',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'settlement_version' => $settlementVersion,
'created_at' => $now,
'updated_at' => $now,
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency);
$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,
]);
$this->syncAgentUsedCredit($player);
}
/**
* @param int $cumulativePaidMinor 账单累计已登记收付minor支持部分收付多笔递增。
*/
public function releaseFromSettlement(Player $player, int $cumulativePaidMinor, int $billId): void
{
if ($cumulativePaidMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$delta = $this->syncSettlementBillLedger(
$player,
$billId,
'settlement_confirm',
$cumulativePaidMinor,
);
if ($delta > 0) {
$this->decreaseUsedCredit($player, $delta);
$this->syncAgentUsedCredit($player);
}
}
/**
* @param int $cumulativePaidMinor 账单累计已登记收付minor支持部分收付多笔递增。
*/
public function applySettlementPayout(Player $player, int $cumulativePaidMinor, int $billId): void
{
if ($cumulativePaidMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$this->syncSettlementBillLedger(
$player,
$billId,
'settlement_payout',
$cumulativePaidMinor,
);
}
/**
* 账期回水进入玩家账单后,已抵扣玩家应付亏损,需同步释放对应已用信用。
*/
public function releaseRebateInBill(Player $player, int $rebateMinor, int $billId): void
{
if ($rebateMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$delta = $this->syncSettlementBillLedger(
$player,
$billId,
'rebate_in_bill',
$rebateMinor,
);
if ($delta > 0) {
$this->decreaseUsedCredit($player, $delta);
$this->syncAgentUsedCredit($player);
}
}
/**
* 同步账期收付台账:每账单每种 reason 仅一行amount 为累计已付;返回本次应追加的 minor 增量。
*/
private function syncSettlementBillLedger(
Player $player,
int $billId,
string $reason,
int $cumulativeMinor,
): int {
$existing = DB::table('credit_ledger')
->where('owner_type', 'player')
->where('owner_id', $player->id)
->where('reason', $reason)
->where('ref_type', 'settlement_bill')
->where('ref_id', $billId)
->lockForUpdate()
->first();
$recorded = $existing !== null ? (int) $existing->amount : 0;
$delta = $cumulativeMinor - $recorded;
if ($delta <= 0) {
return 0;
}
$now = now();
if ($existing === null) {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $cumulativeMinor,
'reason' => $reason,
'ref_type' => 'settlement_bill',
'ref_id' => $billId,
'created_at' => $now,
'updated_at' => $now,
]);
} else {
DB::table('credit_ledger')
->where('id', (int) $existing->id)
->update([
'amount' => $cumulativeMinor,
'updated_at' => $now,
]);
}
return $delta;
}
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(),
]);
}
/** 玩家信用变动后同步直属代理及祖先链的 used_credit团队已用风险。 */
private function syncAgentUsedCredit(Player $player): void
{
$agentNodeId = (int) ($player->agent_node_id ?? 0);
if ($agentNodeId <= 0) {
return;
}
$this->usedCreditSync->syncForPlayerAgentChain($agentNodeId);
}
/**
* 识别数据库唯一约束冲突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;
}
}