Files
zyt/server/app/mcp/controller/AdminController.php
T
2026-09-24 09:45:44 +08:00

202 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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()]);
}
}