更新
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\mcp\controller;
|
||||
|
||||
use app\adminapi\logic\LoginLogic;
|
||||
use app\BaseController;
|
||||
use app\common\cache\AdminTokenCache;
|
||||
use app\mcp\service\Catalog;
|
||||
use app\mcp\service\GrantService;
|
||||
use app\mcp\service\Guard;
|
||||
use app\mcp\service\McpConfig;
|
||||
use app\mcp\service\PermissionService;
|
||||
use think\facade\Db;
|
||||
use think\Response;
|
||||
|
||||
/**
|
||||
* 后台管理页面用的接口(甄养堂后台“AI 助手”菜单):沿用后台登录令牌(token 头)识别管理员。
|
||||
* GET /mcp/admin/grants AI 授权列表(有 ai.grant/lists 看全部,否则只看自己的)
|
||||
* POST /mcp/admin/revoke 撤销授权(自己的,或有 ai.grant/revoke)
|
||||
* GET /mcp/admin/logs AI 访问日志(有 ai.accessLog/lists 看全部,否则只看自己的)
|
||||
* GET /mcp/admin/catalog AI 数据目录与覆盖率(需 ai.catalog/lists)
|
||||
*/
|
||||
class AdminController extends BaseController
|
||||
{
|
||||
private array $adminInfo = [];
|
||||
|
||||
public function grants(): Response
|
||||
{
|
||||
if ($denied = $this->authorize('GET')) {
|
||||
return $denied;
|
||||
}
|
||||
$params = $this->request->get();
|
||||
$query = Db::name('ai_grant')->alias('g')->leftJoin('admin a', 'a.id = g.admin_id')
|
||||
->field('g.id,g.admin_id,a.name as admin_name,a.account as admin_account,g.token_prefix,g.client,g.client_instance,g.label,g.status,'
|
||||
. 'g.expire_time,g.idle_days,g.last_used_time,g.last_used_ip,g.created_ip,g.revoke_time,g.revoke_reason,g.create_time');
|
||||
if (!$this->can('ai.grant/lists')) {
|
||||
$query->where('g.admin_id', $this->adminId());
|
||||
} elseif (!empty($params['admin_id'])) {
|
||||
$query->where('g.admin_id', (int) $params['admin_id']);
|
||||
}
|
||||
if (isset($params['status']) && $params['status'] !== '') {
|
||||
$query->where('g.status', (int) $params['status']);
|
||||
}
|
||||
if (!empty($params['keyword'])) {
|
||||
$keyword = '%' . trim((string) $params['keyword']) . '%';
|
||||
$query->where(static fn ($q) => $q->whereLike('a.name', $keyword)->whereOr('a.account', 'like', $keyword)->whereOr('g.label', 'like', $keyword));
|
||||
}
|
||||
[$pageNo, $pageSize] = $this->page($params);
|
||||
$count = (clone $query)->count();
|
||||
$rows = $query->order('g.id', 'desc')->page($pageNo, $pageSize)->select()->toArray();
|
||||
$now = time();
|
||||
foreach ($rows as &$row) {
|
||||
$idleUntil = (int) $row['last_used_time'] + (int) $row['idle_days'] * 86400;
|
||||
$active = (int) $row['status'] === GrantService::STATUS_ACTIVE && (int) $row['expire_time'] > $now && $idleUntil > $now;
|
||||
$row['status_text'] = $active ? '有效' : ((int) $row['status'] === GrantService::STATUS_REVOKED ? '已撤销' : '已过期');
|
||||
$row['can_revoke'] = $active && ((int) $row['admin_id'] === $this->adminId() || $this->can('ai.grant/revoke'));
|
||||
foreach (['expire_time', 'last_used_time', 'revoke_time', 'create_time'] as $field) {
|
||||
$row[$field . '_text'] = (int) $row[$field] > 0 ? date('Y-m-d H:i', (int) $row[$field]) : '';
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
return $this->lists($rows, $count, $pageNo, $pageSize, ['enabled' => McpConfig::enabled()]);
|
||||
}
|
||||
|
||||
public function revoke(): Response
|
||||
{
|
||||
if ($denied = $this->authorize('POST')) {
|
||||
return $denied;
|
||||
}
|
||||
$id = (int) ($this->request->post('id') ?? 0);
|
||||
$grant = GrantService::find($id);
|
||||
if (!$grant) {
|
||||
return Guard::envelope(0, '授权不存在', [], 200, 1);
|
||||
}
|
||||
if ((int) $grant['admin_id'] !== $this->adminId() && !$this->can('ai.grant/revoke')) {
|
||||
return Guard::envelope(0, '权限不足,无法访问或操作', [], 200, 1);
|
||||
}
|
||||
GrantService::close($id, GrantService::STATUS_REVOKED, 'admin_revoke', $this->adminId());
|
||||
return Guard::envelope(1, '已撤销', [], 200, 1);
|
||||
}
|
||||
|
||||
public function logs(): Response
|
||||
{
|
||||
if ($denied = $this->authorize('GET')) {
|
||||
return $denied;
|
||||
}
|
||||
$params = $this->request->get();
|
||||
$query = Db::name('ai_access_log')->alias('l')->leftJoin('admin a', 'a.id = l.admin_id')
|
||||
->field('l.*,a.name as admin_name,a.account as admin_account');
|
||||
if (!$this->can('ai.accessLog/lists')) {
|
||||
$query->where('l.admin_id', $this->adminId());
|
||||
} elseif (!empty($params['admin_id'])) {
|
||||
$query->where('l.admin_id', (int) $params['admin_id']);
|
||||
}
|
||||
foreach (['status' => 'l.status', 'tool' => 'l.tool', 'client_task_id' => 'l.client_task_id'] as $param => $column) {
|
||||
if (!empty($params[$param])) {
|
||||
$query->where($column, (string) $params[$param]);
|
||||
}
|
||||
}
|
||||
if (!empty($params['resource'])) {
|
||||
$query->whereLike('l.resource', '%' . trim((string) $params['resource']) . '%');
|
||||
}
|
||||
if (!empty($params['record_id'])) {
|
||||
$query->whereRaw('FIND_IN_SET(:rid, l.record_ids)', ['rid' => (string) $params['record_id']]);
|
||||
}
|
||||
if (!empty($params['start_time']) && strtotime((string) $params['start_time'])) {
|
||||
$query->where('l.create_time', '>=', strtotime((string) $params['start_time']));
|
||||
}
|
||||
if (!empty($params['end_time']) && strtotime((string) $params['end_time'])) {
|
||||
$query->where('l.create_time', '<=', strtotime((string) $params['end_time']));
|
||||
}
|
||||
[$pageNo, $pageSize] = $this->page($params);
|
||||
$count = (clone $query)->count();
|
||||
$rows = $query->order('l.id', 'desc')->page($pageNo, $pageSize)->select()->toArray();
|
||||
$names = [];
|
||||
foreach (Catalog::all() as $key => $r) {
|
||||
$names[$key] = $r['name'];
|
||||
}
|
||||
foreach ($rows as &$row) {
|
||||
$row['create_time_text'] = date('Y-m-d H:i:s', (int) $row['create_time']);
|
||||
$row['resource_name'] = $names[$row['resource']] ?? '';
|
||||
}
|
||||
unset($row);
|
||||
return $this->lists($rows, $count, $pageNo, $pageSize);
|
||||
}
|
||||
|
||||
public function catalog(): Response
|
||||
{
|
||||
if ($denied = $this->authorize('GET', 'ai.catalog/lists')) {
|
||||
return $denied;
|
||||
}
|
||||
$params = $this->request->get();
|
||||
$rows = [];
|
||||
foreach (Catalog::all() as $key => $r) {
|
||||
if (!empty($params['status']) && $r['status'] !== $params['status']) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($params['domain']) && $r['domain'] !== $params['domain']) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($params['keyword']) && mb_stripos($r['name'] . ' ' . $key, trim((string) $params['keyword'])) === false) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = ['resource' => $key, 'name' => $r['name'], 'domain' => $r['domain'], 'kind' => $r['kind'], 'status' => $r['status'],
|
||||
'reason' => $r['reason'], 'reviewed' => $r['reviewed'], 'registered' => $r['registered']];
|
||||
}
|
||||
[$pageNo, $pageSize] = $this->page($params, 100);
|
||||
$domains = array_values(array_unique(array_column(Catalog::all(), 'domain')));
|
||||
sort($domains);
|
||||
return $this->lists(array_slice($rows, ($pageNo - 1) * $pageSize, $pageSize), count($rows), $pageNo, $pageSize,
|
||||
['counts' => Catalog::counts(), 'domains' => $domains]);
|
||||
}
|
||||
|
||||
/** 后台登录令牌 + IP 绑定 + 企微强制绑定,与后台登录/权限中间件一致;可再要求一个权限点 */
|
||||
private function authorize(string $method, string $perm = ''): ?Response
|
||||
{
|
||||
if ($this->request->method(true) !== $method) {
|
||||
return response('', 405)->header(['Allow' => $method]);
|
||||
}
|
||||
$token = (string) $this->request->header('token', '');
|
||||
$adminInfo = $token !== '' ? (new AdminTokenCache())->getAdminInfo($token) : false;
|
||||
if (empty($adminInfo)) {
|
||||
return Guard::envelope(-1, '登录超时,请重新登录', [], 200, 0);
|
||||
}
|
||||
if (($adminInfo['login_ip'] ?? '') != $this->request->ip()) {
|
||||
return Guard::envelope(-1, 'ip地址发生变化,请重新登录', [], 200, 0);
|
||||
}
|
||||
if (LoginLogic::adminMustBindWorkWechat($adminInfo)) {
|
||||
return Guard::envelope(LoginLogic::CODE_NEED_BIND_WORK_WECHAT, '请先绑定企业微信后再使用系统', [], 200, 0);
|
||||
}
|
||||
$this->adminInfo = $adminInfo;
|
||||
if ($perm !== '' && !$this->can($perm)) {
|
||||
return Guard::envelope(0, '权限不足,无法访问或操作', [], 200, 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function can(string $perm): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
return PermissionService::isRegistered($perm) && isset(PermissionService::adminPerms($this->adminId())[PermissionService::normalize($perm)]);
|
||||
}
|
||||
|
||||
private function adminId(): int
|
||||
{
|
||||
return (int) ($this->adminInfo['admin_id'] ?? 0);
|
||||
}
|
||||
|
||||
private function page(array $params, int $max = 100): array
|
||||
{
|
||||
return [max(1, (int) ($params['page_no'] ?? 1)), max(1, min($max, (int) ($params['page_size'] ?? 15)))];
|
||||
}
|
||||
|
||||
private function lists(array $rows, int $count, int $pageNo, int $pageSize, array $extend = []): Response
|
||||
{
|
||||
return Guard::envelope(1, '', ['lists' => $rows, 'count' => $count, 'page_no' => $pageNo, 'page_size' => $pageSize, 'extend' => $extend ?: new \stdClass()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\mcp\controller;
|
||||
|
||||
use app\BaseController;
|
||||
use app\mcp\service\AuditLogger;
|
||||
use app\mcp\service\Catalog;
|
||||
use app\mcp\service\GrantService;
|
||||
use app\mcp\service\Guard;
|
||||
use app\mcp\service\McpConfig;
|
||||
use app\mcp\service\McpException;
|
||||
use think\Response;
|
||||
|
||||
/**
|
||||
* AI 授权接口(供行知等客户端调用):
|
||||
* POST /mcp/auth/grant 账号 + 密码 → 只读令牌(密码只用于本次校验,不保存)
|
||||
* POST /mcp/auth/revoke 撤销当前令牌(Bearer)
|
||||
* GET /mcp/auth/whoami 当前令牌对应的账号(Bearer)
|
||||
*/
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
public function grant(): Response
|
||||
{
|
||||
$blocked = $this->blocked('POST');
|
||||
if ($blocked) {
|
||||
return $blocked;
|
||||
}
|
||||
$input = json_decode((string) $this->request->getInput(), true);
|
||||
if (!is_array($input)) {
|
||||
$input = $this->request->post();
|
||||
}
|
||||
$ip = $this->request->ip();
|
||||
try {
|
||||
$data = GrantService::issue($input, $ip);
|
||||
AuditLogger::log(['grant_id' => $data['grant_id'], 'admin_id' => $data['admin']['id'], 'tool' => 'auth.grant',
|
||||
'arguments' => ['client' => $input['client'] ?? '', 'client_instance' => $input['client_instance'] ?? ''], 'status' => 'ok', 'ip' => $ip]);
|
||||
return Guard::envelope(1, '授权成功', $data);
|
||||
} catch (McpException $e) {
|
||||
AuditLogger::log(['tool' => 'auth.grant', 'arguments' => ['account' => (string) ($input['account'] ?? '')], 'status' => 'denied',
|
||||
'message' => $e->reason, 'ip' => $ip]);
|
||||
return Guard::envelope(0, $e->getMessage(), ['reason' => $e->reason], $e->httpStatus === 401 ? 200 : $e->httpStatus, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public function revoke(): Response
|
||||
{
|
||||
$blocked = $this->blocked('POST');
|
||||
if ($blocked) {
|
||||
return $blocked;
|
||||
}
|
||||
try {
|
||||
$identity = GrantService::authenticate($this->request);
|
||||
} catch (McpException $e) {
|
||||
return Guard::envelope(-1, $e->getMessage(), ['reason' => $e->reason], 401);
|
||||
}
|
||||
GrantService::close((int) $identity->grant['id'], GrantService::STATUS_REVOKED, 'client_revoke');
|
||||
AuditLogger::log(['grant_id' => $identity->grant['id'], 'admin_id' => $identity->adminId, 'tool' => 'auth.revoke', 'status' => 'ok', 'ip' => $this->request->ip()]);
|
||||
return Guard::envelope(1, '已撤销');
|
||||
}
|
||||
|
||||
public function whoami(): Response
|
||||
{
|
||||
$blocked = $this->blocked('GET');
|
||||
if ($blocked) {
|
||||
return $blocked;
|
||||
}
|
||||
try {
|
||||
$identity = GrantService::authenticate($this->request);
|
||||
} catch (McpException $e) {
|
||||
return Guard::envelope(-1, $e->getMessage(), ['reason' => $e->reason], 401);
|
||||
}
|
||||
return Guard::envelope(1, '', [
|
||||
'admin' => $identity->publicProfile(),
|
||||
'grant' => GrantService::publicGrant($identity->grant),
|
||||
'data_scope' => $identity->dataScopeText(),
|
||||
'resources' => ['open' => count(Catalog::openFor($identity))],
|
||||
]);
|
||||
}
|
||||
|
||||
private function blocked(string $method): ?Response
|
||||
{
|
||||
if (!McpConfig::enabled()) {
|
||||
return Guard::envelope(0, 'AI 助手接口未启用', ['reason' => 'feature_disabled'], 503, 1);
|
||||
}
|
||||
if ($this->request->method(true) !== $method) {
|
||||
return response('', 405)->header(['Allow' => $method]);
|
||||
}
|
||||
$guard = Guard::check($this->request);
|
||||
if ($guard !== null) {
|
||||
return Guard::envelope(0, $guard[1], ['reason' => $guard[2]], 200, 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\mcp\controller;
|
||||
|
||||
use app\BaseController;
|
||||
use app\mcp\service\GrantService;
|
||||
use app\mcp\service\Guard;
|
||||
use app\mcp\service\McpConfig;
|
||||
use app\mcp\service\McpException;
|
||||
use app\mcp\service\Protocol;
|
||||
use think\Response;
|
||||
|
||||
/**
|
||||
* MCP 端点:POST /mcp(Streamable HTTP,无会话,只返回 JSON)。
|
||||
* 每个请求都要带 Authorization: Bearer <AI 授权令牌>。
|
||||
*/
|
||||
class IndexController extends BaseController
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
if (!McpConfig::enabled()) {
|
||||
return json(Protocol::error(null, -32000, 'AI 助手接口未启用'), 503);
|
||||
}
|
||||
if ($this->request->method(true) !== 'POST') {
|
||||
return response('', 405)->header(['Allow' => 'POST']);
|
||||
}
|
||||
$guard = Guard::check($this->request);
|
||||
if ($guard !== null) {
|
||||
return json(Protocol::error(null, -32000, $guard[1]), $guard[0]);
|
||||
}
|
||||
$version = (string) $this->request->header('mcp-protocol-version', '');
|
||||
if ($version !== '' && !in_array($version, McpConfig::PROTOCOL_VERSIONS, true)) {
|
||||
return json(Protocol::error(null, Protocol::INVALID_REQUEST, 'Unsupported protocol version: ' . $version . '; supported: ' . implode(', ', McpConfig::PROTOCOL_VERSIONS)), 400);
|
||||
}
|
||||
try {
|
||||
$identity = GrantService::authenticate($this->request);
|
||||
} catch (McpException $e) {
|
||||
return Guard::unauthorized($e);
|
||||
}
|
||||
$payload = json_decode((string) $this->request->getInput(), true);
|
||||
if (!is_array($payload)) {
|
||||
return json(Protocol::error(null, Protocol::PARSE_ERROR, 'Parse error'), 400);
|
||||
}
|
||||
$context = [
|
||||
'task_id' => (string) $this->request->header('x-xingzhi-task-id', ''),
|
||||
'ip' => $this->request->ip(),
|
||||
];
|
||||
$isBatch = $payload !== [] && array_keys($payload) === range(0, count($payload) - 1);
|
||||
$messages = $isBatch ? $payload : [$payload];
|
||||
$responses = [];
|
||||
foreach ($messages as $message) {
|
||||
$response = Protocol::handle($message, $identity, $context);
|
||||
if ($response !== null) {
|
||||
$responses[] = $response;
|
||||
}
|
||||
}
|
||||
if ($responses === []) {
|
||||
return response('', 202);
|
||||
}
|
||||
return json($isBatch ? $responses : $responses[0]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user