diff --git a/app/admin/controller/system/McpKey.php b/app/admin/controller/system/McpKey.php new file mode 100644 index 0000000..be90a46 --- /dev/null +++ b/app/admin/controller/system/McpKey.php @@ -0,0 +1,14 @@ +table('system_mcp_key') + ->setComment('MCP密钥表') + ->addColumn('title', 'char', ['limit' => 50, 'null' => true, 'comment' => '密钥名称']) + ->addColumn('key', 'char', ['limit' => 64, 'null' => true, 'comment' => '密钥SHA-256哈希']) + ->addColumn('key_prefix', 'char', ['limit' => 16, 'null' => true, 'default' => '', 'comment' => '密钥前缀(展示用)']) + ->addColumn('bind_admin_id', 'biginteger', ['limit' => 11, 'null' => true, 'signed' => false, 'comment' => '绑定创建者ID']) + ->addColumn('status', 'integer', ['limit' => 11, 'null' => true, 'default' => 1, 'comment' => '状态 {radio} (0:禁用,1:启用)']) + ->addColumn('remark', 'char', ['limit' => 255, 'null' => true, 'default' => '', 'comment' => '']) + ->addColumn('use_num', 'biginteger', ['limit' => 11, 'null' => true, 'default' => 0, 'signed' => false, 'comment' => '调用次数']) + ->addColumn('last_use_time', 'integer', ['limit' => 11, 'null' => true, 'default' => 0, 'signed' => false, 'comment' => '最后使用时间']) + ->addColumn('create_time', 'integer', ['limit' => 11, 'null' => true, 'default' => 0, 'signed' => false, 'comment' => '创建时间']) + ->addColumn('update_time', 'integer', ['limit' => 11, 'null' => true, 'default' => 0, 'signed' => false, 'comment' => '更新时间']) + ->addColumn('delete_time', 'integer', ['limit' => 11, 'null' => true, 'default' => 0, 'signed' => false, 'comment' => '删除时间']) + ->addIndex(['key'], ['unique' => true, 'name' => 'key']) + ->addIndex(['delete_time'], ['name' => 'delete_time']) + ->create(); + } +} diff --git a/database/migrations/20260816203002_system_mcp_key_node.php b/database/migrations/20260816203002_system_mcp_key_node.php new file mode 100644 index 0000000..7875470 --- /dev/null +++ b/database/migrations/20260816203002_system_mcp_key_node.php @@ -0,0 +1,21 @@ +table('system_mcp_key_node') + ->setComment('MCP密钥与节点关系表') + ->addColumn('key_id', 'biginteger', ['limit' => 11, 'null' => true, 'signed' => false, 'comment' => '密钥ID']) + ->addColumn('node', 'char', ['limit' => 100, 'null' => true, 'default' => '', 'comment' => '']) + ->addIndex(['key_id'], ['name' => 'key_id']) + ->create(); + } +} diff --git a/database/migrations/20260816203003_system_mcp_log.php b/database/migrations/20260816203003_system_mcp_log.php new file mode 100644 index 0000000..8741718 --- /dev/null +++ b/database/migrations/20260816203003_system_mcp_log.php @@ -0,0 +1,25 @@ +table('system_mcp_log') + ->setComment('MCP调用日志表') + ->addColumn('key_id', 'biginteger', ['limit' => 11, 'null' => true, 'signed' => false, 'comment' => '密钥ID']) + ->addColumn('node', 'char', ['limit' => 100, 'null' => true, 'comment' => '调用节点']) + ->addColumn('arguments', 'text', ['null' => true, 'comment' => '参数摘要JSON']) + ->addColumn('is_success', 'integer', ['limit' => 1, 'null' => true, 'default' => 1, 'comment' => '是否成功:0=失败,1=成功']) + ->addColumn('cost_ms', 'integer', ['limit' => 11, 'null' => true, 'default' => 0, 'comment' => '耗时毫秒']) + ->addColumn('create_time', 'integer', ['limit' => 11, 'null' => true, 'default' => 0, 'signed' => false, 'comment' => '创建时间']) + ->addIndex(['key_id'], ['name' => 'key_id']) + ->create(); + } +} diff --git a/extend/base/admin/controller/system/McpKeyBase.php b/extend/base/admin/controller/system/McpKeyBase.php new file mode 100644 index 0000000..3401ccb --- /dev/null +++ b/extend/base/admin/controller/system/McpKeyBase.php @@ -0,0 +1,142 @@ + 'desc', + ]; + + public function __construct(App $app) + { + parent::__construct($app); + + $this->model = new \app\admin\model\SystemMcpKey(); + + $this->assign('select_list_status', $this->model::SELECT_LIST_STATUS, true); + + // 行内修改仅允许 status;key/key_prefix/bind_admin_id 为敏感字段,一律排除 + $this->allowModifyFields = [ + 'status', + ]; + } + + /** + * @NodeAnotation(title="列表") + */ + public function index() + { + if ($this->request->isAjax()) { + if (input('selectFields')) { + return $this->selectList(); + } + list($page, $limit, $where, $excludes, $request_options, $group) = $this->buildTableParames(); + $count = $this->model + ->where($where) + ->group($group) + ->count(); + $list = $this->model + ->where($where) + ->page($page, $limit) + ->order($this->sort) + ->group($group) + ->select(); + + // 附加创建者用户名:不用 withJoin(system_admin 与主表有 id/status 等同名字段会歧义), + // 一次 IN 查询建立 id => username 映射后回填,避免 N+1。 + $adminIds = []; + foreach ($list as $vo) { + !empty($vo->bind_admin_id) && $adminIds[] = $vo->bind_admin_id; + } + $adminNames = empty($adminIds) ? [] : SystemAdmin::whereIn('id', array_unique($adminIds))->column('username', 'id'); + foreach ($list as $vo) { + $vo->bind_admin_username = $adminNames[$vo->bind_admin_id] ?? ''; + } + + $data = [ + 'code' => 0, + 'msg' => '', + 'count' => $count, + 'data' => $list, + ]; + + return json($data); + } + + return $this->fetch(); + } + + /** + * @NodeAnotation(title="添加") + */ + public function add() + { + if ($this->request->isPost()) { + $post = $this->request->post(); + // 表单仅提交 title/status/remark;密钥三要素与统计字段一律服务端生成,忽略客户端传入 + unset($post['id'], $post['key'], $post['key_prefix'], $post['bind_admin_id'], $post['use_num'], $post['last_use_time']); + $rule = [ + 'title|密钥名称' => 'require|max:50', + ]; + $this->validate($post, $rule); + + // 明文密钥仅此一次出现在内存与响应中:不落库、不写日志 + $secretKey = 'sk-mcp-' . bin2hex(random_bytes(24)); + $post['key'] = hash('sha256', $secretKey); + $post['key_prefix'] = substr($secretKey, 0, 16); + $post['bind_admin_id'] = $this->getAdminId(); + try { + $save = $this->model->save($post); + } catch (\Exception $e) { + $this->error('保存失败:' . $e->getMessage()); + } + if ($save) { + // data 携带明文密钥,前端弹层展示一次后即丢弃 + $this->success('保存成功', ['secret_key' => $secretKey]); + } + $this->error('保存失败'); + } + + return $this->fetch(); + } + + /** + * @NodeAnotation(title="编辑") + */ + public function edit($id) + { + $row = $this->model->find($id); + empty($row) && $this->error('数据不存在'); + if ($this->request->isPost()) { + $post = $this->request->post(); + // 白名单:仅允许改 title/status/remark;key/key_prefix/bind_admin_id 及统计字段不可改 + $post = array_intersect_key($post, array_flip(['title', 'status', 'remark'])); + $rule = [ + 'title|密钥名称' => 'require|max:50', + ]; + $this->validate($post, $rule); + try { + $save = $row->save($post); + } catch (\Exception $e) { + $this->error('保存失败:' . $e->getMessage()); + } + $save ? $this->success('保存成功') : $this->error('保存失败'); + } + $this->assign('row', $row); + + return $this->fetch(); + } +} diff --git a/extend/base/admin/model/SystemMcpKeyBase.php b/extend/base/admin/model/SystemMcpKeyBase.php index 2fc151e..7b72346 100644 --- a/extend/base/admin/model/SystemMcpKeyBase.php +++ b/extend/base/admin/model/SystemMcpKeyBase.php @@ -4,7 +4,33 @@ namespace base\admin\model; use app\common\model\TimeModel; +/** + * @property int $id + * @property string $title 密钥名称 + * @property string $key 密钥SHA-256哈希 + * @property string $key_prefix 密钥前缀(展示用) + * @property int $bind_admin_id 绑定创建者ID + * @property int $status 状态 0:禁用,1:启用 + * @property string $remark 备注 + * @property int $use_num 调用次数 + * @property int $last_use_time 最后使用时间 + * @property int $create_time 创建时间 + */ class SystemMcpKeyBase extends TimeModel { + protected $name = 'system_mcp_key'; + protected $deleteTime = 'delete_time'; + + public const SELECT_LIST_STATUS = ['0' => '禁用', '1' => '启用']; + + /** + * 创建者(后台用户). + * 仅按需显式调用时才查询;列表页请勿用 withJoin 预载 + * (system_admin 与主表存在 id/status 等同名字段,join 会产生歧义)。 + */ + public function bindAdmin() + { + return $this->belongsTo('app\admin\model\SystemAdmin', 'bind_admin_id', 'id'); + } } diff --git a/extend/base/admin/view/system/mcp_key/_common.js b/extend/base/admin/view/system/mcp_key/_common.js new file mode 100644 index 0000000..2458be2 --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/_common.js @@ -0,0 +1,11 @@ +var init = { + tableElem: '#currentTable', + tableRenderId: 'currentTableRenderId', + indexUrl: 'system.mcp_key/index', + addUrl: 'system.mcp_key/add' + location.search, + editUrl: 'system.mcp_key/edit', + readUrl: 'system.mcp_key/read', + deleteUrl: 'system.mcp_key/delete', + exportUrl: 'system.mcp_key/export', + modifyUrl: 'system.mcp_key/modify', +}; diff --git a/extend/base/admin/view/system/mcp_key/add.html b/extend/base/admin/view/system/mcp_key/add.html new file mode 100644 index 0000000..9d83673 --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/add.html @@ -0,0 +1,40 @@ +
+
+ +
+ +
+ +
+
+
+ +
+ {foreach $select_list_status as $k=>$v} + + {/foreach} +
+
+ +
+ +
+ +
+
+ +
+ 密钥由服务端自动生成,提交后仅展示一次,请立即复制保存。 +
+ +
+
+ {notempty name='$Request.param.backTagId'} +
返回
+ {/notempty} + + +
+ +
+
diff --git a/extend/base/admin/view/system/mcp_key/add.js b/extend/base/admin/view/system/mcp_key/add.js new file mode 100644 index 0000000..518925d --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/add.js @@ -0,0 +1,43 @@ +$(function(){ + // add 成功响应的 data 携带一次性明文密钥(secret_key): + // 用独立 layer.alert 展示 + 复制按钮,替代默认的"成功提示后直接关窗"流程。 + ua.listen(null, function (res) { + var secretKey = res.data && res.data.secret_key ? res.data.secret_key : ''; + if (secretKey) { + var html = '' + + '密钥已创建,仅展示一次,请立即复制保存:' + + '

' + + '' + secretKey + '' + + '  ' + + '

' + + '关闭后将无法再次查看明文,遗失只能删除后重建。'; + layer.alert(html, { + title: '密钥创建成功', + icon: 1, + area: ['520px', 'auto'], + success: function (layero) { + // 弹层内绑定复制按钮(ClipboardJS) + ua.api.copyText(layero); + } + }, function (index) { + layer.close(index); + // 关闭密钥弹窗后再走默认的关窗/刷新流程 + if (ua.checkMobile()) { + location.href = ua.url(init.indexUrl); + } else { + ua.api.closeCurrentOpen({ refreshTable: true }); + } + }); + return false; + } + // 兜底:无 secret_key 时走默认成功流程 + ua.msg.success(res.msg || '保存成功', function () { + if (ua.checkMobile()) { + location.href = ua.url(init.indexUrl); + } else { + ua.api.closeCurrentOpen({ refreshTable: true }); + } + }); + return false; + }); +}) diff --git a/extend/base/admin/view/system/mcp_key/edit.html b/extend/base/admin/view/system/mcp_key/edit.html new file mode 100644 index 0000000..914249e --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/edit.html @@ -0,0 +1,40 @@ +
+
+ +
+ +
+ +
+
+
+ +
+ {foreach $select_list_status as $k=>$v} + + {/foreach} +
+
+ +
+ +
+ +
+
+ +
+ 密钥内容不支持修改;如需更换密钥,请删除后重新创建。 +
+ +
+
+ {notempty name='$Request.param.backTagId'} +
返回
+ {/notempty} + + +
+ +
+
diff --git a/extend/base/admin/view/system/mcp_key/edit.js b/extend/base/admin/view/system/mcp_key/edit.js new file mode 100644 index 0000000..81a35d4 --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/edit.js @@ -0,0 +1,3 @@ +$(function(){ + ua.listen(); +}) diff --git a/extend/base/admin/view/system/mcp_key/index.html b/extend/base/admin/view/system/mcp_key/index.html new file mode 100644 index 0000000..cb45858 --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/index.html @@ -0,0 +1,12 @@ +
+
+ +
+
+
diff --git a/extend/base/admin/view/system/mcp_key/index.js b/extend/base/admin/view/system/mcp_key/index.js new file mode 100644 index 0000000..22cb9fe --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/index.js @@ -0,0 +1,52 @@ +$(function(){ + ua.table.render({ + init: init, + toolbar: ['refresh', 'add', 'delete'], + cols: [[ + {type: 'checkbox'}, + {field: 'id', title: 'ID', width: 80}, + {field: 'title', title: '密钥名称', minWidth: 150}, + { + // 密钥脱敏展示:前缀 + ****(明文与完整哈希均不出现在列表) + field: 'key_prefix', + title: '密钥', + width: 200, + search: false, + templet: function (d) { + return d.key_prefix ? d.key_prefix + '****' : '-'; + } + }, + {field: 'bind_admin_username', title: '创建者', width: 120, search: false}, + {field: 'use_num', title: '调用次数', width: 100, search: false}, + { + field: 'last_use_time', + title: '最后使用时间', + width: 170, + search: false, + templet: function (d) { + if (!d.last_use_time || d.last_use_time == 0) { + return '未使用'; + } + var date = new Date(d.last_use_time * 1000); + var Y = date.getFullYear(); + var m = ('0' + (date.getMonth() + 1)).slice(-2); + var day = ('0' + date.getDate()).slice(-2); + var H = ('0' + date.getHours()).slice(-2); + var i = ('0' + date.getMinutes()).slice(-2); + var s = ('0' + date.getSeconds()).slice(-2); + return Y + '-' + m + '-' + day + ' ' + H + ':' + i + ':' + s; + } + }, + {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: [ + 'edit', + 'delete' + ] + }, + ]], + }); + + ua.listen(); +}) diff --git a/extend/base/admin/view/system/mcp_key/read.html b/extend/base/admin/view/system/mcp_key/read.html new file mode 100644 index 0000000..d7e5e8b --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/read.html @@ -0,0 +1,109 @@ +
+
+
+
+
+
+

#{$row.id} {$row.title}

+
ID: {$row.id}
+
+
+ + +
+
+
+
+
+ +
+
+
+
密钥名称
+
+ {notempty name="row.title"} + {$row.title} + {else/} + 暂无数据 + {/notempty} +
+
+
+
密钥(脱敏)
+
+ {notempty name="row.key_prefix"} + {$row.key_prefix}**** + {else/} + 暂无数据 + {/notempty} +
+
+
+
绑定创建者ID
+
+ {notempty name="row.bind_admin_id"} + {$row.bind_admin_id} + {else/} + 暂无数据 + {/notempty} +
+
+
+
备注
+
+ {notempty name="row.remark"} + {$row.remark|raw} + {else/} + 暂无内容 + {/notempty} +
+
+
+
调用次数
+
{$row.use_num|default=0}
+
+
+
最后使用时间
+
+ {if condition="$row.last_use_time && $row.last_use_time > 0"} + {:date('Y-m-d H:i:s', $row.last_use_time)} + {else/} + 未使用 + {/if} +
+
+ +
+
+ +
+

基础信息

+
+
+
ID
+
{$row.id}
+
+
+
状态
+
+ {$select_list_status[$row.status]|default=''} +
+
+
+
创建时间
+
+ {if condition="$row.create_time && $row.create_time > 0"} + {:date('Y-m-d H:i:s', $row.create_time)} + {else/} + 暂无数据 + {/if} +
+
+ +
+
+
+
+
+
+
diff --git a/extend/base/admin/view/system/mcp_key/read.js b/extend/base/admin/view/system/mcp_key/read.js new file mode 100644 index 0000000..64f2359 --- /dev/null +++ b/extend/base/admin/view/system/mcp_key/read.js @@ -0,0 +1,22 @@ +$(function(){ + // 删除数据 + window.deleteData = function(id) { + layer.confirm('确定要删除这条数据吗?', { + icon: 3, + title: '提示' + }, function(index) { + $.post('{{:url("delete")}}', {id: id}, function(res) { + if (res.code == 0) { + layer.msg('删除成功', {icon: 1}, function() { + location.href = '{{:url("index")}}'; + }); + } else { + layer.msg(res.msg, {icon: 2}); + } + }, 'json'); + layer.close(index); + }); + }; + + ua.listen(); +})