This commit is contained in:
gr
2026-09-24 09:45:44 +08:00
parent dbf474ddd7
commit bd22e5f476
38 changed files with 19152 additions and 0 deletions
@@ -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]);
}
}