diff --git a/app/Console/Commands/SyncAgentUsedCreditCommand.php b/app/Console/Commands/SyncAgentUsedCreditCommand.php new file mode 100644 index 0000000..694d35d --- /dev/null +++ b/app/Console/Commands/SyncAgentUsedCreditCommand.php @@ -0,0 +1,42 @@ +option('site'); + $query = AgentNode::query()->orderBy('id'); + if (is_string($siteCode) && $siteCode !== '') { + $query->whereHas('adminSite', static fn ($q) => $q->where('code', $siteCode)); + } + + $nodes = $query->get(); + $updated = 0; + foreach ($nodes as $node) { + $profile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); + $before = $profile !== null ? (int) $profile->used_credit : null; + $sync->syncForAgent($node); + $profile?->refresh(); + $after = $profile !== null ? (int) $profile->used_credit : null; + if ($before !== $after) { + $updated++; + } + } + + $this->info('已处理 '.count($nodes).' 个代理节点,其中 '.$updated.' 个 used_credit 有变更。'); + + return self::SUCCESS; + } +} diff --git a/app/Services/Admin/AgentDashboardOverviewBuilder.php b/app/Services/Admin/AgentDashboardOverviewBuilder.php index 340701d..da7a987 100644 --- a/app/Services/Admin/AgentDashboardOverviewBuilder.php +++ b/app/Services/Admin/AgentDashboardOverviewBuilder.php @@ -8,6 +8,7 @@ use App\Models\AgentProfile; use App\Models\Player; use App\Support\AdminScopeContext; use App\Support\AdminScopeContextResolver; +use App\Services\Agent\AgentUsedCreditSyncService; use App\Services\AgentSettlement\ShareLedgerScopedProfitAggregator; use App\Support\AdminAgentSettlementScope; use Carbon\Carbon; @@ -20,6 +21,7 @@ final class AgentDashboardOverviewBuilder public function __construct( private readonly AdminReportQueryService $reportQuery, private readonly ShareLedgerScopedProfitAggregator $shareProfitAggregator, + private readonly AgentUsedCreditSyncService $usedCreditSync, ) {} /** @@ -36,6 +38,7 @@ final class AgentDashboardOverviewBuilder return null; } + $this->usedCreditSync->syncForAgentId((int) $node->id); $profile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); $subtreeIds = AgentNode::query() ->where('path', 'like', $node->path.'%') diff --git a/app/Services/Agent/AgentProfileService.php b/app/Services/Agent/AgentProfileService.php index c2870de..57c4269 100644 --- a/app/Services/Agent/AgentProfileService.php +++ b/app/Services/Agent/AgentProfileService.php @@ -17,6 +17,7 @@ final class AgentProfileService private readonly CreditAllocationValidator $creditAllocationValidator, private readonly RebateLimitValidator $rebateLimitValidator, private readonly AgentCreditAllocatedSyncService $allocatedSync, + private readonly AgentUsedCreditSyncService $usedCreditSync, ) {} /** @@ -132,6 +133,7 @@ final class AgentProfileService public function present(AgentProfile $profile): array { $this->allocatedSync->syncForAgentId((int) $profile->agent_node_id); + $this->usedCreditSync->syncForAgentId((int) $profile->agent_node_id); $profile->refresh(); $available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit); diff --git a/app/Services/Agent/AgentUsedCreditSyncService.php b/app/Services/Agent/AgentUsedCreditSyncService.php new file mode 100644 index 0000000..c817ccd --- /dev/null +++ b/app/Services/Agent/AgentUsedCreditSyncService.php @@ -0,0 +1,114 @@ +where('agent_node_id', $agent->id)->first(); + if ($profile === null) { + return; + } + + $expected = $this->calculateUsedCredit($agent); + if ((int) $profile->used_credit === $expected) { + return; + } + + $profile->used_credit = $expected; + $profile->save(); + } + + public function syncForAgentId(int $agentNodeId): void + { + $agent = AgentNode::query()->find($agentNodeId); + if ($agent === null) { + return; + } + + $this->syncForAgent($agent); + } + + /** + * 玩家信用变化后:同步直属代理及所有祖先代理的 used_credit。 + */ + public function syncForPlayerAgentChain(int $playerAgentNodeId): void + { + if ($playerAgentNodeId <= 0) { + return; + } + + $agent = AgentNode::query()->find($playerAgentNodeId); + if ($agent === null) { + return; + } + + $ancestorIds = $this->ancestorAndSelfIds($agent); + + foreach ($ancestorIds as $nodeId) { + $this->syncForAgentId($nodeId); + } + } + + /** + * 计算代理子树下所有信用玩家的 used_credit + frozen_credit 之和。 + */ + public function calculateUsedCredit(AgentNode $agent): int + { + $subtreeIds = $this->subtreeNodeIds($agent); + if ($subtreeIds === []) { + return 0; + } + + return (int) DB::table('player_credit_accounts as pca') + ->join('players as p', 'p.id', '=', 'pca.player_id') + ->whereIn('p.agent_node_id', $subtreeIds) + ->where('p.funding_mode', PlayerFundingMode::CREDIT) + ->sum(DB::raw('pca.used_credit + pca.frozen_credit')); + } + + /** + * @return list + */ + private function subtreeNodeIds(AgentNode $agent): array + { + return AgentNode::query() + ->where('path', 'like', $agent->path.'%') + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->all(); + } + + /** + * @return list + */ + private function ancestorAndSelfIds(AgentNode $agent): array + { + $path = (string) $agent->path; + if ($path === '') { + return [(int) $agent->id]; + } + + $parts = explode('/', trim($path, '/')); + $ancestorIds = array_map('intval', $parts); + $ancestorIds[] = (int) $agent->id; + + return $ancestorIds; + } +} diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index d125067..fad2b5b 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -3,6 +3,7 @@ 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; @@ -12,6 +13,9 @@ use Illuminate\Validation\ValidationException; final class PlayerCreditService { + public function __construct( + private readonly AgentUsedCreditSyncService $usedCreditSync, + ) {} /** * @param array{credit_limit?: int} $payload */ @@ -155,6 +159,8 @@ final class PlayerCreditService 'created_at' => $now, 'updated_at' => $now, ]); + + $this->syncAgentUsedCredit($player); } public function applySettledLoss(Player $player, int $amountMinor, int $ticketItemId): void @@ -208,6 +214,8 @@ final class PlayerCreditService 'used_credit' => (int) $row->used_credit + $majorDelta, 'updated_at' => $now, ]); + + $this->syncAgentUsedCredit($player); } public function applySettledWin(Player $player, int $amountMinor, int $ticketItemId): void @@ -243,6 +251,7 @@ final class PlayerCreditService } $this->decreaseUsedCredit($player, $amountMinor); + $this->syncAgentUsedCredit($player); } public function assertMayPlaceBet(Player $player, int $amountMinor): void @@ -310,6 +319,7 @@ final class PlayerCreditService } $this->decreaseUsedCredit($player, $amountMinor); + $this->syncAgentUsedCredit($player); } public function reverseBetHold(Player $player, int $amountMinor, int $ticketOrderId): void @@ -341,6 +351,7 @@ final class PlayerCreditService } $this->decreaseUsedCredit($player, $amountMinor); + $this->syncAgentUsedCredit($player); } public function reverseGameSettlement(Player $player, int $gameWinLossSigned, int $ticketItemId): void @@ -395,6 +406,8 @@ final class PlayerCreditService 'used_credit' => (int) $row->used_credit + $majorDelta, 'updated_at' => $now, ]); + + $this->syncAgentUsedCredit($player); } /** @@ -414,6 +427,7 @@ final class PlayerCreditService ); if ($delta > 0) { $this->decreaseUsedCredit($player, $delta); + $this->syncAgentUsedCredit($player); } } @@ -508,6 +522,17 @@ final class PlayerCreditService ]); } + /** 玩家信用变动后同步直属代理及祖先链的 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 23505;SQLite SQLSTATE 23000/error code 19)。 * 兜底扫描消息中 unique 字样,兼容驱动返回的 SQLSTATE 缺失场景。 diff --git a/tests/Feature/AgentUsedCreditSyncTest.php b/tests/Feature/AgentUsedCreditSyncTest.php new file mode 100644 index 0000000..57938fa --- /dev/null +++ b/tests/Feature/AgentUsedCreditSyncTest.php @@ -0,0 +1,204 @@ +insertGetId([ + 'code' => $code, + 'name' => $code, + 'is_default' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $rootId = (int) DB::table('agent_nodes')->insertGetId([ + 'admin_site_id' => $siteId, + 'parent_id' => null, + 'depth' => 0, + 'path' => '/', + 'code' => $code, + 'name' => 'Root', + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('agent_nodes')->where('id', $rootId)->update(['path' => "/{$rootId}/"]); + + AgentProfile::query()->create([ + 'agent_node_id' => $rootId, + 'total_share_rate' => 60, + 'credit_limit' => $rootCreditLimit, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 0.01, + 'default_player_rebate' => 0.005, + ]); + + $childId = (int) DB::table('agent_nodes')->insertGetId([ + 'admin_site_id' => $siteId, + 'parent_id' => $rootId, + 'depth' => 1, + 'path' => "/{$rootId}/", + 'code' => $code.'-child', + 'name' => 'Child', + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('agent_nodes')->where('id', $childId)->update(['path' => "/{$rootId}/{$childId}/"]); + + AgentProfile::query()->create([ + 'agent_node_id' => $childId, + 'total_share_rate' => 30, + 'credit_limit' => 5000, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 0.008, + 'default_player_rebate' => 0.003, + ]); + + return [ + 'site_id' => $siteId, + 'root' => AgentNode::query()->findOrFail($rootId), + 'child' => AgentNode::query()->findOrFail($childId), + ]; +} + +function createCreditPlayerForUsedCredit(string $siteCode, int $agentNodeId, string $playerSuffix): int +{ + $playerId = (int) DB::table('players')->insertGetId([ + 'site_code' => $siteCode, + 'agent_node_id' => $agentNodeId, + 'site_player_id' => 'uc-'.$playerSuffix, + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'uc-'.$playerSuffix, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $playerId, + 'credit_limit' => 3000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $playerId; +} + +test('bet hold syncs used_credit up the agent chain', function (): void { + $line = createAgentLineForUsedCredit('uc-hold', 10000); + $playerId = createCreditPlayerForUsedCredit('uc-hold', $line['child']->id, 'p1'); + + $player = \App\Models\Player::query()->find($playerId); + + app(PlayerCreditService::class)->assertMayPlaceBet($player, 500); + + $childProfile = AgentProfile::query()->where('agent_node_id', $line['child']->id)->first(); + $rootProfile = AgentProfile::query()->where('agent_node_id', $line['root']->id)->first(); + + expect((int) $childProfile->used_credit)->toBe(5); + expect((int) $rootProfile->used_credit)->toBe(5); +}); + +test('settled win decreases used_credit up the agent chain', function (): void { + $line = createAgentLineForUsedCredit('uc-win', 10000); + $playerId = createCreditPlayerForUsedCredit('uc-win', $line['child']->id, 'p2'); + + $player = \App\Models\Player::query()->find($playerId); + + $credit = app(PlayerCreditService::class); + $credit->assertMayPlaceBet($player, 500); + + $credit->applySettledWin($player, 200, 9991); + + $childProfile = AgentProfile::query()->where('agent_node_id', $line['child']->id)->first(); + $rootProfile = AgentProfile::query()->where('agent_node_id', $line['root']->id)->first(); + + expect((int) $childProfile->used_credit)->toBe(3); + expect((int) $rootProfile->used_credit)->toBe(3); +}); + +test('used_credit does not participate in available_credit calculation', function (): void { + $line = createAgentLineForUsedCredit('uc-avail', 10000); + $playerId = createCreditPlayerForUsedCredit('uc-avail', $line['child']->id, 'p3'); + + $player = \App\Models\Player::query()->find($playerId); + + app(PlayerCreditService::class)->assertMayPlaceBet($player, 500); + + $rootProfile = AgentProfile::query()->where('agent_node_id', $line['root']->id)->first(); + expect((int) $rootProfile->used_credit)->toBe(5); + + $available = max(0, (int) $rootProfile->credit_limit - (int) $rootProfile->allocated_credit); + expect($available)->toBe(10000); +}); + +test('syncForAgentId recalculates from subtree players', function (): void { + $line = createAgentLineForUsedCredit('uc-sync', 10000); + $playerId = createCreditPlayerForUsedCredit('uc-sync', $line['child']->id, 'p4'); + + DB::table('player_credit_accounts')->where('player_id', $playerId)->update([ + 'used_credit' => 100, + 'frozen_credit' => 50, + ]); + + $sync = app(AgentUsedCreditSyncService::class); + $sync->syncForAgentId($line['root']->id); + + $rootProfile = AgentProfile::query()->where('agent_node_id', $line['root']->id)->first(); + expect((int) $rootProfile->used_credit)->toBe(150); + + $sync->syncForAgentId($line['child']->id); + $childProfile = AgentProfile::query()->where('agent_node_id', $line['child']->id)->first(); + expect((int) $childProfile->used_credit)->toBe(150); +}); + +test('wallet players do not contribute to used_credit', function (): void { + $line = createAgentLineForUsedCredit('uc-wallet', 10000); + + $walletPlayerId = (int) DB::table('players')->insertGetId([ + 'site_code' => 'uc-wallet', + 'agent_node_id' => $line['child']->id, + 'site_player_id' => 'uc-wp', + 'auth_source' => 'main_site_sso', + 'funding_mode' => 'wallet', + 'username' => 'uc-wp', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $walletPlayerId, + 'credit_limit' => 5000, + 'used_credit' => 200, + 'frozen_credit' => 100, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $sync = app(AgentUsedCreditSyncService::class); + $sync->syncForAgentId($line['root']->id); + + $rootProfile = AgentProfile::query()->where('agent_node_id', $line['root']->id)->first(); + expect((int) $rootProfile->used_credit)->toBe(0); +});