refactor: 更新权限管理与请求验证逻辑

- 在多个控制器中将权限检查从 hasAdminPermission 更新为 hasPermissionCode,以增强权限管理的灵活性。
- 引入 AdminScopePolicy,优化基于代理节点的权限和数据过滤逻辑,确保管理员能够更精确地控制访问权限。
- 在请求验证中添加 agent_node_id 字段,确保 API 接口支持代理节点的相关操作。
- 更新 AdminUser 模型,新增 hasPermissionCode 方法,以支持更细粒度的权限检查。
- 优化审计日志记录逻辑,确保在处理请求时能够准确记录管理员的操作。
This commit is contained in:
2026-06-03 10:07:38 +08:00
parent 0841fbed32
commit 1dcd4716c5
64 changed files with 2054 additions and 344 deletions

View File

@@ -69,7 +69,7 @@ final class AdminAgentScope
return true;
}
if (! $admin->hasAdminPermission('prd.agent.manage')) {
if (! $admin->hasPermissionCode('agent.node.manage')) {
return false;
}

View File

@@ -32,6 +32,7 @@ final class AdminAuthProfile
* depth: int
* },
* is_super_admin: bool,
* operational_permissions: list<string>,
* delegation_ceiling: list<string>
* }
*/
@@ -49,6 +50,7 @@ final class AdminAuthProfile
'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs, $fresh),
'agent' => self::agentContext($fresh),
'is_super_admin' => $fresh->isSuperAdmin(),
'operational_permissions' => $permissionSlugs,
'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh),
];
}

View File

