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

220 lines
10 KiB
PHP

<?php
declare(strict_types=1);
/**
* AI 数据目录生成器:静态扫描 app/adminapi/controller 下的全部接口,写出 app/mcp/catalog/generated.php。
*
* 用法(在 server 目录下):
* php app/mcp/cli/catalog.php 只打印统计,不写文件
* php app/mcp/cli/catalog.php --write 重新生成 generated.php
*
* 生成结果只是“盘点”:接口种类、列表类、HTTP 方式、疑似写操作、外部调用、可用参数。
* 是否对 AI 开放由运行时规则 + 人工审核文件 resources.php 共同决定(见 app/mcp/service/Catalog.php)。
*/
use think\App;
$root = dirname(__DIR__, 3) . DIRECTORY_SEPARATOR;
require $root . 'vendor/autoload.php';
(new App($root))->initialize();
$controllerRoot = $root . 'app' . DIRECTORY_SEPARATOR . 'adminapi' . DIRECTORY_SEPARATOR . 'controller';
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($controllerRoot, FilesystemIterator::SKIP_DOTS));
const WRITE_NAME = '/^(add|edit|del|delete|update|save|create|set|bind|unbind|sync|send|import|upload|assign|confirm|cancel|audit|refund|pay|void|copy|sort|change|reset|clear|mark|remove|retry|regenerate|review|generate|export|notify|callback|close|open|start|stop|withdraw|submit|apply|approve|reject|handle|push|rollback|restore|transfer|merge|split|adjust|lock|unlock|enable|disable|publish|login|logout|register|recall|resend|rebind|toggle|batch|move|release|finish|complete|init|install|upgrade|clean|purge|refresh|dispatch|run|execute|trigger|call|hangup|accept|invite|join|leave|kick|share|like|unlike|follow|unfollow|read|reply|forward|archive|unarchive|pin|unpin|star|unstar|download)/i';
const READ_NAME = '/^(lists?|detail|info|overview|stats?|statistics|summary|index|all|options?|trend|leaderboard|multi|reports?|statuses|progress|orders|records?|logs?|dict|count|search|query|check|preview|show|view|tree|config|get[A-Z]|[a-z]+(Lists?|Stats?|Statistics|Options|Trend|Lines|Breakdown|Detail|Info|Summary|Overview|Records?|Logs?|Count|Tree|Matrix|Board|Report|Reports|Data|History|Board)$)/';
const EXTERNAL = '/(Http::|curl_init|curl_exec|GuzzleHttp|new\s+Client\s*\(|easywechat|EasyWeChat|Qywx\w*(Api|Client)|qyapi\.weixin|api\.weixin|TencentCloud|file_get_contents\(\s*[\'"]https?:|HttpClient|Gancao\w*Service|EjPharmacy\w*Service|SmsDriver|sendSms|Tencent\w*Im\w*Service|\bTimService::|\bImService::|Kuaidi|express\w*Service|logisticsTrace)/i';
const WRITES = '/(->save\(|::create\(|->insert(All|GetId)?\(|->update\(\s*\[|::update\(\s*\[|->delete\(|::destroy\(|->inc\(|->dec\(|->setInc\(|->setDec\(|Db::execute|->saveAll\(|markAssignRead|->startTrans\(|Db::startTrans|::transaction\(|->exp\()/';
function useMap(string $source, string $namespace): array
{
$map = [];
if (preg_match_all('/^use\s+([^;\s]+)(?:\s+as\s+(\w+))?;/m', $source, $m, PREG_SET_ORDER)) {
foreach ($m as $u) {
$alias = $u[2] ?? '' ?: substr(strrchr('\\' . $u[1], '\\'), 1);
$map[$alias] = ltrim($u[1], '\\');
}
}
$map['__ns'] = $namespace;
return $map;
}
function resolveClass(string $short, array $uses): ?string
{
if (str_contains($short, '\\')) {
return ltrim($short, '\\');
}
if (isset($uses[$short])) {
return $uses[$short];
}
$guess = $uses['__ns'] . '\\' . $short;
return class_exists($guess) ? $guess : null;
}
function methodSource(ReflectionMethod $method): string
{
$file = $method->getFileName();
if (!$file || !is_file($file)) {
return '';
}
$lines = file($file);
return implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
}
function classMethodSource(string $class, string $method): string
{
try {
return methodSource(new ReflectionMethod($class, $method));
} catch (Throwable $e) {
return '';
}
}
/** 参数名:列表类 setSearch 的字段、$this->params['x']、request->get('x') 以及 Logic 里的 $params['x'] */
function scanParams(string $source): array
{
$params = [];
$patterns = [
'/\$this->params\[\s*[\'"](\w+)[\'"]\s*\]/',
'/\$params\[\s*[\'"](\w+)[\'"]\s*\]/',
'/->(?:get|param|post)\(\s*[\'"](\w+)(?:\/\w)?[\'"]/',
'/request\(\)->(?:get|param|post)\(\s*[\'"](\w+)(?:\/\w)?[\'"]/',
];
foreach ($patterns as $pattern) {
if (preg_match_all($pattern, $source, $m)) {
array_push($params, ...$m[1]);
}
}
return $params;
}
function scanSearch(string $listsClass): array
{
$source = classMethodSource($listsClass, 'setSearch');
if ($source === '') {
return [];
}
$fields = [];
if (preg_match_all('/[\'"]([a-z_]+\.)?([a-z_]\w*)[\'"]/i', $source, $m, PREG_SET_ORDER)) {
foreach ($m as $f) {
$name = $f[2];
if (in_array($name, ['in', 'like', 'between', 'between_time', 'find_in_set'], true)) {
continue;
}
$fields[] = $name;
}
}
if (str_contains($source, 'between_time')) {
array_push($fields, 'start_time', 'end_time');
}
if (preg_match("/['\"]between['\"]/", $source)) {
array_push($fields, 'start', 'end');
}
return $fields;
}
$inventory = [];
foreach ($files as $file) {
if (!str_ends_with($file->getFilename(), 'Controller.php')) {
continue;
}
$source = file_get_contents($file->getPathname());
if (!preg_match('/^namespace\s+([^;]+);/m', $source, $ns) || !preg_match('/^\s*(?:final\s+|abstract\s+)?class\s+(\w+)/m', $source, $cls)) {
continue;
}
$class = $ns[1] . '\\' . $cls[1];
if (!class_exists($class)) {
continue;
}
$ref = new ReflectionClass($class);
if ($ref->isAbstract()) {
continue;
}
$uses = useMap($source, $ns[1]);
$sub = trim(substr($ns[1], strlen('app\\adminapi\\controller')), '\\');
$dotted = ($sub === '' ? '' : str_replace('\\', '.', $sub) . '.') . lcfirst(substr($cls[1], 0, -strlen('Controller')));
$notNeedLogin = $ref->getDefaultProperties()['notNeedLogin'] ?? [];
foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->isStatic() || $method->getDeclaringClass()->getName() !== $class || str_starts_with($method->getName(), '__') || in_array($method->getName(), ['initialize', 'isNotNeedLogin'], true)) {
continue;
}
$action = $method->getName();
$body = methodSource($method);
$lists = null;
if (preg_match('/dataLists\(\s*new\s+\\\\?([\w\\\\]+)\s*\(/', $body, $lm)) {
$lists = resolveClass($lm[1], $uses);
}
$logicCalls = [];
$logicSource = '';
if (preg_match_all('/\b(\w+Logic|\w+Service)::(\w+)\(/', $body, $calls, PREG_SET_ORDER)) {
foreach ($calls as $call) {
$logicClass = resolveClass($call[1], $uses);
if ($logicClass) {
$logicCalls[] = $call[1] . '::' . $call[2];
$logicSource .= classMethodSource($logicClass, $call[2]);
}
}
}
$listsSource = '';
if ($lists && class_exists($lists)) {
$listsRef = new ReflectionClass($lists);
$listsSource = (string) file_get_contents($listsRef->getFileName());
}
$post = (bool) preg_match('/->post\(\)|->isPost\(\)|\$this->request->post\(|request\(\)->post\(/', $body);
if ($lists) {
$kind = 'list';
} elseif (preg_match(WRITE_NAME, $action) && !preg_match(READ_NAME, $action)) {
$kind = 'write';
} elseif (preg_match('/detail|Detail/', $action) || preg_match("/goCheck\(\s*['\"](detail|id)['\"]/", $body)) {
$kind = 'detail';
} elseif (preg_match(READ_NAME, $action)) {
$kind = 'report';
} else {
$kind = 'other';
}
$scanSource = $body . $logicSource;
$writes = [];
if (preg_match_all(WRITES, $body . ($kind === 'list' ? '' : $logicSource), $wm)) {
$writes = array_values(array_unique($wm[1]));
}
$external = [];
if (preg_match_all(EXTERNAL, $scanSource . $listsSource, $em)) {
$external = array_values(array_unique($em[1]));
}
$params = scanParams($body . $logicSource . $listsSource);
if ($lists) {
$params = array_merge(scanSearch($lists), $params);
}
$params = array_values(array_unique(array_filter($params, static fn ($p) => !in_array($p, ['page_no', 'page_size', 'page_type', 'export', 'page_start', 'page_end'], true))));
$inventory[$dotted . '/' . $action] = [
'controller' => $class,
'action' => $action,
'kind' => $kind,
'lists' => $lists,
'http' => $post ? 'POST' : 'GET',
'writes' => $writes,
'external' => $external,
'logic' => array_values(array_unique($logicCalls)),
'params' => $params,
'no_login' => in_array($action, (array) $notNeedLogin, true),
];
}
}
ksort($inventory);
$counts = array_count_values(array_column($inventory, 'kind'));
ksort($counts);
echo 'controllers scanned, actions: ' . count($inventory) . PHP_EOL;
foreach ($counts as $kind => $n) {
echo str_pad($kind, 8) . $n . PHP_EOL;
}
echo 'with external calls: ' . count(array_filter($inventory, static fn ($r) => $r['external'])) . PHP_EOL;
echo 'read-kind with write markers: ' . count(array_filter($inventory, static fn ($r) => $r['writes'] && in_array($r['kind'], ['list', 'report', 'detail'], true))) . PHP_EOL;
if (in_array('--write', $argv, true)) {
$target = $root . 'app' . DIRECTORY_SEPARATOR . 'mcp' . DIRECTORY_SEPARATOR . 'catalog' . DIRECTORY_SEPARATOR . 'generated.php';
$header = "<?php\n// 由 php app/mcp/cli/catalog.php --write 生成,请勿手工修改;人工审核结论写在 resources.php。\n// 生成时间:" . date('Y-m-d H:i:s') . "\nreturn ";
file_put_contents($target, $header . var_export($inventory, true) . ";\n");
echo 'written: ' . $target . PHP_EOL;
}