227 lines
7.3 KiB
PHP
227 lines
7.3 KiB
PHP
<?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;
|
||
}
|
||
}
|