Files
lotteryLaravel/app/Services/Player/PlayerCreditService.php
kang 992195b00c
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
feat(agent-profile): 限制代理及玩家返点和分成范围,增强权限校验
- 添加ensureSuperAdmin方法,限制管理员设置操作权限
- 在AdminSettingController接口中新增权限检查,防止非超级管理员操作
- AdminPlayerIndexController新增direct agent筛选支持
- AdminPlayerUpdateController新增对信用玩家默认币别变更的拒绝逻辑
- 新增WalletSettlementBillsController,实现玩家信用盘账期账单摘要接口
- AgentProfileService调整,新增返点限额和分享比例自动下调机制,保持子代理及玩家配置不超父级
- AgentProfileService增加can_grant_extra_rebate权限继承限制,阻止无权限代理开启
- AgentSettlementPeriodCloseService增加玩家账单回水信用释放逻辑,确保信用额度同步
- PlayerCreditService新增释放账单回水对应信用逻辑,维护账期信用一致性
- TicketPlacementService和TicketPreviewService新增信用玩家投注币别匹配校验,防止币别不符
- PlayerLedgerLogsService优化信用额度计算,增加可用额度上下限限制,防止负值和超限
- 调整后台管理导航,仅超管可见设置入口,强化权限隔离
- 路由新增玩家信用账单查询接口
- 补充多项AgentProfile相关单元测试覆盖额度限制、返点继承、返点下调场景及权限限制
- 增加站点管理员登录测试,验证系统设置菜单不可见,提升用户权限体验
2026-07-01 15:35:46 +08:00

584 lines
19 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\Services\Agent\AgentUsedCreditSyncService;
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
{
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): 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,
]);
$this->syncAgentUsedCredit($player);
}
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,
]);
$this->syncAgentUsedCredit($player);
}
public function applySettledWin(Player $player, int $amountMinor, int $ticketItemId): void
{
if ($amountMinor <= 0) {
return;
}
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
$now = now();
// 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。
// 已存在同 (ticket_item, game_settlement_win) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。
try {
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,
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
$this->decreaseUsedCredit($player, $amountMinor);
$this->syncAgentUsedCredit($player);
}
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;
}
$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,
'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): void
{
if ($gameWinLossSigned === 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$now = now();
$amountMinor = abs($gameWinLossSigned);
// 先写 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,
'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);
}
/**
* @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;
}
}