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),
];
}
}

View File

@@ -430,6 +430,11 @@ final class AdminUser extends Authenticatable
$codes = array_keys($merged);
// 平台账号(站点管理员等)不按线路内 AgentProfile 收紧;该过滤器仅作用于绑定代理节点的主账号。
if (! $this->hasPrimaryAgentBinding()) {
return $codes;
}
return AgentProfileCapabilityFilter::applyToMenuActionCodes(
$codes,
$this->primaryAgentProfile(),

View File

@@ -26,10 +26,20 @@ final class AgentProfileService
{
$parent = $parent ?? ($node->parent_id !== null ? AgentNode::query()->find($node->parent_id) : null);
$totalShare = (float) ($payload['total_share_rate'] ?? 0);
$creditLimit = (int) ($payload['credit_limit'] ?? 0);
$rebateLimit = (float) ($payload['rebate_limit'] ?? 0) / 100;
$defaultRebate = (float) ($payload['default_player_rebate'] ?? 0) / 100;
$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);
@@ -68,18 +78,24 @@ final class AgentProfileService
}
if ($parent !== null) {
$delta = $isNew ? $creditLimit : max(0, $creditLimit - $previousCredit);
if ($delta > 0) {
$this->creditAllocationValidator->assertAllocationWithinParent($parent, $delta);
}
$this->creditAllocationValidator->assertChildCreditLimitWithinParent(
$parent,
$isNew ? 0 : $previousCredit,
$creditLimit,
$isNew,
);
}
if ($defaultRebate > $rebateLimit && $rebateLimit > 0) {
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,
@@ -245,7 +261,7 @@ final class AgentProfileService
*/
public function assertChildCapabilityGrantsWithinParent(AgentNode $parent, array $childPayload, AdminUser $actor): void
{
if ($actor->isSuperAdmin()) {
if ($actor->isSuperAdmin() || \App\Support\AdminAgentSettlementScope::canManageSitePeriods($actor)) {
return;
}

View File

@@ -13,13 +13,47 @@ final class CreditAllocationValidator
private readonly AgentCreditAllocatedSyncService $allocatedSync,
) {}
public function assertChildCreditLimitWithinParent(
AgentNode $parent,
int $previousChildLimit,
int $newChildLimit,
bool $isNewChild,
): void {
if ($newChildLimit < 0) {
throw ValidationException::withMessages([
'credit_limit' => ['invalid'],
]);
}
$this->allocatedSync->syncForAgent($parent);
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
if ($profile === null) {
throw ValidationException::withMessages([
'credit_limit' => ['parent_profile_required'],
]);
}
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
$floor = $isNewChild ? 0 : max(0, $previousChildLimit);
$maxAllowed = $floor + $available;
if ($newChildLimit > $maxAllowed) {
throw ValidationException::withMessages([
'credit_limit' => ['exceeds_available'],
]);
}
}
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;
throw ValidationException::withMessages([
'credit_limit' => ['parent_profile_required'],
]);
}
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);

View File

@@ -12,7 +12,9 @@ final class RebateLimitValidator
{
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
if ($profile === null) {
return;
throw ValidationException::withMessages([
'rebate_rate' => ['agent_profile_required'],
]);
}
// Both $rebateRate and $profile->rebate_limit are ratios (0-1)
@@ -23,10 +25,38 @@ final class RebateLimitValidator
]);
}
if ($extraRebateRate > $limit) {
throw ValidationException::withMessages([
'extra_rebate_rate' => ['exceeds_limit'],
]);
}
if ($extraRebateRate > 0 && ! $profile->can_grant_extra_rebate) {
throw ValidationException::withMessages([
'extra_rebate_rate' => ['not_allowed'],
]);
}
if ($limit > 0 && ($rebateRate + $extraRebateRate) > $limit + 1e-9) {
throw ValidationException::withMessages([
'rebate_rate' => ['exceeds_limit'],
]);
}
}
public function assertChildRebateLimitWithinParent(AgentNode $parent, float $childRebateLimitRatio): void
{
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
if ($profile === null) {
throw ValidationException::withMessages([
'rebate_limit' => ['parent_profile_required'],
]);
}
if ($childRebateLimitRatio > (float) $profile->rebate_limit) {
throw ValidationException::withMessages([
'rebate_limit' => ['exceeds_parent'],
]);
}
}
}

View File

@@ -39,7 +39,14 @@ final class SettlementPaymentService
]);
}
$payAmount = min($amount, abs((int) $bill->unpaid_amount));
$unpaid = abs((int) $bill->unpaid_amount);
if ($amount > $unpaid) {
throw ValidationException::withMessages([
'amount' => ['exceeds_unpaid'],
]);
}
$payAmount = $amount;
if ($payAmount <= 0) {
return;
}

