谈谈 API 接口自描述与权限配置系统
起因
说实话,之前项目的 API 权限管理挺乱的。接口描述散落在 api-registry.ts 里,每次新增接口都得去那个文件手动注册,漏了就出问题。MCP 工具的开关也是硬编码的,想动态调整根本没戏。
最近花时间搞了一套 API 接口自描述系统,把这些问题一并解决了。本文记录下踩坑点,避免后续踩同样的坑。
方案设计
核心思路很简单:每个 route.ts 文件自己声明自己。
typescript
// src/app/api/post/create/route.ts
import type { ApiDescriptor } from '@/types/api-descriptor';
import { POST_CREATE } from '@/constants/permissions';
export const descriptor: ApiDescriptor = {
code: 'post_create',
name: '创建文章',
module: 'post',
method: 'POST',
permissionCode: POST_CREATE,
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: '文章标题' },
content: { type: 'string', description: '文章内容' },
},
required: ['title', 'content'],
},
};
export async function POST(request: NextRequest) {
// 原有逻辑不变
}
一个文件有多个接口?用 xxxDescriptor 命名就行:
typescript
export const getDescriptor: ApiDescriptor = { /* GET */ };
export const updateDescriptor: ApiDescriptor = { /* PUT */ };
export const deleteDescriptor: ApiDescriptor = { /* DELETE */ };
扫描脚本
写了个脚本 pnpm sync:api-registry,扫描所有 route.ts 文件,自动同步到数据库。
一开始想用字符串解析的方式提取 descriptor,结果发现根本没法处理导入的权限码常量。后来改成动态 import,直接读取模块导出,代码从 238 行缩减到 155 行,还省了一堆重复定义。
踩坑记录
踩坑 1:MCP 工具列表动态过滤
改完后台接口管理页面后,想把 MCP 工具列表也做成动态的 — 不同权限的用户看到不同的工具。
一开始以为 MCP 协议限制了,HTTP 形式每次请求会重建连接所以 tools/list 是静态的。后来发现 HTTP 形式的 MCP 是无状态的,每次 POST 请求都会重新 createMcpServer,完全可以提前认证获取用户权限,然后只注册有权限的工具。
typescript
// src/app/api/mcp/route.ts
async function createMcpServer(headers: Headers) {
const token = getTokenFromRequest(headers);
const user = token ? await validateTokenWithPermissions(token) : null;
const allMcpEntries = await getMcpEnabledEntries();
const mcpEntries = user
? allMcpEntries.filter(entry =>
!entry.permissionCode || user.permissions.includes(entry.permissionCode)
)
: allMcpEntries;
// 只注册有权限的工具...
}
结论:HTTP 形式的 MCP 不存在连接保持的问题,每次请求独立处理,动态过滤完全可以做。
踩坑 2:数据库才是 Source of Truth
之前 getMcpEnabledEntries() 读的是代码里的 API_REGISTRY 常量。结果在后台手动添加的接口怎么配置都不生效,工具列表永远是代码里写死的那些。
改成从数据库读取后,后台管理的配置才能实时生效。代码里的 API_REGISTRY 现在只负责提供 handler(业务逻辑),配置数据以数据库为准。
踩坑 3:同步脚本误杀接口
同步脚本有个「禁用代码中找不到的接口」的逻辑。问题出在 API_REGISTRY 中有 handler 的接口(如 post_create),它们的 descriptor 是在 api-registry.ts 里声明的而不是 route.ts 里,扫描路由文件时扫不到。
结果同步脚本认为这些接口「代码中不存在」,把它们全部禁用了(status=0, mcp_enabled=0)。排查了很久才发现是 notIn: codes 的范围不对。
修复:禁用逻辑增加条件,保留有 handler 的接口不被误杀。
踩坑 4:没有 handler 的接口 MCP 开关不能只是关闭
后台可以给任何接口开启 MCP 开关,但如果代码里没有实现 handler,开了也没用,还会让用户看到有工具名但调用失败,很困惑。
解决方案:
- 数据库加
mcp_available字段,标记代码中是否有 handler - 同步脚本维护这个字段
- 前端 Switch 根据这个字段
disabled,不能启用 - 后端 API 拦截,禁止启用没有 handler 的接口
踩坑 5:同步脚本进程不退出
脚本扫描路由时通过动态 import 加载各种模块,有些模块会初始化 Redis 连接。脚本结束时只关闭了 Prisma 连接,Redis 连接没关,导致进程挂起。
解决:process.exit(0) 强制退出。
踩坑 6:Prisma query 日志刷屏
开发环境 Prisma 默认打印所有 SQL 查询,调试时很吵。
在 src/lib/prisma.ts 里原来是写死 log: ['query', 'error', 'warn'],改成支持环境变量控制:
typescript
log: process.env.PRISMA_HIDE_QUERY_LOG === 'true'
? ['error', 'warn']
: (process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']),
.env 里加 PRISMA_HIDE_QUERY_LOG=true 就能屏蔽。也可以直接改成 log: ['error']。
总结
踩坑的核心教训:当「配置」和「代码」两套体系并存时,一定要明确哪个是 Source of Truth,不能两边都当真。这次就是两边不一致导致各种奇怪的问题。