diff --git a/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php b/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php index 83bac13..80e2f0f 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php @@ -3,12 +3,16 @@ namespace App\Http\Controllers\Api\V1\Admin; use App\Http\Controllers\Controller; +use App\Http\Middleware\RecordAdminApiAudit; +use App\Http\Requests\Admin\AdminSettingBatchUpdateRequest; use App\Http\Requests\Admin\AdminSettingIndexRequest; use App\Http\Requests\Admin\AdminSettingUpdateRequest; use App\Models\LotterySetting; +use App\Services\AuditLogger; use App\Support\ApiResponse; use App\Services\LotterySettings; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; /** * 后台:运营配置(KV 设置)读写。 @@ -54,4 +58,47 @@ final class AdminSettingController extends Controller 'description' => $fresh->description_zh, ]); } + + public function batchUpdate(AdminSettingBatchUpdateRequest $request): JsonResponse + { + /** @var list $items */ + $items = $request->validated('items'); + + DB::transaction(static function () use ($items): void { + LotterySettings::putMany($items); + }); + + $keys = array_map(static fn (array $item): string => (string) $item['key'], $items); + $rows = LotterySetting::query() + ->whereIn('setting_key', $keys) + ->orderBy('setting_key') + ->get(); + + $admin = $request->lotteryAdmin(); + if ($admin !== null) { + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'settings', + actionCode: 'batch_update', + targetType: 'lottery_settings', + targetId: 'batch', + beforeJson: null, + afterJson: [ + 'keys' => $keys, + 'count' => count($keys), + ], + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + } + + return ApiResponse::success([ + 'items' => $rows->map(fn (LotterySetting $s): array => [ + 'key' => $s->setting_key, + 'value' => $s->value_json, + 'group' => $s->group_name, + 'description' => $s->description_zh, + ])->values()->all(), + ]); + } } diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentAdminUserRoleSyncController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentAdminUserRoleSyncController.php new file mode 100644 index 0000000..80ac71d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentAdminUserRoleSyncController.php @@ -0,0 +1,56 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $agent = $admin_user->primaryAgentNode(); + if ($agent === null) { + abort(404); + } + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent); + if ($denied !== null) { + return $denied; + } + + if (! $admin->isSuperAdmin() && ! $admin->hasAdminPermission('prd.agent.user.manage')) { + return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent); + } + + $before = AdminUserApiPresenter::listItem($admin_user); + $user = $service->syncRoles($agent, $admin_user, $request->validated('role_ids')); + $after = AdminUserApiPresenter::listItem($user); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'agent', + 'agent_admin_user.sync_roles', + 'admin_user', + (string) $user->id, + $before, + $after, + ); + + return ApiResponse::success($after); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserIndexController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserIndexController.php new file mode 100644 index 0000000..ce47621 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserIndexController.php @@ -0,0 +1,41 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node); + if ($denied !== null) { + return $denied; + } + + $userIds = DB::table('admin_user_agents') + ->where('agent_node_id', $agent_node->id) + ->pluck('admin_user_id'); + + $users = AdminUser::query() + ->whereIn('id', $userIds) + ->orderBy('username') + ->get(); + + return ApiResponse::success([ + 'agent_node_id' => (int) $agent_node->id, + 'items' => $users->map(static fn (AdminUser $user): array => AdminUserApiPresenter::listItem($user))->all(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php new file mode 100644 index 0000000..e2a344a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php @@ -0,0 +1,49 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node); + if ($denied !== null) { + return $denied; + } + + if (! $admin->isSuperAdmin() && ! $admin->hasAdminPermission('prd.agent.user.manage')) { + return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node); + } + + $user = $service->createUnderAgent($agent_node, $request->validated()); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'agent', + 'agent_admin_user.create', + 'admin_user', + (string) $user->id, + null, + AdminUserApiPresenter::listItem($user), + ); + + return ApiResponse::success(AdminUserApiPresenter::listItem($user))->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeChildrenController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeChildrenController.php new file mode 100644 index 0000000..72c0f3f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeChildrenController.php @@ -0,0 +1,33 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node); + if ($denied !== null) { + return $denied; + } + + $items = $agent_node->children() + ->orderBy('code') + ->get() + ->map(static fn (AgentNode $child): array => AgentNodePresenter::item($child)) + ->all(); + + return ApiResponse::success(['items' => $items]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDelegationGrantIndexController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDelegationGrantIndexController.php new file mode 100644 index 0000000..27ee10c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDelegationGrantIndexController.php @@ -0,0 +1,56 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node); + if ($denied !== null) { + return $denied; + } + + if ($agent_node->isRoot()) { + return ApiMessage::errorResponse( + $request, + 'admin.agent_delegation_root_denied', + ErrorCode::ValidationFailed->value, + null, + 422, + ); + } + + if (! AgentDelegationAuthorization::childIsManageableBy($admin, $agent_node)) { + return ApiMessage::errorResponse( + $request, + 'admin.agent_delegation_manage_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + + return ApiResponse::success([ + 'child_agent_id' => (int) $agent_node->id, + 'grants' => $service->listForChild($agent_node, $admin), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDelegationGrantSyncController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDelegationGrantSyncController.php new file mode 100644 index 0000000..fe3e247 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDelegationGrantSyncController.php @@ -0,0 +1,72 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node); + if ($denied !== null) { + return $denied; + } + + if ($agent_node->isRoot()) { + return ApiMessage::errorResponse( + $request, + 'admin.agent_delegation_root_denied', + ErrorCode::ValidationFailed->value, + null, + 422, + ); + } + + if (! AgentDelegationAuthorization::childIsManageableBy($admin, $agent_node)) { + return ApiMessage::errorResponse( + $request, + 'admin.agent_delegation_manage_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + + $before = $service->listForChild($agent_node); + $grants = $request->validated('grants'); + $after = $service->syncGrants($admin, $agent_node, $grants); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'agent', + 'agent.delegation.sync', + 'agent_node', + (string) $agent_node->id, + ['grants' => $before], + ['grants' => $after], + ); + + return ApiResponse::success([ + 'child_agent_id' => (int) $agent_node->id, + 'grants' => $after, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php new file mode 100644 index 0000000..94c5bd1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php @@ -0,0 +1,70 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node); + if ($denied !== null) { + return $denied; + } + + if (! AdminAgentScope::nodeManageableBy($admin, $agent_node)) { + return AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node) + ?? AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node); + } + + if ($agent_node->isRoot()) { + return ApiMessage::errorResponse($request, 'admin.agent_root_delete_denied', ErrorCode::ValidationFailed->value, null, 422); + } + + if ($agent_node->children()->exists()) { + return ApiMessage::errorResponse($request, 'admin.agent_node_has_children_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); + } + + if (DB::table('admin_user_agents')->where('agent_node_id', (int) $agent_node->id)->exists()) { + return ApiMessage::errorResponse($request, 'admin.agent_node_has_users_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); + } + + if (DB::table('admin_roles')->where('owner_agent_id', (int) $agent_node->id)->exists()) { + return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); + } + + $before = AgentNodePresenter::item($agent_node); + $service->destroy($agent_node); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'system', + 'agent_node.destroy', + 'agent_node', + (string) $agent_node->id, + $before, + null, + ); + + return ApiResponse::success(null); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleIndexController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleIndexController.php new file mode 100644 index 0000000..618b6b8 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleIndexController.php @@ -0,0 +1,38 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node); + if ($denied !== null) { + return $denied; + } + + $roles = AdminRole::query() + ->where('scope_type', AdminRole::SCOPE_AGENT) + ->where('owner_agent_id', $agent_node->id) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + + return ApiResponse::success([ + 'agent_node_id' => (int) $agent_node->id, + 'items' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->all(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php new file mode 100644 index 0000000..c2068c5 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php @@ -0,0 +1,49 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node); + if ($denied !== null) { + return $denied; + } + + if (! $admin->isSuperAdmin() && ! $admin->hasAdminPermission('prd.agent.role.manage')) { + return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node); + } + + $role = $service->createForAgent($admin, $agent_node, $request->validated()); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'agent', + 'agent_role.create', + 'admin_role', + (string) $role->id, + null, + AdminRoleApiPresenter::item($role), + ); + + return ApiResponse::success(AdminRoleApiPresenter::item($role)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeShowController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeShowController.php new file mode 100644 index 0000000..02840c2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeShowController.php @@ -0,0 +1,27 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node); + if ($denied !== null) { + return $denied; + } + + return ApiResponse::success(AgentNodePresenter::item($agent_node)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeStoreController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeStoreController.php new file mode 100644 index 0000000..f8b0a64 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeStoreController.php @@ -0,0 +1,43 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $parent = AgentNode::query()->findOrFail((int) $request->validated('parent_id')); + $denied = AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $parent); + if ($denied !== null) { + return $denied; + } + + $node = $service->createChild($admin, $request->validated()); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'system', + 'agent_node.create', + 'agent_node', + (string) $node->id, + null, + AgentNodePresenter::item($node), + ); + + return ApiResponse::success(AgentNodePresenter::item($node)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeTreeController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeTreeController.php new file mode 100644 index 0000000..e9c7244 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeTreeController.php @@ -0,0 +1,37 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $siteId = AdminAgentNodeAccess::resolveAdminSiteId( + $admin, + $request->integer('admin_site_id') ?: null, + ); + + $denied = AdminAgentNodeAccess::denyUnlessSiteResolved($admin, $siteId); + if ($denied !== null) { + return $denied; + } + + $nodes = AdminAgentScope::visibleNodesQuery($admin, (int) $siteId)->get(); + + return ApiResponse::success([ + 'admin_site_id' => (int) $siteId, + 'tree' => AgentNodePresenter::tree($nodes), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeUpdateController.php new file mode 100644 index 0000000..a6ae481 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeUpdateController.php @@ -0,0 +1,57 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node); + if ($denied !== null) { + return $denied; + } + + if (! AdminAgentScope::nodeManageableBy($admin, $agent_node)) { + return AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node) + ?? AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node); + } + + if ($agent_node->isRoot() && ! $admin->isSuperAdmin()) { + return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node); + } + + $before = AgentNodePresenter::item($agent_node); + $node = $service->update($agent_node, $request->validated()); + $after = AgentNodePresenter::item($node); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'system', + 'agent_node.update', + 'agent_node', + (string) $node->id, + $before, + $after, + ); + + return ApiResponse::success($after); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentRoleDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentRoleDestroyController.php new file mode 100644 index 0000000..fbf99d6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentRoleDestroyController.php @@ -0,0 +1,49 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + if (! $admin_role->isAgentScoped()) { + abort(404); + } + + $denied = AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role); + if ($denied !== null) { + return $denied; + } + + $before = AdminRoleApiPresenter::item($admin_role); + $service->destroy($admin_role); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'agent', + 'agent_role.destroy', + 'admin_role', + (string) $admin_role->id, + $before, + null, + ); + + return ApiResponse::success(null); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentRolePermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentRolePermissionSyncController.php new file mode 100644 index 0000000..04a7a0a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentRolePermissionSyncController.php @@ -0,0 +1,55 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + if (! $admin_role->isAgentScoped()) { + abort(404); + } + + $denied = AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role); + if ($denied !== null) { + return $denied; + } + + if ($admin_role->isReadOnlyTemplate()) { + return AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role); + } + + $slugs = array_values(array_unique($request->validated('permission_slugs'))); + $before = AdminRoleApiPresenter::item($admin_role); + $role = $service->syncPermissions($admin, $admin_role, $slugs); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'agent', + 'agent_role.sync_permissions', + 'admin_role', + (string) $role->id, + $before, + AdminRoleApiPresenter::item($role), + ); + + return ApiResponse::success(AdminRoleApiPresenter::item($role)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentRoleUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentRoleUpdateController.php new file mode 100644 index 0000000..ae573e1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentRoleUpdateController.php @@ -0,0 +1,54 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + if (! $admin_role->isAgentScoped()) { + abort(404); + } + + $denied = AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role); + if ($denied !== null) { + return $denied; + } + + if ($admin_role->isReadOnlyTemplate()) { + return AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role); + } + + $before = AdminRoleApiPresenter::item($admin_role); + $role = $service->update($admin_role, $request->validated()); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'agent', + 'agent_role.update', + 'admin_role', + (string) $role->id, + $before, + AdminRoleApiPresenter::item($role), + ); + + return ApiResponse::success(AdminRoleApiPresenter::item($role)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php index 6f7b09a..f596f8a 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php @@ -3,10 +3,12 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw; use Carbon\Carbon; +use App\Models\AdminUser; use App\Models\Draw; use App\Models\TicketItem; use App\Models\TicketOrder; use Illuminate\Http\Request; +use App\Support\AdminSiteScope; use App\Support\AdminApiList; use App\Services\LotterySettings; use Illuminate\Http\JsonResponse; @@ -20,9 +22,13 @@ final class AdminDrawIndexController extends Controller { public function __invoke(Request $request): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $p = AdminApiList::readPaging($request); $drawNo = trim((string) $request->query('draw_no', '')); $status = trim((string) $request->query('status', '')); + $agentNodeId = $request->integer('agent_node_id') ?: null; $q = Draw::query()->orderByDesc('draw_time')->orderByDesc('id'); @@ -39,6 +45,8 @@ final class AdminDrawIndexController extends Controller $statsByDrawId = $this->aggregateListStats( $paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(), + $admin, + $agentNodeId, ); return AdminApiList::jsonWith($paginator, fn (Draw $row) => $this->row($row, $statsByDrawId), [ @@ -55,20 +63,22 @@ final class AdminDrawIndexController extends Controller * @param list $drawIds * @return array */ - private function aggregateListStats(array $drawIds): array + private function aggregateListStats(array $drawIds, AdminUser $admin, ?int $agentNodeId): array { if ($drawIds === []) { return []; } - $betByDraw = TicketOrder::query() - ->whereIn('draw_id', $drawIds) + $betQuery = TicketOrder::query()->whereIn('draw_id', $drawIds); + $this->scopeOrdersToVisiblePlayers($betQuery, $admin, $agentNodeId); + $betByDraw = $betQuery ->groupBy('draw_id') ->selectRaw('draw_id, COALESCE(SUM(total_actual_deduct), 0) AS total_bet') ->pluck('total_bet', 'draw_id'); - $payoutRows = TicketItem::query() - ->whereIn('draw_id', $drawIds) + $payoutQuery = TicketItem::query()->whereIn('draw_id', $drawIds); + $this->scopeTicketItemsToVisiblePlayers($payoutQuery, $admin, $agentNodeId); + $payoutRows = $payoutQuery ->groupBy('draw_id') ->selectRaw( 'draw_id, COALESCE(SUM(win_amount), 0) AS win, COALESCE(SUM(jackpot_win_amount), 0) AS jackpot', @@ -91,6 +101,44 @@ final class AdminDrawIndexController extends Controller return $stats; } + /** + * @param \Illuminate\Database\Eloquent\Builder $query + */ + private function scopeOrdersToVisiblePlayers($query, AdminUser $admin, ?int $agentNodeId): void + { + if ($admin->isSuperAdmin() && ($agentNodeId === null || $agentNodeId <= 0)) { + return; + } + + $query->whereHas('player', static function ($playerQuery) use ($admin, $agentNodeId): void { + AdminSiteScope::applyPlayerFilters( + $playerQuery, + $admin, + null, + $agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null, + ); + }); + } + + /** + * @param \Illuminate\Database\Eloquent\Builder $query + */ + private function scopeTicketItemsToVisiblePlayers($query, AdminUser $admin, ?int $agentNodeId): void + { + if ($admin->isSuperAdmin() && ($agentNodeId === null || $agentNodeId <= 0)) { + return; + } + + $query->whereHas('player', static function ($playerQuery) use ($admin, $agentNodeId): void { + AdminSiteScope::applyPlayerFilters( + $playerQuery, + $admin, + null, + $agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null, + ); + }); + } + /** * @param array $statsByDrawId * @return array diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php index 671e540..50c2d1a 100644 --- a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotContributionIndexController.php @@ -7,6 +7,7 @@ use App\Support\AdminApiList; use Illuminate\Http\JsonResponse; use App\Models\JackpotContribution; use App\Http\Controllers\Controller; +use App\Support\AdminDataScope; /** * GET /api/v1/admin/jackpot/contributions — Jackpot 蓄水流水。 @@ -15,6 +16,9 @@ final class AdminJackpotContributionIndexController extends Controller { public function __invoke(Request $request): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $p = AdminApiList::readPaging($request); $drawNo = trim((string) $request->query('draw_no', '')); @@ -26,6 +30,8 @@ final class AdminJackpotContributionIndexController extends Controller $q->whereHas('draw', fn ($d) => $d->where('draw_no', 'like', '%'.$drawNo.'%')); } + AdminDataScope::applyEloquentViaPlayer($q, $admin); + $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); return AdminApiList::json($paginator, fn (JackpotContribution $r) => [ diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php index 5888cc2..0875e8b 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php @@ -23,15 +23,20 @@ final class AdminPlayerIndexController extends Controller $keyword = trim((string) $request->query('keyword', '')); $status = $request->query('status'); $siteCode = $request->query('site_code'); + $agentNodeId = $request->integer('agent_node_id') ?: null; $q = Player::query() - ->with(['wallets' => static fn ($wq) => $wq->orderBy('wallet_type')->orderBy('currency_code')]) + ->with([ + 'wallets' => static fn ($wq) => $wq->orderBy('wallet_type')->orderBy('currency_code'), + 'agentNode:id,code,name', + ]) ->orderByDesc('id'); AdminSiteScope::applyPlayerFilters( $q, $admin, is_string($siteCode) ? $siteCode : null, + $agentNodeId, ); if ($keyword !== '') { diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php index 59f6b63..bee81d5 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php @@ -7,6 +7,7 @@ use App\Lottery\ErrorCode; use App\Support\ApiMessage; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; +use App\Support\AdminAgentScope; use App\Support\AdminSiteScope; use App\Support\PlayerApiPresenter; use App\Http\Controllers\Controller; @@ -34,8 +35,30 @@ final class AdminPlayerStoreController extends Controller return ApiMessage::errorResponse($request, 'admin.player_already_registered', ErrorCode::ValidationFailed->value, null, 422); } + $agentNodeId = $admin->isSuperAdmin() + ? $this->resolveAgentNodeIdForSuperAdmin($request->validated('agent_node_id'), $siteCode) + : $admin->primaryAgentNodeId(); + + if ($agentNodeId === null) { + return ApiMessage::errorResponse( + $request, + 'admin.player_create_agent_required', + ErrorCode::ValidationFailed->value, + null, + 422, + ); + } + + 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); + } + } + $player = Player::query()->create([ 'site_code' => $request->validated('site_code'), + 'agent_node_id' => $agentNodeId, 'site_player_id' => $request->validated('site_player_id'), 'username' => $request->validated('username'), 'nickname' => $request->validated('nickname'), @@ -45,4 +68,23 @@ final class AdminPlayerStoreController extends Controller return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201); } + + private function resolveAgentNodeIdForSuperAdmin(mixed $requested, string $siteCode): ?int + { + if ($requested !== null && (int) $requested > 0) { + return (int) $requested; + } + + $siteId = \App\Models\AdminSite::query()->where('code', $siteCode)->value('id'); + if ($siteId === null) { + return null; + } + + $rootId = \App\Models\AgentNode::query() + ->where('admin_site_id', (int) $siteId) + ->where('depth', 0) + ->value('id'); + + return $rootId !== null ? (int) $rootId : null; + } } diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php index 36a7abe..27109b8 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php @@ -13,6 +13,9 @@ final class AdminReportDailyProfitController extends Controller { public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $validated = $request->validated(); $p = AdminApiList::readPaging($request); $range = $service->resolveDateRange($validated); @@ -22,6 +25,7 @@ final class AdminReportDailyProfitController extends Controller $range['date_to'], $p['page'], $p['perPage'], + $admin, ); return AdminApiList::json($paginator, static fn (array $row): array => $row); diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php index c782c67..9f61a22 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php @@ -13,6 +13,9 @@ final class AdminReportPlayDimensionController extends Controller { public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $validated = $request->validated(); $p = AdminApiList::readPaging($request); $range = $service->resolveDateRange($validated); @@ -24,6 +27,7 @@ final class AdminReportPlayDimensionController extends Controller $range['date_to'], $p['page'], $p['perPage'], + $admin, ); return AdminApiList::json($paginator, static function (object $row): array { diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php index e6d3fb5..384430c 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php @@ -13,10 +13,14 @@ final class AdminReportPlayerWinLossController extends Controller { public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $validated = $request->validated(); $p = AdminApiList::readPaging($request); $range = $service->resolveDateRange($validated); $playerId = isset($validated['player_id']) ? (int) $validated['player_id'] : null; + $agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null; $paginator = $service->playerWinLossPaginated( $playerId, @@ -24,11 +28,16 @@ final class AdminReportPlayerWinLossController extends Controller $range['date_to'], $p['page'], $p['perPage'], + $admin, + $agentNodeId > 0 ? $agentNodeId : null, ); return AdminApiList::json($paginator, static function (object $row): array { return [ 'player_id' => (int) $row->player_id, + 'agent_node_id' => isset($row->agent_node_id) ? (int) $row->agent_node_id : null, + 'agent_code' => isset($row->agent_code) ? (string) $row->agent_code : null, + 'agent_name' => isset($row->agent_name) ? (string) $row->agent_name : null, 'username' => $row->username !== null ? (string) $row->username : 'player#'.(int) $row->player_id, 'total_bet_minor' => (int) $row->total_bet_minor, 'total_payout_minor' => (int) $row->total_payout_minor, diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php index 94ba1be..0ed857c 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php @@ -13,6 +13,9 @@ final class AdminReportRebateCommissionController extends Controller { public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $validated = $request->validated(); $p = AdminApiList::readPaging($request); $range = $service->resolveDateRange($validated); @@ -24,6 +27,7 @@ final class AdminReportRebateCommissionController extends Controller $range['date_to'], $p['page'], $p['perPage'], + $admin, ); return AdminApiList::json($paginator, static function (object $row): array { diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php index b8a8cc0..8091034 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Reports; +use App\Models\AdminUser; use App\Models\ReportJob; use App\Services\Admin\AdminReportJobService; use App\Services\Admin\AdminReportQueryService; @@ -29,7 +30,8 @@ final class ReportJobDownloadController $dateTo, ); $filename = $label.'_'.$pathSuffix.'.'.$report_job->export_format; - $rows = $service->reportRows((string) $report_job->report_type, $filterJson); + $scopedAdmin = AdminUser::query()->find((int) $report_job->admin_user_id); + $rows = $service->reportRows((string) $report_job->report_type, $filterJson, $scopedAdmin); if ((string) $report_job->export_format === 'xlsx') { return $spreadsheetExporter->streamDownload($rows, $filename); diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php index 5e8c533..57f8f22 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\Settlement; use Illuminate\Http\Request; use App\Support\AdminApiList; +use App\Support\AdminSiteScope; +use App\Support\AgentNodeApiPresenter; use App\Models\SettlementBatch; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; @@ -16,15 +18,33 @@ final class AdminSettlementBatchDetailsController extends Controller { public function __invoke(Request $request, SettlementBatch $batch): JsonResponse { - $p = AdminApiList::readPaging($request); + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); - $paginator = TicketSettlementDetail::query() + $p = AdminApiList::readPaging($request); + $agentNodeId = $request->integer('agent_node_id') ?: null; + + $detailQuery = TicketSettlementDetail::query() ->where('settlement_batch_id', $batch->id) ->with([ 'ticketItem:id,ticket_no,play_code,player_id', - 'ticketItem.player:id,site_code,username,nickname,site_player_id', + 'ticketItem.player:id,site_code,username,nickname,site_player_id,agent_node_id', + 'ticketItem.player.agentNode:id,code,name', 'ticketItem.order:id,currency_code', - ]) + ]); + + if (! $admin->isSuperAdmin() || ($agentNodeId !== null && $agentNodeId > 0)) { + $detailQuery->whereHas('ticketItem.player', static function ($playerQuery) use ($admin, $agentNodeId): void { + AdminSiteScope::applyPlayerFilters( + $playerQuery, + $admin, + null, + $agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null, + ); + }); + } + + $paginator = $detailQuery ->orderBy('id') ->paginate($p['perPage'], ['*'], 'page', $p['page']); @@ -37,6 +57,7 @@ final class AdminSettlementBatchDetailsController extends Controller return [ 'id' => (int) $row->id, 'ticket_item_id' => (int) $row->ticket_item_id, + ...AgentNodeApiPresenter::embed($player?->agentNode), 'ticket_no' => $item?->ticket_no, 'play_code' => $item?->play_code, 'currency_code' => $order?->currency_code, diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php index 30db5e6..e144b60 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Settlement; use Illuminate\Http\Request; use App\Support\AdminApiList; +use App\Support\AdminSiteScope; use App\Models\SettlementBatch; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; @@ -16,14 +17,29 @@ final class AdminSettlementBatchIndexController extends Controller { public function __invoke(Request $request): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $p = AdminApiList::readPaging($request); $drawNo = trim((string) $request->query('draw_no', '')); $status = trim((string) $request->query('status', '')); + $agentNodeId = $request->integer('agent_node_id') ?: null; $q = SettlementBatch::query() ->with(['draw:id,draw_no']) ->orderByDesc('id'); + if (! $admin->isSuperAdmin() || ($agentNodeId !== null && $agentNodeId > 0)) { + $q->whereHas('details.ticketItem.player', static function ($playerQuery) use ($admin, $agentNodeId): void { + AdminSiteScope::applyPlayerFilters( + $playerQuery, + $admin, + null, + $agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null, + ); + }); + } + if ($drawNo !== '') { $q->whereHas('draw', fn ($d) => $d->where('draw_no', 'like', '%'.$drawNo.'%')); } diff --git a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php index 199e01f..7e4b11d 100644 --- a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php @@ -9,6 +9,7 @@ use App\Support\ApiResponse; use App\Support\CurrencyFormatter; use App\Support\PaginationTrait; use App\Support\AdminSiteScope; +use App\Support\AgentNodeApiPresenter; use App\Support\TicketItemListFilters; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -44,7 +45,8 @@ final class AdminTicketItemIndexController extends Controller ->with([ 'draw:id,draw_no,business_date', 'order:id,order_no,currency_code,created_at', - 'player:id,site_code,site_player_id,username,nickname', + 'player:id,site_code,site_player_id,username,nickname,agent_node_id', + 'player.agentNode:id,code,name', ]) ->orderByDesc('ticket_items.id'); @@ -86,10 +88,14 @@ final class AdminTicketItemIndexController extends Controller is_string($validated['end_date'] ?? null) ? $validated['end_date'] : null, ); + $agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null; + AdminSiteScope::applyViaPlayerRelationWithSiteCode( $query, $admin, is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null, + 'player', + $agentNodeId > 0 ? $agentNodeId : null, ); $paginator = $query->paginate(perPage: $perPage, page: $page, columns: ['*']); @@ -103,6 +109,7 @@ final class AdminTicketItemIndexController extends Controller return [ 'id' => $row->id, 'ticket_no' => $row->ticket_no, + ...AgentNodeApiPresenter::embed($row->player?->agentNode), 'player_id' => $row->player_id, 'site_code' => $row->player?->site_code, 'site_player_id' => $row->player?->site_player_id, diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php index 192d942..54440f0 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php @@ -12,7 +12,11 @@ final class AdminRoleIndexController extends Controller { public function __invoke(): JsonResponse { - $roles = AdminRole::query()->orderBy('sort_order')->orderBy('id')->get(); + $roles = AdminRole::query() + ->where('scope_type', AdminRole::SCOPE_SYSTEM) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); return ApiResponse::success([ 'items' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->values()->all(), diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php index ab993af..43d4538 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php @@ -26,6 +26,9 @@ final class AdminRoleStoreController extends Controller 'status' => $request->validated('status', 1), 'is_system' => false, 'sort_order' => 0, + 'scope_type' => AdminRole::SCOPE_SYSTEM, + 'owner_agent_id' => null, + 'delegated_from_role_id' => null, ]); $role->syncLegacyPermissionSlugs($permissionSlugs); diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php index 54cfe13..e69edfc 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php @@ -8,6 +8,7 @@ use App\Models\TransferOrder; use App\Support\PaginationTrait; use Illuminate\Http\JsonResponse; use App\Support\AdminSiteScope; +use App\Support\AgentNodeApiPresenter; use App\Support\CurrencyFormatter; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\TransferOrderListRequest; @@ -44,7 +45,10 @@ final class TransferOrderListController extends Controller $page = $this->page($request); $query = TransferOrder::query() - ->with(['player:id,site_code,site_player_id,username,nickname']) + ->with([ + 'player:id,site_code,site_player_id,username,nickname,agent_node_id', + 'player.agentNode:id,code,name', + ]) ->orderByDesc('id'); if (! empty($validated['player_id'])) { @@ -85,10 +89,14 @@ final class TransferOrderListController extends Controller } } + $agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null; + AdminSiteScope::applyViaPlayerRelationWithSiteCode( $query, $admin, is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null, + 'player', + $agentNodeId > 0 ? $agentNodeId : null, ); $paginator = $query->paginate($perPage, ['*'], 'page', $page); @@ -119,6 +127,7 @@ final class TransferOrderListController extends Controller return [ 'id' => $o->id, 'transfer_no' => $o->transfer_no, + ...AgentNodeApiPresenter::embed($p?->agentNode), 'player_id' => $o->player_id, 'site_code' => $p?->site_code, 'site_player_id' => $p?->site_player_id, diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php index fd88991..502df5b 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php @@ -7,6 +7,7 @@ use App\Support\ApiResponse; use App\Support\PaginationTrait; use Illuminate\Http\JsonResponse; use App\Support\AdminSiteScope; +use App\Support\AgentNodeApiPresenter; use App\Support\CurrencyFormatter; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\WalletTransactionListRequest; @@ -43,7 +44,10 @@ final class WalletTransactionListController extends Controller $page = $this->page($request); $query = WalletTxn::query() - ->with(['player:id,site_code,site_player_id,username,nickname']) + ->with([ + 'player:id,site_code,site_player_id,username,nickname,agent_node_id', + 'player.agentNode:id,code,name', + ]) ->orderByDesc('id'); if (! empty($validated['player_id'])) { @@ -88,10 +92,14 @@ final class WalletTransactionListController extends Controller } } + $agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null; + AdminSiteScope::applyViaPlayerRelationWithSiteCode( $query, $admin, is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null, + 'player', + $agentNodeId > 0 ? $agentNodeId : null, ); $paginator = $query->paginate($perPage, ['*'], 'page', $page); @@ -118,6 +126,7 @@ final class WalletTransactionListController extends Controller return [ 'id' => $t->id, 'txn_no' => $t->txn_no, + ...AgentNodeApiPresenter::embed($p?->agentNode), 'player_id' => $t->player_id, 'site_code' => $p?->site_code, 'site_player_id' => $p?->site_player_id, diff --git a/app/Http/Requests/Admin/AdminPlayerStoreRequest.php b/app/Http/Requests/Admin/AdminPlayerStoreRequest.php index 490413b..5ee3430 100644 --- a/app/Http/Requests/Admin/AdminPlayerStoreRequest.php +++ b/app/Http/Requests/Admin/AdminPlayerStoreRequest.php @@ -26,6 +26,7 @@ final class AdminPlayerStoreRequest extends FormRequest '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'], ]; } diff --git a/app/Http/Requests/Admin/AdminReportQueryRequest.php b/app/Http/Requests/Admin/AdminReportQueryRequest.php index f6b4e53..ba52254 100644 --- a/app/Http/Requests/Admin/AdminReportQueryRequest.php +++ b/app/Http/Requests/Admin/AdminReportQueryRequest.php @@ -21,6 +21,7 @@ final class AdminReportQueryRequest extends FormRequest 'date_to' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after_or_equal:date_from'], 'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'play_code' => ['sometimes', 'nullable', 'string', 'max:32'], + 'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'], ]; } } diff --git a/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php b/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php new file mode 100644 index 0000000..846c119 --- /dev/null +++ b/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php @@ -0,0 +1,22 @@ + ['required', 'array', 'min:1', 'max:50'], + 'items.*.key' => ['required', 'string', 'max:128'], + 'items.*.value' => ['present'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminSettingUpdateRequest.php b/app/Http/Requests/Admin/AdminSettingUpdateRequest.php index f253a84..63a60e4 100644 --- a/app/Http/Requests/Admin/AdminSettingUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminSettingUpdateRequest.php @@ -14,7 +14,7 @@ final class AdminSettingUpdateRequest extends FormRequest public function rules(): array { return [ - 'value' => ['required'], + 'value' => ['present'], ]; } } diff --git a/app/Http/Requests/Admin/AgentAdminUserRoleSyncRequest.php b/app/Http/Requests/Admin/AgentAdminUserRoleSyncRequest.php new file mode 100644 index 0000000..8092dcc --- /dev/null +++ b/app/Http/Requests/Admin/AgentAdminUserRoleSyncRequest.php @@ -0,0 +1,22 @@ + */ + public function rules(): array + { + return [ + 'role_ids' => ['required', 'array'], + 'role_ids.*' => ['integer', 'exists:admin_roles,id'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AgentAdminUserStoreRequest.php b/app/Http/Requests/Admin/AgentAdminUserStoreRequest.php new file mode 100644 index 0000000..3f7f068 --- /dev/null +++ b/app/Http/Requests/Admin/AgentAdminUserStoreRequest.php @@ -0,0 +1,28 @@ + */ + public function rules(): array + { + return [ + 'username' => ['required', 'string', 'max:64', 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'], + 'status' => ['sometimes', 'integer', 'in:0,1'], + 'role_ids' => ['sometimes', 'array'], + 'role_ids.*' => ['integer', 'exists:admin_roles,id'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AgentDelegationGrantSyncRequest.php b/app/Http/Requests/Admin/AgentDelegationGrantSyncRequest.php new file mode 100644 index 0000000..c831b6c --- /dev/null +++ b/app/Http/Requests/Admin/AgentDelegationGrantSyncRequest.php @@ -0,0 +1,25 @@ +lotteryAdmin() !== null; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'grants' => ['required', 'array'], + 'grants.*.menu_action_id' => ['required', 'integer', 'min:1'], + 'grants.*.can_delegate' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AgentNodeStoreRequest.php b/app/Http/Requests/Admin/AgentNodeStoreRequest.php new file mode 100644 index 0000000..c55718b --- /dev/null +++ b/app/Http/Requests/Admin/AgentNodeStoreRequest.php @@ -0,0 +1,24 @@ + */ + public function rules(): array + { + return [ + 'parent_id' => ['required', 'integer', 'exists:agent_nodes,id'], + 'code' => ['required', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_-]+$/'], + 'name' => ['required', 'string', 'max:128'], + 'status' => ['sometimes', 'integer', 'in:0,1'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AgentNodeUpdateRequest.php b/app/Http/Requests/Admin/AgentNodeUpdateRequest.php new file mode 100644 index 0000000..791e2a6 --- /dev/null +++ b/app/Http/Requests/Admin/AgentNodeUpdateRequest.php @@ -0,0 +1,22 @@ + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'string', 'max:128'], + 'status' => ['sometimes', 'integer', 'in:0,1'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AgentRolePermissionSyncRequest.php b/app/Http/Requests/Admin/AgentRolePermissionSyncRequest.php new file mode 100644 index 0000000..e7b1824 --- /dev/null +++ b/app/Http/Requests/Admin/AgentRolePermissionSyncRequest.php @@ -0,0 +1,22 @@ + */ + public function rules(): array + { + return [ + 'permission_slugs' => ['required', 'array'], + 'permission_slugs.*' => ['string', 'max:128'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AgentRoleStoreRequest.php b/app/Http/Requests/Admin/AgentRoleStoreRequest.php new file mode 100644 index 0000000..799a1d0 --- /dev/null +++ b/app/Http/Requests/Admin/AgentRoleStoreRequest.php @@ -0,0 +1,39 @@ + */ + public function rules(): array + { + $agentNode = $this->route('agent_node'); + $ownerId = is_object($agentNode) ? (int) $agentNode->id : (int) $agentNode; + + return [ + 'slug' => [ + 'required', + 'string', + 'max:64', + 'regex:/^[a-z0-9_\\-]+$/', + Rule::unique('admin_roles', 'slug')->where( + static fn ($query) => $query->where('owner_agent_id', $ownerId), + ), + ], + 'name' => ['required', 'string', 'max:128'], + 'description' => ['nullable', 'string', 'max:65535'], + 'status' => ['sometimes', 'integer', 'in:0,1'], + 'permission_slugs' => ['sometimes', 'array'], + 'permission_slugs.*' => ['string', 'max:128'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AgentRoleUpdateRequest.php b/app/Http/Requests/Admin/AgentRoleUpdateRequest.php new file mode 100644 index 0000000..0769ad6 --- /dev/null +++ b/app/Http/Requests/Admin/AgentRoleUpdateRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'string', 'max:128'], + 'description' => ['nullable', 'string', 'max:65535'], + 'status' => ['sometimes', 'integer', 'in:0,1'], + ]; + } +} diff --git a/app/Http/Requests/Admin/TicketItemListRequest.php b/app/Http/Requests/Admin/TicketItemListRequest.php index 4cf9d34..4213fa0 100644 --- a/app/Http/Requests/Admin/TicketItemListRequest.php +++ b/app/Http/Requests/Admin/TicketItemListRequest.php @@ -28,6 +28,7 @@ final class TicketItemListRequest extends FormRequest 'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'player_account' => ['sometimes', 'nullable', 'string', 'max:128'], 'site_code' => ['sometimes', 'nullable', 'string', 'max:64'], + 'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'draw_no' => ['sometimes', 'nullable', 'string', 'max:32'], 'status' => ['sometimes'], 'status.*' => ['string', 'max:32'], diff --git a/app/Http/Requests/Admin/TransferOrderListRequest.php b/app/Http/Requests/Admin/TransferOrderListRequest.php index 67ad4e7..afd823d 100644 --- a/app/Http/Requests/Admin/TransferOrderListRequest.php +++ b/app/Http/Requests/Admin/TransferOrderListRequest.php @@ -28,6 +28,7 @@ final class TransferOrderListRequest extends FormRequest 'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'player_account' => ['sometimes', 'nullable', 'string', 'max:128'], 'site_code' => ['sometimes', 'nullable', 'string', 'max:64'], + 'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'transfer_no' => ['sometimes', 'nullable', 'string', 'max:96'], 'external_ref_no' => ['sometimes', 'nullable', 'string', 'max:96'], 'created_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'], diff --git a/app/Http/Requests/Admin/WalletTransactionListRequest.php b/app/Http/Requests/Admin/WalletTransactionListRequest.php index 2cf2754..e8d08e5 100644 --- a/app/Http/Requests/Admin/WalletTransactionListRequest.php +++ b/app/Http/Requests/Admin/WalletTransactionListRequest.php @@ -28,6 +28,7 @@ final class WalletTransactionListRequest extends FormRequest 'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'player_account' => ['sometimes', 'nullable', 'string', 'max:128'], 'site_code' => ['sometimes', 'nullable', 'string', 'max:64'], + 'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'txn_no' => ['sometimes', 'nullable', 'string', 'max:96'], 'external_ref_no' => ['sometimes', 'nullable', 'string', 'max:96'], 'created_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'], diff --git a/app/Models/AdminRole.php b/app/Models/AdminRole.php index d98a950..47b8e9f 100644 --- a/app/Models/AdminRole.php +++ b/app/Models/AdminRole.php @@ -11,6 +11,10 @@ final class AdminRole extends Model { public const ROLE_SUPER_ADMIN = 'super_admin'; + public const SCOPE_SYSTEM = 'system'; + + public const SCOPE_AGENT = 'agent'; + protected $table = 'admin_roles'; protected static function booted(): void @@ -30,8 +34,32 @@ final class AdminRole extends Model 'status', 'is_system', 'sort_order', + 'owner_agent_id', + 'delegated_from_role_id', + 'scope_type', ]; + protected function casts(): array + { + return [ + 'owner_agent_id' => 'integer', + 'delegated_from_role_id' => 'integer', + 'status' => 'integer', + 'is_system' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function isAgentScoped(): bool + { + return $this->scope_type === self::SCOPE_AGENT && $this->owner_agent_id !== null; + } + + public function isReadOnlyTemplate(): bool + { + return $this->delegated_from_role_id !== null; + } + /** * @return BelongsToMany */ @@ -103,6 +131,15 @@ final class AdminRole extends Model public function assignedUserCount(): int { + $agentCount = (int) DB::table('admin_user_agent_roles') + ->where('role_id', $this->id) + ->distinct() + ->count('admin_user_id'); + + if ($this->isAgentScoped()) { + return $agentCount; + } + return (int) DB::table('admin_user_site_roles') ->where('role_id', $this->id) ->distinct() diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 7bd12ef..aa13620 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -78,6 +78,78 @@ final class AdminUser extends Authenticatable * * @param list $slugs */ + /** + * @param list $roleIds + */ + public function syncAgentRoleIds(int $agentNodeId, array $roleIds): void + { + $roleIds = array_values(array_unique(array_map(static fn ($id): int => (int) $id, $roleIds))); + + DB::transaction(function () use ($agentNodeId, $roleIds): void { + DB::table('admin_user_agent_roles') + ->where('admin_user_id', $this->id) + ->where('agent_node_id', $agentNodeId) + ->delete(); + + $now = now(); + foreach ($roleIds as $roleId) { + DB::table('admin_user_agent_roles')->insert([ + 'admin_user_id' => $this->id, + 'agent_node_id' => $agentNodeId, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + } + + $siteId = (int) (AgentNode::query()->where('id', $agentNodeId)->value('admin_site_id') ?? 0); + if ($siteId > 0) { + DB::table('admin_user_site_roles') + ->where('admin_user_id', $this->id) + ->where('site_id', $siteId) + ->delete(); + + foreach ($roleIds as $roleId) { + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $this->id, + 'site_id' => $siteId, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + } + } + }); + } + + /** + * @return list + */ + private function roleMenuActionPermissionCodes(): array + { + $agentId = $this->primaryAgentNodeId(); + if ($agentId !== null) { + $fromAgent = DB::table('admin_user_agent_roles as uar') + ->join('admin_role_menu_actions as rma', 'rma.role_id', '=', 'uar.role_id') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id') + ->where('uar.admin_user_id', $this->id) + ->where('uar.agent_node_id', $agentId) + ->where('ma.status', 1) + ->pluck('ma.permission_code') + ->all(); + + if ($fromAgent !== []) { + return $fromAgent; + } + } + + return DB::table('admin_user_site_roles as usr') + ->join('admin_role_menu_actions as rma', 'rma.role_id', '=', 'usr.role_id') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id') + ->where('usr.admin_user_id', $this->id) + ->where('ma.status', 1) + ->pluck('ma.permission_code') + ->all(); + } + public function syncRoleSlugsForDefaultSite(array $slugs): void { $siteId = self::defaultAdminSiteId(); @@ -114,6 +186,25 @@ final class AdminUser extends Authenticatable return $this->roles()->where('admin_roles.slug', self::ROLE_SUPER_ADMIN)->exists(); } + public function primaryAgentNodeId(): ?int + { + $id = DB::table('admin_user_agents') + ->where('admin_user_id', $this->id) + ->value('agent_node_id'); + + return $id !== null ? (int) $id : null; + } + + public function primaryAgentNode(): ?AgentNode + { + $id = $this->primaryAgentNodeId(); + if ($id === null) { + return null; + } + + return AgentNode::query()->find($id); + } + /** * 可访问的 admin_sites.id 列表;`null` 表示不限制(超管)。 * @@ -125,6 +216,11 @@ final class AdminUser extends Authenticatable return null; } + $agent = $this->primaryAgentNode(); + if ($agent !== null) { + return [(int) $agent->admin_site_id]; + } + $ids = DB::table('admin_user_site_roles') ->where('admin_user_id', $this->id) ->distinct() @@ -193,13 +289,7 @@ final class AdminUser extends Authenticatable return array_keys($out); } - $fromRoles = DB::table('admin_user_site_roles as usr') - ->join('admin_role_menu_actions as rma', 'rma.role_id', '=', 'usr.role_id') - ->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id') - ->where('usr.admin_user_id', $this->id) - ->where('ma.status', 1) - ->pluck('ma.permission_code') - ->all(); + $fromRoles = $this->roleMenuActionPermissionCodes(); $merged = []; foreach (array_merge($fromRoles, $this->directMenuActionPermissionCodes()) as $c) { diff --git a/app/Models/AgentNode.php b/app/Models/AgentNode.php new file mode 100644 index 0000000..5020100 --- /dev/null +++ b/app/Models/AgentNode.php @@ -0,0 +1,82 @@ + 'integer', + 'parent_id' => 'integer', + 'depth' => 'integer', + 'status' => 'integer', + 'extra_json' => 'array', + ]; + } + + public function isEnabled(): bool + { + return (int) $this->status === 1; + } + + public function isRoot(): bool + { + return $this->parent_id === null || (int) $this->depth === 0; + } + + /** @return BelongsTo */ + public function adminSite(): BelongsTo + { + return $this->belongsTo(AdminSite::class, 'admin_site_id'); + } + + /** @return BelongsTo */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** @return HasMany */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('code'); + } + + public function isDescendantOf(self $ancestor): bool + { + if ((int) $this->admin_site_id !== (int) $ancestor->admin_site_id) { + return false; + } + + $ancestorPath = (string) $ancestor->path; + if ($ancestorPath === '') { + return false; + } + + return str_starts_with((string) $this->path, $ancestorPath); + } + + public function isSameOrDescendantOf(self $ancestor): bool + { + return (int) $this->id === (int) $ancestor->id || $this->isDescendantOf($ancestor); + } +} diff --git a/app/Models/Player.php b/app/Models/Player.php index f686930..038dd03 100644 --- a/app/Models/Player.php +++ b/app/Models/Player.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; /** @@ -12,6 +13,7 @@ final class Player extends Model { protected $fillable = [ 'site_code', + 'agent_node_id', 'site_player_id', 'username', 'nickname', @@ -23,6 +25,7 @@ final class Player extends Model protected function casts(): array { return [ + 'agent_node_id' => 'integer', 'last_login_at' => 'datetime', ]; } @@ -31,4 +34,9 @@ final class Player extends Model { return $this->hasMany(PlayerWallet::class); } + + public function agentNode(): BelongsTo + { + return $this->belongsTo(AgentNode::class, 'agent_node_id'); + } } diff --git a/app/Services/Admin/AdminDashboardAnalyticsBuilder.php b/app/Services/Admin/AdminDashboardAnalyticsBuilder.php index 1bffdba..7d357c1 100644 --- a/app/Services/Admin/AdminDashboardAnalyticsBuilder.php +++ b/app/Services/Admin/AdminDashboardAnalyticsBuilder.php @@ -45,7 +45,7 @@ final class AdminDashboardAnalyticsBuilder $dateFrom = $range['date_from']; $dateTo = $range['date_to']; - $trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo); + $trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo, scopedAdmin: $admin); return [ 'period' => $period, @@ -53,8 +53,8 @@ final class AdminDashboardAnalyticsBuilder 'play_code' => $playCode, 'date_from' => $dateFrom, 'date_to' => $dateTo, - 'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo), - 'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo), + 'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo, $admin), + 'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $admin), 'daily_series' => $trend['series'], 'chart_meta' => [ 'chart_date_from' => $trend['chart_date_from'], @@ -66,6 +66,14 @@ final class AdminDashboardAnalyticsBuilder $dateFrom, $dateTo, $playCode, + scopedAdmin: $admin, + ), + 'agent_breakdown' => $this->reportQuery->agentRankingRows( + $dateFrom, + $dateTo, + $playCode, + limit: 200, + scopedAdmin: $admin, ), ]; } diff --git a/app/Services/Admin/AdminDashboardSnapshotBuilder.php b/app/Services/Admin/AdminDashboardSnapshotBuilder.php index bb0aa3c..51114f9 100644 --- a/app/Services/Admin/AdminDashboardSnapshotBuilder.php +++ b/app/Services/Admin/AdminDashboardSnapshotBuilder.php @@ -13,6 +13,7 @@ use App\Models\SettlementBatch; use App\Models\DrawResultBatch; use App\Lottery\DrawResultBatchStatus; use App\Services\Draw\DrawHallSnapshotBuilder; +use App\Support\AdminDataScope; /** * 后台首页仪表盘:聚合大厅快照、当期财务、期号面板、风控摘要、异常转账计数。 @@ -52,11 +53,11 @@ final class AdminDashboardSnapshotBuilder ]; if ($canDraw) { - $this->fillPlatformOverview($out); + $this->fillPlatformOverview($out, $admin); } if ($canWallet) { - $out['abnormal_transfer_total'] = $this->abnormalTransferTotal(); + $out['abnormal_transfer_total'] = $this->abnormalTransferTotal($admin); } if ($hall === null) { @@ -81,7 +82,7 @@ final class AdminDashboardSnapshotBuilder ]; if ($canDraw) { - $out['finance'] = $this->financeSummary($draw); + $out['finance'] = $this->financeSummary($draw, $admin); $out['draw'] = $this->drawPanel($draw); $out['risk'] = $this->riskPanel($draw); } @@ -90,10 +91,10 @@ final class AdminDashboardSnapshotBuilder } /** @param array $out */ - private function fillPlatformOverview(array &$out): void + private function fillPlatformOverview(array &$out, AdminUser $admin): void { - $out['today_finance'] = $this->todayFinanceSummary(); - $out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals(); + $out['today_finance'] = $this->todayFinanceSummary($admin); + $out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals($admin); $out['platform_risk'] = $this->platformRiskSummary(); $out['result_batch_queue'] = $this->resultBatchQueue(); } @@ -114,11 +115,13 @@ final class AdminDashboardSnapshotBuilder || $admin->hasAdminPermission('prd.wallet_reconcile.view_cs'); } - private function abnormalTransferTotal(): int + private function abnormalTransferTotal(AdminUser $admin): int { - return (int) TransferOrder::query() - ->whereIn('status', ['processing', 'failed', 'pending_reconcile']) - ->count(); + $query = TransferOrder::query() + ->whereIn('status', ['processing', 'failed', 'pending_reconcile']); + AdminDataScope::applyEloquentViaPlayer($query, $admin); + + return (int) $query->count(); } /** @@ -126,10 +129,10 @@ final class AdminDashboardSnapshotBuilder * * @return array */ - private function todayFinanceSummary(): array + private function todayFinanceSummary(AdminUser $admin): array { $today = now()->toDateString(); - $rows = $this->reportQuery->dailyProfitRows($today, $today); + $rows = $this->reportQuery->dailyProfitRows($today, $today, $admin); $row = $rows[0] ?? [ 'business_date' => $today, 'total_bet_minor' => 0, @@ -152,20 +155,23 @@ final class AdminDashboardSnapshotBuilder } /** @return array */ - private function financeSummary(Draw $draw): array + private function financeSummary(Draw $draw, AdminUser $admin): array { $drawId = (int) $draw->id; - $totalBetMinor = (int) TicketOrder::query()->where('draw_id', $drawId)->sum('total_actual_deduct'); - $orderCount = (int) TicketOrder::query()->where('draw_id', $drawId)->count(); - $itemCount = (int) TicketItem::query()->where('draw_id', $drawId)->count(); + $orderQuery = TicketOrder::query()->where('draw_id', $drawId); + $itemQuery = TicketItem::query()->where('draw_id', $drawId); + AdminDataScope::applyEloquentViaPlayer($orderQuery, $admin); + AdminDataScope::applyEloquentViaPlayer($itemQuery, $admin); - $currencyCode = (string) (TicketOrder::query() - ->where('draw_id', $drawId) - ->value('currency_code') ?? ''); + $totalBetMinor = (int) $orderQuery->sum('total_actual_deduct'); + $orderCount = (int) $orderQuery->count(); + $itemCount = (int) $itemQuery->count(); - $totalWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('win_amount'); - $totalJackpotWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('jackpot_win_amount'); + $currencyCode = (string) ((clone $orderQuery)->value('currency_code') ?? ''); + + $totalWinMinor = (int) $itemQuery->sum('win_amount'); + $totalJackpotWinMinor = (int) (clone $itemQuery)->sum('jackpot_win_amount'); $totalPayoutMinor = $totalWinMinor + $totalJackpotWinMinor; $approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor; diff --git a/app/Services/Admin/AdminReportJobService.php b/app/Services/Admin/AdminReportJobService.php index d04359c..a084f4d 100644 --- a/app/Services/Admin/AdminReportJobService.php +++ b/app/Services/Admin/AdminReportJobService.php @@ -68,9 +68,9 @@ final class AdminReportJobService /** * @return list> */ - public function reportRows(string $reportType, ?array $filterJson): array + public function reportRows(string $reportType, ?array $filterJson, ?AdminUser $scopedAdmin = null): array { - return $this->queryService->reportRows($reportType, $filterJson); + return $this->queryService->reportRows($reportType, $filterJson, $scopedAdmin); } public function reportLabel(string $reportType): string diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php index 465645b..0335b02 100644 --- a/app/Services/Admin/AdminReportQueryService.php +++ b/app/Services/Admin/AdminReportQueryService.php @@ -2,7 +2,9 @@ namespace App\Services\Admin; +use App\Models\AdminUser; use App\Models\AuditLog; +use App\Support\AdminDataScope; use App\Models\Draw; use App\Models\RiskPool; use App\Models\RiskPoolLockLog; @@ -109,9 +111,9 @@ final class AdminReportQueryService * business_day_count: int * } */ - public function periodFinanceTotals(string $dateFrom, string $dateTo): array + public function periodFinanceTotals(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array { - $rows = $this->dailyProfitRows($dateFrom, $dateTo); + $rows = $this->dailyProfitRows($dateFrom, $dateTo, $scopedAdmin); $totalBet = 0; $totalPayout = 0; $totalGross = 0; @@ -121,9 +123,11 @@ final class AdminReportQueryService $totalGross += (int) $row['approx_house_gross_minor']; } - $activity = DB::table('draws as d') + $activityQuery = DB::table('draws as d') ->join('ticket_orders as o', 'o.draw_id', '=', 'd.id') - ->whereBetween('d.business_date', [$dateFrom, $dateTo]) + ->whereBetween('d.business_date', [$dateFrom, $dateTo]); + AdminDataScope::applyToTicketOrdersViaPlayer($activityQuery, $scopedAdmin, 'o'); + $activity = $activityQuery ->selectRaw('COUNT(DISTINCT d.id) as draw_count') ->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count') ->first(); @@ -142,7 +146,7 @@ final class AdminReportQueryService * * @return list> */ - public function dailyProfitSeriesFilled(string $dateFrom, string $dateTo, int $maxDays = 90): array + public function dailyProfitSeriesFilled(string $dateFrom, string $dateTo, int $maxDays = 90, ?AdminUser $scopedAdmin = null): array { $from = Carbon::parse($dateFrom)->startOfDay(); $to = Carbon::parse($dateTo)->startOfDay(); @@ -156,7 +160,7 @@ final class AdminReportQueryService $truncated = true; } - $indexed = collect($this->dailyProfitRows($chartFrom, $chartTo))->keyBy('business_date'); + $indexed = collect($this->dailyProfitRows($chartFrom, $chartTo, $scopedAdmin))->keyBy('business_date'); $cursor = Carbon::parse($chartFrom)->startOfDay(); $end = Carbon::parse($chartTo)->startOfDay(); $series = []; @@ -189,8 +193,9 @@ final class AdminReportQueryService string $dateTo, ?string $playCode = null, int $limit = 12, + ?AdminUser $scopedAdmin = null, ): array { - return $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo) + return $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo, $scopedAdmin) ->orderByDesc('total_bet_minor') ->limit($limit) ->get() @@ -207,20 +212,78 @@ final class AdminReportQueryService ->all(); } - public function resolvePeriodCurrencyCode(string $dateFrom, string $dateTo): ?string + /** + * 仪表盘「代理排行」:按代理子树聚合投注/派彩/盈亏(与 daily-profit 同口径)。 + * + * @return list + */ + public function agentRankingRows( + string $dateFrom, + string $dateTo, + ?string $playCode = null, + int $limit = 200, + ?AdminUser $scopedAdmin = null, + ): array { + $query = DB::table('ticket_items as ti') + ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') + ->leftJoin('players as p', 'p.id', '=', 'ti.player_id') + ->leftJoin('agent_nodes as an', 'an.id', '=', 'p.agent_node_id') + ->selectRaw('p.agent_node_id as agent_node_id') + ->selectRaw('an.code as agent_code') + ->selectRaw('an.name as agent_name') + ->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor') + ->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor') + ->selectRaw('SUM(ti.actual_deduct_amount) - SUM(ti.win_amount + ti.jackpot_win_amount) as approx_house_gross_minor') + ->whereDate('o.created_at', '>=', $dateFrom) + ->whereDate('o.created_at', '<=', $dateTo) + ->whereNotNull('p.agent_node_id') + ->groupBy('p.agent_node_id', 'an.code', 'an.name'); + + if ($playCode !== null && $playCode !== '') { + $query->where('ti.play_code', $playCode); + } + + AdminDataScope::applyToPlayersAlias($query, $scopedAdmin, 'p'); + + return $query + ->orderByDesc('total_bet_minor') + ->limit($limit) + ->get() + ->map(static function (object $row): array { + return [ + 'agent_node_id' => (int) $row->agent_node_id, + 'agent_code' => (string) ($row->agent_code ?? ''), + 'agent_name' => (string) ($row->agent_name ?? ''), + 'total_bet_minor' => (int) $row->total_bet_minor, + 'total_payout_minor' => (int) $row->total_payout_minor, + 'approx_house_gross_minor' => (int) $row->approx_house_gross_minor, + ]; + }) + ->values() + ->all(); + } + + public function resolvePeriodCurrencyCode(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): ?string { - $currencyCode = (string) (DB::table('ticket_orders as o') + $currencyQuery = DB::table('ticket_orders as o') ->join('draws as d', 'd.id', '=', 'o.draw_id') - ->whereBetween('d.business_date', [$dateFrom, $dateTo]) - ->orderByDesc('o.id') - ->value('o.currency_code') ?? ''); + ->whereBetween('d.business_date', [$dateFrom, $dateTo]); + AdminDataScope::applyToTicketOrdersViaPlayer($currencyQuery, $scopedAdmin, 'o'); + $currencyCode = (string) ($currencyQuery->orderByDesc('o.id')->value('o.currency_code') ?? ''); return $currencyCode !== '' ? $currencyCode : null; } - public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage): LengthAwarePaginator + public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage, ?AdminUser $scopedAdmin = null): LengthAwarePaginator { - $rows = $this->dailyProfitRows($dateFrom, $dateTo); + $rows = $this->dailyProfitRows($dateFrom, $dateTo, $scopedAdmin); $total = count($rows); $offset = max(0, ($page - 1) * $perPage); $items = array_slice($rows, $offset, $perPage); @@ -233,14 +296,18 @@ final class AdminReportQueryService /** * @return list> */ - public function dailyProfitRows(string $dateFrom, string $dateTo): array + public function dailyProfitRows(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array { - $betSub = DB::table('ticket_orders') - ->selectRaw('draw_id, SUM(total_actual_deduct) as total_bet_minor') - ->groupBy('draw_id'); - $payoutSub = DB::table('ticket_items') - ->selectRaw('draw_id, SUM(win_amount + jackpot_win_amount) as total_payout_minor') - ->groupBy('draw_id'); + $betSub = DB::table('ticket_orders as o') + ->selectRaw('o.draw_id, SUM(o.total_actual_deduct) as total_bet_minor') + ->groupBy('o.draw_id'); + AdminDataScope::applyToTicketOrdersViaPlayer($betSub, $scopedAdmin, 'o'); + + $payoutSub = DB::table('ticket_items as ti') + ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') + ->selectRaw('ti.draw_id, SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor') + ->groupBy('ti.draw_id'); + AdminDataScope::applyToTicketOrdersViaPlayer($payoutSub, $scopedAdmin, 'o'); return DB::table('draws as d') ->whereBetween('d.business_date', [$dateFrom, $dateTo]) @@ -284,19 +351,26 @@ final class AdminReportQueryService * date_to: ?string * } */ - public function platformLifetimeTotals(): array + public function platformLifetimeTotals(?AdminUser $scopedAdmin = null): array { - $totalBetMinor = (int) DB::table('ticket_orders')->sum('total_actual_deduct'); + $betQuery = DB::table('ticket_orders as o'); + AdminDataScope::applyToTicketOrdersViaPlayer($betQuery, $scopedAdmin, 'o'); + $totalBetMinor = (int) $betQuery->sum('o.total_actual_deduct'); - $payoutAgg = DB::table('ticket_items') - ->selectRaw('COALESCE(SUM(win_amount), 0) as win_minor, COALESCE(SUM(jackpot_win_amount), 0) as jackpot_minor') + $payoutQuery = DB::table('ticket_items as ti') + ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id'); + AdminDataScope::applyToTicketOrdersViaPlayer($payoutQuery, $scopedAdmin, 'o'); + $payoutAgg = $payoutQuery + ->selectRaw('COALESCE(SUM(ti.win_amount), 0) as win_minor, COALESCE(SUM(ti.jackpot_win_amount), 0) as jackpot_minor') ->first(); $totalWinMinor = (int) ($payoutAgg->win_minor ?? 0); $totalJackpotMinor = (int) ($payoutAgg->jackpot_minor ?? 0); $totalPayoutMinor = $totalWinMinor + $totalJackpotMinor; - $activity = DB::table('draws as d') - ->join('ticket_orders as o', 'o.draw_id', '=', 'd.id') + $activityQuery = DB::table('draws as d') + ->join('ticket_orders as o', 'o.draw_id', '=', 'd.id'); + AdminDataScope::applyToTicketOrdersViaPlayer($activityQuery, $scopedAdmin, 'o'); + $activity = $activityQuery ->selectRaw('COUNT(DISTINCT d.id) as draw_count') ->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count') ->selectRaw('MIN(d.business_date) as date_from') @@ -309,7 +383,15 @@ final class AdminReportQueryService $dateFrom = $this->formatBusinessDateValue($activity?->date_from); $dateTo = $this->formatBusinessDateValue($activity?->date_to); - $currencyCode = (string) (DB::table('ticket_orders')->orderByDesc('id')->value('currency_code') ?? ''); + $currencyQuery = DB::table('ticket_orders as o'); + AdminDataScope::applyToTicketOrdersViaPlayer($currencyQuery, $scopedAdmin, 'o'); + $currencyCode = (string) ($currencyQuery->orderByDesc('o.id')->value('o.currency_code') ?? ''); + + $orderCountQuery = DB::table('ticket_orders as o'); + AdminDataScope::applyToTicketOrdersViaPlayer($orderCountQuery, $scopedAdmin, 'o'); + $itemCountQuery = DB::table('ticket_items as ti') + ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id'); + AdminDataScope::applyToTicketOrdersViaPlayer($itemCountQuery, $scopedAdmin, 'o'); return [ 'currency_code' => $currencyCode !== '' ? $currencyCode : null, @@ -318,8 +400,8 @@ final class AdminReportQueryService 'total_jackpot_minor' => $totalJackpotMinor, 'total_payout_minor' => $totalPayoutMinor, 'approx_house_gross_minor' => $totalBetMinor - $totalPayoutMinor, - 'order_count' => (int) DB::table('ticket_orders')->count(), - 'ticket_item_count' => (int) DB::table('ticket_items')->count(), + 'order_count' => (int) $orderCountQuery->count(), + 'ticket_item_count' => (int) $itemCountQuery->count('ti.id'), 'draw_count' => $drawCount, 'business_day_count' => $businessDayCount, 'date_from' => $dateFrom, @@ -333,8 +415,10 @@ final class AdminReportQueryService string $dateTo, int $page, int $perPage, + ?AdminUser $scopedAdmin = null, + ?int $requestedAgentNodeId = null, ): LengthAwarePaginator { - $query = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo); + $query = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo, $scopedAdmin, $requestedAgentNodeId); return $query->paginate($perPage, ['*'], 'page', $page); } @@ -345,8 +429,9 @@ final class AdminReportQueryService string $dateTo, int $page, int $perPage, + ?AdminUser $scopedAdmin = null, ): LengthAwarePaginator { - $query = $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo); + $query = $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo, $scopedAdmin); return $query->paginate($perPage, ['*'], 'page', $page); } @@ -357,8 +442,9 @@ final class AdminReportQueryService string $dateTo, int $page, int $perPage, + ?AdminUser $scopedAdmin = null, ): LengthAwarePaginator { - $query = $this->rebateCommissionBaseQuery($playCode, $dateFrom, $dateTo); + $query = $this->rebateCommissionBaseQuery($playCode, $dateFrom, $dateTo, $scopedAdmin); return $query->paginate($perPage, ['*'], 'page', $page); } @@ -366,21 +452,21 @@ final class AdminReportQueryService /** * @return list> */ - public function reportRows(string $reportType, ?array $filterJson): array + public function reportRows(string $reportType, ?array $filterJson, ?AdminUser $scopedAdmin = null): array { $range = $this->resolveDateRange($filterJson); $dateFrom = $range['date_from']; $dateTo = $range['date_to']; return match ($reportType) { - 'draw_profit_summary' => $this->drawProfitExportRows($filterJson), - 'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo), - 'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo), - 'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo), - 'rebate_commission_report' => $this->rebateCommissionExportRows($filterJson, $dateFrom, $dateTo), + 'draw_profit_summary' => $this->drawProfitExportRows($filterJson, $scopedAdmin), + 'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo, $scopedAdmin), + 'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin), + 'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin), + 'rebate_commission_report' => $this->rebateCommissionExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin), 'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo), - 'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo), - 'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo), + 'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin), + 'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin), 'hot_number_risk_report' => $this->hotNumberRiskExportRows($filterJson), 'sold_out_number_report' => $this->soldOutNumberExportRows($filterJson), default => [ @@ -427,12 +513,12 @@ final class AdminReportQueryService /** * @return list> */ - private function dailyProfitExportRows(string $dateFrom, string $dateTo): array + private function dailyProfitExportRows(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array { $rows = [ ['日期', '下注', '派彩', '盈亏'], ]; - foreach ($this->dailyProfitRows($dateFrom, $dateTo) as $row) { + foreach ($this->dailyProfitRows($dateFrom, $dateTo, $scopedAdmin) as $row) { $rows[] = [ $row['business_date'], $row['total_bet_minor'], @@ -447,13 +533,13 @@ final class AdminReportQueryService /** * @return list> */ - private function playerWinLossExportRows(?array $filterJson, string $dateFrom, string $dateTo): array + private function playerWinLossExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array { $playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null; $rows = [ ['玩家ID', '用户名', '下注', '派彩', '净输赢'], ]; - $items = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo)->get(); + $items = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo, $scopedAdmin)->get(); foreach ($items as $row) { $rows[] = [ (int) $row->player_id, @@ -470,13 +556,13 @@ final class AdminReportQueryService /** * @return list> */ - private function playDimensionExportRows(?array $filterJson, string $dateFrom, string $dateTo): array + private function playDimensionExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array { $playCode = isset($filterJson['play_code']) ? trim((string) $filterJson['play_code']) : null; $rows = [ ['玩法', '维度', '下注', '派彩', '盈亏'], ]; - $items = $this->playDimensionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo)->get(); + $items = $this->playDimensionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo, $scopedAdmin)->get(); foreach ($items as $row) { $rows[] = [ (string) $row->play_code, @@ -493,13 +579,13 @@ final class AdminReportQueryService /** * @return list> */ - private function rebateCommissionExportRows(?array $filterJson, string $dateFrom, string $dateTo): array + private function rebateCommissionExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array { $playCode = isset($filterJson['play_code']) ? trim((string) $filterJson['play_code']) : null; $rows = [ ['玩法', '回水', '订单数', '注单数'], ]; - $items = $this->rebateCommissionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo)->get(); + $items = $this->rebateCommissionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo, $scopedAdmin)->get(); foreach ($items as $row) { $rows[] = [ (string) $row->play_code, @@ -545,30 +631,43 @@ final class AdminReportQueryService } /** @return \Illuminate\Database\Query\Builder */ - private function playerWinLossBaseQuery(?int $playerId, string $dateFrom, string $dateTo) - { + private function playerWinLossBaseQuery( + ?int $playerId, + string $dateFrom, + string $dateTo, + ?AdminUser $scopedAdmin = null, + ?int $requestedAgentNodeId = null, + ) { $query = DB::table('ticket_items as ti') ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') ->leftJoin('players as p', 'p.id', '=', 'ti.player_id') + ->leftJoin('agent_nodes as an', 'an.id', '=', 'p.agent_node_id') ->selectRaw('ti.player_id') ->selectRaw('p.username as username') + ->selectRaw('p.agent_node_id as agent_node_id') + ->selectRaw('an.code as agent_code') + ->selectRaw('an.name as agent_name') ->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor') ->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor') ->selectRaw('SUM(ti.actual_deduct_amount) - SUM(ti.win_amount + ti.jackpot_win_amount) as net_win_loss_minor') ->whereDate('o.created_at', '>=', $dateFrom) ->whereDate('o.created_at', '<=', $dateTo) - ->groupBy('ti.player_id', 'p.username') + ->groupBy('ti.player_id', 'p.username', 'p.agent_node_id', 'an.code', 'an.name') ->orderByDesc('net_win_loss_minor'); if ($playerId !== null && $playerId > 0) { $query->where('ti.player_id', $playerId); } + if ($scopedAdmin !== null) { + AdminDataScope::applyToPlayersAlias($query, $scopedAdmin, 'p', $requestedAgentNodeId); + } + return $query; } /** @return \Illuminate\Database\Query\Builder */ - private function playDimensionBaseQuery(?string $playCode, string $dateFrom, string $dateTo) + private function playDimensionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null) { $query = DB::table('ticket_items as ti') ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') @@ -587,11 +686,13 @@ final class AdminReportQueryService $query->where('ti.play_code', $playCode); } + AdminDataScope::applyToTicketOrdersViaPlayer($query, $scopedAdmin, 'o'); + return $query; } /** @return \Illuminate\Database\Query\Builder */ - private function rebateCommissionBaseQuery(?string $playCode, string $dateFrom, string $dateTo) + private function rebateCommissionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null) { $query = DB::table('ticket_items as ti') ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') @@ -608,13 +709,15 @@ final class AdminReportQueryService $query->where('ti.play_code', $playCode); } + AdminDataScope::applyToTicketOrdersViaPlayer($query, $scopedAdmin, 'o'); + return $query; } /** * @return list> */ - private function drawProfitExportRows(?array $filterJson): array + private function drawProfitExportRows(?array $filterJson, ?AdminUser $scopedAdmin = null): array { $draw = $this->resolveDrawForReport($filterJson); if ($draw === null) { @@ -622,12 +725,19 @@ final class AdminReportQueryService } $drawId = (int) $draw->id; - $totalBetMinor = (int) TicketOrder::query()->where('draw_id', $drawId)->sum('total_actual_deduct'); - $orderCount = (int) TicketOrder::query()->where('draw_id', $drawId)->count(); - $itemCount = (int) TicketItem::query()->where('draw_id', $drawId)->count(); - $currencyCode = (string) (TicketOrder::query()->where('draw_id', $drawId)->value('currency_code') ?? ''); - $totalWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('win_amount'); - $totalJackpotWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('jackpot_win_amount'); + $orderQuery = TicketOrder::query()->where('draw_id', $drawId); + $itemQuery = TicketItem::query()->where('draw_id', $drawId); + if ($scopedAdmin !== null) { + AdminDataScope::applyEloquentViaPlayer($orderQuery, $scopedAdmin); + AdminDataScope::applyEloquentViaPlayer($itemQuery, $scopedAdmin); + } + + $totalBetMinor = (int) $orderQuery->sum('total_actual_deduct'); + $orderCount = (int) $orderQuery->count(); + $itemCount = (int) $itemQuery->count(); + $currencyCode = (string) ((clone $orderQuery)->value('currency_code') ?? ''); + $totalWinMinor = (int) $itemQuery->sum('win_amount'); + $totalJackpotWinMinor = (int) (clone $itemQuery)->sum('jackpot_win_amount'); $totalPayoutMinor = $totalWinMinor + $totalJackpotWinMinor; $approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor; @@ -842,7 +952,7 @@ final class AdminReportQueryService /** * @return list> */ - private function transferOrdersExportRows(?array $filterJson, string $dateFrom, string $dateTo): array + private function transferOrdersExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array { $rows = [ ['转账单号', '玩家ID', '用户名', '昵称', '方向', '币种', '金额', '状态', '外部单号', '失败原因', '创建时间', '完成时间'], @@ -852,6 +962,10 @@ final class AdminReportQueryService ->with(['player:id,username,nickname']) ->orderByDesc('id'); + if ($scopedAdmin !== null) { + AdminDataScope::applyEloquentViaPlayer($query, $scopedAdmin); + } + $playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null; if ($playerId !== null && $playerId > 0) { $query->where('player_id', $playerId); @@ -884,7 +998,7 @@ final class AdminReportQueryService /** * @return list> */ - private function walletTxnsExportRows(?array $filterJson, string $dateFrom, string $dateTo): array + private function walletTxnsExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array { $rows = [ ['流水号', '玩家ID', '用户名', '业务类型', '业务单号', '方向', '金额', '变动前余额', '变动后余额', '状态', '外部单号', '备注', '创建时间'], @@ -894,6 +1008,10 @@ final class AdminReportQueryService ->with(['player:id,username']) ->orderByDesc('id'); + if ($scopedAdmin !== null) { + AdminDataScope::applyEloquentViaPlayer($query, $scopedAdmin); + } + $playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null; if ($playerId !== null && $playerId > 0) { $query->where('player_id', $playerId); diff --git a/app/Services/Agent/AgentAdminUserService.php b/app/Services/Agent/AgentAdminUserService.php new file mode 100644 index 0000000..c1a50da --- /dev/null +++ b/app/Services/Agent/AgentAdminUserService.php @@ -0,0 +1,85 @@ + + * } $payload + */ + public function createUnderAgent(AgentNode $agent, array $payload): AdminUser + { + $roleIds = array_values(array_unique(array_map('intval', $payload['role_ids'] ?? []))); + $this->assertRolesAssignable($agent, $roleIds); + + return DB::transaction(function () use ($agent, $payload, $roleIds): AdminUser { + $user = AdminUser::query()->create([ + 'username' => $payload['username'], + 'name' => $payload['nickname'], + 'email' => isset($payload['email']) ? trim((string) $payload['email']) : null, + 'password' => $payload['password'], + 'status' => (int) ($payload['status'] ?? 0), + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $user->id, + 'agent_node_id' => $agent->id, + 'is_primary' => true, + 'granted_at' => now(), + ]); + + $user->syncAgentRoleIds($agent->id, $roleIds); + + return $user->fresh(); + }); + } + + /** + * @param list $roleIds + */ + public function syncRoles(AgentNode $agent, AdminUser $user, array $roleIds): AdminUser + { + if ((int) $user->primaryAgentNodeId() !== (int) $agent->id) { + throw ValidationException::withMessages(['user' => ['agent_mismatch']]); + } + + $roleIds = array_values(array_unique(array_map('intval', $roleIds))); + $this->assertRolesAssignable($agent, $roleIds); + $user->syncAgentRoleIds($agent->id, $roleIds); + + return $user->fresh(); + } + + /** + * @param list $roleIds + */ + private function assertRolesAssignable(AgentNode $agent, array $roleIds): void + { + if ($roleIds === []) { + return; + } + + $validCount = AdminRole::query() + ->where('scope_type', AdminRole::SCOPE_AGENT) + ->where('owner_agent_id', $agent->id) + ->whereIn('id', $roleIds) + ->count(); + + if ($validCount !== count($roleIds)) { + throw ValidationException::withMessages(['role_ids' => ['invalid_for_agent']]); + } + } +} diff --git a/app/Services/Agent/AgentDelegationService.php b/app/Services/Agent/AgentDelegationService.php new file mode 100644 index 0000000..d5aeb79 --- /dev/null +++ b/app/Services/Agent/AgentDelegationService.php @@ -0,0 +1,98 @@ + + */ + public function listForChild(AgentNode $child, ?AdminUser $actor = null): array + { + $rows = DB::table('agent_delegation_grants as g') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'g.menu_action_id') + ->where('g.child_agent_id', $child->id) + ->where('ma.status', 1) + ->orderBy('ma.permission_code') + ->get(['g.menu_action_id', 'g.can_delegate', 'ma.permission_code', 'ma.name']); + + if ($rows->isNotEmpty()) { + return $rows->map(static fn ($row): array => [ + 'menu_action_id' => (int) $row->menu_action_id, + 'permission_code' => (string) $row->permission_code, + 'name' => (string) $row->name, + 'can_delegate' => (bool) $row->can_delegate, + ])->all(); + } + + if ($actor === null) { + return []; + } + + $codes = $actor->effectiveMenuActionPermissionCodes(); + if ($codes === []) { + return []; + } + + return DB::table('admin_menu_actions') + ->where('status', 1) + ->whereIn('permission_code', $codes) + ->orderBy('permission_code') + ->get(['id', 'permission_code', 'name']) + ->map(static fn ($row): array => [ + 'menu_action_id' => (int) $row->id, + 'permission_code' => (string) $row->permission_code, + 'name' => (string) $row->name, + 'can_delegate' => false, + ]) + ->all(); + } + + /** + * @param list $grants + * @return list + */ + public function syncGrants(AdminUser $actor, AgentNode $child, array $grants): array + { + AgentDelegationAuthorization::assertGrantsAllowed($actor, $child, $grants); + + $parentId = (int) ($child->parent_id ?? 0); + if ($parentId <= 0) { + throw new \InvalidArgumentException('Child agent must have parent.'); + } + + $now = Carbon::now(); + + DB::transaction(function () use ($actor, $child, $grants, $parentId, $now): void { + DB::table('agent_delegation_grants') + ->where('child_agent_id', $child->id) + ->delete(); + + foreach ($grants as $grant) { + DB::table('agent_delegation_grants')->insert([ + 'parent_agent_id' => $parentId, + 'child_agent_id' => $child->id, + 'menu_action_id' => (int) $grant['menu_action_id'], + 'can_delegate' => ! empty($grant['can_delegate']), + 'granted_by' => $actor->id, + 'granted_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + }); + + return $this->listForChild($child->fresh()); + } +} diff --git a/app/Services/Agent/AgentNodeService.php b/app/Services/Agent/AgentNodeService.php new file mode 100644 index 0000000..b2b66e2 --- /dev/null +++ b/app/Services/Agent/AgentNodeService.php @@ -0,0 +1,82 @@ +findOrFail((int) $payload['parent_id']); + $code = trim((string) $payload['code']); + $name = trim((string) $payload['name']); + $status = (int) ($payload['status'] ?? 1); + + if ($code === '' || $name === '') { + throw ValidationException::withMessages([ + 'code' => ['required'], + 'name' => ['required'], + ]); + } + + if (AgentNode::query()->where('admin_site_id', $parent->admin_site_id)->where('code', $code)->exists()) { + throw ValidationException::withMessages([ + 'code' => ['unique'], + ]); + } + + return DB::transaction(function () use ($actor, $parent, $code, $name, $status): AgentNode { + $node = AgentNode::query()->create([ + 'admin_site_id' => $parent->admin_site_id, + 'parent_id' => $parent->id, + 'path' => '/', + 'depth' => (int) $parent->depth + 1, + 'code' => $code, + 'name' => $name, + 'status' => $status === 0 ? 0 : 1, + 'created_by' => $actor->id, + 'extra_json' => null, + ]); + + $node->path = (string) $parent->path.$node->id.'/'; + $node->save(); + + return $node->fresh(['adminSite']); + }); + } + + /** + * @param array{name?: string, status?: int} $payload + */ + public function update(AgentNode $node, array $payload): AgentNode + { + if (array_key_exists('name', $payload)) { + $name = trim((string) $payload['name']); + if ($name !== '') { + $node->name = $name; + } + } + + if (array_key_exists('status', $payload)) { + $node->status = (int) $payload['status'] === 0 ? 0 : 1; + } + + $node->save(); + + return $node->fresh(['adminSite']); + } + + public function destroy(AgentNode $node): void + { + DB::transaction(static function () use ($node): void { + $node->delete(); + }); + } +} diff --git a/app/Services/Agent/AgentRoleService.php b/app/Services/Agent/AgentRoleService.php new file mode 100644 index 0000000..c9e687f --- /dev/null +++ b/app/Services/Agent/AgentRoleService.php @@ -0,0 +1,102 @@ +} $payload + */ + public function createForAgent(AdminUser $actor, AgentNode $owner, array $payload): AdminRole + { + AgentRoleAuthorization::assertSlugsForAgentRole( + $actor, + $owner, + array_values(array_unique($payload['permission_slugs'] ?? [])), + ); + + $slug = trim((string) $payload['slug']); + if (AdminRole::query() + ->where('owner_agent_id', $owner->id) + ->where('slug', $slug) + ->exists()) { + throw ValidationException::withMessages(['slug' => ['unique']]); + } + + return DB::transaction(function () use ($payload, $owner, $slug): AdminRole { + $role = AdminRole::query()->create([ + 'slug' => $slug, + 'code' => $slug, + 'name' => trim((string) $payload['name']), + 'description' => $payload['description'] ?? null, + 'status' => (int) ($payload['status'] ?? 1) === 0 ? 0 : 1, + 'is_system' => false, + 'sort_order' => 0, + 'scope_type' => AdminRole::SCOPE_AGENT, + 'owner_agent_id' => $owner->id, + 'delegated_from_role_id' => null, + ]); + + $role->syncLegacyPermissionSlugs($payload['permission_slugs'] ?? []); + + return $role->fresh(); + }); + } + + /** + * @param array{name?: string, description?: ?string, status?: int} $payload + */ + public function update(AdminRole $role, array $payload): AdminRole + { + if (array_key_exists('name', $payload)) { + $name = trim((string) $payload['name']); + if ($name !== '') { + $role->name = $name; + } + } + + if (array_key_exists('description', $payload)) { + $role->description = $payload['description']; + } + + if (array_key_exists('status', $payload)) { + $role->status = (int) $payload['status'] === 0 ? 0 : 1; + } + + $role->save(); + + return $role->fresh(); + } + + /** + * @param list $permissionSlugs + */ + public function syncPermissions(AdminUser $actor, AdminRole $role, array $permissionSlugs): AdminRole + { + $owner = AgentNode::query()->findOrFail((int) $role->owner_agent_id); + AgentRoleAuthorization::assertSlugsForAgentRole($actor, $owner, $permissionSlugs); + $role->syncLegacyPermissionSlugs($permissionSlugs); + + return $role->fresh(); + } + + public function destroy(AdminRole $role): void + { + if ($role->is_system) { + throw ValidationException::withMessages(['role' => ['system_role']]); + } + + if ($role->assignedUserCount() > 0) { + throw ValidationException::withMessages(['role' => ['in_use']]); + } + + $role->delete(); + } +} diff --git a/app/Services/LotterySettings.php b/app/Services/LotterySettings.php index 1807c08..c58a400 100644 --- a/app/Services/LotterySettings.php +++ b/app/Services/LotterySettings.php @@ -160,6 +160,43 @@ final class LotterySettings Cache::put(self::cacheKey($key), self::normalizeValue($value), self::cacheTtlSeconds()); } + /** + * 批量写入(管理端一次保存多块配置,避免 N 次 HTTP + 审计)。 + * + * @param list $items + */ + public static function putMany(array $items): void + { + if ($items === []) { + return; + } + + $keys = array_values(array_unique(array_map( + static fn (array $item): string => (string) $item['key'], + $items, + ))); + + /** @var array $existing */ + $existing = LotterySetting::query() + ->whereIn('setting_key', $keys) + ->get() + ->keyBy('setting_key') + ->all(); + + foreach ($items as $item) { + $key = (string) $item['key']; + $value = $item['value']; + $row = $existing[$key] ?? null; + + self::put( + $key, + $value, + $row ? (string) $row->group_name : (explode('.', $key)[0] ?? 'general'), + $row ? $row->description_zh : null, + ); + } + } + public static function cacheKey(string $key): string { return 'lottery_settings:'.$key; diff --git a/app/Support/AdminAgentNodeAccess.php b/app/Support/AdminAgentNodeAccess.php new file mode 100644 index 0000000..b1951b0 --- /dev/null +++ b/app/Support/AdminAgentNodeAccess.php @@ -0,0 +1,92 @@ +isSuperAdmin()) { + if ($requestedSiteId !== null && $requestedSiteId > 0) { + return $requestedSiteId; + } + + return (int) (AdminSite::query()->where('is_default', true)->value('id') + ?? AdminSite::query()->orderBy('id')->value('id')); + } + + $actor = AdminAgentScope::primaryAgentNode($admin); + if ($actor === null) { + return null; + } + + if ($requestedSiteId !== null && $requestedSiteId > 0 && $requestedSiteId !== (int) $actor->admin_site_id) { + return null; + } + + return (int) $actor->admin_site_id; + } + + public static function denyUnlessSiteResolved(AdminUser $admin, ?int $siteId): ?JsonResponse + { + if ($siteId !== null && $siteId > 0) { + return null; + } + + return ApiMessage::errorResponse( + request(), + 'admin.agent_site_access_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + + public static function denyUnlessNodeVisible(AdminUser $admin, AgentNode $node): ?JsonResponse + { + if (AdminAgentScope::nodeVisibleTo($admin, $node)) { + return null; + } + + return ApiMessage::errorResponse( + request(), + 'admin.agent_node_access_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + + public static function denyUnlessCanManageParent(AdminUser $admin, AgentNode $parent): ?JsonResponse + { + if (! AdminAgentScope::nodeManageableBy($admin, $parent)) { + return ApiMessage::errorResponse( + request(), + 'admin.agent_node_manage_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + + if ($parent->isRoot() && ! $admin->isSuperAdmin()) { + return ApiMessage::errorResponse( + request(), + 'admin.agent_root_create_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + + return null; + } +} diff --git a/app/Support/AdminAgentScope.php b/app/Support/AdminAgentScope.php new file mode 100644 index 0000000..78d7b9d --- /dev/null +++ b/app/Support/AdminAgentScope.php @@ -0,0 +1,167 @@ +isSuperAdmin()) { + return null; + } + + $agentId = $admin->primaryAgentNodeId(); + if ($agentId === null) { + return null; + } + + return AgentNode::query()->find($agentId); + } + + public static function nodeVisibleTo(AdminUser $admin, AgentNode $node): bool + { + if ($admin->isSuperAdmin()) { + return true; + } + + $actor = self::primaryAgentNode($admin); + if ($actor === null) { + return false; + } + + return $node->isSameOrDescendantOf($actor); + } + + public static function playerAccessible(AdminUser $admin, Player $player): bool + { + if ($admin->isSuperAdmin()) { + return true; + } + + $actor = self::primaryAgentNode($admin); + if ($actor === null) { + return false; + } + + if ($player->agent_node_id === null) { + return false; + } + + $playerAgent = AgentNode::query()->find((int) $player->agent_node_id); + if ($playerAgent === null) { + return false; + } + + return $playerAgent->isSameOrDescendantOf($actor); + } + + public static function nodeManageableBy(AdminUser $admin, AgentNode $node): bool + { + if ($admin->isSuperAdmin()) { + return true; + } + + if (! $admin->hasAdminPermission('prd.agent.manage')) { + return false; + } + + return self::nodeVisibleTo($admin, $node); + } + + /** + * @return Builder + */ + public static function visibleNodesQuery(AdminUser $admin, int $adminSiteId): Builder + { + $query = AgentNode::query() + ->where('admin_site_id', $adminSiteId) + ->orderBy('path'); + + if ($admin->isSuperAdmin()) { + return $query; + } + + $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.'%'); + } + + /** + * 玩家必须落在当前代理子树(agent_node_id 必填,由迁移回填根代理)。 + * + * @param Builder $query + */ + public static function applyToPlayerQuery(Builder $query, AdminUser $admin): void + { + if ($admin->isSuperAdmin()) { + return; + } + + $actor = self::primaryAgentNode($admin); + if ($actor === null) { + $query->whereRaw('0 = 1'); + + return; + } + + if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) { + return; + } + + $subtreeIds = AgentNode::query() + ->where('path', 'like', $actor->path.'%') + ->pluck('id') + ->all(); + + if ($subtreeIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->whereIn('agent_node_id', $subtreeIds); + } + + /** + * 在已有站点/代理范围上,再按指定节点子树收窄(超管筛选用)。 + * + * @param Builder $query + */ + public static function applyRequestedAgentNodeFilter(Builder $query, AdminUser $admin, int $agentNodeId): void + { + $node = AgentNode::query()->find($agentNodeId); + if ($node === null || ! self::nodeVisibleTo($admin, $node)) { + $query->whereRaw('0 = 1'); + + return; + } + + if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) { + return; + } + + $subtreeIds = AgentNode::query() + ->where('path', 'like', $node->path.'%') + ->pluck('id') + ->all(); + + if ($subtreeIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->whereIn('agent_node_id', $subtreeIds); + } +} diff --git a/app/Support/AdminAuthProfile.php b/app/Support/AdminAuthProfile.php index e789196..74816d9 100644 --- a/app/Support/AdminAuthProfile.php +++ b/app/Support/AdminAuthProfile.php @@ -3,6 +3,7 @@ namespace App\Support; use App\Models\AdminUser; +use App\Models\AgentNode; final class AdminAuthProfile { @@ -17,9 +18,21 @@ final class AdminAuthProfile * segment: string, * label: string, * href: string, + * nav_group: string, + * platform_only?: bool, * activeMatchPrefix?: string, * requiredAny?: list - * }> + * }>, + * agent: ?array{ + * id: int, + * admin_site_id: int, + * path: string, + * code: string, + * name: string, + * depth: int + * }, + * is_super_admin: bool, + * delegation_ceiling: list * } */ public static function fromAdmin(AdminUser $admin): array @@ -33,7 +46,34 @@ final class AdminAuthProfile 'nickname' => $fresh->name, 'email' => $fresh->email, 'permissions' => $permissionSlugs, - 'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs), + 'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs, $fresh), + 'agent' => self::agentContext($fresh), + 'is_super_admin' => $fresh->isSuperAdmin(), + 'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh), + ]; + } + + /** + * @return array{id: int, admin_site_id: int, path: string, code: string, name: string, depth: int}|null + */ + private static function agentContext(AdminUser $admin): ?array + { + if ($admin->isSuperAdmin()) { + return null; + } + + $node = $admin->primaryAgentNode(); + if (! $node instanceof AgentNode) { + return null; + } + + return [ + 'id' => (int) $node->id, + 'admin_site_id' => (int) $node->admin_site_id, + 'path' => (string) $node->path, + 'code' => (string) $node->code, + 'name' => (string) $node->name, + 'depth' => (int) $node->depth, ]; } } diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index cff3336..da6809b 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -2,6 +2,8 @@ namespace App\Support; +use App\Models\AdminUser; + final class AdminAuthorizationRegistry { /** @@ -25,6 +27,13 @@ final class AdminAuthorizationRegistry ['slug' => 'prd.admin_user.manage', 'name' => '管理员列表·可管理', 'nav_segment' => 'admin_users', 'permission_codes' => ['system.admin_user.manage']], ['slug' => 'prd.admin_role.manage', 'name' => '角色管理·可管理', 'nav_segment' => 'admin_roles', 'permission_codes' => ['system.admin_role.manage']], + ['slug' => 'prd.agent.view', 'name' => '代理管理·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.view']], + ['slug' => 'prd.agent.manage', 'name' => '代理管理·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.manage']], + ['slug' => 'prd.agent.role.view', 'name' => '代理角色·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.view']], + ['slug' => 'prd.agent.role.manage', 'name' => '代理角色·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.manage']], + ['slug' => 'prd.agent.user.view', 'name' => '代理账号·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.view']], + ['slug' => 'prd.agent.user.manage', 'name' => '代理账号·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.manage']], + ['slug' => 'prd.users.manage', 'name' => '用户管理·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.manage']], ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']], ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.tickets.view']], @@ -97,6 +106,7 @@ final class AdminAuthorizationRegistry 'audit' => '审计日志', 'settings' => '系统设置', 'integration' => '接入站点', + 'agents' => '代理管理', ]; return array_map( @@ -108,13 +118,20 @@ final class AdminAuthorizationRegistry ); } + /** 侧栏分组顺序(与 {@see navigationDefinitions} 中 nav_group 一致) */ + public const NAV_GROUP_ORDER = ['overview', 'agent', 'operations', 'finance', 'rules', 'platform']; + /** * 后台菜单注册表。前端侧栏与面包屑都消费这里派生的结果。 * + * platform_only:仅超管可见(全局 RBAC、接入站点、赔率规则等);代理账号走代理控制台与子级授权。 + * * @return list * }> @@ -122,30 +139,26 @@ final class AdminAuthorizationRegistry public static function navigationDefinitions(): array { return [ - // 总览 - ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'requiredAny' => ['prd.dashboard.view']], - // 日常运营:开奖 → 注单 → 玩家 - ['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']], - ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'requiredAny' => ['prd.tickets.view']], - ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']], - // 规则与参数 - ['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], - ['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], - ['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']], - ['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], - // 资金 - ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']], - ['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], - ['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], - ['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'requiredAny' => ['prd.report.view']], - ['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'requiredAny' => ['prd.currency.manage']], - ['segment' => 'integration', 'label' => 'Integration sites', 'href' => '/admin/config/integration-sites', 'activeMatchPrefix' => '/admin/config/integration-sites', 'requiredAny' => AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites')], - // 权限与系统 - ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']], - ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']], - ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.risk.view', 'prd.risk.manage']], - ['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'requiredAny' => ['prd.audit.view']], - ['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']], + ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'nav_group' => 'overview', 'requiredAny' => ['prd.dashboard.view']], + ['segment' => 'agents', 'label' => 'Agents', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => ['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage']], + ['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'nav_group' => 'operations', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']], + ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'nav_group' => 'operations', 'requiredAny' => ['prd.tickets.view']], + ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'nav_group' => 'operations', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']], + ['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'nav_group' => 'operations', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], + ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'nav_group' => 'finance', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']], + ['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'nav_group' => 'finance', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], + ['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'nav_group' => 'finance', 'requiredAny' => ['prd.report.view']], + ['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], + ['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], + ['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']], + ['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], + ['segment' => 'integration', 'label' => 'Integration sites', 'href' => '/admin/config/integration-sites', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/config/integration-sites', 'requiredAny' => AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites')], + ['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.currency.manage']], + ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_user.manage']], + ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_role.manage']], + ['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.audit.view']], + ['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']], + ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.risk.view', 'prd.risk.manage']], ]; } @@ -202,6 +215,7 @@ final class AdminAuthorizationRegistry 'dashboard' => ['prd.dashboard.view'], 'admin_users' => ['prd.admin_user.manage'], 'admin_roles' => ['prd.admin_role.manage'], + 'agents' => ['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage'], 'players' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage'], 'currencies' => ['prd.currency.manage'], 'wallet' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.view_finance'], @@ -254,17 +268,24 @@ final class AdminAuthorizationRegistry * segment: string, * label: string, * href: string, + * nav_group: string, + * platform_only?: bool, * activeMatchPrefix?: string, * requiredAny?: list * }> */ - public static function visibleNavigationItems(array $permissionSlugs): array + public static function visibleNavigationItems(array $permissionSlugs, ?AdminUser $admin = null): array { $granted = array_fill_keys($permissionSlugs, true); + $isSuperAdmin = $admin === null || $admin->isSuperAdmin(); return array_values(array_filter( self::navigationItems(), - static function (array $item) use ($granted): bool { + static function (array $item) use ($granted, $isSuperAdmin): bool { + if (($item['platform_only'] ?? false) && ! $isSuperAdmin) { + return false; + } + $required = $item['requiredAny'] ?? []; if ($required === []) { return true; @@ -366,6 +387,26 @@ final class AdminAuthorizationRegistry ['code' => 'admin.admin-roles.destroy', 'module_code' => 'system', 'name' => '删除角色', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}', 'route_name' => 'api.v1.admin.admin-roles.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], ['code' => 'admin.admin-roles.permissions.sync', 'module_code' => 'system', 'name' => '角色权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}/permissions', 'route_name' => 'api.v1.admin.admin-roles.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], + ['code' => 'admin.agent-nodes.tree', 'module_code' => 'agent', 'name' => '代理树', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/tree', 'route_name' => 'api.v1.admin.agent-nodes.tree', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']], + ['code' => 'admin.agent-nodes.store', 'module_code' => 'agent', 'name' => '创建下级代理', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/agent-nodes', 'route_name' => 'api.v1.admin.agent-nodes.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + ['code' => 'admin.agent-nodes.show', 'module_code' => 'agent', 'name' => '代理节点详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}', 'route_name' => 'api.v1.admin.agent-nodes.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']], + ['code' => 'admin.agent-nodes.update', 'module_code' => 'agent', 'name' => '更新代理节点', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}', 'route_name' => 'api.v1.admin.agent-nodes.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + ['code' => 'admin.agent-nodes.destroy', 'module_code' => 'agent', 'name' => '删除代理节点', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}', 'route_name' => 'api.v1.admin.agent-nodes.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + ['code' => 'admin.agent-nodes.children', 'module_code' => 'agent', 'name' => '代理直属下级', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/children', 'route_name' => 'api.v1.admin.agent-nodes.children', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']], + + ['code' => 'admin.agent-roles.index', 'module_code' => 'agent', 'name' => '代理角色列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/roles', 'route_name' => 'api.v1.admin.agent-roles.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']], + ['code' => 'admin.agent-roles.store', 'module_code' => 'agent', 'name' => '创建代理角色', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/roles', 'route_name' => 'api.v1.admin.agent-roles.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + ['code' => 'admin.agent-roles.update', 'module_code' => 'agent', 'name' => '更新代理角色', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-roles/{admin_role}', 'route_name' => 'api.v1.admin.agent-roles.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + ['code' => 'admin.agent-roles.permissions.sync', 'module_code' => 'agent', 'name' => '代理角色权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-roles/{admin_role}/permissions', 'route_name' => 'api.v1.admin.agent-roles.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + ['code' => 'admin.agent-roles.destroy', 'module_code' => 'agent', 'name' => '删除代理角色', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/agent-roles/{admin_role}', 'route_name' => 'api.v1.admin.agent-roles.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + + ['code' => 'admin.agent-admin-users.index', 'module_code' => 'agent', 'name' => '代理账号列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/admin-users', 'route_name' => 'api.v1.admin.agent-admin-users.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']], + ['code' => 'admin.agent-admin-users.store', 'module_code' => 'agent', 'name' => '创建代理账号', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/admin-users', 'route_name' => 'api.v1.admin.agent-admin-users.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + ['code' => 'admin.agent-admin-users.roles.sync', 'module_code' => 'agent', 'name' => '代理账号角色同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-admin-users/{admin_user}/roles', 'route_name' => 'api.v1.admin.agent-admin-users.roles.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + + ['code' => 'admin.agent-delegation-grants.index', 'module_code' => 'agent', 'name' => '代理下放上限查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']], + ['code' => 'admin.agent-delegation-grants.sync', 'module_code' => 'agent', 'name' => '代理下放上限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + ['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view', 'prd.rebate.manage', 'prd.rebate.view']], ['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']], ['code' => 'admin.config.play-versions.index', 'module_code' => 'config', 'name' => '玩法版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], @@ -387,6 +428,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.config.risk-cap-versions.publish', 'module_code' => 'config', 'name' => '发布封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.risk-cap-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']], ['code' => 'admin.config.risk-cap-versions.destroy', 'module_code' => 'config', 'name' => '删除封顶版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']], ['code' => 'admin.settings.index', 'module_code' => 'settings', 'name' => '系统设置列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings', 'route_name' => 'api.v1.admin.settings.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.payout.manage']], + ['code' => 'admin.settings.batch-update', 'module_code' => 'settings', 'name' => '系统设置批量更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/batch', 'route_name' => 'api.v1.admin.settings.batch-update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.payout.manage']], ['code' => 'admin.settings.update', 'module_code' => 'settings', 'name' => '系统设置更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/{key}', 'route_name' => 'api.v1.admin.settings.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.payout.manage']], ['code' => 'admin.currencies.index', 'module_code' => 'settings', 'name' => '币种列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.currency.manage']], ['code' => 'admin.currencies.store', 'module_code' => 'settings', 'name' => '创建币种', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']], diff --git a/app/Support/AdminDataScope.php b/app/Support/AdminDataScope.php new file mode 100644 index 0000000..888cdfe --- /dev/null +++ b/app/Support/AdminDataScope.php @@ -0,0 +1,137 @@ + $query 已 join `players as {alias}` + */ + public static function applyToPlayersAlias( + Builder $query, + AdminUser $admin, + string $alias = 'p', + ?int $requestedAgentNodeId = null, + ): void { + if ($admin->isSuperAdmin()) { + if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) { + self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId); + } + + return; + } + + $codes = AdminSiteScope::accessibleSiteCodes($admin); + if ($codes !== null) { + if ($codes === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->whereIn($alias.'.site_code', $codes); + } + + $actor = AdminAgentScope::primaryAgentNode($admin); + if ($actor === null) { + $query->whereRaw('0 = 1'); + + return; + } + + if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) { + return; + } + + $subtreeIds = AgentNode::query() + ->where('path', 'like', $actor->path.'%') + ->pluck('id') + ->all(); + + if ($subtreeIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->whereIn($alias.'.agent_node_id', $subtreeIds); + + if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) { + self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId); + } + } + + /** + * @param Builder $query + */ + private static function applyAgentNodeIdOnAlias( + Builder $query, + AdminUser $admin, + string $alias, + int $agentNodeId, + ): void { + $node = AgentNode::query()->find($agentNodeId); + if ($node === null || ! AdminAgentScope::nodeVisibleTo($admin, $node)) { + $query->whereRaw('0 = 1'); + + return; + } + + $subtreeIds = AgentNode::query() + ->where('path', 'like', $node->path.'%') + ->pluck('id') + ->all(); + + if ($subtreeIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->whereIn($alias.'.agent_node_id', $subtreeIds); + } + + /** + * Eloquent 模型经 player 关联做站点 + 代理子树过滤。 + * + * @param \Illuminate\Database\Eloquent\Builder $query + */ + public static function applyEloquentViaPlayer( + \Illuminate\Database\Eloquent\Builder $query, + AdminUser $admin, + string $relation = 'player', + ): void { + if ($admin->isSuperAdmin()) { + return; + } + + $query->whereHas($relation, static function (Builder $playerQuery) use ($admin): void { + AdminSiteScope::applyToPlayerQuery($playerQuery, $admin); + }); + } + + /** + * 约束 ticket_orders(别名 o)仅统计可见玩家。 + * + * @param Builder $query + */ + public static function applyToTicketOrdersViaPlayer(Builder $query, ?AdminUser $admin, string $orderAlias = 'o'): void + { + if ($admin === null || $admin->isSuperAdmin()) { + return; + } + + $query->whereExists(function (Builder $sub) use ($admin, $orderAlias): void { + $sub->from('players as scope_p') + ->whereColumn('scope_p.id', $orderAlias.'.player_id'); + self::applyToPlayersAlias($sub, $admin, 'scope_p'); + }); + } +} diff --git a/app/Support/AdminRoleApiPresenter.php b/app/Support/AdminRoleApiPresenter.php index 6fb0743..5387edb 100644 --- a/app/Support/AdminRoleApiPresenter.php +++ b/app/Support/AdminRoleApiPresenter.php @@ -17,6 +17,10 @@ final class AdminRoleApiPresenter 'status' => (int) $role->status, 'is_system' => (bool) $role->is_system, 'sort_order' => (int) $role->sort_order, + 'scope_type' => (string) ($role->scope_type ?? AdminRole::SCOPE_SYSTEM), + 'owner_agent_id' => $role->owner_agent_id !== null ? (int) $role->owner_agent_id : null, + 'delegated_from_role_id' => $role->delegated_from_role_id !== null ? (int) $role->delegated_from_role_id : null, + 'is_read_only_template' => $role->isReadOnlyTemplate(), 'permission_slugs' => $role->legacyPermissionSlugs(), 'user_count' => $role->assignedUserCount(), ]; diff --git a/app/Support/AdminSiteScope.php b/app/Support/AdminSiteScope.php index 84df4a7..42acac3 100644 --- a/app/Support/AdminSiteScope.php +++ b/app/Support/AdminSiteScope.php @@ -53,7 +53,11 @@ final class AdminSiteScope public static function playerAccessible(AdminUser $admin, Player $player): bool { - return self::siteCodeAllowed($admin, (string) $player->site_code); + if (! self::siteCodeAllowed($admin, (string) $player->site_code)) { + return false; + } + + return AdminAgentScope::playerAccessible($admin, $player); } /** @@ -63,6 +67,8 @@ final class AdminSiteScope { $codes = self::accessibleSiteCodes($admin); if ($codes === null) { + AdminAgentScope::applyToPlayerQuery($query, $admin); + return; } @@ -73,6 +79,7 @@ final class AdminSiteScope } $query->whereIn('site_code', $codes); + AdminAgentScope::applyToPlayerQuery($query, $admin); } /** @@ -80,22 +87,28 @@ final class AdminSiteScope * * @param Builder $query */ - public static function applyPlayerFilters(Builder $query, AdminUser $admin, ?string $requestedSiteCode): void - { + public static function applyPlayerFilters( + Builder $query, + AdminUser $admin, + ?string $requestedSiteCode, + ?int $requestedAgentNodeId = null, + ): void { self::applyToPlayerQuery($query, $admin); $siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : ''; - if ($siteCode === '') { - return; + if ($siteCode !== '') { + if (! self::siteCodeAllowed($admin, $siteCode)) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->where('site_code', $siteCode); } - if (! self::siteCodeAllowed($admin, $siteCode)) { - $query->whereRaw('0 = 1'); - - return; + if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) { + AdminAgentScope::applyRequestedAgentNodeFilter($query, $admin, $requestedAgentNodeId); } - - $query->where('site_code', $siteCode); } /** @@ -103,19 +116,12 @@ final class AdminSiteScope */ public static function applyViaPlayerRelation(Builder $query, AdminUser $admin, string $relation = 'player'): void { - $codes = self::accessibleSiteCodes($admin); - if ($codes === null) { + if ($admin->isSuperAdmin()) { return; } - if ($codes === []) { - $query->whereRaw('0 = 1'); - - return; - } - - $query->whereHas($relation, static function (Builder $playerQuery) use ($codes): void { - $playerQuery->whereIn('site_code', $codes); + $query->whereHas($relation, static function (Builder $playerQuery) use ($admin): void { + self::applyToPlayerQuery($playerQuery, $admin); }); } @@ -127,22 +133,27 @@ final class AdminSiteScope AdminUser $admin, ?string $requestedSiteCode, string $relation = 'player', + ?int $requestedAgentNodeId = null, ): void { - self::applyViaPlayerRelation($query, $admin, $relation); + if ($admin->isSuperAdmin()) { + $siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : ''; + $agentNodeId = $requestedAgentNodeId !== null && $requestedAgentNodeId > 0 + ? $requestedAgentNodeId + : null; - $siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : ''; - if ($siteCode === '') { - return; - } + if ($siteCode === '' && $agentNodeId === null) { + return; + } - if (! self::siteCodeAllowed($admin, $siteCode)) { - $query->whereRaw('0 = 1'); + $query->whereHas($relation, static function (Builder $playerQuery) use ($admin, $siteCode, $agentNodeId): void { + self::applyPlayerFilters($playerQuery, $admin, $siteCode !== '' ? $siteCode : null, $agentNodeId); + }); return; } - $query->whereHas($relation, static function (Builder $playerQuery) use ($siteCode): void { - $playerQuery->where('site_code', $siteCode); + $query->whereHas($relation, static function (Builder $playerQuery) use ($admin, $requestedSiteCode, $requestedAgentNodeId): void { + self::applyPlayerFilters($playerQuery, $admin, $requestedSiteCode, $requestedAgentNodeId); }); } diff --git a/app/Support/AgentDelegationAuthorization.php b/app/Support/AgentDelegationAuthorization.php new file mode 100644 index 0000000..ef6aafb --- /dev/null +++ b/app/Support/AgentDelegationAuthorization.php @@ -0,0 +1,151 @@ + menu_action.permission_code + */ + public static function delegationMenuActionCodesForAgent(AgentNode $agent): array + { + if ($agent->isRoot()) { + return DB::table('admin_menu_actions')->where('status', 1)->pluck('permission_code')->all(); + } + + return DB::table('agent_delegation_grants as g') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'g.menu_action_id') + ->where('g.child_agent_id', $agent->id) + ->where('ma.status', 1) + ->pluck('ma.permission_code') + ->all(); + } + + /** + * @return list prd.* + */ + public static function delegationLegacySlugsForAgent(AgentNode $agent): array + { + $codes = self::delegationMenuActionCodesForAgent($agent); + + return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($codes); + } + + /** + * @return list prd.* + */ + public static function delegationLegacySlugsForAdminUser(AdminUser $admin): array + { + if ($admin->isSuperAdmin()) { + return AdminPermissionBridge::allLegacySlugs(); + } + + $node = $admin->primaryAgentNode(); + if ($node === null) { + return []; + } + + return self::delegationLegacySlugsForAgent($node); + } + + public static function childIsManageableBy(AdminUser $admin, AgentNode $child): bool + { + if ($admin->isSuperAdmin()) { + return true; + } + + if (! AdminAgentScope::nodeVisibleTo($admin, $child)) { + return false; + } + + $parentId = $child->parent_id; + if ($parentId === null) { + return false; + } + + $actor = AdminAgentScope::primaryAgentNode($admin); + if ($actor === null) { + return false; + } + + return (int) $parentId === (int) $actor->id + || AdminAgentScope::nodeManageableBy($admin, AgentNode::query()->find($parentId) ?? $child); + } + + /** + * @param list $grants + */ + public static function assertGrantsAllowed(AdminUser $actor, AgentNode $child, array $grants): void + { + if ($actor->isSuperAdmin()) { + return; + } + + if (! self::childIsManageableBy($actor, $child)) { + throw ValidationException::withMessages(['child_agent_id' => ['not_manageable']]); + } + + $actorCodes = $actor->effectiveMenuActionPermissionCodes(); + $actorCodeSet = array_fill_keys($actorCodes, true); + + $parent = $child->parent; + if ($parent === null && $child->parent_id !== null) { + $parent = AgentNode::query()->find($child->parent_id); + } + + $parentCeiling = $parent !== null + ? self::delegationMenuActionCodesForAgent($parent) + : $actorCodes; + + if ($parent !== null && ! $parent->isRoot() && $parentCeiling === []) { + $parentCeiling = $actorCodes; + } + + $parentCeilingSet = array_fill_keys($parentCeiling, true); + + foreach ($grants as $grant) { + $actionId = (int) ($grant['menu_action_id'] ?? 0); + $code = DB::table('admin_menu_actions')->where('id', $actionId)->value('permission_code'); + if (! is_string($code) || $code === '') { + throw ValidationException::withMessages(['grants' => ['invalid_menu_action']]); + } + + if (! isset($actorCodeSet[$code])) { + throw ValidationException::withMessages(['grants' => ['exceeds_actor: '.$code]]); + } + + if ($parent !== null && ! $parent->isRoot() && ! isset($parentCeilingSet[$code])) { + throw ValidationException::withMessages(['grants' => ['exceeds_parent_ceiling: '.$code]]); + } + } + } + + /** + * @param list $permissionSlugs + */ + public static function assertRoleSlugsWithinAgentCeiling( + AgentNode $ownerAgent, + array $permissionSlugs, + AdminUser $actor, + ): void { + $ceiling = self::delegationLegacySlugsForAgent($ownerAgent); + if ($ceiling === []) { + // 尚未配置下放上限:与 P2 一致,仅校验操作者自身权限 + AgentRoleAuthorization::assertSlugsWithinActor($actor, $permissionSlugs); + + return; + } + + $invalid = array_values(array_diff($permissionSlugs, $ceiling)); + if ($invalid !== []) { + throw ValidationException::withMessages([ + 'permission_slugs' => ['exceeds_delegation_ceiling: '.implode(', ', $invalid)], + ]); + } + } +} diff --git a/app/Support/AgentNodeApiPresenter.php b/app/Support/AgentNodeApiPresenter.php new file mode 100644 index 0000000..11fb0b4 --- /dev/null +++ b/app/Support/AgentNodeApiPresenter.php @@ -0,0 +1,33 @@ + null, + 'agent_code' => null, + 'agent_name' => null, + ]; + } + + return [ + 'agent_node_id' => (int) $node->id, + 'agent_code' => (string) $node->code, + 'agent_name' => (string) $node->name, + ]; + } +} diff --git a/app/Support/AgentNodePresenter.php b/app/Support/AgentNodePresenter.php new file mode 100644 index 0000000..5e5edc0 --- /dev/null +++ b/app/Support/AgentNodePresenter.php @@ -0,0 +1,79 @@ + (int) $node->id, + 'admin_site_id' => (int) $node->admin_site_id, + 'parent_id' => $node->parent_id !== null ? (int) $node->parent_id : null, + 'path' => (string) $node->path, + 'depth' => (int) $node->depth, + 'code' => (string) $node->code, + 'name' => (string) $node->name, + 'status' => (int) $node->status, + 'is_root' => $node->isRoot(), + ]; + } + + /** + * @param iterable $nodes + * @return list> + */ + public static function tree(iterable $nodes): array + { + $items = []; + $byParent = []; + + foreach ($nodes as $node) { + $row = self::item($node); + $row['children'] = []; + $items[(int) $node->id] = $row; + $parentKey = $node->parent_id !== null ? (int) $node->parent_id : 0; + $byParent[$parentKey][] = (int) $node->id; + } + + $attach = static function (int $id) use (&$attach, &$items, $byParent): array { + $node = $items[$id]; + foreach ($byParent[$id] ?? [] as $childId) { + $node['children'][] = $attach($childId); + } + + return $node; + }; + + $ids = array_keys($items); + $rootIds = []; + foreach ($items as $id => $row) { + $parentId = $row['parent_id']; + if ($parentId === null || ! in_array((int) $parentId, $ids, true)) { + $rootIds[] = (int) $id; + } + } + + $roots = []; + foreach ($rootIds as $rootId) { + $roots[] = $attach($rootId); + } + + return $roots; + } +} diff --git a/app/Support/AgentRoleAuthorization.php b/app/Support/AgentRoleAuthorization.php new file mode 100644 index 0000000..56a0994 --- /dev/null +++ b/app/Support/AgentRoleAuthorization.php @@ -0,0 +1,91 @@ +isSuperAdmin()) { + return true; + } + + if ($role->scope_type !== AdminRole::SCOPE_AGENT || $role->owner_agent_id === null) { + return false; + } + + $owner = AgentNode::query()->find((int) $role->owner_agent_id); + if ($owner === null) { + return false; + } + + return AdminAgentScope::nodeVisibleTo($admin, $owner); + } + + public static function roleManageableBy(AdminUser $admin, AdminRole $role): bool + { + if ($role->delegated_from_role_id !== null) { + return false; + } + + if (! self::roleVisibleTo($admin, $role)) { + return false; + } + + return $admin->isSuperAdmin() || $admin->hasAdminPermission('prd.agent.role.manage'); + } + + /** + * @param list $permissionSlugs + */ + public static function assertSlugsWithinActor(AdminUser $actor, array $permissionSlugs): void + { + if ($actor->isSuperAdmin()) { + return; + } + + $allowed = $actor->adminPermissionSlugs(); + $invalid = array_values(array_diff($permissionSlugs, $allowed)); + if ($invalid !== []) { + throw ValidationException::withMessages([ + 'permission_slugs' => ['permission_exceeds_actor: '.implode(', ', $invalid)], + ]); + } + } + + /** + * @param list $permissionSlugs + */ + public static function assertSlugsForAgentRole( + AdminUser $actor, + AgentNode $ownerAgent, + array $permissionSlugs, + ): void { + if ($actor->isSuperAdmin()) { + return; + } + + self::assertSlugsWithinActor($actor, $permissionSlugs); + AgentDelegationAuthorization::assertRoleSlugsWithinAgentCeiling($ownerAgent, $permissionSlugs, $actor); + } + + public static function denyUnlessRoleManageable(AdminUser $admin, AdminRole $role): ?\Illuminate\Http\JsonResponse + { + if (self::roleManageableBy($admin, $role)) { + return null; + } + + return ApiMessage::errorResponse( + request(), + 'admin.agent_role_manage_denied', + \App\Lottery\ErrorCode::AdminForbidden->value, + null, + 403, + ); + } +} diff --git a/app/Support/PlayerApiPresenter.php b/app/Support/PlayerApiPresenter.php index d2cdd0c..5278b4d 100644 --- a/app/Support/PlayerApiPresenter.php +++ b/app/Support/PlayerApiPresenter.php @@ -24,8 +24,13 @@ final class PlayerApiPresenter 'status' => (int) $w->status, ])->values()->all(); + $agent = $player->relationLoaded('agentNode') + ? $player->agentNode + : ($player->agent_node_id ? $player->agentNode()->first() : null); + return [ 'id' => (int) $player->id, + ...AgentNodeApiPresenter::embed($agent), 'site_code' => $player->site_code, 'site_player_id' => $player->site_player_id, 'username' => $player->username, diff --git a/bootstrap/app.php b/bootstrap/app.php index 701516b..acccdaf 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -43,6 +43,10 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->api(prepend: [ NegotiateLotteryLocale::class, ]); + $middleware->convertEmptyStringsToNull([ + static fn (Request $request): bool => $request->is('api/v1/admin/settings') + || $request->is('api/v1/admin/settings/*'), + ]); $middleware->alias([ // 玩家端需登录路由使用;解析 Bearer → Player 'lottery.player' => EnsurePlayerApi::class, diff --git a/database/migrations/2026_06_01_100000_add_admin_settings_batch_update_api_resource.php b/database/migrations/2026_06_01_100000_add_admin_settings_batch_update_api_resource.php new file mode 100644 index 0000000..a0737ae --- /dev/null +++ b/database/migrations/2026_06_01_100000_add_admin_settings_batch_update_api_resource.php @@ -0,0 +1,106 @@ +first(fn (array $item): bool => $item['code'] === self::RESOURCE_CODE); + + if ($resource === null) { + return; + } + + $resourceId = DB::table('admin_api_resources') + ->where('code', $resource['code']) + ->value('id'); + + $payload = [ + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'updated_at' => $now, + ]; + + if ($resourceId === null) { + $resourceId = DB::table('admin_api_resources')->insertGetId($payload + [ + 'code' => $resource['code'], + 'created_at' => $now, + ]); + } else { + DB::table('admin_api_resources') + ->where('id', (int) $resourceId) + ->update($payload); + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + $sourceResourceId = DB::table('admin_api_resources') + ->where('code', self::CLONE_BINDINGS_FROM) + ->value('id'); + + if ($sourceResourceId !== null) { + $bindings = DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $sourceResourceId) + ->get(['menu_action_id']); + + foreach ($bindings as $binding) { + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => (int) $binding->menu_action_id, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + if (Schema::hasTable('admin_role_api_resources')) { + $roleResourceRows = DB::table('admin_role_menu_actions as rma') + ->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id') + ->where('arb.api_resource_id', (int) $resourceId) + ->select('rma.role_id') + ->distinct() + ->get(); + + foreach ($roleResourceRows as $row) { + DB::table('admin_role_api_resources')->updateOrInsert([ + 'role_id' => (int) $row->role_id, + 'api_resource_id' => (int) $resourceId, + ], []); + } + } + } + + public function down(): void + { + $resourceId = DB::table('admin_api_resources')->where('code', self::RESOURCE_CODE)->value('id'); + if ($resourceId === null) { + return; + } + + if (Schema::hasTable('admin_role_api_resources')) { + DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete(); + } + DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete(); + DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete(); + } +}; diff --git a/database/migrations/2026_06_02_100000_create_agent_hierarchy_tables.php b/database/migrations/2026_06_02_100000_create_agent_hierarchy_tables.php new file mode 100644 index 0000000..d416614 --- /dev/null +++ b/database/migrations/2026_06_02_100000_create_agent_hierarchy_tables.php @@ -0,0 +1,132 @@ +id(); + $table->foreignId('admin_site_id')->constrained('admin_sites')->cascadeOnDelete(); + $table->foreignId('parent_id')->nullable()->constrained('agent_nodes')->nullOnDelete(); + $table->string('path', 512); + $table->unsignedSmallInteger('depth')->default(0); + $table->string('code', 64); + $table->string('name', 128); + $table->unsignedTinyInteger('status')->default(1)->comment('1=enabled,0=disabled'); + $table->foreignId('created_by')->nullable()->constrained('admin_users')->nullOnDelete(); + $table->json('extra_json')->nullable(); + $table->timestamps(); + + $table->unique(['admin_site_id', 'code'], 'uk_agent_nodes_site_code'); + $table->index(['admin_site_id', 'parent_id'], 'idx_agent_nodes_site_parent'); + $table->index('path', 'idx_agent_nodes_path'); + }); + + Schema::create('admin_user_agents', function (Blueprint $table): void { + $table->foreignId('admin_user_id')->primary()->constrained('admin_users')->cascadeOnDelete(); + $table->foreignId('agent_node_id')->constrained('agent_nodes')->cascadeOnDelete(); + $table->boolean('is_primary')->default(true); + $table->timestamp('granted_at')->nullable(); + }); + + $this->seedRootAgentNodes(); + $this->backfillAdminUserAgents(); + } + + public function down(): void + { + Schema::dropIfExists('admin_user_agents'); + Schema::dropIfExists('agent_nodes'); + } + + private function seedRootAgentNodes(): void + { + $now = Carbon::now(); + $sites = DB::table('admin_sites')->orderBy('id')->get(['id', 'code', 'name']); + + foreach ($sites as $site) { + if (DB::table('agent_nodes')->where('admin_site_id', (int) $site->id)->where('depth', 0)->exists()) { + continue; + } + + $code = 'root-'.(string) $site->code; + $nodeId = DB::table('agent_nodes')->insertGetId([ + 'admin_site_id' => (int) $site->id, + 'parent_id' => null, + 'path' => '/', + 'depth' => 0, + 'code' => $code, + 'name' => (string) $site->name, + 'status' => 1, + 'created_by' => null, + 'extra_json' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('agent_nodes')->where('id', $nodeId)->update([ + 'path' => '/'.$nodeId.'/', + ]); + } + } + + private function backfillAdminUserAgents(): void + { + $superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id'); + $now = Carbon::now(); + + $userIds = DB::table('admin_users')->pluck('id'); + foreach ($userIds as $userId) { + $userId = (int) $userId; + if (DB::table('admin_user_agents')->where('admin_user_id', $userId)->exists()) { + continue; + } + + if ($superRoleId !== null) { + $isSuper = DB::table('admin_user_site_roles') + ->where('admin_user_id', $userId) + ->where('role_id', (int) $superRoleId) + ->exists(); + if ($isSuper) { + continue; + } + } + + $siteId = DB::table('admin_user_site_roles') + ->where('admin_user_id', $userId) + ->orderBy('site_id') + ->value('site_id'); + + if ($siteId === null) { + $siteId = DB::table('admin_sites')->where('is_default', true)->value('id') + ?? DB::table('admin_sites')->orderBy('id')->value('id'); + } + + if ($siteId === null) { + continue; + } + + $rootId = DB::table('agent_nodes') + ->where('admin_site_id', (int) $siteId) + ->where('depth', 0) + ->value('id'); + + if ($rootId === null) { + continue; + } + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $userId, + 'agent_node_id' => (int) $rootId, + 'is_primary' => true, + 'granted_at' => $now, + ]); + } + } +}; diff --git a/database/migrations/2026_06_02_100001_seed_agent_node_permissions.php b/database/migrations/2026_06_02_100001_seed_agent_node_permissions.php new file mode 100644 index 0000000..99a2e61 --- /dev/null +++ b/database/migrations/2026_06_02_100001_seed_agent_node_permissions.php @@ -0,0 +1,195 @@ +where('code', 'view')->value('id'); + $manageActionId = DB::table('admin_action_catalog')->where('code', 'manage')->value('id'); + if ($viewActionId === null || $manageActionId === null) { + return; + } + + $systemMenuId = DB::table('admin_menus')->where('code', 'system')->value('id'); + if ($systemMenuId === null) { + $systemMenuId = DB::table('admin_menus')->insertGetId([ + 'parent_id' => null, + 'menu_type' => 'directory', + 'code' => 'system', + 'name' => '系统', + 'path' => null, + 'route_name' => null, + 'component' => null, + 'icon' => null, + 'active_menu_code' => null, + 'sort_order' => 90, + 'is_visible' => true, + 'is_cache' => false, + 'is_external' => false, + 'status' => 1, + 'meta_json' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $agentMenuId = DB::table('admin_menus')->where('code', 'system.agents')->value('id'); + if ($agentMenuId === null) { + $agentMenuId = DB::table('admin_menus')->insertGetId([ + 'parent_id' => (int) $systemMenuId, + 'menu_type' => 'page', + 'code' => 'system.agents', + 'name' => '代理管理', + 'path' => '/admin/agents', + 'route_name' => 'admin.agents', + 'component' => 'agents/index', + 'icon' => null, + 'active_menu_code' => null, + 'sort_order' => 25, + 'is_visible' => true, + 'is_cache' => false, + 'is_external' => false, + 'status' => 1, + 'meta_json' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + foreach ([ + ['permission_code' => 'agent.node.view', 'action_id' => (int) $viewActionId, 'name' => '代理节点查看'], + ['permission_code' => 'agent.node.manage', 'action_id' => (int) $manageActionId, 'name' => '代理节点管理'], + ] as $row) { + if (DB::table('admin_menu_actions')->where('permission_code', $row['permission_code'])->exists()) { + continue; + } + + DB::table('admin_menu_actions')->insert([ + 'menu_id' => (int) $agentMenuId, + 'action_id' => $row['action_id'], + 'permission_code' => $row['permission_code'], + 'name' => $row['name'], + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code'); + $resources = array_values(array_filter( + AdminAuthorizationRegistry::resources(), + static fn (array $resource): bool => str_starts_with((string) $resource['code'], 'admin.agent-nodes.'), + )); + + foreach ($resources as $resource) { + $resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id'); + $payload = [ + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'updated_at' => $now, + ]; + + if ($resourceId === null) { + $resourceId = DB::table('admin_api_resources')->insertGetId($payload + [ + 'code' => $resource['code'], + 'created_at' => $now, + ]); + } else { + DB::table('admin_api_resources')->where('id', (int) $resourceId)->update($payload); + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + $permissionCodes = $resource['permission_codes'] ?? []; + foreach ($permissionCodes as $permissionCode) { + $menuActionId = $menuActionIds[$permissionCode] ?? null; + if ($menuActionId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => (int) $menuActionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + if (! Schema::hasTable('admin_role_api_resources')) { + return; + } + + $superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id'); + if ($superRoleId === null) { + return; + } + + foreach ($resources as $resource) { + $resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id'); + if ($resourceId === null) { + continue; + } + + DB::table('admin_role_api_resources')->updateOrInsert([ + 'role_id' => (int) $superRoleId, + 'api_resource_id' => (int) $resourceId, + ], []); + } + + $menuActionIdList = DB::table('admin_menu_actions') + ->whereIn('permission_code', ['agent.node.view', 'agent.node.manage']) + ->pluck('id'); + + foreach ($menuActionIdList as $menuActionId) { + DB::table('admin_role_menu_actions')->updateOrInsert([ + 'role_id' => (int) $superRoleId, + 'menu_action_id' => (int) $menuActionId, + ], []); + } + } + + public function down(): void + { + $codes = [ + 'admin.agent-nodes.tree', + 'admin.agent-nodes.store', + 'admin.agent-nodes.show', + 'admin.agent-nodes.update', + 'admin.agent-nodes.children', + ]; + + foreach ($codes as $code) { + $resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id'); + if ($resourceId === null) { + continue; + } + + if (Schema::hasTable('admin_role_api_resources')) { + DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete(); + } + DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete(); + DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete(); + } + + DB::table('admin_menu_actions') + ->whereIn('permission_code', ['agent.node.view', 'agent.node.manage']) + ->delete(); + } +}; diff --git a/database/migrations/2026_06_02_110000_agent_scoped_roles_and_player_agent.php b/database/migrations/2026_06_02_110000_agent_scoped_roles_and_player_agent.php new file mode 100644 index 0000000..9599933 --- /dev/null +++ b/database/migrations/2026_06_02_110000_agent_scoped_roles_and_player_agent.php @@ -0,0 +1,78 @@ +foreignId('owner_agent_id')->nullable()->after('sort_order')->constrained('agent_nodes')->nullOnDelete(); + $table->foreignId('delegated_from_role_id')->nullable()->after('owner_agent_id')->constrained('admin_roles')->nullOnDelete(); + $table->string('scope_type', 16)->default('system')->after('delegated_from_role_id'); + }); + + Schema::create('admin_user_agent_roles', function (Blueprint $table): void { + $table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete(); + $table->foreignId('agent_node_id')->constrained('agent_nodes')->cascadeOnDelete(); + $table->foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete(); + $table->timestamp('granted_at')->nullable(); + $table->primary(['admin_user_id', 'agent_node_id', 'role_id'], 'pk_admin_user_agent_roles'); + }); + + Schema::table('players', function (Blueprint $table): void { + $table->foreignId('agent_node_id')->nullable()->after('site_code')->constrained('agent_nodes')->nullOnDelete(); + $table->index(['site_code', 'agent_node_id'], 'idx_players_site_agent'); + }); + + DB::table('admin_roles')->update(['scope_type' => 'system']); + + $this->backfillAdminUserAgentRoles(); + } + + public function down(): void + { + Schema::table('players', function (Blueprint $table): void { + $table->dropIndex('idx_players_site_agent'); + $table->dropConstrainedForeignId('agent_node_id'); + }); + + Schema::dropIfExists('admin_user_agent_roles'); + + Schema::table('admin_roles', function (Blueprint $table): void { + $table->dropConstrainedForeignId('delegated_from_role_id'); + $table->dropConstrainedForeignId('owner_agent_id'); + $table->dropColumn('scope_type'); + }); + } + + private function backfillAdminUserAgentRoles(): void + { + $now = Carbon::now(); + $rows = DB::table('admin_user_site_roles as usr') + ->join('admin_user_agents as uaa', 'uaa.admin_user_id', '=', 'usr.admin_user_id') + ->join('agent_nodes as an', static function ($join): void { + $join->on('an.id', '=', 'uaa.agent_node_id') + ->on('an.admin_site_id', '=', 'usr.site_id'); + }) + ->select(['usr.admin_user_id', 'uaa.agent_node_id', 'usr.role_id', 'usr.granted_at']) + ->get(); + + foreach ($rows as $row) { + DB::table('admin_user_agent_roles')->updateOrInsert( + [ + 'admin_user_id' => (int) $row->admin_user_id, + 'agent_node_id' => (int) $row->agent_node_id, + 'role_id' => (int) $row->role_id, + ], + [ + 'granted_at' => $row->granted_at ?? $now, + ], + ); + } + } +}; diff --git a/database/migrations/2026_06_02_110001_seed_agent_role_permissions.php b/database/migrations/2026_06_02_110001_seed_agent_role_permissions.php new file mode 100644 index 0000000..d7a2e8d --- /dev/null +++ b/database/migrations/2026_06_02_110001_seed_agent_role_permissions.php @@ -0,0 +1,116 @@ +pluck('id', 'permission_code'); + + $resources = array_values(array_filter( + AdminAuthorizationRegistry::resources(), + static fn (array $resource): bool => str_starts_with((string) $resource['code'], 'admin.agent-roles.') + || str_starts_with((string) $resource['code'], 'admin.agent-admin-users.'), + )); + + foreach ($resources as $resource) { + $resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id'); + $payload = [ + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'updated_at' => $now, + ]; + + if ($resourceId === null) { + $resourceId = DB::table('admin_api_resources')->insertGetId($payload + [ + 'code' => $resource['code'], + 'created_at' => $now, + ]); + } else { + DB::table('admin_api_resources')->where('id', (int) $resourceId)->update($payload); + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + foreach ($resource['permission_codes'] ?? [] as $permissionCode) { + $menuActionId = $menuActionIds[$permissionCode] ?? null; + if ($menuActionId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => (int) $menuActionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + if (! Schema::hasTable('admin_role_api_resources')) { + return; + } + + $superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id'); + if ($superRoleId === null) { + return; + } + + foreach ($resources as $resource) { + $resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id'); + if ($resourceId === null) { + continue; + } + + DB::table('admin_role_api_resources')->updateOrInsert([ + 'role_id' => (int) $superRoleId, + 'api_resource_id' => (int) $resourceId, + ], []); + } + } + + public function down(): void + { + $codes = [ + 'admin.agent-roles.update', + 'admin.agent-roles.destroy', + 'admin.agent-roles.permissions.sync', + 'admin.agent-roles.index', + 'admin.agent-roles.store', + 'admin.agent-admin-users.index', + 'admin.agent-admin-users.store', + 'admin.agent-admin-users.roles.sync', + ]; + + foreach ($codes as $code) { + $resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id'); + if ($resourceId === null) { + continue; + } + + if (Schema::hasTable('admin_role_api_resources')) { + DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete(); + } + DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete(); + DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete(); + } + } +}; diff --git a/database/migrations/2026_06_02_120000_create_agent_delegation_grants.php b/database/migrations/2026_06_02_120000_create_agent_delegation_grants.php new file mode 100644 index 0000000..f35f49a --- /dev/null +++ b/database/migrations/2026_06_02_120000_create_agent_delegation_grants.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('parent_agent_id')->constrained('agent_nodes')->cascadeOnDelete(); + $table->foreignId('child_agent_id')->constrained('agent_nodes')->cascadeOnDelete(); + $table->foreignId('menu_action_id')->constrained('admin_menu_actions')->cascadeOnDelete(); + $table->boolean('can_delegate')->default(false); + $table->foreignId('granted_by')->nullable()->constrained('admin_users')->nullOnDelete(); + $table->timestamp('granted_at')->nullable(); + $table->timestamps(); + + $table->unique(['child_agent_id', 'menu_action_id'], 'uk_agent_delegation_child_action'); + $table->index(['parent_agent_id', 'child_agent_id'], 'idx_agent_delegation_parent_child'); + }); + } + + public function down(): void + { + Schema::dropIfExists('agent_delegation_grants'); + } +}; diff --git a/database/migrations/2026_06_02_130000_backfill_players_agent_node_id.php b/database/migrations/2026_06_02_130000_backfill_players_agent_node_id.php new file mode 100644 index 0000000..2cdd84f --- /dev/null +++ b/database/migrations/2026_06_02_130000_backfill_players_agent_node_id.php @@ -0,0 +1,34 @@ +join('admin_sites as s', 's.id', '=', 'an.admin_site_id') + ->where('an.depth', 0) + ->get(['an.id as root_id', 's.code as site_code']); + + foreach ($roots as $root) { + DB::table('players') + ->where('site_code', (string) $root->site_code) + ->whereNull('agent_node_id') + ->update(['agent_node_id' => (int) $root->root_id]); + } + } + + public function down(): void + { + // 不回滚归属,避免误清空业务绑定。 + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index 7e4b36f..2c618e2 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -19,6 +19,10 @@ return [ 'role_cannot_delete_super_admin' => 'Cannot delete the super admin role.', 'role_builtin_cannot_delete' => 'Built-in roles cannot be deleted.', 'role_has_users_cannot_delete' => 'This role still has assigned admins and cannot be deleted.', + 'agent_root_delete_denied' => 'Root agent nodes cannot be deleted.', + 'agent_node_has_children_cannot_delete' => 'This agent node has child nodes. Delete children first.', + 'agent_node_has_users_cannot_delete' => 'This agent node still has bound admin accounts and cannot be deleted.', + 'agent_node_has_roles_cannot_delete' => 'This agent node still has bound roles and cannot be deleted.', 'user_cannot_delete_self' => 'Cannot delete your own account.', 'user_cannot_delete_last_super_admin' => 'Cannot delete the last super admin.', 'super_admin_only_for_roles' => 'Only super admins can manage roles.', diff --git a/lang/ne/admin.php b/lang/ne/admin.php index 74fe05e..3cc0e6d 100644 --- a/lang/ne/admin.php +++ b/lang/ne/admin.php @@ -19,6 +19,10 @@ return [ 'role_cannot_delete_super_admin' => 'सुपर एडमिन भूमिका मेटाउन मिल्दैन।', 'role_builtin_cannot_delete' => 'बिल्ट-इन भूमिका मेटाउन मिल्दैन।', 'role_has_users_cannot_delete' => 'यो भूमिकामा अझै एडमिन छ, मेटाउन मिल्दैन।', + 'agent_root_delete_denied' => 'रुट एजेन्ट नोड मेटाउन मिल्दैन।', + 'agent_node_has_children_cannot_delete' => 'यस एजेन्ट नोडमा चाइल्ड नोडहरू छन्, पहिले तिनीहरू हटाउनुहोस्।', + 'agent_node_has_users_cannot_delete' => 'यस एजेन्ट नोडमा अझै एडमिन खाता जोडिएको छ, मेटाउन मिल्दैन।', + 'agent_node_has_roles_cannot_delete' => 'यस एजेन्ट नोडमा अझै भूमिका जोडिएको छ, मेटाउन मिल्दैन।', 'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।', 'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।', 'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।', diff --git a/lang/zh/admin.php b/lang/zh/admin.php index 014d021..94f7dbd 100644 --- a/lang/zh/admin.php +++ b/lang/zh/admin.php @@ -19,6 +19,10 @@ return [ 'role_cannot_delete_super_admin' => '不能删除超级管理员角色。', 'role_builtin_cannot_delete' => '系统内置角色不允许删除。', 'role_has_users_cannot_delete' => '该角色下仍有关联管理员,不能删除。', + 'agent_root_delete_denied' => '根节点不允许删除。', + 'agent_node_has_children_cannot_delete' => '该代理节点存在下级代理,请先清空下级后再删除。', + 'agent_node_has_users_cannot_delete' => '该代理节点下仍有关联账号,不能删除。', + 'agent_node_has_roles_cannot_delete' => '该代理节点下仍有关联角色,不能删除。', 'user_cannot_delete_self' => '不能删除当前登录账号。', 'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。', 'super_admin_only_for_roles' => '仅超级管理员可管理角色。', diff --git a/routes/api.php b/routes/api.php index b3b2f85..2e74190 100644 --- a/routes/api.php +++ b/routes/api.php @@ -33,6 +33,7 @@ Route::prefix('v1')->group(function (): void { require __DIR__.'/api/v1/admin/jackpot.php'; require __DIR__.'/api/v1/admin/config.php'; require __DIR__.'/api/v1/admin/user.php'; + require __DIR__.'/api/v1/admin/agent.php'; require __DIR__.'/api/v1/admin/report.php'; }); }); diff --git a/routes/api/v1/admin/agent.php b/routes/api/v1/admin/agent.php new file mode 100644 index 0000000..0a3e81e --- /dev/null +++ b/routes/api/v1/admin/agent.php @@ -0,0 +1,57 @@ +group(function (): void { + Route::get('agent-nodes/tree', AgentNodeTreeController::class) + ->name('api.v1.admin.agent-nodes.tree'); + Route::post('agent-nodes', AgentNodeStoreController::class) + ->name('api.v1.admin.agent-nodes.store'); + Route::get('agent-nodes/{agent_node}/roles', AgentNodeRoleIndexController::class) + ->name('api.v1.admin.agent-roles.index'); + Route::post('agent-nodes/{agent_node}/roles', AgentNodeRoleStoreController::class) + ->name('api.v1.admin.agent-roles.store'); + Route::get('agent-nodes/{agent_node}/admin-users', AgentNodeAdminUserIndexController::class) + ->name('api.v1.admin.agent-admin-users.index'); + Route::post('agent-nodes/{agent_node}/admin-users', AgentNodeAdminUserStoreController::class) + ->name('api.v1.admin.agent-admin-users.store'); + Route::get('agent-nodes/{agent_node}/delegation-grants', AgentNodeDelegationGrantIndexController::class) + ->name('api.v1.admin.agent-delegation-grants.index'); + Route::put('agent-nodes/{agent_node}/delegation-grants', AgentNodeDelegationGrantSyncController::class) + ->name('api.v1.admin.agent-delegation-grants.sync'); + Route::get('agent-nodes/{agent_node}', AgentNodeShowController::class) + ->name('api.v1.admin.agent-nodes.show'); + Route::put('agent-nodes/{agent_node}', AgentNodeUpdateController::class) + ->name('api.v1.admin.agent-nodes.update'); + Route::delete('agent-nodes/{agent_node}', AgentNodeDestroyController::class) + ->name('api.v1.admin.agent-nodes.destroy'); + Route::get('agent-nodes/{agent_node}/children', AgentNodeChildrenController::class) + ->name('api.v1.admin.agent-nodes.children'); + + Route::put('agent-roles/{admin_role}', AgentRoleUpdateController::class) + ->name('api.v1.admin.agent-roles.update'); + Route::put('agent-roles/{admin_role}/permissions', AgentRolePermissionSyncController::class) + ->name('api.v1.admin.agent-roles.permissions.sync'); + Route::delete('agent-roles/{admin_role}', AgentRoleDestroyController::class) + ->name('api.v1.admin.agent-roles.destroy'); + + Route::put('agent-admin-users/{admin_user}/roles', AgentAdminUserRoleSyncController::class) + ->name('api.v1.admin.agent-admin-users.roles.sync'); + }); diff --git a/routes/api/v1/admin/config.php b/routes/api/v1/admin/config.php index 228a54b..bccb9c8 100644 --- a/routes/api/v1/admin/config.php +++ b/routes/api/v1/admin/config.php @@ -126,6 +126,8 @@ Route::middleware('admin.api-resource') ->group(function (): void { Route::get('/', AdminSettingController::class.'@index') ->name('index'); + Route::put('batch', AdminSettingController::class.'@batchUpdate') + ->name('batch-update'); Route::put('{key}', AdminSettingController::class.'@update') ->name('update'); }); diff --git a/tests/Feature/AdminAgentDelegationApiTest.php b/tests/Feature/AdminAgentDelegationApiTest.php new file mode 100644 index 0000000..b7137a8 --- /dev/null +++ b/tests/Feature/AdminAgentDelegationApiTest.php @@ -0,0 +1,192 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +function grantDelegationAgentOperator(AdminUser $admin, AgentNode $agent, bool $manage = true): void +{ + $now = now(); + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'agent_ops_'.$admin->id, + 'code' => 'agent_ops_'.$admin->id, + 'name' => 'Agent Ops', + 'description' => null, + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $codes = $manage + ? ['agent.node.view', 'agent.node.manage'] + : ['agent.node.view']; + + $actionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', $codes) + ->pluck('id'); + + foreach ($actionIds as $actionId) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $roleId, + 'menu_action_id' => (int) $actionId, + ]); + } + + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => (int) $agent->admin_site_id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'is_primary' => true, + 'granted_at' => $now, + ]); +} + +test('parent agent can sync delegation grants for direct child', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'deleg_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $parent = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'deleg-parent', + 'name' => 'Deleg Parent', + ]); + $child = $service->createChild($super, [ + 'parent_id' => $parent->id, + 'code' => 'deleg-child', + 'name' => 'Deleg Child', + ]); + + $parentAdmin = AdminUser::query()->create([ + 'username' => 'deleg_parent_admin', + 'name' => 'Parent Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantDelegationAgentOperator($parentAdmin, $parent, manage: true); + + $viewActionId = (int) DB::table('admin_menu_actions') + ->where('permission_code', 'agent.node.view') + ->value('id'); + + $token = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/agent-nodes/'.$child->id.'/delegation-grants', [ + 'grants' => [ + ['menu_action_id' => $viewActionId, 'can_delegate' => true], + ], + ]) + ->assertOk() + ->assertJsonPath('data.child_agent_id', $child->id) + ->assertJsonCount(1, 'data.grants'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/agent-nodes/'.$child->id.'/delegation-grants') + ->assertOk() + ->assertJsonPath('data.grants.0.can_delegate', true); +}); + +test('delegation ceiling blocks role permissions beyond child grants', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'ceil_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'ceil-branch', + 'name' => 'Ceil Branch', + ]); + + $viewActionId = (int) DB::table('admin_menu_actions') + ->where('permission_code', 'agent.node.view') + ->value('id'); + + DB::table('agent_delegation_grants')->insert([ + 'parent_agent_id' => $rootId, + 'child_agent_id' => $branch->id, + 'menu_action_id' => $viewActionId, + 'can_delegate' => true, + 'granted_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $operator = AdminUser::query()->create([ + 'username' => 'ceil_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantDelegationAgentOperator($operator, $branch, manage: true); + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [ + 'slug' => 'ceil_ok', + 'name' => 'OK', + 'permission_slugs' => ['prd.agent.view'], + ]) + ->assertOk(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [ + 'slug' => 'ceil_bad', + 'name' => 'Bad', + 'permission_slugs' => ['prd.dashboard.view'], + ]) + ->assertStatus(422); +}); + +test('auth me includes delegation ceiling for agent user', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $root = AgentNode::query()->where('admin_site_id', $siteId)->where('depth', 0)->firstOrFail(); + $admin = AdminUser::query()->create([ + 'username' => 'me_deleg', + 'name' => 'Me', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantDelegationAgentOperator($admin, $root, manage: false); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->assertJsonStructure(['data' => ['admin' => ['delegation_ceiling']]]); +}); diff --git a/tests/Feature/AdminAgentNodeApiTest.php b/tests/Feature/AdminAgentNodeApiTest.php new file mode 100644 index 0000000..8d679e3 --- /dev/null +++ b/tests/Feature/AdminAgentNodeApiTest.php @@ -0,0 +1,280 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +function agentRootNodeId(int $siteId): int +{ + return (int) DB::table('agent_nodes') + ->where('admin_site_id', $siteId) + ->where('depth', 0) + ->value('id'); +} + +function grantAgentOperatorRole(AdminUser $admin, AgentNode $agent, bool $manage = true): void +{ + $now = now(); + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'agent_ops_'.$admin->id, + 'code' => 'agent_ops_'.$admin->id, + 'name' => 'Agent Ops', + 'description' => null, + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $codes = $manage + ? ['agent.node.view', 'agent.node.manage'] + : ['agent.node.view']; + + $actionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', $codes) + ->pluck('id'); + + foreach ($actionIds as $actionId) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $roleId, + 'menu_action_id' => (int) $actionId, + ]); + } + + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => (int) $agent->admin_site_id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'is_primary' => true, + 'granted_at' => $now, + ]); +} + +test('each admin site has exactly one root agent node after migration', function (): void { + $siteIds = DB::table('admin_sites')->pluck('id'); + foreach ($siteIds as $siteId) { + expect( + DB::table('agent_nodes')->where('admin_site_id', (int) $siteId)->where('depth', 0)->count() + )->toBe(1); + } +}); + +test('super admin can load full agent tree', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $admin = AdminUser::query()->create([ + 'username' => 'agent_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/agent-nodes/tree?admin_site_id='.$siteId) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonStructure(['data' => ['admin_site_id', 'tree']]); +}); + +test('agent operator only sees own subtree', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = agentRootNodeId($siteId); + + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'bootstrap', + 'name' => 'Bootstrap', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $nodeA = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'branch-a', + 'name' => 'Branch A', + 'status' => 1, + ]); + $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'branch-b', + 'name' => 'Branch B', + 'status' => 1, + ]); + + $operator = AdminUser::query()->create([ + 'username' => 'agent_a_ops', + 'name' => 'A Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantAgentOperatorRole($operator, $nodeA); + + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/agent-nodes/tree'); + + $response->assertOk(); + $tree = $response->json('data.tree'); + expect($tree)->toBeArray()->and(count($tree))->toBe(1); + expect($tree[0]['code'])->toBe('branch-a'); + expect(collect($tree)->pluck('code'))->not->toContain('branch-b'); +}); + +test('agent operator can create child under own node but not under sibling', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = agentRootNodeId($siteId); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'bootstrap2', + 'name' => 'Bootstrap', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $nodeA = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'branch-a2', + 'name' => 'Branch A', + ]); + $nodeB = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'branch-b2', + 'name' => 'Branch B', + ]); + + $operator = AdminUser::query()->create([ + 'username' => 'agent_a2_ops', + 'name' => 'A Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantAgentOperatorRole($operator, $nodeA); + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-nodes', [ + 'parent_id' => $nodeA->id, + 'code' => 'a-child', + 'name' => 'A Child', + ]) + ->assertOk() + ->assertJsonPath('data.code', 'a-child'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-nodes', [ + 'parent_id' => $nodeB->id, + 'code' => 'hack-child', + 'name' => 'Hack', + ]) + ->assertForbidden(); +}); + +test('auth me returns agent context for bound operator', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = agentRootNodeId($siteId); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'bootstrap3', + 'name' => 'Bootstrap', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $node = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'me-branch', + 'name' => 'Me Branch', + ]); + + $operator = AdminUser::query()->create([ + 'username' => 'me_agent_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantAgentOperatorRole($operator, $node); + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->assertJsonPath('data.admin.agent.code', 'me-branch') + ->assertJsonPath('data.admin.is_super_admin', false); +}); + +test('agent node deletion requires leaf node without bindings', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = agentRootNodeId($siteId); + $service = app(\App\Services\Agent\AgentNodeService::class); + + $super = AdminUser::query()->create([ + 'username' => 'delete_agent_super', + 'name' => 'Delete Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $nodeWithChild = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'delete-parent', + 'name' => 'Delete Parent', + ]); + $service->createChild($super, [ + 'parent_id' => $nodeWithChild->id, + 'code' => 'delete-child', + 'name' => 'Delete Child', + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->deleteJson('/api/v1/admin/agent-nodes/'.$rootId) + ->assertStatus(422) + ->assertJsonPath('msg', __('admin.agent_root_delete_denied')); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->deleteJson('/api/v1/admin/agent-nodes/'.$nodeWithChild->id) + ->assertStatus(422) + ->assertJsonPath('msg', __('admin.agent_node_has_children_cannot_delete')); + + $leaf = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'delete-leaf', + 'name' => 'Delete Leaf', + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->deleteJson('/api/v1/admin/agent-nodes/'.$leaf->id) + ->assertOk() + ->assertJsonPath('code', 0); + + expect(AgentNode::query()->find($leaf->id))->toBeNull(); +}); diff --git a/tests/Feature/AdminAgentRoleApiTest.php b/tests/Feature/AdminAgentRoleApiTest.php new file mode 100644 index 0000000..45fc374 --- /dev/null +++ b/tests/Feature/AdminAgentRoleApiTest.php @@ -0,0 +1,205 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +function grantAgentRoleManager(AdminUser $admin, AgentNode $agent): void +{ + $now = now(); + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'agent_mgr_'.$admin->id, + 'code' => 'agent_mgr_'.$admin->id, + 'name' => 'Agent Manager', + 'scope_type' => 'system', + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $codes = ['agent.node.view', 'agent.node.manage', 'service.players.view']; + $actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id'); + foreach ($actionIds as $actionId) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $roleId, + 'menu_action_id' => (int) $actionId, + ]); + } + + $siteId = (int) $agent->admin_site_id; + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => $siteId, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => $agent->id, + 'is_primary' => true, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agent_roles')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => $agent->id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); +} + +test('agent operator can create role with subset of own permissions', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'super_role', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'role-branch', + 'name' => 'Role Branch', + ]); + + $operator = AdminUser::query()->create([ + 'username' => 'role_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantAgentRoleManager($operator, $branch); + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [ + 'slug' => 'branch_cs', + 'name' => 'Branch CS', + 'permission_slugs' => ['prd.agent.view', 'prd.agent.role.view'], + ]) + ->assertOk() + ->assertJsonPath('data.scope_type', 'agent') + ->assertJsonPath('data.owner_agent_id', $branch->id); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [ + 'slug' => 'branch_hack', + 'name' => 'Hack', + 'permission_slugs' => ['prd.dashboard.view'], + ]) + ->assertStatus(422); +}); + +test('agent scoped roles are excluded from global admin roles index', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'super_idx', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'idx-branch', + 'name' => 'Idx Branch', + ]); + + DB::table('admin_roles')->insert([ + 'slug' => 'idx_agent_role', + 'code' => 'idx_agent_role', + 'name' => 'Idx Agent Role', + 'scope_type' => 'agent', + 'owner_agent_id' => $branch->id, + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $slugs = collect($this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-roles') + ->json('data.items')) + ->pluck('slug'); + + expect($slugs)->not->toContain('idx_agent_role'); +}); + +test('players list respects agent subtree when agent_node_id is set', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'super_player', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branchA = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'pa', 'name' => 'A']); + $branchB = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'pb', 'name' => 'B']); + + Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $branchA->id, + 'site_player_id' => 'p-a', + 'username' => 'pa', + 'nickname' => 'PA', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $branchB->id, + 'site_player_id' => 'p-b', + 'username' => 'pb', + 'nickname' => 'PB', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $operator = AdminUser::query()->create([ + 'username' => 'player_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantAgentRoleManager($operator, $branchA); + + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/players?site_code='.$siteCode); + + $response->assertOk(); + $usernames = collect($response->json('data.items'))->pluck('username'); + expect($usernames)->toContain('pa')->not->toContain('pb'); +}); diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index 5eabe08..f98702e 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -91,7 +91,10 @@ test('admin login returns bearer token when captcha passes validation', function ->assertJsonPath('data.admin.nickname', '测试昵称') ->assertJsonPath('data.admin.navigation.0.segment', 'dashboard') ->assertJsonPath('data.admin.navigation.0.href', '/admin') - ->assertJsonPath('data.admin.navigation.1.segment', 'draws') + ->assertJsonPath('data.admin.navigation.0.nav_group', 'overview') + ->assertJsonPath('data.admin.navigation.1.segment', 'agents') + ->assertJsonPath('data.admin.navigation.1.nav_group', 'agent') + ->assertJsonPath('data.admin.navigation.2.segment', 'draws') ->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions', 'navigation']]]); $token = $resp->json('data.token'); @@ -103,6 +106,67 @@ test('admin login returns bearer token when captcha passes validation', function ->assertJsonPath('data.scope', 'admin'); }); +test('agent operator auth me omits platform-only navigation', function (): void { + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); + + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $admin = AdminUser::query()->create([ + 'username' => 'agent_nav_ops', + 'name' => 'Agent Nav', + 'email' => null, + 'password' => 'secret-strong', + 'status' => 0, + ]); + + $now = now(); + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'agent_nav_ops_role', + 'code' => 'agent_nav_ops_role', + 'name' => 'Agent Nav Ops', + 'description' => null, + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $codes = ['agent.node.view', 'service.report.view']; + $actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id'); + foreach ($actionIds as $actionId) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $roleId, + 'menu_action_id' => (int) $actionId, + ]); + } + + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => $siteId, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => $rootId, + 'is_primary' => true, + 'granted_at' => $now, + ]); + + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $segments = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->json('data.admin.navigation'); + + $keys = array_column($segments, 'segment'); + expect($keys)->toContain('agents', 'reports') + ->and($keys)->not->toContain('admin_users', 'admin_roles', 'settings', 'integration', 'rules_plays'); +}); + test('admin captcha exposes key and image base64', function () { $resp = $this->getJson('/api/v1/admin/auth/captcha'); diff --git a/tests/Feature/AdminPlayerAgentBindingTest.php b/tests/Feature/AdminPlayerAgentBindingTest.php new file mode 100644 index 0000000..381357b --- /dev/null +++ b/tests/Feature/AdminPlayerAgentBindingTest.php @@ -0,0 +1,149 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('orphan players can be backfilled to site root agent', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $playerId = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'orphan-1', + 'username' => 'orphan', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => null, + ])->id; + + DB::table('players') + ->where('site_code', $siteCode) + ->whereNull('agent_node_id') + ->update(['agent_node_id' => $rootId]); + + expect((int) Player::query()->find($playerId)?->agent_node_id)->toBe($rootId); +}); + +test('transfer list excludes players outside agent subtree', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'bind_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'bind-a', 'name' => 'A']); + $other = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'bind-b', 'name' => 'B']); + + $inPlayer = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $branch->id, + 'site_player_id' => 'bind-in', + 'username' => 'bind_in', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $outPlayer = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $other->id, + 'site_player_id' => 'bind-out', + 'username' => 'bind_out', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + foreach ([$inPlayer, $outPlayer] as $index => $player) { + TransferOrder::query()->create([ + 'transfer_no' => 'TR-BIND-'.$index, + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => 'NPR', + 'amount' => 100, + 'status' => 'completed', + 'idempotent_key' => 'idem-bind-'.$index, + 'external_ref_no' => 'ext-'.$index, + 'fail_reason' => null, + 'created_at' => now(), + 'updated_at' => now(), + 'finished_at' => now(), + ]); + } + + $operator = AdminUser::query()->create([ + 'username' => 'bind_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantAgentOpsForBinding($operator, $branch); + + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/wallet/transfer-orders?site_code='.$siteCode); + + $response->assertOk(); + $playerIds = collect($response->json('data.items'))->pluck('player_id')->map(static fn ($id): int => (int) $id); + expect($playerIds)->toContain($inPlayer->id)->not->toContain($outPlayer->id); +}); + +function grantAgentOpsForBinding(AdminUser $admin, \App\Models\AgentNode $agent): void +{ + $now = now(); + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'bind_ops_'.$admin->id, + 'code' => 'bind_ops_'.$admin->id, + 'name' => 'Bind Ops', + 'scope_type' => 'system', + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + foreach (['agent.node.view', 'service.wallet.view', 'service.reconcile.view'] as $code) { + $actionId = DB::table('admin_menu_actions')->where('permission_code', $code)->value('id'); + if ($actionId) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $roleId, + 'menu_action_id' => (int) $actionId, + ]); + } + } + + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => (int) $agent->admin_site_id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'is_primary' => true, + 'granted_at' => $now, + ]); +} diff --git a/tests/Feature/AdminReportAgentScopeTest.php b/tests/Feature/AdminReportAgentScopeTest.php new file mode 100644 index 0000000..750a735 --- /dev/null +++ b/tests/Feature/AdminReportAgentScopeTest.php @@ -0,0 +1,184 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('player win loss report excludes players outside agent subtree', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'rpt_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'rpt-branch', + 'name' => 'Report Branch', + ]); + $otherBranch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'rpt-other', + 'name' => 'Other Branch', + ]); + + $today = now()->toDateString(); + $draw = Draw::query()->create([ + 'draw_no' => 'RPT-'.now()->format('YmdHis'), + 'business_date' => $today, + 'sequence_no' => 901, + 'status' => DrawStatus::Open->value, + 'start_time' => now()->subMinute(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $inScope = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'rpt-in', + 'username' => 'rpt_in', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $branch->id, + ]); + $outScope = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'rpt-out', + 'username' => 'rpt_out', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $otherBranch->id, + ]); + + foreach ([$inScope, $outScope] as $index => $player) { + $order = TicketOrder::query()->create([ + 'order_no' => 'RPT-ORD-'.$index, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 1000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 1000, + 'total_estimated_payout' => 0, + 'status' => 'placed', + 'submit_source' => 'h5', + 'client_trace_id' => 'rpt-trace-'.$index, + 'created_at' => now(), + 'updated_at' => now(), + ]); + TicketItem::query()->create([ + 'ticket_no' => 'RPT-TK-'.$index, + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 1000, + 'total_bet_amount' => 1000, + 'rebate_rate_snapshot' => '0.0000', + 'commission_rate_snapshot' => '0.0000', + 'actual_deduct_amount' => 1000, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'status' => 'placed', + ]); + } + + $operator = AdminUser::query()->create([ + 'username' => 'rpt_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantReportAgentManager($operator, $branch); + + $query = app(AdminReportQueryService::class); + $scoped = $query->playerWinLossPaginated(null, $today, $today, 1, 20, $operator); + $all = $query->playerWinLossPaginated(null, $today, $today, 1, 20, null); + + $scopedUsernames = collect($scoped->items())->pluck('username')->all(); + $allUsernames = collect($all->items())->pluck('username')->all(); + + expect($scopedUsernames)->toContain('rpt_in')->not->toContain('rpt_out') + ->and($allUsernames)->toContain('rpt_in', 'rpt_out'); +}); + +function grantReportAgentManager(AdminUser $admin, AgentNode $agent): void +{ + $now = now(); + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'rpt_mgr_'.$admin->id, + 'code' => 'rpt_mgr_'.$admin->id, + 'name' => 'Report Manager', + 'scope_type' => 'system', + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $codes = ['agent.node.view', 'agent.node.manage', 'service.players.view', 'service.report.view']; + $actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id'); + foreach ($actionIds as $actionId) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $roleId, + 'menu_action_id' => (int) $actionId, + ]); + } + + $siteId = (int) $agent->admin_site_id; + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => $siteId, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'is_primary' => true, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agent_roles')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); +} diff --git a/tests/Feature/AdminSettingBatchUpdateTest.php b/tests/Feature/AdminSettingBatchUpdateTest.php new file mode 100644 index 0000000..922c1a9 --- /dev/null +++ b/tests/Feature/AdminSettingBatchUpdateTest.php @@ -0,0 +1,103 @@ +create([ + 'username' => 'settings_editor', + 'name' => 'Settings Editor', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $role = AdminRole::query()->create([ + 'slug' => 'settings_role', + 'name' => 'Settings Role', + ]); + $role->syncLegacyPermissionSlugs(['prd.payout.manage']); + + $admin->roles()->sync([ + (int) $role->id => [ + 'site_id' => AdminUser::defaultAdminSiteId(), + 'granted_at' => now(), + ], + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('admin can batch update settings in one request', function (): void { + LotterySettings::put('draw.interval_minutes', 5, 'draw'); + LotterySettings::put('draw.cooldown_minutes', 15, 'draw'); + + $token = settingsAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/batch', [ + 'items' => [ + ['key' => 'draw.interval_minutes', 'value' => 10], + ['key' => 'draw.cooldown_minutes', 'value' => 20], + ], + ]) + ->assertOk() + ->assertJsonPath('data.items.0.key', 'draw.cooldown_minutes'); + + expect(LotterySetting::query()->where('setting_key', 'draw.interval_minutes')->value('value_json'))->toBe(10) + ->and(LotterySetting::query()->where('setting_key', 'draw.cooldown_minutes')->value('value_json'))->toBe(20); +}); + +test('admin settings batch update rejects empty items', function (): void { + $token = settingsAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/batch', ['items' => []]) + ->assertUnprocessable(); +}); + +test('admin can batch update settings with false and empty string values', function (): void { + LotterySettings::put('settlement.auto_run_on_tick', true, 'settlement'); + LotterySettings::put('frontend.play_rules_html_zh', '
old
', 'frontend'); + + $token = settingsAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/batch', [ + 'items' => [ + ['key' => 'settlement.auto_run_on_tick', 'value' => false], + ['key' => 'frontend.play_rules_html_zh', 'value' => ''], + ], + ]) + ->assertOk() + ->assertJsonPath('data.items.0.key', 'frontend.play_rules_html_zh') + ->assertJsonPath('data.items.0.value', '') + ->assertJsonPath('data.items.1.key', 'settlement.auto_run_on_tick') + ->assertJsonPath('data.items.1.value', false); + + expect(LotterySetting::query()->where('setting_key', 'settlement.auto_run_on_tick')->value('value_json'))->toBeFalse() + ->and(LotterySetting::query()->where('setting_key', 'frontend.play_rules_html_zh')->value('value_json'))->toBe(''); +}); + +test('admin can update single setting with false value', function (): void { + LotterySettings::put('settlement.apply_rebate_to_payout', true, 'settlement'); + + $token = settingsAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/settlement.apply_rebate_to_payout', [ + 'value' => false, + ]) + ->assertOk() + ->assertJsonPath('data.key', 'settlement.apply_rebate_to_payout') + ->assertJsonPath('data.value', false); + + expect(LotterySetting::query()->where('setting_key', 'settlement.apply_rebate_to_payout')->value('value_json'))->toBeFalse(); +}); diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php index 2181f5d..40bd9c8 100644 --- a/tests/Feature/AdminUserPermissionApiTest.php +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -148,30 +148,32 @@ test('permission catalog groups permissions by admin navigation order', function expect(array_column($groups, 'key'))->toBe([ 'dashboard', + 'agents', 'draws', 'tickets', 'players', + 'settlement', + 'wallet', + 'reconcile', + 'reports', 'rules_plays', 'rules_odds', 'jackpot', 'risk_cap', - 'wallet', - 'settlement', - 'reconcile', - 'reports', - 'currencies', 'integration', + 'currencies', 'admin_users', 'admin_roles', - 'risk', 'audit', 'settings', + 'risk', ]); - expect($groups[1]['key'])->toBe('draws'); - expect($groups[14]['label'])->toBe('管理列表'); - expect(array_column($groups[14]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']); - expect($groups[15]['label'])->toBe('角色管理'); - expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']); + expect($groups[1]['key'])->toBe('agents'); + expect($groups[2]['key'])->toBe('draws'); + expect($groups[15]['label'])->toBe('管理列表'); + expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']); + expect($groups[16]['label'])->toBe('角色管理'); + expect(array_column($groups[16]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']); $groupsByKey = collect($groups)->keyBy('key'); expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([ diff --git a/tests/Pest.php b/tests/Pest.php index b369203..46aae35 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -71,3 +71,16 @@ function grantSuperAdminRole(AdminUser $admin): void ['granted_at' => $now], ); } + +/** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */ +function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void +{ + DB::table('admin_user_agents')->updateOrInsert( + ['admin_user_id' => $admin->id], + [ + 'agent_node_id' => $agentNodeId, + 'is_primary' => true, + 'granted_at' => now(), + ], + ); +}