View File

@@ -25,15 +25,7 @@ final class PlayerNativeAuthService
);
}
if ($siteCode === '') {
$siteCode = trim((string) config('lottery.integration.default_site_code', ''));
}
$player = Player::query()
->where('site_code', $siteCode)
->where('username', $username)
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE)
->first();
$player = $this->resolveNativePlayer($siteCode, $username);
if ($player === null || ! is_string($player->password_hash) || $player->password_hash === '') {
throw new PlayerAuthenticationException(
@@ -132,4 +124,29 @@ final class PlayerNativeAuthService
$player->forceFill($updates)->save();
}
/**
* 彩票端登录:玩家只填账号密码,不传站点编号。
* 若请求带了 site_code部署绑站则优先在该站查找否则按账号全局匹配唯一彩票原生玩家。
*/
private function resolveNativePlayer(string $siteCode, string $username): ?Player
{
$query = Player::query()
->where('username', $username)
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE);
if ($siteCode !== '') {
$scoped = (clone $query)->where('site_code', $siteCode)->first();
if ($scoped !== null) {
return $scoped;
}
}
$candidates = $query->get();
if ($candidates->count() === 1) {
return $candidates->first();
}
return null;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Services\Ticket;
use App\Models\Player;
use App\Support\PlayerFundingMode;
/**
* 下注行回水:钱包盘立减实扣;信用盘仅展示账期回水预估,实扣仍按全额占用授信。
*/
final class TicketLineInstantRebateApplicator
{
public function __construct(
private readonly InstantRebateResolver $instantRebateResolver,
) {}
/**
* @param array<string, mixed> $evaluated
* @return array<string, mixed>
*/
public function apply(Player $player, array $evaluated): array
{
$resolved = $this->instantRebateResolver->resolveForPlayer(
$player,
(string) $evaluated['play_code'],
(float) $evaluated['rebate_rate_snapshot'],
);
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
$evaluated['rule_snapshot_json']['instant_rebate_applied'] = ! PlayerFundingMode::usesCredit($player);
$finalRate = $resolved['final_rebate_rate'];
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
if (PlayerFundingMode::usesCredit($player)) {
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
return $evaluated;
}
$evaluated['actual_deduct_amount'] = max(
0,
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
);
return $evaluated;
}
/**
* 确认弹窗展示用回水:信用盘为账期回水预估,钱包盘为下注立减额。
*
* @param array<string, mixed> $evaluated
*/
public function displayRebateAmount(Player $player, array $evaluated): int
{
$bet = (int) $evaluated['total_bet_amount'];
if (PlayerFundingMode::usesCredit($player)) {
$rate = (float) ($evaluated['rebate_rate_snapshot'] ?? 0);
return (int) floor($bet * max(0.0, min(1.0, $rate)));
}
return max(0, $bet - (int) $evaluated['actual_deduct_amount']);
}
/** 落库订单/注单上的即时回水合计(信用盘为 0。 */
public function persistedInstantRebateAmount(Player $player, array $evaluated): int
{
if (PlayerFundingMode::usesCredit($player)) {
return 0;
}
return $this->displayRebateAmount($player, $evaluated);
}
}

View File

@@ -24,7 +24,7 @@ final class TicketPlacementService
public function __construct(
private readonly PlayCatalogResolver $catalogResolver,
private readonly PlayRuleEngine $ruleEngine,
private readonly InstantRebateResolver $instantRebateResolver,
private readonly TicketLineInstantRebateApplicator $rebateApplicator,
private readonly RiskPoolService $riskPoolService,
private readonly TicketWalletService $ticketWalletService,
private readonly JackpotContributionService $jackpotContribution,
@@ -132,7 +132,7 @@ final class TicketPlacementService
$resolved['play_config'],
$resolved['odds_items'],
);
$evaluated = $this->applyCreditLineInstantRebatePolicy($player, $evaluated);
$evaluated = $this->rebateApplicator->apply($player, $evaluated);
$locks = array_map(fn (array $combo): array => [
'number_4d' => $combo['number_4d'],
@@ -141,7 +141,7 @@ final class TicketPlacementService
// place 阶段以 acquire 的原子扣减结果为准,允许单行售罄后形成混合成功/失败结果。
$evaluatedLines[] = $evaluated;
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
$rebateAmount = $this->rebateApplicator->persistedInstantRebateAmount($player, $evaluated);
$totalBet += (int) $evaluated['total_bet_amount'];
$totalRebate += $rebateAmount;
$totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
@@ -283,7 +283,7 @@ final class TicketPlacementService
continue;
}
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
$rebateAmount = $this->rebateApplicator->persistedInstantRebateAmount($player, $evaluated);
$item->forceFill([
'actual_deduct_amount' => (int) $evaluated['actual_deduct_amount'],
'risk_locked_amount' => $lockedAmount,
@@ -370,7 +370,7 @@ final class TicketPlacementService
])->save();
});
} catch (\Throwable $e) {
DB::transaction(function () use ($order): void {
DB::transaction(function () use ($order, $player): void {
$items = TicketItem::query()
->where('order_id', $order->id)
->where('status', 'pending_confirm')
@@ -396,10 +396,12 @@ final class TicketPlacementService
}
$order->forceFill(['status' => 'refunded'])->save();
// Reverse the bet deduct first to restore balance
if (! PlayerFundingMode::usesCredit($player)) {
$this->ticketWalletService->reverseBetDeduct($order);
$this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release');
}
$this->ticketWalletService->reverseBetDeduct($order);
});
throw $e;
@@ -526,37 +528,4 @@ final class TicketPlacementService
return str_pad($number, 4, '0', STR_PAD_LEFT);
}
/**
* @param array<string, mixed> $evaluated
* @return array<string, mixed>
*/
private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array
{
$resolved = $this->instantRebateResolver->resolveForPlayer(
$player,
(string) $evaluated['play_code'],
(float) $evaluated['rebate_rate_snapshot'],
);
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
if (PlayerFundingMode::usesCredit($player)) {
$evaluated['rebate_rate_snapshot'] = '0.0000';
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
return $evaluated;
}
$finalRate = $resolved['final_rebate_rate'];
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
$evaluated['actual_deduct_amount'] = max(
0,
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
);
return $evaluated;
}
}

