, paths: array}> 进程内缓存,一次请求里中间件与 codes 接口各要用一次 */ private array $roleMemo = []; /** @var null|array 接口注册表 path => id */ private ?array $pathMemo = null; public static function getInstance(): static { $name = get_called_class(); if (!isset(self::$_instance[$name])) { self::$_instance[$name] = new static(); } return self::$_instance[$name]; } /** * 超级管理员:代码里多处硬判断 role_id === 1 全量放行,这里保持一致 */ public function isSuper(int $roleId): bool { return $roleId === 1; } /** * 角色可用的权限码 * @return array */ public function codes(int $roleId): array { return $this->load($roleId)['codes']; } /** * 判断角色能否调用某接口 * * @param string $path 已去掉 /api/ 前缀的路径,如 admin/list */ public function allows(int $roleId, string $path): bool { if ($this->isSuper($roleId)) { return true; } $path = trim($path, '/'); if ($path === '' || in_array($path, (array) config('nl.api.permission.always_allow', []), true)) { return true; } $registered = $this->registeredPaths(); if (!isset($registered[$path])) { // 没登记进接口注册表的接口:迁移期放行,strict 模式拒绝 return !config('nl.api.permission.strict', false); } return in_array($path, $this->load($roleId)['paths'], true); } /** * 接口授权树:按控制器分组,供角色授权抽屉勾选 * @return array> */ public function endpointTree(): array { $rows = ApiEndpointModel::where('deleted_at', 0) ->where('status', 1) ->orderBy('controller') ->orderBy('url') ->get(['id', 'url', 'name', 'method', 'controller']) ->toArray(); $groups = []; foreach ($rows as $row) { $controller = $row['controller'] !== '' ? $row['controller'] : '未归类'; if (!isset($groups[$controller])) { $groups[$controller] = [ // 分组节点的 id 取负值,避免和真实 endpoint_id 混淆 'id' => -1 * (count($groups) + 1), 'title' => $controller, 'code' => '', 'children' => [], ]; } $groups[$controller]['children'][] = [ 'id' => (int) $row['id'], 'title' => $row['name'] !== '' ? $row['name'] : $row['url'], 'code' => $this->pathToCode($row['url']), 'url' => $row['url'], ]; } return array_values($groups); } /** * 角色已授权的接口 id * @return array */ public function grantedIds(int $roleId): array { return RoleEndpointRelationModel::where('role_id', $roleId) ->pluck('endpoint_id') ->map(fn ($v) => (int) $v) ->all(); } /** * 保存角色的接口授权(全量覆盖) */ public function grant(int $roleId, array $endpointIds): bool { // 分组节点用的是负 id,落库前剔掉 $ids = array_values(array_unique(array_filter(array_map('intval', $endpointIds), fn ($id) => $id > 0))); $exists = $this->grantedIds($roleId); $toDelete = array_diff($exists, $ids); if (!empty($toDelete)) { RoleEndpointRelationModel::where('role_id', $roleId) ->whereIn('endpoint_id', array_values($toDelete)) ->delete(); } $toAdd = array_diff($ids, $exists); if (!empty($toAdd)) { $now = time(); RoleEndpointRelationModel::insert(array_map( fn ($id) => ['role_id' => $roleId, 'endpoint_id' => $id, 'created_at' => $now], array_values($toAdd) )); } $this->clear($roleId); return true; } /** * 清角色权限缓存;不传角色则全清 */ public function clear(?int $roleId = null): void { $this->pathMemo = null; $redis = RedisService::getInstance()->init(config('nl.redis.permission_key')); if ($roleId === null) { $this->roleMemo = []; $redis->delAll(); return; } unset($this->roleMemo[$roleId]); $redis->del($roleId); } /** * admin/list → admin:list */ public function pathToCode(string $path): string { return str_replace('/', ':', trim($path, '/')); } /** * @return array{codes: array, paths: array} */ private function load(int $roleId): array { if (isset($this->roleMemo[$roleId])) { return $this->roleMemo[$roleId]; } $redis = RedisService::getInstance()->init(config('nl.redis.permission_key')); $cached = $redis->get($roleId); if (!empty($cached)) { $decoded = json_decode($cached, true); if (is_array($decoded) && isset($decoded['codes'], $decoded['paths'])) { return $this->roleMemo[$roleId] = $decoded; } } if ($this->isSuper($roleId)) { // 超管拿全量码:前端按码匹配,给 ['*'] 反而什么都匹配不上 $paths = array_keys($this->registeredPaths()); } else { $paths = ApiEndpointModel::where('deleted_at', 0) ->where('status', 1) ->whereIn('id', $this->grantedIds($roleId)) ->pluck('url') ->all(); } $paths = array_values(array_unique(array_map(fn ($p) => trim((string) $p, '/'), $paths))); $data = [ 'paths' => $paths, 'codes' => array_map(fn ($p) => $this->pathToCode($p), $paths), ]; $redis->set($roleId, json_encode($data, JSON_UNESCAPED_UNICODE), 3600); return $this->roleMemo[$roleId] = $data; } /** * 已登记的接口路径表(path => id) * @return array */ private function registeredPaths(): array { if ($this->pathMemo !== null) { return $this->pathMemo; } $rows = ApiEndpointModel::where('deleted_at', 0) ->where('status', 1) ->pluck('id', 'url') ->all(); $normalized = []; foreach ($rows as $url => $id) { $normalized[trim((string) $url, '/')] = (int) $id; } return $this->pathMemo = $normalized; } }