220 lines
10 KiB
PHP
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;
|
|
}
|