CRMEB/backend/modules/service/mcp/tools/DevDocsSearchTool.php
sugar1569 3b5bd37bfb feat(mcp): 为内置工具添加分类方法并优化订单列表搜索逻辑
为所有 MCP 内置工具实现 category() 分类方法,支持管理后台按业务分组展示;优化订单列表关键词搜索逻辑,使用闭包显式 OR 条件匹配订单号和商品标题,避免管道字段静默丢弃问题;新增优惠券库存调整原子方法,提升并发安全性;同步更新相关文档和注释,完善分类展示规则说明。
2026-09-24 11:57:27 +08:00

365 lines
13 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
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace modules\service\mcp\tools;
use crmeb\interfaces\McpToolInterface;
use modules\service\mcp\McpException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use think\App as ThinkApp;
/**
* MCP 工具:开发文档检索(查询开发规则 / 技能手册 / 项目文档)
*
* 面向 AI 二开者的"运行时知识"入口:在开发期检索仓库内的开发资料,
* 避免模型凭记忆猜测项目约定。检索范围为硬编码白名单(仓库根的
* help/docs、.crmeb 知识资产与各级 README),不会触及任何环境配置、
* 密钥或业务数据文件。
*
* 知识资产结构与定位见 .crmeb/AGENTS.md;返回结果为命中文件清单
* (含标题与命中行摘录),正文以仓库内文件为准。
*/
class DevDocsSearchTool implements McpToolInterface
{
/**
* 检索范围 => 相对仓库根的路径清单(目录递归扫描 .md;文件直接检索)
*
* 顺序即返回优先级:开发期规则与手册优先于背景文档。
* 白名单只包含文档类资产,杜绝环境配置 / 密钥文件被检索输出。
*/
private const SCOPE_ROOTS = [
'rules' => ['.crmeb/rules'],
'skills' => ['.crmeb/skills'],
'agents' => ['.crmeb/agents'],
'docs' => ['help/docs', 'backend/README.md', 'frontend/admin/README.md', 'frontend/uni-app/README.md'],
'entry' => ['README.md', 'CONTRIBUTING.md', 'llms.txt', '.crmeb/AGENTS.md', '.crmeb/README.md'],
];
/**
* 单文件大小上限(字节),超限跳过
*/
private const MAX_FILE_SIZE = 524288;
/**
* 单文件返回的最大命中行数
*/
private const MAX_MATCHES_PER_FILE = 5;
/**
* 单行摘录最大字符数
*/
private const MAX_LINE_LENGTH = 300;
/**
* 应用实例(用于定位仓库根目录)
* @var ThinkApp
*/
private ThinkApp $app;
/**
* 构造函数
* @param ThinkApp $app 应用实例
*/
public function __construct(ThinkApp $app)
{
$this->app = $app;
}
/**
* 工具名称
* @return string
*/
public function name(): string
{
return 'crmeb_dev_docs_search';
}
/**
* 工具描述
* @return string
*/
public function description(): string
{
return '检索本仓库的开发资料:架构规则(rules)、开发技能手册(skills)、'
. '专项角色说明(agents)、项目文档(docs,含架构/模块/数据库/API/部署/运维)与入口文档(entry)。'
. '开发 / 二开本系统前,应先检索「依赖铁律」「插件开发」「事件规范」「数据库规范」等关键词'
. '了解项目强制约定,再动手写代码;返回命中文件的标题与摘录行,正文以文件为准';
}
/**
* 工具中文名(管理后台展示)
* @return string
*/
public function label(): string
{
return '开发文档检索';
}
/**
* 工具分类(管理后台分组展示)
* @return string
*/
public function category(): string
{
return '开发支持';
}
/**
* 入参 Schema
* @return array
*/
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => '检索关键词,支持多词(空格分隔,需在同一文件中同时命中),如「依赖铁律」「插件 plugin.json」「订单 售后」',
],
'scope' => [
'type' => 'string',
'description' => '检索范围:rules=开发规则 skills=技能手册 agents=专项角色 docs=项目文档 entry=入口文档,不传=全部范围',
'enum' => ['rules', 'skills', 'agents', 'docs', 'entry'],
],
'limit' => [
'type' => 'integer',
'description' => '最多返回的文件数(默认 8,上限 20)',
],
],
'required' => ['query'],
];
}
/**
* 执行工具
*
* @param array $args 参数(query 必填,scope/limit 可选)
* @return array 命中文件清单(含标题与命中行摘录)
* @throws McpException 关键词为空或范围内无命中
*/
public function execute(array $args): array
{
$query = trim((string)($args['query'] ?? ''));
if ($query === '') {
throw new McpException('query 不能为空:请给出要检索的关键词,如「依赖铁律」「插件开发」');
}
$scope = strtolower(trim((string)($args['scope'] ?? '')));
if ($scope !== '' && !isset(self::SCOPE_ROOTS[$scope])) {
throw new McpException('scope 仅支持:' . implode(' / ', array_keys(self::SCOPE_ROOTS)));
}
$limit = max(1, min(20, (int)($args['limit'] ?? 8)));
// 多词按空格拆分,要求同一文件内全部命中(文件级 AND)
$words = array_values(array_filter(array_map('trim', preg_split('/\s+/u', $query) ?: []), 'strlen'));
// 仓库根定位:backend/ 的父目录;Docker 等隔离部署可用 CRMEB_REPO_ROOT 指向挂载的仓库根
$env = getenv('CRMEB_REPO_ROOT');
$repoRoot = is_string($env) && trim($env) !== ''
? rtrim(trim($env), '/\\')
: dirname(rtrim($this->app->getRootPath(), '/\\'));
if (!is_dir($repoRoot . '/help/docs') && !is_dir($repoRoot . '/.crmeb')) {
throw new McpException('开发文档目录不可达:当前部署形态下容器内没有仓库根(只有 backend/)。'
. 'Docker 部署请在 docker-compose.yml 为对应容器追加只读挂载 '
. '"./help:/repo/help:ro" 与 "./.crmeb:/repo/.crmeb:ro",并设置环境变量 CRMEB_REPO_ROOT=/repo;'
. '宿主机手动部署无需任何配置');
}
$files = [];
$totalHits = 0;
foreach (self::SCOPE_ROOTS as $scopeName => $paths) {
if ($scope !== '' && $scopeName !== $scope) {
continue;
}
foreach ($paths as $path) {
$totalHits += $this->collectFiles($repoRoot, $path, $scopeName, $words, $limit, $files);
}
}
if ($files === []) {
throw new McpException("范围内未检索到「{$query}」相关内容:可拆分关键词、去掉 scope 限定后重试");
}
return [
'query' => $query,
'scope' => $scope === '' ? 'all' : $scope,
'count' => count($files),
'total_hits' => $totalHits,
'list' => $files,
];
}
/**
* 检索单个路径(目录递归 / 单文件),把命中的文件写入结果集
* @param string $repoRoot 仓库根目录
* @param string $path 相对路径(目录或文件)
* @param string $scopeName 范围标识
* @param array<int, string> $words 关键词(文件级 AND)
* @param int $limit 结果集上限
* @param array<int, array> $files 结果集(引用写入)
* @return int 本路径贡献的命中文件数
*/
private function collectFiles(string $repoRoot, string $path, string $scopeName, array $words, int $limit, array &$files): int
{
$fullPath = $repoRoot . '/' . $path;
if (!file_exists($fullPath)) {
return 0;
}
$count = 0;
$targets = is_dir($fullPath) ? $this->markdownFiles($fullPath) : [$fullPath];
foreach ($targets as $file) {
if (count($files) >= $limit) {
break;
}
$hit = $this->searchFile($repoRoot, $file, $scopeName, $words);
if ($hit !== null) {
$files[] = $hit;
$count++;
}
}
return $count;
}
/**
* 递归收集目录下的 Markdown 文件(按文件名排序,跳过隐藏目录与超大文件)
* @param string $dir 目录绝对路径
* @return array<int, string> 文件绝对路径
*/
private function markdownFiles(string $dir): array
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
$files = [];
/** @var SplFileInfo $item */
foreach ($iterator as $item) {
if (!$item->isFile() || strtolower($item->getExtension()) !== 'md') {
continue;
}
// 跳过隐藏目录(如 .crmeb/skills 下的本地工具目录)与超大文件
$relative = str_replace('\\', '/', substr($item->getPathname(), strlen($dir) + 1));
foreach (explode('/', $relative) as $segment) {
if (str_starts_with($segment, '.')) {
continue 2;
}
}
if ($item->getSize() > self::MAX_FILE_SIZE) {
continue;
}
$files[] = $item->getPathname();
}
sort($files);
return $files;
}
/**
* 检索单个文件:全部关键词在文件内命中才算命中
* @param string $repoRoot 仓库根目录
* @param string $file 文件绝对路径
* @param string $scopeName 范围标识
* @param array<int, string> $words 关键词
* @return array|null 命中结果(含相对路径 / 标题 / 命中行),未命中返回 null
*/
private function searchFile(string $repoRoot, string $file, string $scopeName, array $words): ?array
{
$content = @file_get_contents($file);
if ($content === false || $content === '') {
return null;
}
// 大小写不敏感:统一转小写后定位,命中行仍取原文
$lower = mb_strtolower($content);
$lines = explode("\n", $content);
$lowerLines = explode("\n", $lower);
// 文件级 AND:每个关键词至少在一行出现
foreach ($words as $word) {
if (mb_strpos($lower, mb_strtolower($word)) === false) {
return null;
}
}
// 收集命中行(首个词所在的行),附上一行上下文
$matches = [];
$firstWord = mb_strtolower($words[0]);
foreach ($lowerLines as $index => $lowerLine) {
if (count($matches) >= self::MAX_MATCHES_PER_FILE) {
break;
}
if (!str_contains($lowerLine, $firstWord)) {
continue;
}
// 其余关键词要求在同窗口(命中行 ±1 行)内出现,保证摘录相关
$windowFrom = max(0, $index - 1);
$window = implode("\n", array_slice($lowerLines, $windowFrom, min(3, count($lowerLines) - $windowFrom)));
$relevant = true;
for ($i = 1; $i < count($words); $i++) {
if (!str_contains($window, mb_strtolower($words[$i]))) {
$relevant = false;
break;
}
}
if (!$relevant) {
continue;
}
$matches[] = [
'line' => $index + 1,
'text' => $this->clipLine($lines[$index] ?? ''),
];
}
if ($matches === []) {
return null;
}
return [
'file' => str_replace('\\', '/', substr($file, strlen($repoRoot) + 1)),
'scope' => $scopeName,
'title' => $this->pickTitle($lines),
'matches' => $matches,
];
}
/**
* 摘录单行并截断到上限
* @param string $line 原始行
* @return string 摘录文本
*/
private function clipLine(string $line): string
{
$line = trim($line);
if (mb_strlen($line) > self::MAX_LINE_LENGTH) {
return mb_substr($line, 0, self::MAX_LINE_LENGTH) . '…';
}
return $line;
}
/**
* 取文件标题(首个一级 / 二级标题行,无标题则取文件名)
* @param array<int, string> $lines 文件行
* @return string 标题
*/
private function pickTitle(array $lines): string
{
foreach (array_slice($lines, 0, 30) as $line) {
$line = trim($line);
if (preg_match('/^#{1,2}\s+(.+)$/u', $line, $m)) {
return trim($m[1]);
}
}
return '';
}
}