'诊单与处方', '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', ]; }