From 744a0819f2b95828247fee295516b2be9e7ee732 Mon Sep 17 00:00:00 2001 From: augushong Date: Sun, 16 Aug 2026 21:18:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E5=AF=86=E9=92=A5=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E9=A1=B5=EF=BC=8C=E4=BB=85=E5=88=9B=E5=BB=BA=E8=80=85?= =?UTF-8?q?=E6=8B=A5=E6=9C=89=E7=9A=84=E8=8A=82=E7=82=B9=E5=8F=AF=E6=8E=88?= =?UTF-8?q?=E6=9D=83=EF=BC=88=E9=98=B2=E8=B6=8A=E6=9D=83=E4=B8=8E=E6=BC=82?= =?UTF-8?q?=E7=A7=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/system/McpKeyBase.php | 119 ++++++++++++++++++ .../base/admin/view/system/mcp_key/_common.js | 1 + .../admin/view/system/mcp_key/authorize.html | 59 +++++++++ .../admin/view/system/mcp_key/authorize.js | 50 ++++++++ .../base/admin/view/system/mcp_key/index.html | 1 + .../base/admin/view/system/mcp_key/index.js | 10 +- .../base/common/service/AuthServiceBase.php | 62 ++++++++- 7 files changed, 295 insertions(+), 7 deletions(-) create mode 100644 extend/base/admin/view/system/mcp_key/authorize.html create mode 100644 extend/base/admin/view/system/mcp_key/authorize.js diff --git a/extend/base/admin/controller/system/McpKeyBase.php b/extend/base/admin/controller/system/McpKeyBase.php index 3401ccb..1a7ae76 100644 --- a/extend/base/admin/controller/system/McpKeyBase.php +++ b/extend/base/admin/controller/system/McpKeyBase.php @@ -3,9 +3,12 @@ namespace base\admin\controller\system; use app\admin\model\SystemAdmin; +use app\admin\model\SystemMcpKeyNode; use app\admin\service\annotation\ControllerAnnotation; use app\admin\service\annotation\NodeAnotation; +use app\admin\service\NodeService; use app\common\controller\AdminController; +use app\common\service\AuthService; use think\App; /** @@ -139,4 +142,120 @@ class McpKeyBase extends AdminController return $this->fetch(); } + + /** + * 授权页:勾选创建者当前权限的子集给密钥. + * @NodeAnotation(title="授权") + */ + public function authorize($id) + { + $row = $this->model->find($id); + empty($row) && $this->error('数据不存在'); + + // 全量动作节点来自 getNodeTree 展开全部 type=2 节点 + $nodeService = new NodeService(); + $module_list = $nodeService->getNodeTree(); + + // 创建者当前节点集合:与 checkNode 完全一致的判定链(含超管直通/auth_on/动态黑名单) + $authService = new AuthService($row->bind_admin_id); + $allActionNodes = []; + foreach ($module_list as $module) { + foreach ($module['children'] as $controller) { + foreach ($controller['children'] as $action) { + $allActionNodes[] = $action['node']; + } + } + } + $creatorAllowedNodes = $authService->getAdminAllowedNodes($allActionNodes, $row->bind_admin_id); + + // 已选节点 = 库中记录 ∩ 创建者当前集合(防漂移:创建者权限被回收后旧勾选自动失效) + $checkedNodeList = (new SystemMcpKeyNode()) + ->where('key_id', $id) + ->column('node'); + $checkedNodeList = array_values(array_intersect($checkedNodeList, $creatorAllowedNodes)); + + // 树过滤:动作节点不在创建者集合内整级剔除;无动作节点的控制器、无控制器的模块不显示 + foreach ($module_list as $module_key => $module) { + foreach ($module['children'] as $controllerKey => $controller) { + foreach ($controller['children'] as $actionKey => $action) { + if (!in_array($action['node'], $creatorAllowedNodes)) { + unset($module_list[$module_key]['children'][$controllerKey]['children'][$actionKey]); + continue; + } + $checked = in_array($action['node'], $checkedNodeList); + $checked_string = $checked ? 'checked' : ''; + $disabled_string = ''; + if (!$action['auth']) { + // 免鉴权节点创建者恒有权限,照 auth/authorize 惯例固定勾选并禁用 + $checked_string = 'checked'; + $disabled_string = 'disabled'; + } + $module_list[$module_key]['children'][$controllerKey]['children'][$actionKey]['checked'] = $checked_string; + $module_list[$module_key]['children'][$controllerKey]['children'][$actionKey]['disabled'] = $disabled_string; + } + if (empty($module_list[$module_key]['children'][$controllerKey]['children'])) { + unset($module_list[$module_key]['children'][$controllerKey]); + } + } + if (empty($module_list[$module_key]['children'])) { + unset($module_list[$module_key]); + } + } + + $this->assign('row', $row); + $this->assign('checked_node_list', $checkedNodeList); + $this->assign('module_list', array_values($module_list)); + + return $this->fetch(); + } + + /** + * 授权保存:先删后插,服务端强校验防越权. + * @NodeAnotation(title="授权保存") + */ + public function saveAuthorize() + { + $this->checkPostRequest(); + $id = $this->request->post('id'); + $node = (array) $this->request->post('node', []); + + $row = $this->model->find($id); + empty($row) && $this->error('数据不存在'); + + // 服务端强校验:每个提交节点必须 ∈ 创建者当前节点集合(重新计算,防权限已变) + $nodeService = new NodeService(); + $allActionNodes = []; + foreach ($nodeService->getNodeTree() as $module) { + foreach ($module['children'] as $controller) { + foreach ($controller['children'] as $action) { + $allActionNodes[] = $action['node']; + } + } + } + $authService = new AuthService($row->bind_admin_id); + $creatorAllowedNodes = $authService->getAdminAllowedNodes($allActionNodes, $row->bind_admin_id); + foreach ($node as $vo) { + if (!in_array($authService->parseNodeStr($vo), $creatorAllowedNodes)) { + $this->error('包含无权限节点'); + } + } + + try { + $keyNode = new SystemMcpKeyNode(); + $keyNode->where('key_id', $id)->delete(); + if (!empty($node)) { + $saveAll = []; + foreach ($node as $vo) { + $saveAll[] = [ + 'key_id' => $id, + 'node' => $authService->parseNodeStr($vo), + ]; + } + $keyNode->saveAll($saveAll); + } + } catch (\Exception $e) { + $this->error('保存失败'); + } + $this->success('保存成功'); + } } diff --git a/extend/base/admin/view/system/mcp_key/_common.js b/extend/base/admin/view/system/mcp_key/_common.js index 2458be2..41d9c83 100644 --- a/extend/base/admin/view/system/mcp_key/_common.js +++ b/extend/base/admin/view/system/mcp_key/_common.js @@ -8,4 +8,5 @@ var init = { deleteUrl: 'system.mcp_key/delete', exportUrl: 'system.mcp_key/export', modifyUrl: 'system.mcp_key/modify', + authorize_url: 'system.mcp_key/authorize', }; diff --git a/extend/base/admin/view/system/mcp_key/authorize.html b/extend/base/admin/view/system/mcp_key/authorize.html new file mode 100644 index 0000000..6719244 --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/authorize.html @@ -0,0 +1,59 @@ +
+
+ +
+ +
+ +
+
+ +
+ +
+ + + + + + + + + + + + + + + {volist name='$module_list' id='module'} + {volist name='$module.children' id='controller_item'} + + + + + + {/volist} + {/volist} + +
模块功能权限
{$controller_item.module} + + + {volist name='controller_item.children' id='action'} +
+ +
+ {/volist} +
+
+
+ + + +
+
+ + +
+ +
+
diff --git a/extend/base/admin/view/system/mcp_key/authorize.js b/extend/base/admin/view/system/mcp_key/authorize.js new file mode 100644 index 0000000..be939ac --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/authorize.js @@ -0,0 +1,50 @@ +$(function () { + + layui.form.on('checkbox(controller-checkbox)', function (data) { + var elem = data.elem; + var checked = elem.checked; + $(elem).closest('tr').find('input').each(function(i,input){ + if(!$(input).prop('disabled')){ + $(input).prop('checked', checked); + } + }) + }); + layui.form.on('checkbox(action-checkbox)', function (data) { + initCheckedStatus(); + }); + function initCheckedStatus() { + $('.auth-node-tr').each(function (i, elem) { + var checkedCount = 0; + var totalCount = 0; + $(elem).find('[lay-filter="action-checkbox"]').each(function (j, checkbox) { + totalCount++; + if ($(checkbox).prop('checked')) { + checkedCount++; + } + }); + + var checkedStatus; + if (checkedCount === totalCount) { + checkedStatus = 1; // 全部选中 + } else if (checkedCount === 0) { + checkedStatus = -1; // 全部未选中 + } else { + checkedStatus = 0; // 部分选中 + } + if (checkedStatus === 0) { + $(elem).find('[lay-filter="controller-checkbox"]').prop('checked', false); + $(elem).find('[lay-filter="controller-checkbox"]').prop('indeterminate', true); + } else if (checkedStatus > 0) { + $(elem).find('[lay-filter="controller-checkbox"]').prop('checked', true); + $(elem).find('[lay-filter="controller-checkbox"]').prop('indeterminate', false); + } else { + $(elem).find('[lay-filter="controller-checkbox"]').prop('checked', false); + $(elem).find('[lay-filter="controller-checkbox"]').prop('indeterminate', false); + } + layui.form.render('checkbox'); + }); + } + initCheckedStatus(); + + ua.listen(); +}); diff --git a/extend/base/admin/view/system/mcp_key/index.html b/extend/base/admin/view/system/mcp_key/index.html index cb45858..68e8969 100644 --- a/extend/base/admin/view/system/mcp_key/index.html +++ b/extend/base/admin/view/system/mcp_key/index.html @@ -6,6 +6,7 @@ data-auth-edit="{:auth('system.mcp_key/edit')}" data-auth-delete="{:auth('system.mcp_key/delete')}" data-auth-modify="{:auth('system.mcp_key/modify')}" + data-auth-authorize="{:auth('system.mcp_key/authorize')}" lay-filter="currentTable"> diff --git a/extend/base/admin/view/system/mcp_key/index.js b/extend/base/admin/view/system/mcp_key/index.js index 22cb9fe..2ccb33f 100644 --- a/extend/base/admin/view/system/mcp_key/index.js +++ b/extend/base/admin/view/system/mcp_key/index.js @@ -40,7 +40,15 @@ $(function(){ {field: 'status', search: 'select', selectList: ua.getDataBrage('select_list_status'), title: '状态', width: 100, templet: ua.table.switch}, {field: 'remark', title: '备注', templet: ua.table.text}, { - width: 150, title: '操作', templet: ua.table.tool, fixed: 'right', operat: [ + width: 210, title: '操作', templet: ua.table.tool, fixed: 'right', operat: [ + [{ + text: '授权', + url: init.authorize_url, + method: 'open', + auth: 'authorize', + class: 'layui-btn layui-btn-normal layui-btn-xs', + extend: 'data-full="true"' + }], 'edit', 'delete' ] diff --git a/extend/base/common/service/AuthServiceBase.php b/extend/base/common/service/AuthServiceBase.php index 1345c46..ab96f2f 100644 --- a/extend/base/common/service/AuthServiceBase.php +++ b/extend/base/common/service/AuthServiceBase.php @@ -96,6 +96,33 @@ class AuthServiceBase return static::$dynamicNodeList[$node]; } + // 判断是否需要获取当前节点 + if (empty($node)) { + $node = $this->getCurrentNode(); + } else { + $node = $this->parseNodeStr($node); + } + + return $this->checkNodeResolved($node); + } + + /** + * 对已规范化(parseNodeStr 后)的单个节点执行完整判定链. + * + * checkNode 与 getAdminAllowedNodes 共用此实现,严禁平行复制导致语义漂移。 + * 判定链(顺序与原 checkNode 保持一致): + * 动态节点黑名单 → 超管直通 → auth_on 开关 → 未注册节点 default_auth_check + * → 注解 auth=false → status/auth_ids 校验 → 白名单成员判定 + * @param string $node + * @return bool + */ + protected function checkNodeResolved($node) + { + // 动态节点开关(按规范化节点命中) + if (isset(static::$dynamicNodeList[$node])) { + return static::$dynamicNodeList[$node]; + } + // 判断是否为超级管理员 if ($this->isSuperAdmin()) { return true; @@ -104,12 +131,6 @@ class AuthServiceBase if ($this->config['auth_on'] == false) { return true; } - // 判断是否需要获取当前节点 - if (empty($node)) { - $node = $this->getCurrentNode(); - } else { - $node = $this->parseNodeStr($node); - } // 判断是否加入节点控制,优先获取缓存信息 if (!isset($this->nodeList[$node])) { @@ -131,6 +152,35 @@ class AuthServiceBase return false; } + /** + * 批量获取管理员对候选节点集合的允许子集. + * + * 对每个候选节点走与 checkNode() 完全一致的判定链(共用 checkNodeResolved), + * 返回其中允许访问的节点(规范化后的形式,保持候选顺序并去重)。 + * 用于 MCP 密钥授权(创建者权限子集)等批量场景。 + * @param array $candidateNodes 候选节点列表(controller/action 形式,内部会做 parseNodeStr 规范化) + * @param int|null $adminId 管理员ID,为 null 时沿用当前实例的管理员 + * @return array 允许的节点列表 + */ + public function getAdminAllowedNodes(array $candidateNodes, ?int $adminId = null): array + { + if (empty($adminId)) { + $adminId = $this->adminId; + } + // 目标管理员与当前实例不一致时,按目标管理员重建判定上下文 + $service = ($adminId == $this->adminId) ? $this : new static($adminId); + + $allowedNodes = []; + foreach ($candidateNodes as $node) { + $node = $service->parseNodeStr($node); + if ($service->checkNodeResolved($node) && !in_array($node, $allowedNodes)) { + $allowedNodes[] = $node; + } + } + + return $allowedNodes; + } + /** * 获取当前节点. * @return string