View File

@@ -14,7 +14,7 @@ final class TicketPreviewService
public function __construct(
private readonly PlayCatalogResolver $catalogResolver,
private readonly PlayRuleEngine $ruleEngine,
private readonly InstantRebateResolver $instantRebateResolver,
private readonly TicketLineInstantRebateApplicator $rebateApplicator,
private readonly RiskPoolService $riskPoolService,
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
) {}
@@ -65,7 +65,7 @@ final class TicketPreviewService
$resolved['play_config'],
$resolved['odds_items'],
);
$evaluated = $this->applyPlayerInstantRebate($player, $evaluated);
$evaluated = $this->rebateApplicator->apply($player, $evaluated);
$locks = array_map(fn (array $combo): array => [
'number_4d' => $combo['number_4d'],
@@ -81,7 +81,7 @@ final class TicketPreviewService
}
}
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
$rebateAmount = $this->rebateApplicator->displayRebateAmount($player, $evaluated);
$totalBet += (int) $evaluated['total_bet_amount'];
$totalRebate += $rebateAmount;
$totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
@@ -130,42 +130,10 @@ final class TicketPreviewService
'total_rebate_amount' => $totalRebate,
'total_actual_deduct' => $totalActualDeduct,
'total_estimated_payout' => $totalEstimatedPayout,
'instant_rebate_applied' => ! PlayerFundingMode::usesCredit($player),
],
'lines' => $lines,
'warnings' => $warningRows,
];
}
/**
* @param array<string, mixed> $evaluated
* @return array<string, mixed>
*/
private function applyPlayerInstantRebate(Player $player, array $evaluated): array
{
$resolved = $this->instantRebateResolver->resolveForPlayer(
$player,
(string) $evaluated['play_code'],
(float) $evaluated['rebate_rate_snapshot'],
);
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
if (PlayerFundingMode::usesCredit($player)) {
$evaluated['rebate_rate_snapshot'] = '0.0000';
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
return $evaluated;
}
$finalRate = $resolved['final_rebate_rate'];
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
$evaluated['actual_deduct_amount'] = max(
0,
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
);
return $evaluated;
}
}

View File

@@ -133,14 +133,13 @@ final class TicketWalletService
return;
}
if (WalletTxn::query()->where('biz_type', 'bet_deduct')->where('biz_no', $order->order_no)->where('status', self::TXN_POSTED)->exists()) {
return;
}
if (WalletTxn::query()->where('biz_type', self::BIZ_BET_RESERVE_RELEASE)->where('idempotent_key', $releaseIdempotentKey)->exists()) {
return;
}
// Even if bet_deduct exists, we still need to release frozen balance
// because finalizeReservedBetDeduct may have failed to release it completely
$wallet = PlayerWallet::query()
->where('player_id', $order->player_id)
->where('wallet_type', 'lottery')

View File

@@ -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;

View File

@@ -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');

View File

@@ -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',
);
}

View File

@@ -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');

View File

@@ -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
}
/**

View File

@@ -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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);