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
+219
View File
@@ -0,0 +1,219 @@
<?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;
}
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
/**
* AI 数据目录:数据表覆盖检查。逐张表判断它能否通过 AI 查到:
* endpoint 后台接口(adminapi)用到这张表,由目录里的接口资源覆盖
* table 目录里有针对这张表的“数据表资源”(后台没有页面的表)
* system 凭据、会话、配置、日志、队列等系统表,不对 AI 开放
* uncovered 以上都不是:需要补一个数据表资源或写明不开放
*
* 用法(在 server 目录下,连接预发/测试库):
* php app/mcp/cli/coverage.php 打印覆盖报告
* php app/mcp/cli/coverage.php --write-tables 为 uncovered 的表生成 review/tables.php(仅超级管理员可查,去掉凭据列)
*/
use app\mcp\service\Catalog;
use think\App;
use think\facade\Db;
use think\helper\Str;
$root = dirname(__DIR__, 3) . DIRECTORY_SEPARATOR;
require $root . 'vendor/autoload.php';
(new App($root))->initialize();
const SYSTEM_TABLE = '/(session|token|^config$|_config$|^dev_|generate|crontab|^jobs|migration|install|^ai_grant$|^ai_access_log$|operation_log|^system_|^decorate|^notice_setting|^sms_log|file_cate|^file$|^article|^hot_search|^dict_|^iam_|^admin_role$|^admin_dept$|^admin_jobs$|^jobs$|pay_config|pay_way|^refund_log$|^recharge_order$|^user_auth$|^asset_|_cursor$|provider_state|_inbox$|^prescription_ai_(attempt|limit|request)$|query_log$|patient_trtc|click_log$|allocator$)/';
const CREDENTIAL_COLUMN = '/(password|salt|secret|token|cipher|session_key|private_key|api_key|app_key|access_key|aes_key|signature|sign_key|user_?sig|cookie|credential|ticket)/i';
$prefix = (string) config('database.connections.' . config('database.default') . '.prefix');
$tables = [];
foreach (Db::query('SHOW TABLES') as $row) {
$name = (string) array_values($row)[0];
if ($prefix === '' || str_starts_with($name, $prefix)) {
$tables[] = substr($name, strlen($prefix));
}
}
sort($tables);
// 后台接口涉及的表:adminapi 代码里 Db::name/table 直接写的表名,以及 use 的模型类对应的表
$modelTable = static function (string $class) use ($prefix): ?string {
if (!class_exists($class)) {
return null;
}
$ref = new ReflectionClass($class);
if ($ref->isAbstract() || !$ref->isSubclassOf(\think\Model::class)) {
return null;
}
$defaults = $ref->getDefaultProperties();
if (!empty($defaults['table'])) {
return preg_replace('/^' . preg_quote($prefix, '/') . '/', '', (string) $defaults['table']);
}
return !empty($defaults['name']) ? (string) $defaults['name'] : Str::snake($ref->getShortName());
};
$reachable = [];
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . 'app' . DIRECTORY_SEPARATOR . 'adminapi', FilesystemIterator::SKIP_DOTS));
foreach ($files as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$source = (string) file_get_contents($file->getPathname());
if (preg_match_all('/(?:Db::|->)(?:name|table)\(\s*[\'"](\w+)/', $source, $m)) {
foreach ($m[1] as $table) {
$reachable[preg_replace('/^' . preg_quote($prefix, '/') . '/', '', $table)] = true;
}
}
if (preg_match_all('/^use\s+(app\\\\common\\\\model\\\\[\w\\\\]+);/m', $source, $m)) {
foreach ($m[1] as $class) {
if ($table = $modelTable($class)) {
$reachable[$table] = true;
}
}
}
}
$tableResources = [];
foreach (Catalog::all() as $key => $resource) {
if (!empty($resource['handler']['table'])) {
$tableResources[$resource['handler']['table']] = $key;
}
}
$report = [];
foreach ($tables as $table) {
$report[$table] = isset($tableResources[$table]) ? 'table' : (preg_match(SYSTEM_TABLE, $table) ? 'system' : (isset($reachable[$table]) ? 'endpoint' : 'uncovered'));
}
$counts = array_count_values($report);
ksort($counts);
echo 'tables: ' . count($tables) . ' ' . json_encode($counts) . PHP_EOL;
foreach ($report as $table => $status) {
if ($status === 'uncovered' || in_array('--verbose', $argv, true)) {
echo str_pad($status, 10) . $table . PHP_EOL;
}
}
if (in_array('--write-tables', $argv, true)) {
$entries = [];
foreach ($report as $table => $status) {
if ($status !== 'uncovered' && $status !== 'table') {
continue;
}
$columns = [];
$types = [];
foreach (Db::query('SHOW COLUMNS FROM `' . $prefix . $table . '`') as $column) {
$types[$column['Field']] = strtolower((string) $column['Type']);
if (!preg_match(CREDENTIAL_COLUMN, (string) $column['Field'])) {
$columns[] = $column['Field'];
}
}
$filters = [];
foreach ($columns as $column) {
if ($column === 'id' || str_ends_with($column, '_id') || in_array($column, ['status', 'type'], true)) {
$filters[$column] = '=';
}
}
$date = isset($types['create_time']) ? 'create_time' : null;
$entries['table.' . $table . '/lists'] = [
'status' => 'open',
'kind' => 'table',
'perm' => 'ai.mcp/tables',
'name' => '数据表 ' . $table,
'domain' => '其他数据表',
'note' => '后台没有页面的数据表:目前仅超级管理员可查;如需给其他角色开放,把 scope 改为属主列(如 [\'owner\' => [\'doctor_id\']])并审核',
'handler' => array_filter([
'table' => $table,
'columns' => $columns,
'filters' => $filters,
'date' => $date,
'date_type' => $date && str_contains($types[$date], 'int') ? 'int' : ($date ? 'datetime' : null),
'soft_delete' => isset($types['delete_time']) ? 'delete_time' : null,
'order' => in_array('id', $columns, true) ? 'id desc' : null,
'scope' => 'root',
], static fn ($v) => $v !== null),
];
}
$target = $root . 'app' . DIRECTORY_SEPARATOR . 'mcp' . DIRECTORY_SEPARATOR . 'catalog' . DIRECTORY_SEPARATOR . 'review' . DIRECTORY_SEPARATOR . 'tables.php';
$header = "<?php\n// 后台没有页面的数据表(php app/mcp/cli/coverage.php --write-tables 生成,可手工调整 scope/columns)。\n// 生成时间:" . date('Y-m-d H:i:s') . "\nreturn ";
file_put_contents($target, $header . var_export($entries, true) . ";\n");
echo 'written ' . count($entries) . ' table resources: ' . $target . PHP_EOL;
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* AI 数据目录探测:以指定后台账号的身份,在只读事务里逐个执行候选资源,报告哪些正常、哪些会写库、哪些报错。
* 用于人工审核(在测试/预发环境的数据库上运行,不要在生产库上跑)。
*
* 用法(在 server 目录下):
* php app/mcp/cli/probe.php --admin=1 [--only=tcm.] [--external] [--json=runtime/probe.json]
* --admin 用哪个后台账号(ID)的权限执行,建议用 root 账号看全貌
* --only 只探测以此开头的资源
* --external 也探测会调用外部接口的资源(默认跳过)
* 注意:只读事务只能挡住写库;起进程、写缓存、调外部接口挡不住。所以写操作类、已标记不开放的、
* 以及扫描出外部调用的资源默认一律不执行。
*/
use app\common\model\auth\Admin;
use app\mcp\service\Catalog;
use app\mcp\service\Dispatcher;
use app\mcp\service\Identity;
use think\App;
$root = dirname(__DIR__, 3) . DIRECTORY_SEPARATOR;
require $root . 'vendor/autoload.php';
$app = new App($root);
$app->initialize();
$options = getopt('', ['admin:', 'only:', 'external', 'json:']);
$admin = Admin::where('id', (int) ($options['admin'] ?? 0))->findOrEmpty();
if ($admin->isEmpty()) {
fwrite(STDERR, "请用 --admin=<后台账号ID> 指定执行身份\n");
exit(1);
}
$identity = new Identity(['id' => 0, 'expire_time' => time() + 3600], $admin);
$only = (string) ($options['only'] ?? '');
$results = [];
foreach (Catalog::all() as $key => $resource) {
if ($only !== '' && !str_starts_with($key, $only)) {
continue;
}
if ($resource['kind'] === 'write' || $resource['http'] === 'POST' || !empty($resource['no_login']) || $resource['status'] === Catalog::EXCLUDED) {
continue;
}
if (!isset($options['external']) && !empty($resource['external'])) {
continue;
}
$params = match ($resource['kind']) {
'list' => ['page_no' => 1, 'page_size' => 3, 'page_type' => 1],
'detail' => [(string) ($resource['guard']['param'] ?? $resource['id_param'] ?? 'id') => 1],
default => [],
};
$started = microtime(true);
$envelope = Dispatcher::call($identity, $resource, array_merge($params, (array) ($resource['force'] ?? [])));
$ms = (int) round((microtime(true) - $started) * 1000);
$msg = $envelope['msg'];
$outcome = $envelope['code'] === 1 ? 'ok' : (str_contains($msg, '只读保护') ? 'writes' : (str_contains($msg, '查询失败') ? 'error' : 'fail'));
$rows = is_array($envelope['data']['lists'] ?? null) ? count($envelope['data']['lists']) : null;
$results[$key] = ['status' => $resource['status'], 'kind' => $resource['kind'], 'outcome' => $outcome, 'msg' => mb_substr($msg, 0, 120), 'rows' => $rows, 'ms' => $ms];
printf("%-8s %-8s %-7s %5dms %s %s\n", $outcome, $resource['status'], $resource['kind'], $ms, $key, $outcome === 'ok' ? '' : mb_substr($msg, 0, 80));
}
$summary = array_count_values(array_column($results, 'outcome'));
ksort($summary);
echo PHP_EOL . json_encode($summary, JSON_UNESCAPED_UNICODE) . PHP_EOL;
if (!empty($options['json'])) {
file_put_contents($root . $options['json'], json_encode($results, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}