项目:nestjs-admin | 版本:v1.0 | 生成日期:2026-10-06 | 依据:源码分析
系统对外只暴露一个 HTTP 入口:网关 :3040,全局前缀 /api(gateway/src/main.ts 的 setGlobalPrefix('api'),唯一例外是 GET /uploads/:key 被显式 exclude,供前端代理路径不变)。网关启用了 URI 版本控制(VersioningType.URI,VERSION 环境变量为空时取 VERSION_NEUTRAL,即不带版本号段也可路由),实际所有控制器都未标注版本,因此线上路径形如 /api/auth/signin。前端所有请求经 vite 代理(开发)或 nginx 反代(生产)到达网关,网关再以 gRPC 调用 user/system 两个微服务——业务服务不直接暴露任何 HTTP 接口,它们的"接口"即 proto 契约中的 RPC 方法(proto-pkg/proto/user.proto 26 个、system.proto 31 个,各另含 1 个由 proto-pkg HealthModule 应答的 Health.Check)。
辅助入口两个:health 服务 :3030 的 GET /api/v1/health?service=<key>(Consul 健康检查用)与 Swagger 文档 GET /api/docs(main.ts 中 swaggerEnabled = SWAGGER_ENABLED ?? (NODE_ENV !== 'production'),生产默认关闭)。
这套接口体系有五条贯穿性的设计原则,理解它们比记住任何单个端点都重要。其一,单一入口原则:前端永不直连业务服务,所有跨切能力(认证、限流、日志、协议转换)因这个收口而成为可能,也意味着网关是整个系统的安全边界与性能边界。其二,契约先行:每个端点背后都有 proto 契约背书,网关控制器的职责被压缩到"守卫 + 透传 + 形状微调"三层,几乎不写业务逻辑——这是有意为之的薄网关(BFF)取向,业务规则全部下沉到微服务。其三,权限码即元数据:接口的访问控制不写死在代码配置里,而是引用数据库中的权限码字符串,改权限不改代码。其四,错误即契约:错误体结构与状态码映射规则全局唯一(异常过滤器单点实现),前端只需要写一次错误适配。其五,日志即副产品:接口不自带日志代码,可观测性由中间件在协议层统一供给。这五条原则共同保证了"加接口"是低认知负荷的流水线作业,而"改接口语义"则必须先改 proto、全链路评估。
每个请求进入网关后依次经过(装配位置见 app.module.ts 与 main.ts):
trust proxy 1:反代链路下 req.ip 取 XFF 真实客户端 IP(main.ts:21),保证限流按真实来源分桶。app.use(helmet()))。ThrottlerModule.forRoot([{ttl:60_000, limit:100}]));敏感口单独 @Throttle 收紧:signin 10/min、signup 5/min、refresh 30/min(auth.controller.ts 各装饰器)。触发限流返回 429。traceId 并回写 X-Request-Id 响应头;Bearer 请求刷新会话活跃时间;响应 finish 后按规则采集登录/操作日志(见 01 篇 §4)。AuthGuard('jwt')(解析 Bearer JWT + JwtStrategy.validate 校验内存会话存在性)→ PoliciesGuard(读取 @CheckPolicies 元数据,经 AuthorizationService.getAbility(username) 取 60s 缓存的 CASL 能力并判定)。GrpcExceptionInterceptor(auth 控制器挂载)、全局 AllExceptionsFilter 统一错误形态与系统日志落库。路由约定:RESTful 风格,资源名复数;批量删除用 DELETE /xxx/:ids(ids 逗号分隔),清空用 DELETE /xxx/clean;子资源动作用动词后缀(PUT /users/:id/roles、PUT /notices/:id/read)。
"守卫"列:🔓 公开(仅限流);🔒JWT(登录即可);🔑Perm(登录 + 指定权限码)。响应为网关 JSON 直出(非信封式,前端
api/*.ts负责适配)。
gateway/src/auth/auth.controller.ts,@Controller('auth'))| 方法 | 路径 | 守卫/限流 | 用途 | 关键入参 | 响应要点 |
|---|---|---|---|---|---|
| POST | /api/auth/signin | 🔓 10/min | 登录,签发双 token 并登记内存会话 | {username, password}(SigninUserDto) | {userId, access_token, refresh_token, expires_in:7200} |
| POST | /api/auth/refresh | 🔓 30/min | 换发 access token(会话不换) | {refreshToken} | {access_token, expires_in};会话被踢时 401 |
| POST | /api/auth/signup | 🔓 5/min | 注册(argon2id 落库,重复 409) | {username, password} | PublicUserDto 序列化后的用户公开字段 |
| POST | /api/auth/logout | 🔒JWT | 登出(删内存会话,access+refresh 双失效) | 无(token 取自头) | {ok:true} |
| GET | /api/auth/me | 🔒JWT | 当前用户摘要(角色来自 user 服务,失败降级空) | 无 | {userId, username, roles[], nickname, avatar} |
| GET | /api/auth/profile | 🔒JWT | 完整资料 | 无 | {id, username, status, deptId, deptName, roleIds, roleNames, nickname, phone, email, avatar, createdAt} |
| PUT | /api/auth/profile | 🔒JWT | 改资料 | {nickname?, phone?, email?} | 最新 profile |
| PUT | /api/auth/password | 🔒JWT | 改密码(验旧密码;p1user 403) | {oldPassword, newPassword} | {ok:true} |
| POST | /api/auth/avatar | 🔒JWT | 上传头像(multipart file,图片 ≤2MB,存 MinIO) | form-data | {url:"/uploads/avatar-xxx"} |
| GET | /api/auth/settings | 🔒JWT | 偏好+密保读取 | 无 | {preferences(JSON串), securityQuestion, backupEmail, phone} |
| PUT | /api/auth/preferences | 🔒JWT | 更新偏好(JSON 合法性校验在 user 服务) | {preferences:{...}} | {ok:true} |
| PUT | /api/auth/security | 🔒JWT | 更新密保 | {securityQuestion?, securityAnswer?, backupEmail?, phone?} | {ok:true} |
| GET | /api/auth/profile-demo | 🔑Perm read user | CASL 演示端点 | 无 | {userId, username} |
gateway/src/system/,均 🔒JWT + 各自权限码)| 方法 | 路径 | 权限码 | 用途 | 响应要点 |
|---|---|---|---|---|
| GET | /api/system/menus | system:menu:list | 全量菜单树(平铺) | {menus:[MenuData]} |
| POST/PUT/DELETE | /api/system/menus( /:id) | system:menu:create/update/delete | 菜单 CRUD | 菜单对象 / {ok:true} |
| GET | /api/system/my-menus | 🔒JWT(登录即可) | 当前用户授权菜单(侧边栏/权限码数据源) | {menus:[含 type/permission/icon...]} |
| GET/POST/PUT/DELETE | /api/system/depts( /:id) | system:dept:* | 部门 CRUD(删校验子部门/在编用户) | {depts:[...]} / 对象 / {ok:true} |
| GET/POST | /api/system/users | system:user:list/create | 用户分页列表(page,size,username,deptId,phone 过滤)/ 建用户 | {users,total} / 用户对象 |
| PUT | /api/system/users/:id | system:user:update | 改资料/部门/状态 | 用户对象 |
| PUT | /api/system/users/:id/roles | system:user:update | 分配角色(全量覆盖) | {ok:true} |
| PUT | /api/system/users/:id/status | system:user:update | 启停 | {ok:true} |
| PUT | /api/system/users/:id/password | system:user:reset-pwd | 管理员重置他人密码(≥6 位) | {ok:true} |
| POST | /api/system/users/:id/avatar | system:user:update-avatar | 管理员改他人头像(multipart) | {url} |
| DELETE | /api/system/users/:id | system:user:delete | 删用户(连带清 user_roles) | {ok:true} |
| GET/POST/PUT/DELETE | /api/system/roles( /:id) | system:role:list/create/update | 角色 CRUD(重名 409) | {roles:[...]} / 对象 / {ok:true} |
| GET/PUT | /api/system/role-menus/:roleId | system:role:assign | 查/授权角色菜单 | {menuIds} / {ok:true} |
| GET | /api/system/dicts | system:dict:list | 字典类型列表 | {types:[...]} |
| POST | /api/system/dicts | system:dict:create | 建类型(name/code 唯一) | 类型对象 |
| GET/POST | /api/system/dicts/:code/items | system:dict:list/create | 按编码查/建字典项(code→typeId 解析在网关 createDictItemByCode) | {items:[...]} / 项对象 |
| PUT/DELETE | /api/system/dicts/type/:id、/api/system/dicts/item/:id | system:dict:update/delete | 类型/项改删 | 对象 / {ok:true} |
| GET/POST | /api/system/notices | 🔒JWT / system:notice:create | 铃铛三 Tab 通知 / 建通知 | {tabs:[{key,name,list,emptyText,unread}]} |
| PUT | /api/system/notices/:id/read、/read-all | 🔒JWT | 单条/按类全部已读 | {ok:true} |
| GET | /api/system/dashboard-stats | 🔒JWT | 7 项统计聚合 | {userCount,deptCount,roleCount,menuCount,operLogCount,loginLogCount,onlineCount} |
gateway/src/system/monitor.controller.ts,@Controller('monitor'))| 方法 | 路径 | 权限码 | 用途 | 关键入参 |
|---|---|---|---|---|
| GET | /api/monitor/oper-logs | monitor:operlog:list | 操作日志真分页 | username,module(模糊),status(1/0/-1不筛),startTime,endTime,page,pageSize |
| GET | /api/monitor/oper-logs/:id | 同上 | 日志详情 | — |
| GET | /api/monitor/login-logs | monitor:loginlog:list | 登录日志真分页(参数同上) | 同上 |
| DELETE | /api/monitor/oper-logs/:ids、/login-logs/:ids | monitor:operlog:delete / monitor:loginlog:delete | 批量删除(ids 逗号分隔) | — |
| DELETE | /api/monitor/oper-logs/clean、/login-logs/clean | 同上 | 清空 | — |
| GET | /api/monitor/system-logs | monitor:operlog:list | 系统运行日志(无分页参数) | — |
| GET | /api/monitor/online-users | monitor:online:list | 在线会话列表(不暴露 token) | — |
| DELETE | /api/monitor/online-users/:sessionId | monitor:online:kick | 强退会话 | — |
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /uploads/:key | 从 MinIO 流式回读对象(UploadsController,不受 /api 前缀约束) |
| GET | /api(根) | 网关存活探针(app.controller.ts,dev-start.sh 末尾用它验证) |
| GET | :3030/api/v1/health?service=user_grpc|system_grpc | terminus gRPC 健康聚合;200 健康/404 未知服务/500 探测失败 |
认证(Authentication)——双 token + 内存会话:
signin 校验 argon2 密码后用 JwtService.signAsync 签发 access(2h)与 refresh(7d,payload 附加 type:'refresh'),均带 jwtid: randomUUID()(防同秒同 payload 撞签,HANDOFF 陷阱 27),返回体带 userId。SessionService 登记三条映射(access_token→会话、sessionId→token、refresh_token→sessionId),会话记录 userId/username/ip/userAgent。passport-jwt 的 JwtStrategy 校验签名与过期后,必须 sessionService.has(token) 通过,否则 401"会话已失效"——这就是登出/强退能立即生效的原因。POST /auth/refresh;网关校验 refresh 签名与 type==='refresh' 后签发新 access,并 rotateAccessToken 换会话键(旧 access 立即失效,单活跃会话语义),会话活跃时间随之续期。UNAUTHENTICATED(16) 会被异常过滤器映射为 HTTP 401。鉴权(Authorization)——CASL 双源能力模型:AuthorizationService.buildAbility(username) 合并两路规则——① user 服务 GetUserRules:user_db 中 用户→user_roles→roles→role_policies→policy 表链路输出的 {action, subject, inverted, fields, conditions};② system 服务 GetPermissionCodes:system_db 中 菜单表 permission 字段按角色授权汇总的权限码(编译为 {action: code, subject: 'perm'})。控制器声明 @CheckPolicies((ability) => ability.can('system:user:list', 'perm')),PoliciesGuard 执行判定,失败 403"无权限执行该操作"。能力按用户名缓存 60 秒(CACHE_TTL_MS),并有周期清扫器防无界增长——因此角色授权变更最迟 60 秒生效。
对鉴权机制再作三点补充说明。第一,双源规则的历史成因与现状:user 库的 Policy 表是项目早期(course 课程权限模型)引入的通用 CASL 策略存储,支持字段级与条件级规则(fields/conditions 是 JSON),表达力强但配置繁琐;system 库的菜单权限码则是 M1 引入的"菜单即权限"方案,表达力弱(只有"有无")但运维直观——管理页点几下就能给角色授权。当前线上实际依赖的是后者,前者保留了 can('read','user') 这类数据能力的示范(/api/auth/profile-demo 端点)与扩展空间。第二,粒度边界:网关守卫只做到"接口级",服务内部不再校验权限——也就是说所有鉴权信任都建立在"请求必然经过网关"之上,这也是业务服务绝不能直接暴露端口给外部的根本原因。第三,角色与用户的映射实时性:findOne 查角色每次都实时读库(不缓存角色本身,只缓存编译后的 Ability),因此"给用户加角色"最迟 60 秒后生效;而登出/强退是即时的(会话层拦截)。若未来需要"改权限立即生效",只需调小缓存 TTL 或在授权写接口里主动失效对应条目,改动收敛在 authorization.service.ts 一个文件内。
| HTTP 码 | 产生场景 | 来源 |
|---|---|---|
| 200/201 | 成功(Nest POST 默认 201,signin/refresh/logout 显式 @HttpCode(200)) | 控制器 |
| 400 | DTO 校验失败(message 为字符串数组)/ gRPC INVALID_ARGUMENT(3)、FAILED_PRECONDITION(9)(如部门删除约束) | ValidationPipe / AllExceptionsFilter 映射表 |
| 401 | JWT 无效/过期、会话不存在(被踢/登出)、refresh 无效、gRPC UNAUTHENTICATED(16) | JwtStrategy / AuthController / 过滤器 |
| 403 | 密码错误("用户名或者密码错误")、权限不足、p1user 改密保护(PERMISSION_DENIED(7))、限流超限(throttler 文案) | AuthService / PoliciesGuard / ThrottlerGuard |
| 404 | gRPC NOT_FOUND(5)(用户不存在等) | 过滤器映射 |
| 409 | 用户名/角色名重复:Prisma P2002 → ALREADY_EXISTS(6) | user 服务 catch 块 |
| 429 | 限流(全局 100/min 或分口限额) | gRPC RESOURCE_EXHAUSTED(8) 同映射 |
| 502 | dev 代理后端不可用(vite/nginx 兜底 JSON) | vue-admin/vite.config.ts proxy error handler |
| 500 | 未映射异常,统一 {statusCode:500, message:'Internal server error'} 并落 system_logs | 过滤器 |
错误体统一 {statusCode, message, error?};HttpException(含 Nest 内建)按其自身 getResponse() 结构透出。所有 ≥500 错误异步写 system_logs(level=error,module=gateway,detail 截断 2000 字符),失败静默——日志通道故障不影响错误响应本身(all-exceptions.filter.ts 的 recordSystemError 整体 try/catch)。
关于错误处理还有两条经验性约定值得写入肌肉记忆。一是枚举错误的语义化:gRPC 状态码在本项目中被当作"业务错误词汇表"使用——NOT_FOUND 表达资源不存在、ALREADY_EXISTS 表达唯一约束冲突、FAILED_PRECONDITION 表达前置条件不满足(如删除非空部门)、PERMISSION_DENIED 表达业务性拒绝(区别于鉴权失败的 403,比如演示账号改密保护),这些语义映射集中在过滤器一张表里,新增错误类型时先查表再决定抛什么码,避免前端出现"同一类错误两种状态码"的割裂。二是错误信息透出策略的演变:项目早期前端曾把所有登录失败统一成"账号或密码错误",结果吞掉了限流 429 等真实原因,后来改为透出后端 message;但透出也有边界——上游异常过滤器兜底的 500 统一返回"Internal server error"而不泄露堆栈,system_logs 里才存完整细节,这个"对外最小信息、对内全量留痕"的分工是排障与安全的平衡点。另外前端所有 API 适配器都把 class-validator 产生的 message 数组 join(';') 再展示,这是 ValidationPipe 白名单模式下最常见的错误形态,写新适配器时务必保留这一处理。
列表类接口遵循同一套分页语义,虽然后端两个服务的实现细节略有差异,但对前端的契约是一致的。入参:page 从 1 起、size/pageSize 为页大小,user 服务在服务端钳制边界(page = max(1, page)、size = min(100, max(1, size))),system 服务的日志接口则把 page=0 视为 1、pageSize=0 视为沿用历史默认 100——前端适配器(api/system.ts)统一做了缺省值兜底,调用方无需关心这些差异。筛选:字符串字段(username、phone、module)一律模糊匹配(Prisma contains),状态字段用数值(status 传 1 或 0,-1 或缺省表示不筛选,M9 曾因此修复过 proto3 默认值把 0 误判为有效条件的坑),时间范围用 startTime/endTime 两个 ISO 字符串。出参:一律 {<复数资源名>: [...], total: number} 双键结构(如 {users, total}、{logs, total}),total 是过滤后命中总数而非当前页条数——前端据此计算分页器页码。历史教训:登录日志页曾因适配器误把对象当数组解包(res.data ?? [] 应为 res.data.logs)而空表运行了数个里程碑,HANDOFF 第四排核对时为此专门写了"逐项核对返回结构"的流程,新写适配器时应当先 curl 一遍真实接口确认形状。
写接口的并发安全由数据库约束与服务层事务共同保证,调用方可以按如下预期设计交互。唯一性冲突可安全重试:注册与建用户/建角色撞唯一约束时返回 409 而非 500,前端据此提示而非报错;并发注册的兜底在 user 服务侧完成(P2002 捕获),网关无需嗅探错误文本(auth.service.ts 注释明确此设计)。分配类接口是全量覆盖语义:分配角色(PUT users/:id/roles)与角色授权(PUT role-menus/:roleId)都接收完整目标集合,服务端在事务内先删后插并预校验外键存在性,天然幂等,重复提交无副作用。敏感操作有独立防线:改密需验旧密码、管理员重置密码要求新密码至少 6 位、头像上传限制图片类型与 2MB 大小、演示账号 p1user 在本人与管理员两个入口都被硬编码拒绝。传输安全:生产链路外层 HTTPS(VPS nginx + Let's Encrypt)、内层 gRPC TLS 双重加密;JWT 密钥经环境变量注入,缺配置时服务拒绝启动。限流即防爆破:signin/signup/refresh 三个未认证或半认证端点的独立限流配置是安全组件而非性能组件,调整前应按"爆破成本"评估而非仅看压测数据。
POST /api/auth/signin// 请求体(SigninUserDto,白名单剥离多余字段)
{ "username": "p1user", "password": "secret123" }
// 200 响应(auth.service.ts signin())
{
"userId": 1,
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_in": 7200
}链路:AuthController(10/min 限流)→ authService.signin:gRPC UserService.FindOne 取用户 → argon2.verify 验密 → 双 token 签发(均带随机 jwtid)→ sessionService.create(access, refresh, {userId, username, ip, userAgent})。失败分支:用户不存在 403"用户不存在";密码错 403"用户名或者密码错误";第 11 次请求 429。同时该请求会被中间件记入 login_logs(success=statusCode<400)。
GET /api/system/users?page=1&size=10&username=li// 200 响应(网关透传 user 服务 ListUsers 出参)
{
"users": [
{
"id": 2, "username": "p0user", "status": 1,
"deptId": 3, "deptName": "研发部",
"roleIds": [1], "roleNames": ["user"],
"nickname": "", "phone": "13800000000", "email": "",
"avatar": "", "createdAt": "2026-09-01T10:00:00.000Z"
}
],
"total": 12
}链路:UserController.list(system:user:list 权限码)→ UserClientService(system-client.service.ts 同款订阅式客户端 + promisify)→ gRPC UserService.ListUsers → Prisma 事务(findMany{skip,take,include:{dept,UserRole.Role}} + count)。分页参数在服务端钳制:page = max(1, page)、size = min(100, max(1, size))(user/src/app.controller.ts:364-365)。前端 api/system.ts 的 getUserList 把该形状适配为模板表格信封 {code:0,data:{list,total,pageSize,currentPage}}。
| 网关控制器 | 主要 RPC(user.proto / system.proto) |
|---|---|
| auth | FindOne、Create、GetProfile、UpdateProfile、UpdatePassword、UpdateAvatar、GetSettings、UpdatePreferences、UpdateSecurity |
| user.controller | ListUsers、CreateUser、UpdateUser、DeleteUser、UpdateUserRoles、UpdateUserStatus、AdminResetPassword、AdminUpdateAvatar、ListRoles、CreateRole、UpdateRole |
| dept.controller | ListDepts、CreateDept、UpdateDept、DeleteDept |
| system/rolemenu/notice/dashboard/monitor | ListMenus、Create/Update/DeleteMenu、GetPermissionCodes、ListRoleMenus、AssignRoleMenus、ListNotices、CreateNotice、MarkNoticeRead、MarkAllNoticesRead、GetSystemDashboardStats、GetDashboardStats、Create/GetOperLogDetail/ListOperLogs、Create/ListLoginLogs、Create/ListSystemLogs、Delete/ClearOperLogs、Delete/ClearLoginLogs |
完整字段级契约以 proto-pkg/proto/*.proto 为准(生成 TS 类型在 proto-pkg/src/nestjs-ts/,tsup 构建)。新增接口的固定动作序列:改 proto → pnpm --filter @toimc/proto-pkg build 重生成 → 服务端 @GrpcMethod 实现 → 网关 client service 补方法 → 控制器挂权限码 → system/prisma/seed.sql 补菜单/权限码种子与 role_menus(HANDOFF 陷阱 20,否则前端拿不到按钮码、调用 403)。