feat: 增强管理员功能与数据处理
- 在多个控制器中引入 agent_node_id,以支持基于代理节点的权限和数据过滤。 - 更新 AdminRole 和 AdminUser 模型,新增角色范围和代理节点相关功能,提升角色管理的灵活性。 - 在请求验证中添加 agent_node_id 字段,确保 API 接口支持代理节点的相关操作。 - 优化 LotterySettings 服务,支持批量写入设置,提升配置管理的效率。 - 更新仪表板和报告服务,增强数据统计功能,确保管理员能够获取更全面的统计信息。
This commit is contained in:
92
app/Support/AdminAgentNodeAccess.php
Normal file
92
app/Support/AdminAgentNodeAccess.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
final class AdminAgentNodeAccess
|
||||
{
|
||||
public static function resolveAdminSiteId(AdminUser $admin, ?int $requestedSiteId): ?int
|
||||
{
|
||||
if ($admin->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;
|
||||
}
|
||||
}
|
||||
167
app/Support/AdminAgentScope.php
Normal file
167
app/Support/AdminAgentScope.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* 代理子树数据范围(P1:节点访问;P2 起叠加玩家 agent_node_id)。
|
||||
*/
|
||||
final class AdminAgentScope
|
||||
{
|
||||
public static function primaryAgentNode(AdminUser $admin): ?AgentNode
|
||||
{
|
||||
if ($admin->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<AgentNode>
|
||||
*/
|
||||
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<Player> $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<Player> $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);
|
||||
}
|
||||
}
|
||||
@@ -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<string>
|
||||
* }>
|
||||
* }>,
|
||||
* agent: ?array{
|
||||
* id: int,
|
||||
* admin_site_id: int,
|
||||
* path: string,
|
||||
* code: string,
|
||||
* name: string,
|
||||
* depth: int
|
||||
* },
|
||||
* is_super_admin: bool,
|
||||
* delegation_ceiling: list<string>
|
||||
* }
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<array{
|
||||
* segment: string,
|
||||
* label: string,
|
||||
* href: string,
|
||||
* nav_group: string,
|
||||
* platform_only?: bool,
|
||||
* activeMatchPrefix?: string,
|
||||
* requiredAny?: list<string>
|
||||
* }>
|
||||
@@ -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<string>
|
||||
* }>
|
||||
*/
|
||||
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']],
|
||||
|
||||
137
app/Support/AdminDataScope.php
Normal file
137
app/Support/AdminDataScope.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
/**
|
||||
* 站点 + 代理子树:用于报表等 Query Builder(players 表别名)。
|
||||
*/
|
||||
final class AdminDataScope
|
||||
{
|
||||
/**
|
||||
* @param Builder<mixed> $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<mixed> $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<mixed> $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<mixed> $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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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<Player> $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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
151
app/Support/AgentDelegationAuthorization.php
Normal file
151
app/Support/AgentDelegationAuthorization.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class AgentDelegationAuthorization
|
||||
{
|
||||
/**
|
||||
* @return list<string> 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<string> prd.*
|
||||
*/
|
||||
public static function delegationLegacySlugsForAgent(AgentNode $agent): array
|
||||
{
|
||||
$codes = self::delegationMenuActionCodesForAgent($agent);
|
||||
|
||||
return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($codes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> 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<array{menu_action_id: int, can_delegate?: bool}> $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<string> $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)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/Support/AgentNodeApiPresenter.php
Normal file
33
app/Support/AgentNodeApiPresenter.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
|
||||
/** 列表/详情中嵌入的代理节点摘要字段。 */
|
||||
final class AgentNodeApiPresenter
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* agent_node_id: ?int,
|
||||
* agent_code: ?string,
|
||||
* agent_name: ?string
|
||||
* }
|
||||
*/
|
||||
public static function embed(?AgentNode $node): array
|
||||
{
|
||||
if (! $node instanceof AgentNode) {
|
||||
return [
|
||||
'agent_node_id' => null,
|
||||
'agent_code' => null,
|
||||
'agent_name' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'agent_node_id' => (int) $node->id,
|
||||
'agent_code' => (string) $node->code,
|
||||
'agent_name' => (string) $node->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
79
app/Support/AgentNodePresenter.php
Normal file
79
app/Support/AgentNodePresenter.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
|
||||
final class AgentNodePresenter
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* id: int,
|
||||
* admin_site_id: int,
|
||||
* parent_id: ?int,
|
||||
* path: string,
|
||||
* depth: int,
|
||||
* code: string,
|
||||
* name: string,
|
||||
* status: int,
|
||||
* is_root: bool
|
||||
* }
|
||||
*/
|
||||
public static function item(AgentNode $node): array
|
||||
{
|
||||
return [
|
||||
'id' => (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<AgentNode> $nodes
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
91
app/Support/AgentRoleAuthorization.php
Normal file
91
app/Support/AgentRoleAuthorization.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class AgentRoleAuthorization
|
||||
{
|
||||
public static function roleVisibleTo(AdminUser $admin, AdminRole $role): bool
|
||||
{
|
||||
if ($admin->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<string> $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<string> $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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user