Files
lgp-admin-plus-api/app/Service/PermissionService.php
LQ bcf54c2727 初始化
缺陷:主题配色需要优化整体的同风格
2026-08-14 23:21:21 +08:00

227 lines
7.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}