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:
@@ -93,14 +93,22 @@ final class AdminAgentNodeAccess
|
||||
);
|
||||
}
|
||||
|
||||
// Only pure platform accounts (site admin without agent binding) can create under root node
|
||||
// Agent accounts (even if they also have site roles) are restricted from creating under root
|
||||
if ($parent->isRoot() && ! $admin->isSuperAdmin()) {
|
||||
return ApiMessage::errorResponse(
|
||||
request(),
|
||||
'admin.agent_root_create_denied',
|
||||
ErrorCode::AdminForbidden->value,
|
||||
null,
|
||||
403,
|
||||
);
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
$hasAgentBinding = AdminAgentScope::primaryAgentNode($admin) !== null;
|
||||
|
||||
// If user has agent binding, treat as agent account and restrict root creation
|
||||
if ($hasAgentBinding || $accessibleSiteIds === null) {
|
||||
return ApiMessage::errorResponse(
|
||||
request(),
|
||||
'admin.agent_root_create_denied',
|
||||
ErrorCode::AdminForbidden->value,
|
||||
null,
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -32,6 +32,13 @@ final class AdminAgentScope
|
||||
return true;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node) - check first
|
||||
// Even if they also have site roles, agent binding takes precedence for visibility
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor !== null) {
|
||||
return $node->isSameOrDescendantOf($actor);
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
@@ -39,13 +46,7 @@ final class AdminAgentScope
|
||||
return in_array((int) $node->admin_site_id, $accessibleSiteIds, true);
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $node->isSameOrDescendantOf($actor);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function playerAccessible(AdminUser $admin, Player $player): bool
|
||||
@@ -54,9 +55,18 @@ final class AdminAgentScope
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) can access all players in the site
|
||||
// Site check is done by AdminSiteScope::playerAccessible before calling this
|
||||
return true;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($player->agent_node_id === null) {
|
||||
@@ -91,6 +101,11 @@ final class AdminAgentScope
|
||||
return true;
|
||||
}
|
||||
|
||||
// 一级代理 profile 仅超管可维护(站点总额度、占成、回水等)
|
||||
if ($node->isRoot()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
! $admin->hasPermissionCode('agent.profile.manage')
|
||||
&& ! $admin->hasPermissionCode('agent.node.manage')
|
||||
@@ -102,7 +117,15 @@ final class AdminAgentScope
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) can edit all nodes in the site
|
||||
return in_array((int) $node->admin_site_id, $accessibleSiteIds, true);
|
||||
// EXCEPT their own bound agent node
|
||||
if (in_array((int) $node->admin_site_id, $accessibleSiteIds, true)) {
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor !== null && (int) $actor->id === (int) $node->id) {
|
||||
return false; // Cannot edit own bound node
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
@@ -131,6 +154,16 @@ final class AdminAgentScope
|
||||
return $query;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node) - check first
|
||||
// Even if they also have site roles, agent binding takes precedence
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor !== null) {
|
||||
if ((int) $actor->admin_site_id !== $adminSiteId) {
|
||||
return $query->whereRaw('0 = 1');
|
||||
}
|
||||
return $query->where('path', 'like', $actor->path.'%');
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
@@ -141,13 +174,7 @@ final class AdminAgentScope
|
||||
return $query->whereRaw('0 = 1');
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor === null || (int) $actor->admin_site_id !== $adminSiteId) {
|
||||
return $query->whereRaw('0 = 1');
|
||||
}
|
||||
|
||||
return $query->where('path', 'like', $actor->path.'%');
|
||||
return $query->whereRaw('0 = 1');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +188,15 @@ final class AdminAgentScope
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) - site filtering is handled by AdminSiteScope
|
||||
// No agent node filtering needed
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
@@ -191,7 +191,7 @@ final class AdminAgentSettlementScope
|
||||
public static function assertCanManageSitePeriods(AdminUser $admin): void
|
||||
{
|
||||
if (! self::canManageSitePeriods($admin)) {
|
||||
abort(403, 'agent_bound_cannot_manage_periods');
|
||||
abort(403, 'admin.agent_bound_cannot_manage_periods');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ final class AdminAgentSettlementScope
|
||||
public static function assertCanPerformFinanceAdjustments(AdminUser $admin): void
|
||||
{
|
||||
if (! self::canPerformFinanceAdjustments($admin)) {
|
||||
abort(403, 'agent_bound_cannot_finance_adjust');
|
||||
abort(403, 'admin.agent_bound_cannot_finance_adjust');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,12 +286,14 @@ final class AdminAgentSettlementScope
|
||||
abort_if($bill === null, 404);
|
||||
|
||||
$actorId = self::boundAgentNodeId($admin);
|
||||
abort_if($actorId === null, 403, 'agent_cannot_operate_bill');
|
||||
abort_if($actorId === null, 403, 'admin.agent_cannot_operate_bill');
|
||||
|
||||
abort_if(
|
||||
! self::billOperableByBoundAgent($actorId, $bill),
|
||||
403,
|
||||
(string) $bill->owner_type === 'player' ? 'agent_cannot_operate_player_bill' : 'agent_cannot_operate_bill',
|
||||
(string) $bill->owner_type === 'player'
|
||||
? 'admin.agent_cannot_operate_player_bill'
|
||||
: 'admin.agent_cannot_operate_bill',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,17 @@ final class AdminDataScope
|
||||
$query->whereIn($alias.'.site_code', $codes);
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) - no agent node filtering needed
|
||||
if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) {
|
||||
self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = AdminAgentScope::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
@@ -77,6 +77,7 @@ final class AdminSiteScope
|
||||
{
|
||||
$codes = self::accessibleSiteCodes($admin);
|
||||
if ($codes === null) {
|
||||
// Super admin - no site filtering
|
||||
AdminAgentScope::applyToPlayerQuery($query, $admin);
|
||||
|
||||
return;
|
||||
@@ -90,9 +91,13 @@ final class AdminSiteScope
|
||||
|
||||
$query->whereIn('site_code', $codes);
|
||||
|
||||
if (AdminAgentScope::primaryAgentNode($admin) !== null) {
|
||||
// Apply agent node filtering only for agent accounts, not platform accounts
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds === null) {
|
||||
// Agent account - apply agent node filtering
|
||||
AdminAgentScope::applyToPlayerQuery($query, $admin);
|
||||
}
|
||||
// Platform account - no additional agent node filtering needed
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,15 @@ final class AgentAdminUserAuthorization
|
||||
|
||||
$agent = $target->primaryAgentNode();
|
||||
if ($agent === null) {
|
||||
return false;
|
||||
// Target is a platform account (site admin)
|
||||
// Check if admin can access the same sites
|
||||
$adminSiteIds = $admin->accessibleAdminSiteIds();
|
||||
$targetSiteIds = $target->accessibleAdminSiteIds();
|
||||
if ($adminSiteIds === null || $targetSiteIds === null) {
|
||||
return false;
|
||||
}
|
||||
// Check if they share any accessible sites
|
||||
return !empty(array_intersect($adminSiteIds, $targetSiteIds));
|
||||
}
|
||||
|
||||
return AdminAgentScope::nodeVisibleTo($admin, $agent);
|
||||
@@ -36,8 +44,13 @@ final class AgentAdminUserAuthorization
|
||||
}
|
||||
|
||||
$agent = $target->primaryAgentNode();
|
||||
if ($agent === null) {
|
||||
// Target is a platform account (site admin)
|
||||
// Platform accounts can manage other platform accounts in the same site
|
||||
return true;
|
||||
}
|
||||
|
||||
return $agent !== null && AdminAgentScope::nodeManageableBy($admin, $agent);
|
||||
return AdminAgentScope::nodeManageableBy($admin, $agent);
|
||||
}
|
||||
|
||||
public static function denyUnlessUserManageable(AdminUser $admin, AdminUser $target): ?\Illuminate\Http\JsonResponse
|
||||
|
||||
@@ -45,6 +45,14 @@ final class AgentDelegationAuthorization
|
||||
return AdminPermissionBridge::allLegacySlugs();
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) - return their actual permissions
|
||||
return $admin->adminPermissionSlugs();
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$node = $admin->primaryAgentNode();
|
||||
if ($node === null) {
|
||||
return [];
|
||||
@@ -68,6 +76,14 @@ final class AgentDelegationAuthorization
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) can manage all nodes in the site
|
||||
return true;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = AdminAgentScope::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
return false;
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Support;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
|
||||
/**
|
||||
* API 响应文案(zh / en / ne),供 msg 字段与 RuntimeException reason 翻译。
|
||||
@@ -15,7 +14,7 @@ use Illuminate\Support\Facades\Lang;
|
||||
final class ApiMessage
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const LOOKUP_PREFIXES = ['api.reasons.', 'api.', 'admin.', 'jackpot.', 'wallet.'];
|
||||
private const LOOKUP_PREFIXES = ['api.reasons.', 'api.', 'admin.', 'sso.', 'jackpot.', 'wallet.'];
|
||||
|
||||
public static function locale(?Request $request = null): string
|
||||
{
|
||||
@@ -33,16 +32,18 @@ final class ApiMessage
|
||||
*/
|
||||
public static function get(?Request $request, string $key, array $replace = []): string
|
||||
{
|
||||
$locale = self::locale($request);
|
||||
$fallback = (string) config('lottery.locales.fallback', 'en');
|
||||
$key = trim($key);
|
||||
if ($key === '') {
|
||||
return self::translateKey($request, 'api.client_error', $replace);
|
||||
}
|
||||
|
||||
foreach ([$locale, $fallback] as $tryLocale) {
|
||||
foreach (self::candidateKeys($key) as $fullKey) {
|
||||
$msg = trans($fullKey, $replace, $tryLocale);
|
||||
if ($msg !== $fullKey && $msg !== '') {
|
||||
return $msg;
|
||||
}
|
||||
}
|
||||
if (preg_match('/\p{Han}/u', $key) === 1) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
$resolved = self::translateKey($request, $key, $replace);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return $key;
|
||||
@@ -57,15 +58,69 @@ final class ApiMessage
|
||||
{
|
||||
$reasonKey = trim($reasonKey);
|
||||
if ($reasonKey === '') {
|
||||
return self::get($request, 'client_error', $replace);
|
||||
return self::translateKey($request, 'api.client_error', $replace)
|
||||
?? trans('api.client_error', [], self::locale($request));
|
||||
}
|
||||
|
||||
$translated = self::get($request, $reasonKey, $replace);
|
||||
if ($translated !== $reasonKey) {
|
||||
return $translated;
|
||||
if (preg_match('/\p{Han}/u', $reasonKey) === 1) {
|
||||
return $reasonKey;
|
||||
}
|
||||
|
||||
return self::get($request, 'client_error', $replace);
|
||||
$resolved = self::translateKey($request, $reasonKey, $replace);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$resolved = self::translateBusinessKey($request, $reasonKey, $replace);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return $reasonKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* abort() / HttpException 的 message 转为用户可见文案。
|
||||
*/
|
||||
public static function httpExceptionMessage(?Request $request, int $status, string $rawMessage): string
|
||||
{
|
||||
$trimmed = trim($rawMessage);
|
||||
if ($trimmed !== '') {
|
||||
if (preg_match('/\p{Han}/u', $trimmed) === 1) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, 'admin.')) {
|
||||
return self::get($request, $trimmed);
|
||||
}
|
||||
|
||||
$resolved = self::translateKey($request, $trimmed);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$resolved = self::translateBusinessKey($request, $trimmed);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
if (str_contains($trimmed, ' ')) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
return match ($status) {
|
||||
401 => self::get($request, 'admin.unauthenticated'),
|
||||
403 => self::get($request, 'admin.permission_denied'),
|
||||
404 => self::get($request, 'api.not_found'),
|
||||
422 => self::get($request, 'api.validation_failed'),
|
||||
429 => self::get($request, 'api.too_many_requests'),
|
||||
default => $status >= 500
|
||||
? self::get($request, 'api.server_error')
|
||||
: self::get($request, 'api.client_error'),
|
||||
};
|
||||
}
|
||||
|
||||
public static function successMessage(?Request $request = null): string
|
||||
@@ -109,6 +164,54 @@ final class ApiMessage
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int|float> $replace
|
||||
*/
|
||||
private static function translateKey(?Request $request, string $key, array $replace = []): ?string
|
||||
{
|
||||
$locale = self::locale($request);
|
||||
$fallback = (string) config('lottery.locales.fallback', 'en');
|
||||
|
||||
foreach ([$locale, $fallback] as $tryLocale) {
|
||||
foreach (self::candidateKeys($key) as $fullKey) {
|
||||
$msg = trans($fullKey, $replace, $tryLocale);
|
||||
if ($msg !== $fullKey && $msg !== '') {
|
||||
return $msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int|float> $replace
|
||||
*/
|
||||
private static function translateBusinessKey(?Request $request, string $key, array $replace = []): ?string
|
||||
{
|
||||
$locale = self::locale($request);
|
||||
$fallback = (string) config('lottery.locales.fallback', 'en');
|
||||
$candidates = array_values(array_unique([
|
||||
$key,
|
||||
str_contains($key, '.') ? substr($key, strrpos($key, '.') + 1) : $key,
|
||||
]));
|
||||
|
||||
foreach ([$locale, $fallback] as $tryLocale) {
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate === '') {
|
||||
continue;
|
||||
}
|
||||
$businessKey = 'validation.business.'.$candidate;
|
||||
$msg = trans($businessKey, $replace, $tryLocale);
|
||||
if ($msg !== $businessKey) {
|
||||
return $msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -124,6 +227,7 @@ final class ApiMessage
|
||||
$key,
|
||||
'api.'.$key,
|
||||
'admin.'.$key,
|
||||
'sso.'.$key,
|
||||
'jackpot.'.$key,
|
||||
'wallet.'.$key,
|
||||
'api.reasons.'.$key,
|
||||
|
||||
@@ -79,7 +79,7 @@ final class ApiValidationErrors
|
||||
$businessKey = 'validation.business.'.$trimmed;
|
||||
$businessLine = trans($businessKey, ['attribute' => $attribute], $locale);
|
||||
if ($businessLine !== $businessKey) {
|
||||
return $businessLine;
|
||||
return self::refineBusinessLine($field, $trimmed, $businessLine, $locale);
|
||||
}
|
||||
|
||||
$customLine = self::customRuleLine($field, $trimmed, $attribute, $locale);
|
||||
@@ -105,6 +105,54 @@ final class ApiValidationErrors
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
private static function refineBusinessLine(string $field, string $rule, string $line, string $locale): string
|
||||
{
|
||||
if ($rule === 'exceeds_limit') {
|
||||
$flat = self::flatFieldName($field);
|
||||
$specificKey = match ($flat) {
|
||||
'rebate_rate', 'extra_rebate_rate' => 'validation.business.exceeds_player_rebate_limit',
|
||||
'rebate_limit' => 'validation.business.exceeds_parent_rebate',
|
||||
'default_player_rebate' => 'validation.business.exceeds_default_rebate_limit',
|
||||
'total_share_rate', 'relative_share_rate' => 'validation.business.exceeds_parent',
|
||||
default => null,
|
||||
};
|
||||
if ($specificKey !== null) {
|
||||
$specific = trans($specificKey, [], $locale);
|
||||
if ($specific !== $specificKey) {
|
||||
return $specific;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($rule === 'exceeds_available' && self::flatFieldName($field) === 'credit_limit') {
|
||||
$specific = trans('validation.business.exceeds_available', [], $locale);
|
||||
if ($specific !== 'validation.business.exceeds_available') {
|
||||
return $specific;
|
||||
}
|
||||
}
|
||||
|
||||
$flat = self::flatFieldName($field);
|
||||
$contextKey = match (true) {
|
||||
$flat === 'bill' && in_array($rule, ['not_payable', 'not_confirmable', 'not_eligible', 'no_unpaid', 'locked'], true)
|
||||
=> 'validation.business.'.$rule.'_bill',
|
||||
$flat === 'period' && $rule === 'completed'
|
||||
=> 'validation.business.completed_period',
|
||||
$flat === 'credit' && in_array($rule, ['insufficient', 'overdue'], true)
|
||||
=> 'validation.business.'.$rule.'_credit',
|
||||
$flat === 'amount' && $rule === 'zero'
|
||||
=> 'validation.business.zero_amount',
|
||||
default => null,
|
||||
};
|
||||
if ($contextKey !== null) {
|
||||
$specific = trans($contextKey, [], $locale);
|
||||
if ($specific !== $contextKey) {
|
||||
return $specific;
|
||||
}
|
||||
}
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
private static function exactMessage(string $message, string $locale): ?string
|
||||
{
|
||||
$map = trans('validation.exact', [], $locale);
|
||||
|
||||
Reference in New Issue
Block a user