Files
zyt/server/app/mcp/service/Catalog.php
T
2026-09-24 11:03:44 +08:00

219 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace app\mcp\service;
/**
* AI 数据目录:把后台全部接口的盘点结果(catalog/generated.php)与人工审核结论(catalog/resources.php)合并,
* 再结合线上菜单(权限点是否登记、中文名称、所属目录)得出每个资源的开放状态:
* open 已开放:以调用账号身份执行后台原有代码
* pending 待审核:说明原因(未登记权限点、详情缺逐条校验、调用外部接口、疑似写库……)
* excluded 不开放:写操作、免登录接口、凭据类配置
*/
class Catalog
{
public const OPEN = 'open';
public const PENDING = 'pending';
public const EXCLUDED = 'excluded';
/** 任何资源都不接受的参数:导出、关闭分页、扩大数据范围的旁路开关等 */
public const GLOBAL_FORBID = ['export', 'page_type', 'page_start', 'page_end', 'progress_board', 'pending_assign',
'diag_scope_relax', 'scene', 'apply_data_scope', '_method', 'token', 'callback', 'jsonp', 'file', 'ids_all'];
private const DOMAINS = [
'tcm' => '诊单与处方', 'doctor' => '医生、挂号与排班', 'order' => '订单与收款', 'stats' => '数据统计',
'firstvisit' => '初诊与转化', 'qywx' => '企业微信', 'finance' => '财务', 'auth' => '员工与权限',
'dept' => '组织架构', 'user' => '用户', 'pharmacy' => '药房', 'setting' => '系统设置', 'recharge' => '充值',
'article' => '文章', 'notice' => '消息通知', 'channel' => '渠道设置', 'decorate' => '装修', 'crontab' => '定时任务',
'tools' => '开发工具', 'asset' => '资产', 'fan' => '粉丝', 'chat' => '消息', 'oa' => 'OA', 'patient' => '患者',
];
private static ?array $all = null;
/** 合并后的全部资源(键为资源标识,即权限点写法) */
public static function all(): array
{
if (self::$all !== null) {
return self::$all;
}
$dir = app()->getRootPath() . 'app' . DIRECTORY_SEPARATOR . 'mcp' . DIRECTORY_SEPARATOR . 'catalog' . DIRECTORY_SEPARATOR;
$generated = is_file($dir . 'generated.php') ? (array) require $dir . 'generated.php' : [];
$reviewed = is_file($dir . 'resources.php') ? (array) require $dir . 'resources.php' : [];
$menus = PermissionService::menuIndex();
$all = [];
foreach ($generated + $reviewed as $key => $_) {
$entry = array_merge(['kind' => 'report', 'http' => 'GET', 'writes' => [], 'external' => [], 'params' => [], 'no_login' => false],
$generated[$key] ?? [], $reviewed[$key] ?? []);
$entry['key'] = $key;
$entry['perm'] = self::effectivePerm((string) ($entry['perm'] ?? $key), (array) ($entry['perm_fallback'] ?? []), $menus);
$entry['reviewed'] = isset($reviewed[$key]);
$menu = $menus[PermissionService::normalize($entry['perm'])] ?? null;
$entry['registered'] = $menu !== null;
$entry['name'] = $entry['name'] ?? self::menuName($menu) ?? $key;
$entry['domain'] = $entry['domain'] ?? (($menu['top'] ?? '') ?: (self::DOMAINS[strtok($key, './')] ?? '其他'));
[$entry['status'], $entry['reason']] = self::decide($entry);
$all[$key] = $entry;
}
ksort($all);
return self::$all = $all;
}
public static function get(string $key): ?array
{
$all = self::all();
if (isset($all[$key])) {
return $all[$key];
}
$normalized = PermissionService::normalize($key);
foreach ($all as $k => $entry) {
if (PermissionService::normalize($k) === $normalized) {
return $entry;
}
}
return null;
}
/** 该账号可以查询的资源(已开放 + 拥有权限点) */
public static function openFor(Identity $identity): array
{
return array_filter(self::all(), static fn ($r) => $r['status'] === self::OPEN && $identity->can($r['perm']));
}
/** 资源对某账号的可用性:返回 null 表示可用,否则返回给模型看的原因 */
public static function denialFor(Identity $identity, ?array $resource): ?string
{
if ($resource === null) {
return '没有这个数据资源,请先用 zyt_catalog 查看可查询的资源';
}
if ($resource['status'] !== self::OPEN) {
return '「' . $resource['name'] . '」暂未对 AI 开放:' . $resource['reason'];
}
if (!$identity->can($resource['perm'])) {
return '无权限:当前账号没有「' . $resource['name'] . '」(' . $resource['perm'] . ')权限,请联系管理员开通';
}
return null;
}
public static function counts(): array
{
$counts = [self::OPEN => 0, self::PENDING => 0, self::EXCLUDED => 0];
foreach (self::all() as $r) {
$counts[$r['status']]++;
}
return $counts;
}
/** 资源允许的查询参数:审核文件给了 params_allow 就只用它,否则用扫描结果去掉禁用参数 */
public static function allowedParams(array $resource): array
{
$forbid = array_merge(self::GLOBAL_FORBID, (array) ($resource['forbid'] ?? []));
if (isset($resource['params_allow'])) {
$allow = array_keys((array) $resource['params_allow']);
} elseif (!empty($resource['handler']['table'])) {
$allow = array_merge(array_keys((array) ($resource['handler']['filters'] ?? [])), empty($resource['handler']['date']) ? [] : ['start_date', 'end_date']);
} else {
$allow = (array) $resource['params'];
}
return array_values(array_diff(array_unique($allow), $forbid));
}
/** 参数说明:审核文件的中文说明优先,其次常见字段词典 */
public static function paramDocs(array $resource): array
{
$docs = [];
foreach (self::allowedParams($resource) as $name) {
$docs[$name] = (string) (($resource['params_allow'][$name] ?? null) ?: (self::PARAM_WORDS[$name] ?? ''));
}
return $docs;
}
public static function reset(): void
{
self::$all = null;
}
/**
* 实际用来判断的权限点。有些子接口在部分环境没有单独登记权限点:后台对未登记接口不做校验,
* 页面靠 Tab/页面权限控制能不能看到。审核文件用 perm_fallback 按顺序给出替代项:
* 自身已登记就用自身(与后台完全一致),否则用第一个已登记的替代项(与后台页面可见性一致),
* 从不比后台页面更宽。
*/
private static function effectivePerm(string $perm, array $fallback, array $menus): string
{
if ($fallback === [] || isset($menus[PermissionService::normalize($perm)])) {
return $perm;
}
foreach ($fallback as $candidate) {
if (isset($menus[PermissionService::normalize((string) $candidate)])) {
return (string) $candidate;
}
}
return $perm;
}
private static function decide(array $r): array
{
if (isset($r['status'])) {
$status = (string) $r['status'];
if ($status === self::OPEN && !$r['registered']) {
return [self::PENDING, '权限点 ' . $r['perm'] . ' 未在菜单登记或已停用,登记后自动开放'];
}
return [$status, (string) ($r['reason'] ?? '')];
}
if ($r['no_login']) {
return [self::EXCLUDED, '免登录接口,不属于后台账号数据'];
}
// 系统配置、渠道/支付/短信设置、开发工具、定时任务等可能返回密钥或服务器信息,默认不开放(审核文件可单独放开)
if (preg_match('#^(setting|channel|notice|tools|crontab|decorate|login|iam|desktop|upload|file|download|config)[./]#', $r['key'])
|| preg_match('#/(getConfig|config|info|environment)$#i', $r['key'])) {
return [self::EXCLUDED, '系统配置或工具类接口(可能含密钥或服务器信息),不对 AI 开放'];
}
if ($r['kind'] === 'write' || $r['http'] === 'POST') {
return [self::EXCLUDED, '写操作或需要提交的接口,AI 只读'];
}
if ($r['external']) {
return [self::PENDING, '会调用外部接口(' . implode('、', array_slice($r['external'], 0, 3)) . '),需人工审核'];
}
if ($r['writes']) {
return [self::PENDING, '检测到写库代码(' . implode('、', array_slice($r['writes'], 0, 3)) . '),需人工审核'];
}
if ($r['kind'] === 'detail') {
return [self::PENDING, '详情接口需确认有逐条权限校验后开放'];
}
if ($r['kind'] === 'other') {
return [self::PENDING, '接口用途需人工确认'];
}
if (!$r['registered']) {
return [self::PENDING, '权限点 ' . $r['perm'] . ' 未在菜单登记,后台对这类接口不做权限校验,登记后自动开放'];
}
return [self::OPEN, ''];
}
private static function menuName(?array $menu): ?string
{
if (!$menu) {
return null;
}
if ($menu['type'] === 'A' && $menu['parent'] !== '') {
return $menu['parent'] . ' · ' . $menu['name'];
}
return $menu['name'];
}
/** 常见查询参数的中文含义(审核文件可覆盖) */
private const PARAM_WORDS = [
'id' => '记录ID', 'keyword' => '关键字(姓名/手机号等模糊匹配)', 'name' => '名称(模糊)', 'status' => '状态',
'start_time' => '开始时间 YYYY-MM-DD HH:mm:ss', 'end_time' => '结束时间 YYYY-MM-DD HH:mm:ss',
'start_date' => '开始日期 YYYY-MM-DD', 'end_date' => '结束日期 YYYY-MM-DD', 'date' => '日期 YYYY-MM-DD',
'month' => '月份 YYYY-MM', 'time_type' => '时间范围 today/week/month/custom', 'days' => '最近天数',
'patient_name' => '患者姓名(模糊)', 'patient_id' => '患者/诊单ID', 'diagnosis_id' => '诊单ID',
'doctor_id' => '医生(后台账号)ID', 'doctor_name' => '医生姓名', 'assistant_id' => '医助(后台账号)ID',
'dept_id' => '部门ID', 'dept_ids' => '部门ID,多个用逗号分隔', 'creator_id' => '创建人ID', 'order_no' => '订单号',
'order_type' => '订单类型', 'sn' => '编号', 'phone' => '手机号', 'mobile' => '手机号', 'gender' => '性别',
'role_id' => '角色ID', 'channel_code' => '渠道编码', 'prescription_id' => '处方ID', 'appointment_type' => '问诊方式',
'appointment_date' => '预约日期 YYYY-MM-DD', 'field' => '排序字段', 'order_by' => '排序方向 asc/desc',
];
}