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.
This commit is contained in:
2026-06-14 21:13:27 +08:00
parent 395e1c7400
commit 5b6d4cb74d
56 changed files with 1558 additions and 222 deletions

View File

@@ -51,6 +51,18 @@ final class AgentNodeAdminUserStoreController extends Controller
);
}
// Agent accounts can only create admin users on their own node, not descendants
$primaryNode = AdminAgentScope::primaryAgentNode($admin);
if ($primaryNode !== null && (int) $primaryNode->id !== (int) $agent_node->id) {
return ApiMessage::errorResponse(
$request,
'admin.agent_user_manage_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
$user = $service->createUnderAgent($agent_node, $request->validated());
AuditLogger::recordForAdmin(

View File

@@ -51,6 +51,7 @@ final class AgentNodeProfileController extends Controller
: null;
$payload = $request->validated();
if ($parent !== null) {
$service->assertChildCapabilityGrantsWithinParent($parent, $payload, $admin);
}

View File

@@ -51,6 +51,18 @@ final class AgentNodeRoleStoreController extends Controller
);
}
// Agent accounts can only create roles on their own node, not descendants
$primaryNode = AdminAgentScope::primaryAgentNode($admin);
if ($primaryNode !== null && (int) $primaryNode->id !== (int) $agent_node->id) {
return ApiMessage::errorResponse(
$request,
'admin.agent_role_manage_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
$role = $service->createForAgent($admin, $agent_node, $request->validated());
AuditLogger::recordForAdmin(

View File

@@ -29,11 +29,11 @@ final class AdminCreditLedgerIndexController extends Controller
abort_if($admin === null, 401);
$adminSiteId = (int) $request->query('admin_site_id', 0);
abort_if($adminSiteId <= 0, 422, 'admin_site_id required');
abort_if($adminSiteId <= 0, 422, 'admin.admin_site_id_required');
abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $adminSiteId), 403);
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
abort_if($siteCode === '', 422, 'admin_site not found');
abort_if($siteCode === '', 422, 'admin.admin_site_not_found');
$periodId = (int) $request->query('settlement_period_id', 0);
if ($periodId > 0) {

View File

@@ -33,7 +33,7 @@ final class AgentSettlementReportShowController extends Controller
abort_unless(in_array($type, self::TYPES, true), 404);
if ($type === 'platform_pnl' && AdminAgentScope::primaryAgentNode($admin) !== null) {
abort(403, 'agent_cannot_view_platform_pnl');
abort(403, 'admin.agent_cannot_view_platform_pnl');
}
$periodId = (int) $request->query('settlement_period_id', 0);

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player;
use App\Models\AdminSite;
use App\Models\AgentNode;
use App\Models\AdminUser;
use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
@@ -92,7 +93,7 @@ final class AdminPlayerStoreController extends Controller
$agentNodeId = $admin->isSuperAdmin()
? $this->resolveAgentNodeIdForSuperAdmin($request->validated('agent_node_id'), $siteCode)
: $admin->primaryAgentNodeId();
: $this->resolveAgentNodeIdForNonSuperAdmin($admin, $request->validated('agent_node_id'), $siteCode);
if ($agentNodeId === null) {
return ApiMessage::errorResponse(
@@ -104,17 +105,10 @@ final class AdminPlayerStoreController extends Controller
);
}
if (! $admin->isSuperAdmin()) {
$agent = AdminAgentScope::primaryAgentNode($admin);
if ($agent === null || (int) $agentNodeId !== (int) $agent->id) {
return ApiMessage::errorResponse($request, 'admin.player_create_agent_forbidden', ErrorCode::AdminForbidden->value, null, 403);
}
}
$agent = AgentNode::query()->findOrFail($agentNodeId);
$rebateRate = 0.0;
$extraRebateRate = 0.0;
if ($request->has('rebate_rate')) {
if ($request->has('rebate_rate') || $request->has('extra_rebate_rate')) {
$rebateRate = (float) $request->input('rebate_rate', 0) / 100;
$extraRebateRate = (float) $request->input('extra_rebate_rate', 0) / 100;
$rebateLimitValidator->assertPlayerRebateWithinAgent(
@@ -124,6 +118,12 @@ final class AdminPlayerStoreController extends Controller
);
}
if (! $isNative && ($request->has('credit_limit') || $request->has('rebate_rate') || $request->has('extra_rebate_rate'))) {
throw ValidationException::withMessages([
'credit_limit' => ['wallet_player_prohibited'],
]);
}
$creditLimit = $request->has('credit_limit')
? (int) $request->input('credit_limit', 0)
: ($isNative ? 0 : 0);
@@ -201,6 +201,43 @@ final class AdminPlayerStoreController extends Controller
return $rootId !== null ? (int) $rootId : null;
}
private function resolveAgentNodeIdForNonSuperAdmin(AdminUser $admin, mixed $requested, string $siteCode): ?int
{
// Check if admin is a platform account (bound via admin_user_site_roles)
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
if ($accessibleSiteIds !== null) {
// Platform account (site admin) can specify agent_node_id
if ($requested !== null && (int) $requested > 0) {
$agent = AgentNode::query()->find((int) $requested);
if ($agent !== null && in_array((int) $agent->admin_site_id, $accessibleSiteIds, true)) {
return (int) $requested;
}
}
// Default to root node of the site
$siteId = AdminSite::query()->where('code', $siteCode)->value('id');
if ($siteId !== null && in_array((int) $siteId, $accessibleSiteIds, true)) {
$rootId = AgentNode::query()
->where('admin_site_id', (int) $siteId)
->where('depth', 0)
->value('id');
return $rootId !== null ? (int) $rootId : null;
}
return null;
}
// Agent account (bound via agent node) - can only create under own node
$agent = AdminAgentScope::primaryAgentNode($admin);
if ($agent === null) {
return null;
}
if ($requested !== null && (int) $requested > 0 && (int) $requested !== (int) $agent->id) {
return null; // Agent account cannot create under other nodes
}
return (int) $agent->id;
}
private function generateNativeSitePlayerId(string $siteCode): string
{
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $siteCode) ?: 'LP', 0, 2));

View File

@@ -7,12 +7,14 @@ use App\Models\AgentNode;
use App\Support\ApiResponse;
use App\Support\AdminSiteScope;
use Illuminate\Http\JsonResponse;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use App\Support\PlayerApiPresenter;
use App\Http\Controllers\Controller;
use App\Services\Agent\AgentProfileService;
use App\Services\Agent\RebateLimitValidator;
use App\Services\Player\PlayerCreditService;
use Illuminate\Validation\ValidationException;
use App\Services\Player\PlayerRebateProfileService;
use App\Http\Requests\Admin\AdminPlayerUpdateRequest;
@@ -44,9 +46,18 @@ final class AdminPlayerUpdateController extends Controller
? AgentNode::query()->find((int) $player->agent_node_id)
: null;
if (
($request->has('credit_limit') || $request->has('rebate_rate') || $request->has('extra_rebate_rate'))
&& ! PlayerFundingMode::usesCredit($player)
) {
throw ValidationException::withMessages([
'credit_limit' => ['wallet_player_prohibited'],
]);
}
$rebateRate = 0.0;
$extraRebateRate = 0.0;
if ($agent !== null && $request->has('rebate_rate')) {
if ($agent !== null && ($request->has('rebate_rate') || $request->has('extra_rebate_rate'))) {
$rebateRate = (float) $request->input('rebate_rate', 0) / 100;
$extraRebateRate = (float) $request->input('extra_rebate_rate', 0) / 100;
$rebateLimitValidator->assertPlayerRebateWithinAgent(
@@ -67,7 +78,7 @@ final class AdminPlayerUpdateController extends Controller
unset($data['credit_limit']);
}
if ($request->has('rebate_rate')) {
if ($request->has('rebate_rate') || $request->has('extra_rebate_rate')) {
DB::table('player_rebate_profiles')->updateOrInsert(
['player_id' => $player->id, 'game_type' => '*'],
[

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Player\PlayerAuthLoginRequest;
use App\Services\Player\PlayerNativeAuthService;
use App\Support\ApiResponse;
use App\Support\LotteryMessage;
use App\Exceptions\PlayerAuthenticationException;
use Illuminate\Http\JsonResponse;
@@ -22,7 +23,7 @@ final class PlayerAuthLoginController extends Controller
);
} catch (PlayerAuthenticationException $e) {
return ApiResponse::error(
$e->getMessage(),
LotteryMessage::sso($request, $e->lotteryCode),
$e->lotteryCode,
null,
$e->httpStatus,

View File

@@ -2,6 +2,7 @@
namespace App\Http\Requests\Admin;
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules;
use App\Http\Requests\ApiFormRequest;
use App\Models\AdminSite;
@@ -11,6 +12,7 @@ use Illuminate\Validation\Validator;
final class AdminAgentLineStoreRequest extends ApiFormRequest
{
use AdminAccountFieldRules;
use AgentProfileFieldRules;
public function authorize(): bool
@@ -20,6 +22,7 @@ final class AdminAgentLineStoreRequest extends ApiFormRequest
protected function prepareForValidation(): void
{
$this->normalizeAdminAccountFields();
$this->prepareAgentProfileFieldsForValidation();
if ($this->has('site_code')) {
@@ -42,8 +45,8 @@ final class AdminAgentLineStoreRequest extends ApiFormRequest
'site_code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::exists('admin_sites', 'code')],
'code' => ['sometimes', 'nullable', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('agent_nodes', 'code')],
'name' => ['required', 'string', 'max:128'],
'username' => ['required', 'string', 'max:64', Rule::unique('admin_users', 'username')],
'password' => ['required', 'string', 'min:8', 'max:128'],
'username' => [...$this->adminOperatorUsernameRules(), Rule::unique('admin_users', 'username')],
'password' => $this->adminLoginPasswordRules(),
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('admin_users', 'email')],
'status' => ['sometimes', 'integer', 'in:0,1'],
...$this->agentProfileFieldRules(),

View File

@@ -11,9 +11,32 @@ final class AdminAgentProfileUpdateRequest extends ApiFormRequest
public function authorize(): bool
{
$admin = $this->user();
if (! $admin instanceof \App\Models\AdminUser) {
return false;
}
$agentNode = $this->route('agent_node');
if (! $agentNode instanceof \App\Models\AgentNode) {
return false;
}
if (! \App\Support\AdminAgentScope::nodeVisibleTo($admin, $agentNode)) {
return false;
}
if (! \App\Support\AdminAgentScope::nodeProfileEditableBy($admin, $agentNode)) {
return false;
}
return true;
}
protected function failedAuthorization(): void
{
abort(403, 'admin.permission_denied');
}
protected function prepareForValidation(): void
{
$this->prepareAgentProfileFieldsForValidation();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Requests\Admin;
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
use App\Http\Requests\ApiFormRequest;
/**
@@ -11,19 +12,26 @@ use App\Http\Requests\ApiFormRequest;
*/
final class AdminLoginRequest extends ApiFormRequest
{
use AdminAccountFieldRules;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->normalizeAdminAccountFields();
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'account' => ['required', 'string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u'],
'password' => ['required', 'string', 'max:256'],
'account' => $this->adminLoginAccountRules(),
'password' => $this->adminLoginPasswordRules(),
'captcha_key' => ['required', 'string', 'uuid'],
'captcha_code' => ['required', 'string', 'max:32'],
];

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests\Admin;
use Illuminate\Validation\Rule;
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
use App\Http\Requests\ApiFormRequest;
/**
@@ -12,6 +13,8 @@ use App\Http\Requests\ApiFormRequest;
*/
final class AdminPlayerStoreRequest extends ApiFormRequest
{
use AdminAccountFieldRules;
public function authorize(): bool
{
return true;
@@ -22,13 +25,13 @@ final class AdminPlayerStoreRequest extends ApiFormRequest
return [
'site_code' => ['required', 'string', 'max:64'],
'site_player_id' => ['nullable', 'string', 'max:128'],
'username' => ['nullable', 'string', 'max:128'],
'password' => ['nullable', 'string', 'min:6', 'max:128'],
'username' => $this->nativePlayerUsernameRules(),
'password' => $this->nativePlayerPasswordRules(),
'nickname' => ['nullable', 'string', 'max:128'],
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
'status' => ['sometimes', 'integer', 'in:0,1,2'],
'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'credit_limit' => ['sometimes', 'integer', 'min:0'],
'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1', 'exists:agent_nodes,id'],
'credit_limit' => ['sometimes', 'integer', 'min:0', 'max:999999999'],
'rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
'extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
];
@@ -36,6 +39,8 @@ final class AdminPlayerStoreRequest extends ApiFormRequest
protected function prepareForValidation(): void
{
$this->normalizeAdminAccountFields();
if (! $this->has('default_currency')) {
return;
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests\Admin;
use Illuminate\Validation\Rule;
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
use App\Http\Requests\ApiFormRequest;
/**
@@ -12,6 +13,8 @@ use App\Http\Requests\ApiFormRequest;
*/
final class AdminPlayerUpdateRequest extends ApiFormRequest
{
use AdminAccountFieldRules;
public function authorize(): bool
{
return true;
@@ -20,11 +23,11 @@ final class AdminPlayerUpdateRequest extends ApiFormRequest
public function rules(): array
{
return [
'username' => ['sometimes', 'string', 'max:128'],
'username' => $this->nativePlayerUsernameRules(required: false),
'nickname' => ['sometimes', 'nullable', 'string', 'max:128'],
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
'status' => ['sometimes', 'integer', Rule::in([0, 1, 2])],
'credit_limit' => ['sometimes', 'integer', 'min:0'],
'credit_limit' => ['sometimes', 'integer', 'min:0', 'max:999999999'],
'rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
'extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
'rebate_profiles' => ['sometimes', 'array'],
@@ -39,6 +42,8 @@ final class AdminPlayerUpdateRequest extends ApiFormRequest
protected function prepareForValidation(): void
{
$this->normalizeAdminAccountFields();
if (! $this->has('default_currency')) {
return;
}

View File

@@ -2,24 +2,32 @@
namespace App\Http\Requests\Admin;
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule;
final class AgentAdminUserStoreRequest extends ApiFormRequest
{
use AdminAccountFieldRules;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->normalizeAdminAccountFields();
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'username' => ['required', 'string', 'max:64', Rule::unique('admin_users', 'username')],
'username' => [...$this->adminOperatorUsernameRules(), Rule::unique('admin_users', 'username')],
'nickname' => ['required', 'string', 'max:128'],
'email' => ['nullable', 'email', 'max:255', Rule::unique('admin_users', 'email')],
'password' => ['required', 'string', 'min:8', 'max:128'],
'password' => $this->adminLoginPasswordRules(),
'status' => ['sometimes', 'integer', 'in:0,1'],
'role_ids' => ['sometimes', 'array'],
'role_ids.*' => ['integer', 'exists:admin_roles,id'],

View File

@@ -2,12 +2,14 @@
namespace App\Http\Requests\Admin;
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules;
use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule;
final class AgentNodeStoreRequest extends ApiFormRequest
{
use AdminAccountFieldRules;
use AgentProfileFieldRules;
public function authorize(): bool
{
@@ -16,6 +18,7 @@ final class AgentNodeStoreRequest extends ApiFormRequest
protected function prepareForValidation(): void
{
$this->normalizeAdminAccountFields();
$this->prepareAgentProfileFieldsForValidation();
}
@@ -26,7 +29,7 @@ final class AgentNodeStoreRequest extends ApiFormRequest
'parent_id' => ['required', 'integer', 'exists:agent_nodes,id'],
'code' => ['sometimes', 'nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_-]+$/'],
'name' => ['required', 'string', 'max:128'],
'username' => ['sometimes', 'nullable', 'string', 'max:64', Rule::unique('admin_users', 'username')],
'username' => ['sometimes', 'nullable', ...$this->adminOperatorUsernameRules(required: false), Rule::unique('admin_users', 'username')],
'email' => ['nullable', 'email', 'max:255', Rule::unique('admin_users', 'email')],
'password' => ['required', 'string', 'min:8', 'max:128'],
'status' => ['sometimes', 'integer', 'in:0,1'],

View File

@@ -2,23 +2,31 @@
namespace App\Http\Requests\Admin;
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
use App\Http\Requests\ApiFormRequest;
final class AgentNodeUpdateRequest extends ApiFormRequest
{
use AdminAccountFieldRules;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->normalizeAdminAccountFields();
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:128'],
'username' => ['sometimes', 'string', 'max:64'],
'username' => $this->adminOperatorUsernameRules(required: false),
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
'password' => ['sometimes', 'nullable', 'string', 'min:8', 'max:128'],
'password' => $this->adminLoginPasswordRules(required: false),
'status' => ['sometimes', 'integer', 'in:0,1'],
];
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Requests\Admin\Concerns;
trait AdminAccountFieldRules
{
/** @return array<int, mixed> */
protected function adminLoginAccountRules(bool $required = true): array
{
$rules = ['string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u'];
return $required ? array_merge(['required'], $rules) : array_merge(['sometimes'], $rules);
}
/** @return array<int, mixed> */
protected function adminLoginPasswordRules(bool $required = true, int $min = 8, int $max = 256): array
{
$rules = ['string', 'min:'.$min, 'max:'.$max];
if ($required) {
return array_merge(['required'], $rules);
}
return array_merge(['sometimes', 'nullable'], $rules);
}
/** @return array<int, mixed> */
protected function adminOperatorUsernameRules(bool $required = true): array
{
$rules = ['string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u'];
return $required ? array_merge(['required'], $rules) : array_merge(['sometimes'], $rules);
}
/** @return array<int, mixed> */
protected function nativePlayerUsernameRules(bool $required = false): array
{
$rules = ['string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u'];
return $required ? array_merge(['required'], $rules) : array_merge(['nullable'], $rules);
}
/** @return array<int, mixed> */
protected function nativePlayerPasswordRules(bool $required = false): array
{
$rules = ['string', 'min:6', 'max:128'];
return $required ? array_merge(['required'], $rules) : array_merge(['nullable'], $rules);
}
protected function normalizeAdminAccountFields(): void
{
if ($this->has('account')) {
$this->merge(['account' => strtolower(trim((string) $this->input('account')))]);
}
if ($this->has('username')) {
$this->merge(['username' => strtolower(trim((string) $this->input('username')))]);
}
}
}

View File

@@ -10,7 +10,7 @@ trait AgentProfileFieldRules
return [
'total_share_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
'relative_share_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
'credit_limit' => ['sometimes', 'integer', 'min:0'],
'credit_limit' => ['sometimes', 'integer', 'min:0', 'max:999999999'],
'rebate_limit' => ['sometimes', 'numeric', 'min:0', 'max:100'],
'default_player_rebate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
'can_grant_extra_rebate' => ['sometimes', 'boolean'],

View File

@@ -2,21 +2,32 @@
namespace App\Http\Requests\Player;
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
use App\Http\Requests\ApiFormRequest;
final class PlayerAuthLoginRequest extends ApiFormRequest
{
use AdminAccountFieldRules;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->normalizeAdminAccountFields();
if ($this->has('username')) {
$this->merge(['username' => strtolower(trim((string) $this->input('username')))]);
}
}
public function rules(): array
{
return [
'site_code' => ['sometimes', 'nullable', 'string', 'max:64'],
'username' => ['required', 'string', 'max:128'],
'password' => ['required', 'string', 'min:6', 'max:128'],
'username' => $this->nativePlayerUsernameRules(required: true),
'password' => $this->nativePlayerPasswordRules(required: true),
];
}
}