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

138 lines
6.4 KiB
PHP

<?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;
}