feat(mcp): MCP 密钥管理后台(CURD 重组 Base/App,密钥仅创建时明文展示)

This commit is contained in:
augushong
2026-08-16 20:55:02 +08:00
parent 15a6f2d841
commit 918d1d4dd5
15 changed files with 593 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
<?php
namespace app\admin\controller\system;
use app\admin\service\annotation\ControllerAnnotation;
use base\admin\controller\system\McpKeyBase;
/**
* @ControllerAnnotation(title="MCP密钥管理",module="系统")
* Class McpKey
*/
class McpKey extends McpKeyBase
{
}

View File

@@ -0,0 +1,33 @@
<?php
use think\migration\Migrator;
class SystemMcpKey extends Migrator
{
/**
* MCP密钥表.
*
* 明文密钥永不出现在本表:
* - key 存 SHA-256 哈希char 64唯一索引
* - key_prefix 存明文前 16 位char 16展示用
*/
public function change()
{
$table = $this->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();
}
}

View File

@@ -0,0 +1,21 @@
<?php
use think\migration\Migrator;
class SystemMcpKeyNode extends Migrator
{
/**
* MCP密钥与节点关系表.
*
* 纯关系表:无任何时间戳字段。
*/
public function change()
{
$table = $this->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();
}
}

View File

@@ -0,0 +1,25 @@
<?php
use think\migration\Migrator;
class SystemMcpLog extends Migrator
{
/**
* MCP调用日志表.
*
* 仅 create_time写入后不变无 update_time / delete_time。
*/
public function change()
{
$table = $this->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();
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace base\admin\controller\system;
use app\admin\model\SystemAdmin;
use app\admin\service\annotation\ControllerAnnotation;
use app\admin\service\annotation\NodeAnotation;
use app\common\controller\AdminController;
use think\App;
/**
* Class McpKeyBase.
* @ControllerAnnotation(title="MCP密钥管理")
*/
class McpKeyBase extends AdminController
{
use \app\admin\traits\Curd;
protected $sort = [
'id' => '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);
// 行内修改仅允许 statuskey/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();
// 附加创建者用户名:不用 withJoinsystem_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/remarkkey/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();
}
}

View File

@@ -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');
}
}

View File

@@ -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',
};

View File

@@ -0,0 +1,40 @@
<div class="layuimini-container">
<form id="app-form" class="layui-form layuimini-form">
<div class="layui-form-item">
<label class="layui-form-label">密钥名称</label>
<div class="layui-input-block">
<input type="text" name="title" class="layui-input" lay-verify="required" placeholder="请输入密钥名称" value="{$Request.param.title|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
{foreach $select_list_status as $k=>$v}
<input type="radio" name="status" value="{$k}" title="{$v}" {in name="k" value="$Request.param.status"}checked=""{/in}>
{/foreach}
</div>
</div>
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">备注</label>
<div class="layui-input-block">
<textarea name="remark" class="layui-textarea" placeholder="请输入备注">{$Request.param.remark|default=''}</textarea>
</div>
</div>
<blockquote class="layui-elem-quote layui-quote-nm">
密钥由服务端自动生成,提交后仅展示一次,请立即复制保存。
</blockquote>
<div class="hr-line"></div>
<div class="layui-form-item text-center">
{notempty name='$Request.param.backTagId'}
<div class="layui-btn layui-btn-sm page-back-button" layuimini-content-href="{$Request.param.backTagId}" data-back="1">返回</div>
{/notempty}
<button type="submit" class="layui-btn layui-btn-normal layui-btn-sm" lay-submit>确认</button>
<button type="reset" class="layui-btn layui-btn-primary layui-btn-sm">重置</button>
</div>
</form>
</div>

View File

@@ -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 = ''
+ '密钥已创建,<span style="color:#e74c3c;font-weight:bold;">仅展示一次,请立即复制保存</span>'
+ '<br><br>'
+ '<span style="user-select:all;word-break:break-all;font-family:monospace;font-size:14px;background:#f2f2f2;padding:8px 12px;display:inline-block;max-width:100%;">' + secretKey + '</span>'
+ '&nbsp;&nbsp;<button type="button" class="layui-btn layui-btn-primary layui-btn-xs" data-toggle="copy-text" data-clipboard-text="' + secretKey + '"><i class="fa fa-copy"></i> 复制</button>'
+ '<br><br>'
+ '<span style="color:#999;">关闭后将无法再次查看明文,遗失只能删除后重建。</span>';
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;
});
})

View File