@@ -379,7 +379,6 @@ final class AdminAuthorizationRegistry
['code' => 'admin.admin-users.update', 'module_code' => 'system', 'name' => '更新管理员', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}', 'route_name' => 'api.v1.admin.admin-users.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
['code' => 'admin.admin-users.destroy', 'module_code' => 'system', 'name' => '删除管理员', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}', 'route_name' => 'api.v1.admin.admin-users.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
['code' => 'admin.admin-users.permission-catalog', 'module_code' => 'system', 'name' => '管理员权限目录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-user-permission-catalog', 'route_name' => 'api.v1.admin.admin-users.permission-catalog', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage', 'prd.admin_role.manage']],
['code' => 'admin.admin-users.permissions.sync', 'module_code' => 'system', 'name' => '管理员权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}/permissions', 'route_name' => 'api.v1.admin.admin-users.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
['code' => 'admin.admin-users.roles.sync', 'module_code' => 'system', 'name' => '管理员角色同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}/roles', 'route_name' => 'api.v1.admin.admin-users.roles.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
['code' => 'admin.admin-roles.index', 'module_code' => 'system', 'name' => '角色列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-roles', 'route_name' => 'api.v1.admin.admin-roles.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_role.manage']],
['code' => 'admin.admin-roles.store', 'module_code' => 'system', 'name' => '创建角色', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/admin-roles', 'route_name' => 'api.v1.admin.admin-roles.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']],
@@ -473,6 +472,7 @@ final class AdminAuthorizationRegistry
['code' => 'admin.settlement-batches.approve', 'module_code' => 'settlement', 'name' => '审核结算批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/approve', 'route_name' => 'api.v1.admin.settlement-batches.approve', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.review']],
['code' => 'admin.settlement-batches.reject', 'module_code' => 'settlement', 'name' => '驳回结算批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/reject', 'route_name' => 'api.v1.admin.settlement-batches.reject', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.review']],
['code' => 'admin.settlement-batches.payout', 'module_code' => 'settlement', 'name' => '执行派彩', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/payout', 'route_name' => 'api.v1.admin.settlement-batches.payout', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.manage']],
['code' => 'admin.settlement-batches.adjustments.store', 'module_code' => 'settlement', 'name' => '结算补差调账', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/adjustments', 'route_name' => 'api.v1.admin.settlement-batches.adjustments.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.manage']],
['code' => 'admin.jackpot.pools.index', 'module_code' => 'jackpot', 'name' => '奖池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/pools', 'route_name' => 'api.v1.admin.jackpot.pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['code' => 'admin.jackpot.payout-logs.index', 'module_code' => 'jackpot', 'name' => '奖池派彩日志', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/payout-logs', 'route_name' => 'api.v1.admin.jackpot.payout-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Support;
final class AdminPermissionInheritance
{
/**
* @var array<string, list<string>>
*/
private const IMPLIED_BY_SLUG = [
'prd.agent.manage' => ['prd.agent.view'],
'prd.agent.role.manage' => ['prd.agent.role.view'],
'prd.agent.user.manage' => ['prd.agent.user.view'],
'prd.integration.manage' => ['prd.integration.view'],
'prd.wallet_reconcile.manage' => ['prd.wallet_reconcile.view'],
'prd.draw_result.manage' => ['prd.draw_result.view'],
'prd.odds.manage' => ['prd.odds.view'],
'prd.risk_cap.manage' => ['prd.risk_cap.view'],
'prd.rebate.manage' => ['prd.rebate.view'],
'prd.jackpot.manage' => ['prd.jackpot.view'],
'prd.payout.manage' => ['prd.payout.view'],
'prd.payout.review' => ['prd.payout.view'],
'prd.report.export' => ['prd.report.view'],
'prd.risk.manage' => ['prd.risk.view'],
];
/**
* @param list<string> $permissionSlugs
* @return list<string>
*/
public static function expand(array $permissionSlugs): array
{
$expanded = [];
foreach ($permissionSlugs as $slug) {
if (! is_string($slug) || $slug === '') {
continue;
}
self::appendWithImplications($expanded, $slug);
}
return array_values(array_keys($expanded));
}
/**
* @param array<string, true> $accumulator
*/
private static function appendWithImplications(array &$accumulator, string $slug): void
{
if (isset($accumulator[$slug])) {
return;
}
$accumulator[$slug] = true;
foreach (self::IMPLIED_BY_SLUG[$slug] ?? [] as $implied) {
self::appendWithImplications($accumulator, $implied);
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use App\Models\AgentNode;
final class AdminScopeContext
{
public function __construct(
public readonly AdminUser $admin,
public readonly ?string $requestedSiteCode = null,
public readonly ?int $requestedAgentNodeId = null,
) {}
public function isSuperAdmin(): bool
{
return $this->admin->isSuperAdmin();
}
public function actorAgentNode(): ?AgentNode
{
return $this->admin->primaryAgentNode();
}
public function actorAgentNodeId(): ?int
{
$node = $this->actorAgentNode();
return $node !== null ? (int) $node->id : null;
}
public function adminSiteId(): ?int
{
$node = $this->actorAgentNode();
return $node !== null ? (int) $node->admin_site_id : null;
}
public function effectiveRequestedAgentNodeId(): ?int
{
return $this->requestedAgentNodeId !== null && $this->requestedAgentNodeId > 0
? $this->requestedAgentNodeId
: null;
}
public function effectiveRequestedSiteCode(): ?string
{
$siteCode = is_string($this->requestedSiteCode) ? trim($this->requestedSiteCode) : '';
return $siteCode !== '' ? $siteCode : null;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use Illuminate\Http\Request;
final class AdminScopeContextResolver
{
public static function fromRequest(
Request $request,
AdminUser $admin,
string $siteParam = 'site_code',
string $agentParam = 'agent_node_id',
): AdminScopeContext {
$siteCode = $request->query($siteParam);
$agentNodeId = $request->integer($agentParam) ?: null;
return self::fromValues(
$admin,
is_string($siteCode) ? $siteCode : null,
$agentNodeId,
);
}
public static function fromValues(
AdminUser $admin,
?string $requestedSiteCode = null,
?int $requestedAgentNodeId = null,
): AdminScopeContext {
$siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : '';
$agentNodeId = $requestedAgentNodeId !== null && $requestedAgentNodeId > 0
? $requestedAgentNodeId
: null;
return new AdminScopeContext(
admin: $admin,
requestedSiteCode: $siteCode !== '' ? $siteCode : null,
requestedAgentNodeId: $agentNodeId,
);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use App\Models\TransferOrder;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
/**
* 统一后台数据范围策略:站点优先 + 代理子树收敛。
*/
final class AdminScopePolicy
{
public static function resolveContext(
Request $request,
AdminUser $admin,
string $siteParam = 'site_code',
string $agentParam = 'agent_node_id',
): AdminScopeContext {
return AdminScopeContextResolver::fromRequest($request, $admin, $siteParam, $agentParam);
}
/**
* @param EloquentBuilder<mixed> $query
*/
public static function applyViaPlayer(
EloquentBuilder $query,
AdminUser|AdminScopeContext $scope,
string $relation = 'player',
): void {
$context = self::normalizeContext($scope);
AdminSiteScope::applyViaPlayerRelation($query, $context->admin, $relation);
}
/**
* @param EloquentBuilder<\App\Models\Player> $query
*/
public static function applyPlayerFilters(EloquentBuilder $query, AdminScopeContext $context): void
{
AdminSiteScope::applyPlayerFilters(
$query,
$context->admin,
$context->effectiveRequestedSiteCode(),
$context->effectiveRequestedAgentNodeId(),
);
}
/**
* @param EloquentBuilder<mixed> $query
*/
public static function applyViaPlayerRelationWithContext(
EloquentBuilder $query,
AdminScopeContext $context,
string $relation = 'player',
): void {
AdminSiteScope::applyViaPlayerRelationWithSiteCode(
$query,
$context->admin,
$context->effectiveRequestedSiteCode(),
$relation,
$context->effectiveRequestedAgentNodeId(),
);
}
/**
* @param QueryBuilder<mixed> $query
*/
public static function applyPlayersAlias(
QueryBuilder $query,
AdminUser|AdminScopeContext $scope,
string $alias = 'p',
): void {
$context = self::normalizeContext($scope);
AdminDataScope::applyToPlayersAlias(
$query,
$context->admin,
$alias,
$context->effectiveRequestedAgentNodeId(),
);
}
/**
* @param QueryBuilder<mixed> $query
*/
public static function applyTicketOrdersViaPlayer(
QueryBuilder $query,
AdminUser|AdminScopeContext $scope,
string $orderAlias = 'o',
): void {
$context = self::normalizeContext($scope);
AdminDataScope::applyToTicketOrdersViaPlayer($query, $context->admin, $orderAlias);
}
public static function transferOrderAccessible(AdminUser|AdminScopeContext $scope, TransferOrder $order): bool
{
$context = self::normalizeContext($scope);
$player = $order->player;
if ($player === null) {
return false;
}
return AdminSiteScope::playerAccessible($context->admin, $player);
}
private static function normalizeContext(AdminUser|AdminScopeContext $scope): AdminScopeContext
{
if ($scope instanceof AdminScopeContext) {
return $scope;
}
return AdminScopeContextResolver::fromValues($scope);
}
}

View File

@@ -37,7 +37,7 @@ final class AgentRoleAuthorization
return false;
}
return $admin->isSuperAdmin() || $admin->hasAdminPermission('prd.agent.role.manage');
return $admin->isSuperAdmin() || $admin->hasPermissionCode('agent.node.manage');
}
/**

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Support;
use App\Models\AuditLog;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
/**
* 审计日志列表展示:模块 / 动作 / 目标中文标签API 资源名 + 业务码映射)。
*/
final class AuditLogApiPresenter
{
/** @var array<string, string> */
private const MODULE_LABELS = [
'agent' => '代理',
'system' => '系统',
'settings' => '系统设置',
'integration' => '对接站点',
'player_manage' => '玩家管理',
'risk_cap' => '风险池',
'odds' => '赔率',
'play_config' => '玩法配置',
'settlement' => '结算',
'report_jobs' => '报表任务',
'reconcile_jobs' => '对账任务',
'draw' => '开奖',
'wallet' => '钱包',
'reconcile' => '对账',
'job' => '任务',
'bootstrap' => '系统',
];
/** @var array<string, string> */
private const ACTION_LABELS = [
'agent_role.sync_permissions' => '同步代理角色权限',
'admin_role.sync_permissions' => '同步平台角色权限',
'admin_role.create' => '创建平台角色',
'admin_role.update' => '更新平台角色',
'admin_role.delete' => '删除平台角色',
'agent_role.create' => '创建代理角色',
'agent_role.update' => '更新代理角色',
'agent_role.destroy' => '删除代理角色',
'agent_admin_user.create' => '创建代理账号',
'agent_admin_user.sync_roles' => '同步代理账号角色',
'agent_node.create' => '创建代理节点',
'agent_node.update' => '更新代理节点',
'agent_node.destroy' => '删除代理节点',
'agent.delegation.sync' => '同步代理授权',
'admin_user.create' => '创建管理员',
'admin_user.update' => '更新管理员',
'admin_user.delete' => '删除管理员',
'sync_permissions' => '同步权限',
'batch_update' => '批量更新设置',
'payout_adjustment' => '派彩调整',
'rotate_secrets' => '轮换密钥',
'toggle_active' => '切换启用状态',
'enqueue' => '提交报表任务',
];
/** @var array<string, string> */
private const TARGET_TYPE_LABELS = [
'admin_role' => '角色',
'admin_user' => '管理员',
'agent_node' => '代理节点',
'admin_site' => '对接站点',
'player' => '玩家',
'lottery_settings' => '系统设置',
'lottery_setting' => '设置项',
'risk_cap_version' => '风险池版本',
'odds_version' => '赔率版本',
'play_config_item' => '玩法',
'play_config_version' => '玩法配置版本',
'settlement_batch_adjustment' => '结算调整单',
'report_job' => '报表任务',
'reconcile_job' => '对账任务',
'transfer_no' => '转账单',
'draw' => '期次',
'batch' => '批次',
];
/** @var array<string, string> */
private const VERB_LABELS = [
'sync' => '同步',
'store' => '创建',
'update' => '更新',
'destroy' => '删除',
'delete' => '删除',
'create' => '创建',
'publish' => '发布',
'reopen' => '重新开奖',
'freeze' => '冻结',
'unfreeze' => '解冻',
'run' => '执行',
'transfer' => '转账',
'start' => '开始',
'test' => '测试',
];
/**
* @param LengthAwarePaginator<AuditLog> $paginator
* @return array{items: list<array<string, mixed>>, meta: array{current_page: int, per_page: int, total: int, last_page: int}}
*/
public static function listPayload(LengthAwarePaginator $paginator): array
{
$items = collect($paginator->items());
$resourceNames = self::loadResourceNames($items);
return AdminApiList::payload(
$paginator,
fn (AuditLog $r) => self::row($r, $resourceNames),
);
}
/**
* @param array<string, string> $resourceNames
* @return array<string, mixed>
*/
public static function row(AuditLog $r, array $resourceNames = []): array
{
return [
'id' => (int) $r->id,
'operator_type' => $r->operator_type,
'operator_id' => (int) $r->operator_id,
'module_code' => $r->module_code,
'action_code' => $r->action_code,
'target_type' => $r->target_type,
'target_id' => $r->target_id,
'module_label' => self::moduleLabel($r->module_code),
'action_label' => self::actionLabel($r, $resourceNames),
'target_label' => self::targetLabel($r, $resourceNames),
'before_json' => $r->before_json,
'after_json' => $r->after_json,
'ip' => $r->ip,
'user_agent' => $r->user_agent,
'created_at' => $r->created_at?->toIso8601String(),
];
}
/**
* @param Collection<int, AuditLog> $items
* @return array<string, string>
*/
private static function loadResourceNames(Collection $items): array
{
$codes = $items
->pluck('target_type')
->filter(fn (?string $type) => self::isApiResourceCode($type))
->unique()
->values()
->all();
if ($codes === []) {
return [];
}
/** @var array<string, string> */
return DB::table('admin_api_resources')
->whereIn('code', $codes)
->pluck('name', 'code')
->all();
}
private static function moduleLabel(?string $code): string
{
if ($code === null || $code === '') {
return '—';
}
return self::MODULE_LABELS[$code] ?? $code;
}
/**
* @param array<string, string> $resourceNames
*/
private static function actionLabel(AuditLog $r, array $resourceNames): string
{
$action = (string) ($r->action_code ?? '');
if ($action === '') {
return '—';
}
if (isset(self::ACTION_LABELS[$action])) {
return self::ACTION_LABELS[$action];
}
$targetType = (string) ($r->target_type ?? '');
if (self::isApiResourceCode($targetType) && isset($resourceNames[$targetType])) {
return $resourceNames[$targetType];
}
if (isset(self::VERB_LABELS[$action])) {
return self::VERB_LABELS[$action];
}
return $action;
}
/**
* @param array<string, string> $resourceNames
*/
private static function targetLabel(AuditLog $r, array $resourceNames): string
{
$type = (string) ($r->target_type ?? '');
$id = trim((string) ($r->target_id ?? ''));
if ($type === '') {
return $id !== '' ? '#'.$id : '—';
}
if (self::isApiResourceCode($type)) {
$name = $resourceNames[$type] ?? $type;
return $id !== '' ? sprintf('%s #%s', $name, $id) : $name;
}
$typeLabel = self::TARGET_TYPE_LABELS[$type] ?? $type;
return $id !== '' ? sprintf('%s #%s', $typeLabel, $id) : $typeLabel;
}
private static function isApiResourceCode(?string $code): bool
{
return is_string($code) && str_starts_with($code, 'admin.');
}
}