feat: 增强代理和玩家管理功能

- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。
- 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。
- 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。
- 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。
- 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
This commit is contained in:
2026-06-04 18:00:50 +08:00
parent 96545f87f6
commit a44679665d
183 changed files with 10054 additions and 857 deletions

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Services\Agent;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use Illuminate\Support\Facades\DB;
/**
* 按「下发即占用」真理源重算代理已下发额度:
* 直属玩家 credit_limit 之和 + 直属下级代理 credit_limit 之和。
*/
final class AgentCreditAllocatedSyncService
{
public function syncForAgent(AgentNode $agent): void
{
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
if ($profile === null) {
return;
}
$expected = $this->calculateAllocatedCredit($agent);
if ((int) $profile->allocated_credit === $expected) {
return;
}
$profile->allocated_credit = $expected;
$profile->save();
}
public function syncForAgentId(int $agentNodeId): void
{
$agent = AgentNode::query()->find($agentNodeId);
if ($agent === null) {
return;
}
$this->syncForAgent($agent);
}
public function calculateAllocatedCredit(AgentNode $agent): int
{
$playerTotal = (int) DB::table('player_credit_accounts as pca')
->join('players as p', 'p.id', '=', 'pca.player_id')
->where('p.agent_node_id', $agent->id)
->sum('pca.credit_limit');
$childIds = AgentNode::query()->where('parent_id', $agent->id)->pluck('id');
$childAgentTotal = $childIds->isEmpty()
? 0
: (int) AgentProfile::query()->whereIn('agent_node_id', $childIds)->sum('credit_limit');
return $playerTotal + $childAgentTotal;
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\AdminUserStatus;
use App\Support\AgentPlatformRole;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -16,25 +17,6 @@ final class AgentNodeService
private readonly AgentProfileService $agentProfileService,
) {}
/** @var list<string> */
private const BASE_AGENT_ROLE_SLUGS = [
'prd.agent.view',
'prd.tickets.view',
'prd.report.view',
'prd.wallet_reconcile.view',
'prd.wallet_reconcile.view_cs',
];
/** @var list<string> */
private const CHILD_AGENT_MANAGE_SLUGS = ['prd.agent.manage', 'prd.agent.profile.manage'];
/** @var list<string> */
private const PLAYER_MANAGE_SLUGS = [
'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs',
];
/**
* @param array{
* parent_id: int,
@@ -120,19 +102,7 @@ final class AgentNodeService
$node->path = (string) $parent->path.$node->id.'/';
$node->save();
$role = AdminRole::query()->create([
'slug' => 'agent_owner_'.$node->id,
'code' => 'agent_owner_'.$node->id,
'name' => '代理账号',
'description' => '系统自动生成的一代理一账号默认角色',
'status' => $status === 0 ? 0 : 1,
'is_system' => false,
'sort_order' => 0,
'scope_type' => AdminRole::SCOPE_AGENT,
'owner_agent_id' => $node->id,
'delegated_from_role_id' => null,
]);
$role->syncLegacyPermissionSlugs($this->buildRoleSlugsForNewChild($payload, $actor));
AgentPlatformRole::resolve();
$user = AdminUser::query()->create([
'username' => $username,
@@ -148,9 +118,9 @@ final class AgentNodeService
'is_primary' => true,
'granted_at' => now(),
]);
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]);
AgentPlatformRole::assignPrimaryOperator($user, $node);
$profile = $this->agentProfileService->upsertForNode($node, [
$this->agentProfileService->upsertForNode($node, [
'total_share_rate' => (float) ($payload['total_share_rate'] ?? 0),
'credit_limit' => (int) ($payload['credit_limit'] ?? 0),
'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0),
@@ -161,8 +131,6 @@ final class AgentNodeService
'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
], $parent);
$this->syncPrimaryOwnerRoleFromProfile($node, $profile);
return $node->fresh(['adminSite']);
});
}
@@ -352,32 +320,7 @@ final class AgentNodeService
}
return DB::transaction(function () use ($node, $username, $password, $email, $status): AdminUser {
$role = AdminRole::query()
->where('owner_agent_id', $node->id)
->where('slug', 'agent_owner_'.$node->id)
->first();
if ($role === null) {
$role = AdminRole::query()->create([
'slug' => 'agent_owner_'.$node->id,
'code' => 'agent_owner_'.$node->id,
'name' => '代理账号',
'description' => '系统自动生成的一代理一账号默认角色',
'status' => $status === 0 ? 0 : 1,
'is_system' => false,
'sort_order' => 0,
'scope_type' => AdminRole::SCOPE_AGENT,
'owner_agent_id' => $node->id,
'delegated_from_role_id' => null,
]);
}
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
$role->syncLegacyPermissionSlugs(
$profile !== null
? $this->roleSlugsFromProfile($profile)
: $this->defaultOwnerRoleSlugs(),
);
AgentPlatformRole::resolve();
$user = AdminUser::query()->create([
'username' => $username,
@@ -393,93 +336,12 @@ final class AgentNodeService
'is_primary' => true,
'granted_at' => now(),
]);
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]);
AgentPlatformRole::assignPrimaryOperator($user, $node);
return $user;
});
}
/**
* @return list<string>
*/
private function defaultOwnerRoleSlugs(): array
{
return array_values(array_unique(array_merge(
self::BASE_AGENT_ROLE_SLUGS,
self::PLAYER_MANAGE_SLUGS,
)));
}
public function syncPrimaryOwnerRoleFromProfile(AgentNode $node, ?AgentProfile $profile = null): void
{
$profile ??= AgentProfile::query()->where('agent_node_id', $node->id)->first();
if ($profile === null) {
return;
}
$role = AdminRole::query()
->where('owner_agent_id', $node->id)
->where('slug', 'agent_owner_'.$node->id)
->first();
if ($role === null) {
return;
}
$role->syncLegacyPermissionSlugs($this->roleSlugsFromProfile($profile));
}
/**
* @param array<string, mixed> $payload
* @return list<string>
*/
private function buildRoleSlugsForNewChild(array $payload, AdminUser $actor): array
{
$slugs = self::BASE_AGENT_ROLE_SLUGS;
if ((bool) ($payload['can_create_child_agent'] ?? false)) {
$slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS);
}
if ((bool) ($payload['can_create_player'] ?? true)) {
$slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS);
}
return $this->filterSlugsByActor($actor, $slugs);
}
/**
* @return list<string>
*/
private function roleSlugsFromProfile(AgentProfile $profile): array
{
$slugs = self::BASE_AGENT_ROLE_SLUGS;
if ($profile->can_create_child_agent) {
$slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS);
}
if ($profile->can_create_player) {
$slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS);
}
return array_values(array_unique($slugs));
}
/**
* @param list<string> $slugs
* @return list<string>
*/
private function filterSlugsByActor(AdminUser $actor, array $slugs): array
{
if ($actor->isSuperAdmin()) {
return array_values(array_unique($slugs));
}
$mine = array_fill_keys($actor->adminPermissionSlugs(), true);
return array_values(array_filter(
$slugs,
static fn (string $slug): bool => isset($mine[$slug]),
));
}
private function resolveCodeForCreate(AgentNode $parent, mixed $rawCode, string $username): string
{
$preferred = trim((string) $rawCode);

View File

@@ -6,6 +6,7 @@ use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\AdminAgentScope;
use App\Support\AgentOverdueGuard;
use App\Support\AgentSettlementCycle;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -16,6 +17,7 @@ final class AgentProfileService
private readonly ShareRateValidator $shareRateValidator,
private readonly CreditAllocationValidator $creditAllocationValidator,
private readonly RebateLimitValidator $rebateLimitValidator,
private readonly AgentCreditAllocatedSyncService $allocatedSync,
) {}
/**
@@ -39,10 +41,17 @@ final class AgentProfileService
$previousCredit = (int) $profile->credit_limit;
$isNew = ! $profile->exists;
if (! $isNew && $creditLimit < (int) $profile->allocated_credit) {
throw ValidationException::withMessages([
'credit_limit' => ['below_allocated'],
]);
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) {
@@ -53,7 +62,7 @@ final class AgentProfileService
}
if ($defaultRebate > $rebateLimit && $rebateLimit > 0) {
throw \Illuminate\Validation\ValidationException::withMessages([
throw ValidationException::withMessages([
'default_player_rebate' => ['exceeds_limit'],
]);
}
@@ -77,14 +86,7 @@ final class AgentProfileService
$profile->save();
if ($parent !== null) {
$parentProfile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
if ($parentProfile !== null) {
$creditDelta = $isNew ? $creditLimit : ($creditLimit - $previousCredit);
if ($creditDelta !== 0) {
$parentProfile->allocated_credit = max(0, (int) $parentProfile->allocated_credit + $creditDelta);
$parentProfile->save();
}
}
$this->allocatedSync->syncForAgent($parent);
}
return $profile;
@@ -96,6 +98,9 @@ final class AgentProfileService
*/
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 [
@@ -119,6 +124,65 @@ final class AgentProfileService
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' => (float) $profile->rebate_limit,
'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()) {
@@ -135,6 +199,12 @@ final class AgentProfileService
'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
@@ -153,6 +223,12 @@ final class AgentProfileService
'site_code' => ['cannot_create_player'],
]);
}
if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) {
throw ValidationException::withMessages([
'site_code' => ['agent_overdue'],
]);
}
}
/**
@@ -193,4 +269,15 @@ final class AgentProfileService
return $profile === null || $profile->can_create_player;
}
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'],
]);
}
}

View File

@@ -2,41 +2,29 @@
namespace App\Services\Agent;
use App\Models\AdminRole;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Services\Integration\IntegrationSiteService;
use App\Support\AdminUserStatus;
use App\Support\AgentPlatformRole;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentSiteProvisioningService
{
/** @var list<string> */
private const LINE_ROOT_ROLE_SLUGS = [
'prd.agent.view',
'prd.agent.manage',
'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs',
'prd.tickets.view',
'prd.report.view',
'prd.wallet_reconcile.view',
'prd.wallet_reconcile.view_cs',
];
public function __construct(
private readonly IntegrationSiteService $integrationSiteService,
private readonly AgentProfileService $agentProfileService,
) {}
/**
* @param array<string, mixed> $payload site fields + name, username, password, email?, status?
* @return array{site: AdminSite, agent_node: AgentNode, secrets: array{sso_jwt_secret: string, wallet_api_key: string}}
* 在已存在的接入站点上创建一级代理(根节点)及后台登录账号。
*
* @param array<string, mixed> $payload site_code, code, name, username, password, email?, status?, profile fields
* @return array{site: AdminSite, agent_node: AgentNode}
*/
public function createRootAgent(AdminUser $actor, array $payload): array
{
$siteCode = strtolower(trim((string) ($payload['site_code'] ?? '')));
$code = strtolower(trim((string) ($payload['code'] ?? '')));
$name = trim((string) ($payload['name'] ?? ''));
$username = trim((string) ($payload['username'] ?? ''));
@@ -44,8 +32,9 @@ final class AgentSiteProvisioningService
$email = isset($payload['email']) ? trim((string) $payload['email']) : null;
$status = (int) ($payload['status'] ?? 1);
if ($code === '' || $name === '' || $username === '' || $password === '') {
if ($siteCode === '' || $code === '' || $name === '' || $username === '' || $password === '') {
throw ValidationException::withMessages([
'site_code' => $siteCode === '' ? ['required'] : [],
'code' => $code === '' ? ['required'] : [],
'name' => $name === '' ? ['required'] : [],
'username' => $username === '' ? ['required'] : [],
@@ -53,6 +42,11 @@ final class AgentSiteProvisioningService
]);
}
$site = AdminSite::query()->where('code', $siteCode)->first();
if ($site === null) {
throw ValidationException::withMessages(['site_code' => ['exists']]);
}
if (AgentNode::query()->where('code', $code)->exists()) {
throw ValidationException::withMessages(['code' => ['unique']]);
}
@@ -61,28 +55,18 @@ final class AgentSiteProvisioningService
throw ValidationException::withMessages(['username' => ['unique']]);
}
$siteData = array_merge($payload, [
'code' => $code,
'name' => $name,
'status' => $status === 0 ? 0 : 1,
]);
$existingRoot = AgentNode::query()
->where('admin_site_id', $site->id)
->where('depth', 0)
->first();
return DB::transaction(function () use ($actor, $siteData, $code, $name, $username, $password, $email, $status): array {
$created = $this->integrationSiteService->create($siteData);
$site = $created['site'];
$secrets = $created['secrets'];
$existingRoot = AgentNode::query()
->where('admin_site_id', $site->id)
->where('depth', 0)
->first();
if ($existingRoot !== null) {
throw ValidationException::withMessages([
'code' => ['site_root_exists'],
]);
}
if ($existingRoot !== null) {
throw ValidationException::withMessages([
'site_code' => ['site_root_exists'],
]);
}
return DB::transaction(function () use ($actor, $site, $code, $name, $username, $password, $email, $status, $payload): array {
$node = AgentNode::query()->create([
'admin_site_id' => $site->id,
'parent_id' => null,
@@ -97,19 +81,7 @@ final class AgentSiteProvisioningService
$node->path = '/'.$node->id.'/';
$node->save();
$role = AdminRole::query()->create([
'slug' => 'agent_owner_'.$node->id,
'code' => 'agent_owner_'.$node->id,
'name' => '代理账号',
'description' => '线路根代理默认角色',
'status' => $status === 0 ? 0 : 1,
'is_system' => false,
'sort_order' => 0,
'scope_type' => AdminRole::SCOPE_AGENT,
'owner_agent_id' => $node->id,
'delegated_from_role_id' => null,
]);
$role->syncLegacyPermissionSlugs(self::LINE_ROOT_ROLE_SLUGS);
AgentPlatformRole::resolve();
$user = AdminUser::query()->create([
'username' => $username,
@@ -125,14 +97,15 @@ final class AgentSiteProvisioningService
'is_primary' => true,
'granted_at' => now(),
]);
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]);
AgentPlatformRole::assignPrimaryOperator($user, $node);
$defaults = config('agent_line_defaults', []);
$this->agentProfileService->upsertForNode($node, [
'total_share_rate' => (float) ($payload['total_share_rate'] ?? 100),
'credit_limit' => (int) ($payload['credit_limit'] ?? 0),
'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0),
'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? 0),
'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? 'weekly'),
'total_share_rate' => (float) ($payload['total_share_rate'] ?? $defaults['total_share_rate'] ?? 100),
'credit_limit' => (int) ($payload['credit_limit'] ?? $defaults['credit_limit'] ?? 0),
'rebate_limit' => (float) ($payload['rebate_limit'] ?? $defaults['rebate_limit'] ?? 0),
'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? $defaults['default_player_rebate'] ?? 0),
'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? $defaults['settlement_cycle'] ?? 'weekly'),
'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? true),
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? true),
'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
@@ -141,7 +114,6 @@ final class AgentSiteProvisioningService
return [
'site' => $site->fresh(),
'agent_node' => $node->fresh(['adminSite']),
'secrets' => $secrets,
];
});
}

View File

@@ -4,12 +4,19 @@ namespace App\Services\Agent;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\AgentOverdueGuard;
use Illuminate\Validation\ValidationException;
final class CreditAllocationValidator
{
public function __construct(
private readonly AgentCreditAllocatedSyncService $allocatedSync,
) {}
public function assertAllocationWithinParent(AgentNode $parent, int $additionalCredit): void
{
$this->allocatedSync->syncForAgent($parent);
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
if ($profile === null) {
return;
@@ -25,15 +32,37 @@ final class CreditAllocationValidator
public function assertPlayerCreditWithinAgent(AgentNode $agent, int $playerCreditLimit): void
{
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
if ($profile === null) {
return;
}
if ($playerCreditLimit < 0) {
throw ValidationException::withMessages([
'credit_limit' => ['invalid'],
]);
}
$this->assertPlayerCreditDeltaWithinAgent($agent, $playerCreditLimit);
}
public function assertPlayerCreditDeltaWithinAgent(AgentNode $agent, int $additionalCredit): void
{
if ($additionalCredit <= 0) {
return;
}
AgentOverdueGuard::assertAgentMayGrantCredit((int) $agent->id);
$this->allocatedSync->syncForAgent($agent);
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
if ($profile === null) {
throw ValidationException::withMessages([
'credit_limit' => ['agent_profile_required'],
]);
}
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
if ($additionalCredit > $available) {
throw ValidationException::withMessages([
'credit_limit' => ['exceeds_available'],
]);
}
}
}