@@ -0,0 +1,40 @@
<div class="layuimini-container">
<form id="app-form" class="layui-form layuimini-form">
<div class="layui-form-item">
<label class="layui-form-label">密钥名称</label>
<div class="layui-input-block">
<input type="text" name="title" class="layui-input" lay-verify="required" placeholder="请输入密钥名称" value="{$row.title|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
{foreach $select_list_status as $k=>$v}
<input type="radio" name="status" value="{$k}" title="{$v}" {in name="k" value="$row.status"}checked=""{/in}>
{/foreach}
</div>
</div>
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">备注</label>
<div class="layui-input-block">
<textarea name="remark" class="layui-textarea" placeholder="请输入备注">{$row.remark|raw|default=''}</textarea>
</div>
</div>
<blockquote class="layui-elem-quote layui-quote-nm">
密钥内容不支持修改;如需更换密钥,请删除后重新创建。
</blockquote>
<div class="hr-line"></div>
<div class="layui-form-item text-center">
{notempty name='$Request.param.backTagId'}
<div class="layui-btn layui-btn-sm page-back-button" layuimini-content-href="{$Request.param.backTagId}" data-back="1">返回</div>
{/notempty}
<button type="submit" class="layui-btn layui-btn-normal layui-btn-sm" lay-submit>确认</button>
<button type="reset" class="layui-btn layui-btn-primary layui-btn-sm">重置</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,3 @@
$(function(){
ua.listen();
})

View File

@@ -0,0 +1,12 @@
<div class="layuimini-container">
<div class="layuimini-main">
<table id="currentTable" class="layui-table layui-hide"
data-auth-index="{:auth('system.mcp_key/index')}"
data-auth-add="{:auth('system.mcp_key/add')}"
data-auth-edit="{:auth('system.mcp_key/edit')}"
data-auth-delete="{:auth('system.mcp_key/delete')}"
data-auth-modify="{:auth('system.mcp_key/modify')}"
lay-filter="currentTable">
</table>
</div>
</div>

View File

@@ -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 '<span class="layui-text-em">未使用</span>';
}
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();
})

View File

@@ -0,0 +1,109 @@
<div class="layuimini-container detail-container">
<div class="layuimini-main">
<div class="layui-card detail-card">
<div class="layui-card-header detail-header">
<div class="layui-row">
<div class="layui-col-md9">
<h2 class="detail-title">#{$row.id} {$row.title}</h2>
<div class="detail-id">ID: {$row.id}</div>
</div>
<div class="layui-col-md3 text-right detail-actions">
<button class="layui-btn layui-btn-primary" layuimini-content-href="{$Request.param.backTagId}" data-back="1">返回</button>
<button class="layui-btn" onclick="location.href='{:url("edit", ["id" => $row.id])}'">编辑</button>
</div>
</div>
</div>
<div class="layui-card-body detail-content">
<div class="layui-row layui-col-space12">
<!-- 左侧主体内容 -->
<div class="layui-col-md8 detail-main">
<div class="detail-field-group">
<div class="detail-field-item">
<div class="detail-field-label">密钥名称</div>
<div class="detail-field-value">
{notempty name="row.title"}
{$row.title}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">密钥(脱敏)</div>
<div class="detail-field-value">
{notempty name="row.key_prefix"}
{$row.key_prefix}****
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">绑定创建者ID</div>
<div class="detail-field-value">
{notempty name="row.bind_admin_id"}
{$row.bind_admin_id}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">备注</div>
<div class="detail-field-value" style="white-space: pre-wrap;">
{notempty name="row.remark"}
{$row.remark|raw}
{else/}
<span class="layui-text-em">暂无内容</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">调用次数</div>
<div class="detail-field-value">{$row.use_num|default=0}</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">最后使用时间</div>
<div class="detail-field-value">
{if condition="$row.last_use_time && $row.last_use_time > 0"}
{:date('Y-m-d H:i:s', $row.last_use_time)}
{else/}
<span class="layui-text-em">未使用</span>
{/if}
</div>
</div>
</div>
</div>
<!-- 右侧基础信息 -->
<div class="layui-col-md4 detail-side">
<h3 class="detail-side-title">基础信息</h3>
<div class="detail-field-group">
<div class="detail-field-item">
<div class="detail-field-label">ID</div>
<div class="detail-field-value">{$row.id}</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">状态</div>
<div class="detail-field-value">
<span class="layui-badge">{$select_list_status[$row.status]|default=''}</span>
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">创建时间</div>
<div class="detail-field-value">
{if condition="$row.create_time && $row.create_time > 0"}
{:date('Y-m-d H:i:s', $row.create_time)}
{else/}
<span class="layui-text-em">暂无数据</span>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -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();
})