Files
lgp-admin-plus-api/app/Service/PermissionService.php

227 lines
7.3 KiB
PHP
Raw Normal View History

<?php
namespace App\Service;
use App\Models\ApiEndpointModel;
use App\Models\RoleEndpointRelationModel;
use App\Service\common\RedisService;
/**
* 接口级权限
*
* 权限码由接口路径推导admin/list admin:list,前端 v-access / TableAction auth 直接用它。
* 不继承 BaseService中间件在鉴权阶段就要用它 BaseService 的构造函数本身会做鉴权,会绕成环。
*/
class PermissionService
{
private static mixed $_instance;
/** @var array<int, array{codes: array<int, string>, paths: array<int, string>}> 进程内缓存,一次请求里中间件与 codes 接口各要用一次 */
private array $roleMemo = [];
/** @var null|array<string, int> 接口注册表 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<int, string>
*/
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<int, array<string, mixed>>
*/
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<int, int>
*/
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<int, string>, paths: array<int, string>}
*/
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<string, int>
*/
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;
}
}