Files
lotteryLaravel/app/Services/Agent/AgentProfileService.php
kang 5b6d4cb74d feat: enhance agent management and validation logic
- Updated AGENTS.md to clarify agent account restrictions and permissions.
- Implemented checks in AgentNodeAdminUserStoreController and AgentNodeRoleStoreController to restrict admin user and role creation to the agent's own node.
- Enhanced validation in AdminPlayerStoreController and AdminPlayerUpdateController to enforce credit limit and rebate rate rules based on player funding mode.
- Refactored various request classes to utilize shared admin account field rules for consistency.
- Improved error handling in services related to credit allocation and rebate limits to ensure proper validation and messaging.
2026-06-14 21:13:27 +08:00

309 lines
11 KiB
PHP

<?php
namespace App\Services\Agent;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\AdminAgentScope;
use App\Support\AgentOverdueGuard;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentProfileService
{
public function __construct(
private readonly ShareRateValidator $shareRateValidator,
private readonly CreditAllocationValidator $creditAllocationValidator,
private readonly RebateLimitValidator $rebateLimitValidator,
private readonly AgentCreditAllocatedSyncService $allocatedSync,
) {}
/**
* @param array<string, mixed> $payload
*/
public function upsertForNode(AgentNode $node, array $payload, ?AgentNode $parent = null): AgentProfile
{
$parent = $parent ?? ($node->parent_id !== null ? AgentNode::query()->find($node->parent_id) : null);
$existingProfile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
$totalShare = array_key_exists('total_share_rate', $payload)
? (float) $payload['total_share_rate']
: (float) ($existingProfile->total_share_rate ?? 0);
$creditLimit = array_key_exists('credit_limit', $payload)
? (int) $payload['credit_limit']
: (int) ($existingProfile->credit_limit ?? 0);
$rebateLimit = array_key_exists('rebate_limit', $payload)
? (float) $payload['rebate_limit'] / 100
: (float) ($existingProfile->rebate_limit ?? 0);
$defaultRebate = array_key_exists('default_player_rebate', $payload)
? (float) $payload['default_player_rebate'] / 100
: (float) ($existingProfile->default_player_rebate ?? 0);
$useRelative = $parent !== null && array_key_exists('relative_share_rate', $payload);
// 如果提供了相对占成比例,计算绝对总占成
if ($useRelative) {
$relativeShare = (float) $payload['relative_share_rate'];
$this->shareRateValidator->assertRelativeShareWithinBounds($relativeShare);
$parentRate = $this->shareRateValidator->totalShareRateForNode($parent);
$totalShare = round($parentRate * $relativeShare / 100, 2);
}
if ($parent !== null) {
$this->shareRateValidator->assertChildWithinParent(
$parent,
$totalShare,
$useRelative ? 'relative_share_rate' : 'total_share_rate',
);
}
return DB::transaction(function () use ($node, $payload, $parent, $totalShare, $creditLimit, $rebateLimit, $defaultRebate): AgentProfile {
$profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $node->id]);
$previousCredit = (int) $profile->credit_limit;
$isNew = ! $profile->exists;
if ($parent !== null && ! $isNew) {
$this->allocatedSync->syncForAgent($parent);
}
if (! $isNew) {
$this->allocatedSync->syncForAgent($node);
if ($creditLimit < (int) $profile->allocated_credit) {
throw ValidationException::withMessages([
'credit_limit' => ['below_allocated'],
]);
}
}
if ($parent !== null) {
$this->creditAllocationValidator->assertChildCreditLimitWithinParent(
$parent,
$isNew ? 0 : $previousCredit,
$creditLimit,
$isNew,
);
}
if ($defaultRebate > $rebateLimit) {
throw ValidationException::withMessages([
'default_player_rebate' => ['exceeds_limit'],
]);
}
if ($parent !== null) {
$this->rebateLimitValidator->assertChildRebateLimitWithinParent($parent, $rebateLimit);
}
$profile->fill([
'total_share_rate' => $totalShare,
'credit_limit' => $creditLimit,
'rebate_limit' => $rebateLimit,
'default_player_rebate' => $defaultRebate,
'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? $profile->can_grant_extra_rebate ?? false),
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? ($isNew ? false : $profile->can_create_child_agent)),
'can_create_player' => (bool) ($payload['can_create_player'] ?? ($isNew ? true : $profile->can_create_player ?? true)),
]);
if (! $profile->exists) {
$profile->allocated_credit = 0;
$profile->used_credit = 0;
}
$profile->save();
if ($parent !== null) {
$this->allocatedSync->syncForAgent($parent);
}
return $profile;
});
}
/**
* @return array<string, mixed>
*/
public function present(AgentProfile $profile): array
{
$this->allocatedSync->syncForAgentId((int) $profile->agent_node_id);
$profile->refresh();
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
return [
'agent_node_id' => (int) $profile->agent_node_id,
'total_share_rate' => (float) $profile->total_share_rate,
'credit_limit' => (int) $profile->credit_limit,
'allocated_credit' => (int) $profile->allocated_credit,
'used_credit' => (int) $profile->used_credit,
'available_credit' => $available,
'rebate_limit' => round((float) $profile->rebate_limit * 100, 4),
'default_player_rebate' => round((float) $profile->default_player_rebate * 100, 4),
'can_grant_extra_rebate' => (bool) $profile->can_grant_extra_rebate,
'can_create_child_agent' => (bool) $profile->can_create_child_agent,
'can_create_player' => (bool) $profile->can_create_player,
];
}
public function profileForNode(int $agentNodeId): ?AgentProfile
{
return AgentProfile::query()->where('agent_node_id', $agentNodeId)->first();
}
/**
* @return array<string, mixed>|null
*/
public function parentCapsForNode(?AgentNode $parent): ?array
{
if ($parent === null) {
return null;
}
$this->allocatedSync->syncForAgent($parent);
$profile = $this->profileForNode((int) $parent->id);
if ($profile === null) {
return null;
}
return [
'agent_node_id' => (int) $parent->id,
'total_share_rate' => (float) $profile->total_share_rate,
'rebate_limit' => round((float) $profile->rebate_limit * 100, 4),
'available_credit' => max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit),
];
}
/** 玩家授信写入前:校验代理可下发是否足够(按当前库内已占用重算)。 */
public function assertMayIncreasePlayerCredit(AgentNode $agent, int $additionalCredit): void
{
if ($additionalCredit <= 0) {
return;
}
$this->assertAgentProfileExists($agent);
$this->allocatedSync->syncForAgent($agent);
$this->creditAllocationValidator->assertPlayerCreditDeltaWithinAgent($agent, $additionalCredit);
}
/** 玩家授信变更后:按直属玩家+直属下级代理重算已下发额度(无 profile 时跳过)。 */
public function refreshAllocatedCredit(AgentNode $agent): void
{
$this->allocatedSync->syncForAgent($agent);
}
public function adjustPlayerCreditAllocation(AgentNode $agent, int $previousLimit, int $newLimit, int $playerUsedCredit = 0): void
{
if ($newLimit < $playerUsedCredit) {
throw ValidationException::withMessages([
'credit_limit' => ['below_player_used'],
]);
}
$delta = $newLimit - $previousLimit;
$this->assertAgentProfileExists($agent);
$this->allocatedSync->syncForAgent($agent);
if ($delta > 0) {
$this->creditAllocationValidator->assertPlayerCreditDeltaWithinAgent($agent, $delta);
}
}
public function assertActorMayCreateChildAgent(AdminUser $admin): void
{
if ($admin->isSuperAdmin()) {
return;
}
$node = AdminAgentScope::primaryAgentNode($admin);
if ($node === null) {
return;
}
if (! $this->nodeMayCreateChildAgent($node->id)) {
throw ValidationException::withMessages([
'parent_id' => ['cannot_create_child_agent'],
]);
}
if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) {
throw ValidationException::withMessages([
'parent_id' => ['agent_overdue'],
]);
}
}
public function assertActorMayCreatePlayer(AdminUser $admin): void
{
if ($admin->isSuperAdmin()) {
return;
}
$node = AdminAgentScope::primaryAgentNode($admin);
if ($node === null) {
return;
}
if (! $this->nodeMayCreatePlayer($node->id)) {
throw ValidationException::withMessages([
'site_code' => ['cannot_create_player'],
]);
}
if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) {
throw ValidationException::withMessages([
'site_code' => ['agent_overdue'],
]);
}
}
/**
* @param array<string, mixed> $childPayload
*/
public function assertChildCapabilityGrantsWithinParent(AgentNode $parent, array $childPayload, AdminUser $actor): void
{
if ($actor->isSuperAdmin() || \App\Support\AdminAgentSettlementScope::canManageSitePeriods($actor)) {
return;
}
$parentProfile = $this->profileForNode((int) $parent->id);
if ((bool) ($childPayload['can_create_child_agent'] ?? false)
&& ! ($parentProfile?->can_create_child_agent ?? false)) {
throw ValidationException::withMessages([
'can_create_child_agent' => ['parent_cannot_delegate'],
]);
}
if ((bool) ($childPayload['can_create_player'] ?? true)
&& ! ($parentProfile?->can_create_player ?? false)) {
throw ValidationException::withMessages([
'can_create_player' => ['parent_cannot_delegate'],
]);
}
}
public function nodeMayCreateChildAgent(int $agentNodeId): bool
{
$profile = $this->profileForNode($agentNodeId);
return (bool) ($profile?->can_create_child_agent ?? false);
}
public function nodeMayCreatePlayer(int $agentNodeId): bool
{
$profile = $this->profileForNode($agentNodeId);
return (bool) ($profile?->can_create_player ?? false);
}
private function assertAgentProfileExists(AgentNode $agent): void
{
if (AgentProfile::query()->where('agent_node_id', $agent->id)->exists()) {
return;
}
throw ValidationException::withMessages([
'credit_limit' => ['agent_profile_required'],
]);
}
}