Files
lotteryLaravel/app/Services/Agent/AgentProfileService.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

463 lines
17 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,
private readonly AgentUsedCreditSyncService $usedCreditSync,
) {}
/**
* @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);
$settlementCycle = array_key_exists('settlement_cycle', $payload)
? (string) $payload['settlement_cycle']
: (string) ($existingProfile->settlement_cycle ?? config('agent_line_defaults.settlement_cycle', 'weekly'));
if ($settlementCycle === '') {
$settlementCycle = 'weekly';
}
$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, $settlementCycle): 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)),
'settlement_cycle' => $settlementCycle,
]);
if (! $profile->exists) {
$profile->allocated_credit = 0;
$profile->used_credit = 0;
}
$profile->save();
$this->clampDescendantAgentShareRates($node, (float) $profile->total_share_rate);
$this->clampDirectPlayerRebates(
$node,
(float) $profile->rebate_limit,
(bool) $profile->can_grant_extra_rebate,
);
$this->clampDescendantAgentRebates(
$node,
(float) $profile->rebate_limit,
(bool) $profile->can_grant_extra_rebate,
);
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);
$this->usedCreditSync->syncForAgentId((int) $profile->agent_node_id);
$profile->refresh();
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
$totalShareRate = (float) $profile->total_share_rate;
$parentTotalShareRate = $this->parentTotalShareRateForProfile($profile);
return [
'agent_node_id' => (int) $profile->agent_node_id,
'total_share_rate' => $totalShareRate,
'relative_share_rate' => $parentTotalShareRate > 0
? round($totalShareRate / $parentTotalShareRate * 100, 2)
: null,
'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,
'settlement_cycle' => (string) ($profile->settlement_cycle ?? 'weekly'),
];
}
private function parentTotalShareRateForProfile(AgentProfile $profile): float
{
$node = AgentNode::query()->find((int) $profile->agent_node_id);
if ($node === null || $node->parent_id === null) {
return 0.0;
}
$parentProfile = AgentProfile::query()
->where('agent_node_id', (int) $node->parent_id)
->first();
return $parentProfile !== null ? (float) $parentProfile->total_share_rate : 100.0;
}
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
{
$parentProfile = $this->profileForNode((int) $parent->id);
if ((bool) ($childPayload['can_grant_extra_rebate'] ?? false)
&& ! ($parentProfile?->can_grant_extra_rebate ?? false)) {
throw ValidationException::withMessages([
'can_grant_extra_rebate' => ['parent_cannot_delegate'],
]);
}
if ($actor->isSuperAdmin() || \App\Support\AdminAgentSettlementScope::canManageSitePeriods($actor)) {
return;
}
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 clampDescendantAgentShareRates(AgentNode $parent, float $parentTotalShareRate): void
{
$children = AgentNode::query()
->where('parent_id', (int) $parent->id)
->orderBy('id')
->get();
foreach ($children as $child) {
$profile = AgentProfile::query()
->where('agent_node_id', (int) $child->id)
->first();
if ($profile === null) {
continue;
}
$totalShareRate = max(0.0, (float) $profile->total_share_rate);
$nextTotalShareRate = min($totalShareRate, $parentTotalShareRate);
if (abs($nextTotalShareRate - $totalShareRate) >= 1e-9) {
$profile->forceFill([
'total_share_rate' => $nextTotalShareRate,
])->save();
}
$this->clampDescendantAgentShareRates($child, $nextTotalShareRate);
}
}
private function clampDirectPlayerRebates(AgentNode $agent, float $rebateLimit, bool $canGrantExtraRebate): void
{
$rows = DB::table('player_rebate_profiles as prp')
->join('players as p', 'p.id', '=', 'prp.player_id')
->where('p.agent_node_id', (int) $agent->id)
->where('prp.inherit_from_agent', false)
->select([
'prp.id',
'prp.rebate_rate',
'prp.extra_rebate_rate',
])
->get();
$now = now();
foreach ($rows as $row) {
$rebateRate = max(0.0, (float) $row->rebate_rate);
$extraRate = max(0.0, (float) $row->extra_rebate_rate);
$nextRebate = min($rebateRate, $rebateLimit);
$remaining = max(0.0, $rebateLimit - $nextRebate);
$nextExtra = $canGrantExtraRebate ? min($extraRate, $remaining) : 0.0;
if (abs($nextRebate - $rebateRate) < 1e-9 && abs($nextExtra - $extraRate) < 1e-9) {
continue;
}
DB::table('player_rebate_profiles')
->where('id', (int) $row->id)
->update([
'rebate_rate' => $nextRebate,
'extra_rebate_rate' => $nextExtra,
'updated_at' => $now,
]);
}
}
private function clampDescendantAgentRebates(
AgentNode $parent,
float $parentRebateLimit,
bool $parentCanGrantExtraRebate,
): void {
$children = AgentNode::query()
->where('parent_id', (int) $parent->id)
->orderBy('id')
->get();
foreach ($children as $child) {
$profile = AgentProfile::query()
->where('agent_node_id', (int) $child->id)
->first();
if ($profile === null) {
continue;
}
$rebateLimit = max(0.0, (float) $profile->rebate_limit);
$defaultRebate = max(0.0, (float) $profile->default_player_rebate);
$canGrantExtraRebate = (bool) $profile->can_grant_extra_rebate;
$nextRebateLimit = min($rebateLimit, $parentRebateLimit);
$nextDefaultRebate = min($defaultRebate, $nextRebateLimit);
$nextCanGrantExtraRebate = $parentCanGrantExtraRebate && $canGrantExtraRebate;
if (abs($nextRebateLimit - $rebateLimit) >= 1e-9
|| abs($nextDefaultRebate - $defaultRebate) >= 1e-9
|| $nextCanGrantExtraRebate !== $canGrantExtraRebate) {
$profile->forceFill([
'rebate_limit' => $nextRebateLimit,
'default_player_rebate' => $nextDefaultRebate,
'can_grant_extra_rebate' => $nextCanGrantExtraRebate,
])->save();
}
$this->clampDirectPlayerRebates($child, $nextRebateLimit, $nextCanGrantExtraRebate);
$this->clampDescendantAgentRebates($child, $nextRebateLimit, $nextCanGrantExtraRebate);
}
}
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'],
]);
}
}