项目:nestjs-admin | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本文给 10 个核心接口与 1 个 gRPC 消息提供可直接执行的请求/响应示例:每个示例给 curl 命令、成功响应 JSON、错误响应 JSON 与契约注解。JSON 中的值基于实体构造,凡非真实结构的取值一律以"示意"标注(token 为截断字符串、时间为示例时刻、数值 ID 以 seed.sql 的实际种子为参照);与《15-接口全量参考》的分工是:那一篇是 64 个端点的全量速查表,本篇只挑最高频的 10 个给出端到端可复制的完整报文与解释。通用约定速记(详见《03-接口设计》§5 与《15-接口全量参考》卷首):网关 JSON 直出非信封;错误体统一 {statusCode, message, error?},class-validator 校验失败时 message 为字符串数组;写操作统一 {ok:true};列表统一 {<复数名>: [...], total};POST 默认 201,signin/refresh/logout 显式 200。示例的运行前提:按《06-部署运行》§2 起栈,网关 :3040、health :3030,演示账号 p1user/secret123(注意 signin 限流 10/min,多轮执行示例时留意 429)。
在逐个看示例之前,先交代三条把本文读"活"的方法。第一,对比着看同形异义的字段:同一个字符串字段在不同端点承担不同职责——signin 响应里的 token 是凭证本身,其余请求头里的 Authorization 是凭证的引用;my-menus 里的 path 是前端路由路径,而 type=3/4 时它却是完整 URL。报文字段没有孤立的含义,含义由端点、方向与类型共同决定,复制报文时不要跨端点套用。第二,数值字段的零值要警惕 proto3 语义:网关 JSON 层看不到 proto 的存在,但底层字段全部经 proto3 传输——未传与"传了 0/空串"在服务端不可区分,这解释了筛选参数约定 -1 表示不筛、page=0 视为 1 的设计(详见《15-接口全量参考》§7)。第三,错误响应是契约的一半:本文每个示例都至少给一条错误报文,且错误体只有 statusCode/message/error 三个固定键——任何不符合这个形状的"错误"都值得怀疑(比如 vite 代理兜底的 502 JSON 只有 message 与 statusCode 两个键,那是代理层伪造的,不是网关产生的,见《12-FAQ与故障排查》§2 的 502 行)。
另有一个执行层面的提醒:示例中的 access_token 一律以"示意"占位,实际执行时用示例一返回的真实 token 替换;token 是两小时有效的凭证,批量执行示例时若出现成片 401"会话已失效",先重新登录再继续,不要把 401 当成接口故障排查。
鉴权与限流:公开端点,独立限流 10 次/分钟/IP(含失败尝试,Throttler 跨进程累计——陷阱 26)。入参经 SigninUserDto 校验(username 5-20、password 6-32,白名单剥离多余字段)。
curl -s -X POST http://127.0.0.1:3040/api/auth/signin \
-H 'Content-Type: application/json' \
-d '{"username":"p1user","password":"secret123"}'// 200 成功(gateway/src/auth/auth.service.ts signin)
{
"userId": 1, // 示意:users 表 p1user 的自增 ID
"access_token": "eyJhbGciOiJIUzI1NiIs...(截断示意,HS256 JWT,2h)",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...(截断示意,7d,payload 带 type:'refresh')",
"expires_in": 7200 // access 有效期秒数(固定值)
}// 403 密码错误 / 用户不存在(文案分离,存在用户名枚举面,见《08-安全与权限设计》§6 隐患 3)
{ "statusCode": 403, "message": "用户名或者密码错误", "error": "Forbidden" }
// 400 入参校验失败(message 为字符串数组)
{ "message": ["username must be longer than or equal to 5 characters"], "error": "Bad Request", "statusCode": 400 }
// 429 限流(Throttler 默认文案)
{ "statusCode": 429, "message": "ThrottlerException: Too Many Requests" }契约注解:成功响应同时触发两个副作用——网关内存会话登记(三张 Map,sessionService.create,ip/userAgent 取自请求)与登录日志落库(中间件按路径采集,success = statusCode < 400,因此 429/403 也会留下审计记录)。前端拿到 expires_in 换算过期时间戳存入 authorized-token cookie(vue-admin/src/utils/auth.ts setToken)。两枚 token 均带 jwtid: randomUUID(),同秒签发也不相同(陷阱 27)。
鉴权与限流:公开端点,30 次/分钟。入参 {refreshToken} 单字段。
curl -s -X POST http://127.0.0.1:3040/api/auth/refresh \
-H 'Content-Type: application/json' \
-d '{"refreshToken":"eyJhbGciOiJIUzI1NiIs...(示意:signin 返回的 refresh_token 原文)"}'// 200 成功
{
"access_token": "eyJhbGciOiJIUzI1NiIs...(示意:新签发的 access)",
"expires_in": 7200
}// 403 refresh 本身无效(验签失败/类型不对)
{ "statusCode": 403, "message": "刷新令牌无效或已过期", "error": "Forbidden" }
// 401 refresh 有效但会话已被登出/强退清除(rotate 失败)
{ "statusCode": 401, "message": "刷新令牌无效或已过期" }契约注解:403 与 401 的分野是本端点最微妙处——前者 refresh JWT 本身有问题(auth.service.refresh 验签/类型分支),后者 refresh 合法但 sessionService.rotateAccessToken 换键失败(auth.controller.ts refresh 内 if (!rotated) 分支),即"refresh 还没过期但服务端会话已经没了"(被强退或网关重启)。换发成功后旧 access 立即失效(单活跃会话语义,陷阱 28 特性),前端 http 封装的单飞刷新队列正是为这个端点服务的(见《18-代码走读实录》§3.1)。
鉴权与限流:JWT + 权限码 system:user:list。全局限流 100/min。
curl -s "http://127.0.0.1:3040/api/system/users?page=1&size=10&username=li&deptId=3" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs...(示意:access_token)"// 200 成功(user 服务 ListUsers 出参,网关原样透传)
{
"users": [
{
"id": 2,
"username": "p0user",
"status": 1, // 1 启用 / 0 停用
"deptId": 3,
"deptName": "研发部", // 示意:depts 表种子名
"roleIds": [1],
"roleNames": ["user"],
"createdAt": "2026-09-01T10:00:00.000Z", // 示意时刻
"nickname": "普通用户",
"phone": "13800000000",
"email": "",
"avatar": "/uploads/avatar-p0user-1759300000000.png" // 示意对象名
}
],
"total": 12
}// 403 权限码未授权(九成是 seed/role_menus 数据链问题,见《12-FAQ与故障排查》§4)
{ "statusCode": 403, "message": "无权限执行该操作", "error": "Forbidden" }契约注解:分页参数服务端钳制 page = max(1, page)、size = min(100, max(1, size))(user/src/app.controller.ts listUsers 开头两行),调用方传 0 或超大值不会 400 而是被静默矫正;username 与 phone 是模糊匹配(Prisma contains),deptId 是精确匹配;total 是过滤后命中总数而非当前页条数。注意响应不含 password 字段——服务端手工拼装返回形状,这是服务端只返回安全字段约定的机制化。前端适配层把该形状转为表格信封 {list,total,pageSize,currentPage}(api/system.ts getUserList)。
鉴权与限流:JWT + 权限码 system:user:create。
curl -s -X POST http://127.0.0.1:3040/api/system/users \
-H "Authorization: Bearer <access_token>" \
-H 'Content-Type: application/json' \
-d '{"username":"newbie","password":"secret123","nickname":"新同学","deptId":3,"roleIds":[1]}'// 201 成功(Nest POST 默认 201;UserData 形状)
{
"id": 13, // 示意:新用户 ID(seed setval 之后自增)
"username": "newbie",
"status": 1,
"deptId": 3,
"deptName": "研发部",
"roleIds": [1],
"roleNames": [], // 注意:create 返回中 roleNames 固定为空数组(include 未取 Role 名),前端列表页回读时才有值
"createdAt": "2026-10-06T09:00:00.000Z",
"nickname": "新同学",
"phone": "",
"email": ""
}// 409 用户名已存在(P2002 → ALREADY_EXISTS(6) → 过滤器映射)
{ "statusCode": 409, "message": "用户名已存在", "error": "Conflict" }
// 404 部分角色不存在(事务内预校验)
{ "statusCode": 404, "message": "部分角色不存在", "error": "Not Found" }契约注解:roleIds 缺省/空数组时回落默认角色(ROLE_ID 环境变量,缺省 1,需与 roles 种子一致);密码在 user 服务事务内 argon2.hash(argon2id) 后落库。409 可安全重试(唯一约束在数据库层兜底并发注册,网关侧不做错误文本嗅探);404 与 409 是本端点仅有的两个业务错误分支。完整链路与走读见《18-代码走读实录》§2,事务语义见《04-数据模型》§2.3。
鉴权与限流:JWT + 权限码 system:user:update(与编辑资料共用一个码——权限码与端点不是一一对应,《15-接口全量参考》§7 第一条)。
curl -s -X PUT http://127.0.0.1:3040/api/system/users/2/roles \
-H "Authorization: Bearer <access_token>" \
-H 'Content-Type: application/json' \
-d '{"roleIds":[1,2]}'// 200 成功
{ "ok": true }
// 404 部分角色不存在
{ "statusCode": 404, "message": "部分角色不存在", "error": "Not Found" }契约注解:全量覆盖语义——服务端事务内先 userRole.deleteMany({userId}) 再 createMany(user/src/app.controller.ts updateUserRoles),因此请求体的 roleIds 必须是完整目标集合而非增量;空数组表示清空全部角色(合法)。接口幂等,重复提交无副作用。角色变更对鉴权的影响要等网关 Ability 缓存过期(最长 60 秒,CACHE_TTL_MS),急用可重启网关立即生效。给新角色赋权后想立即让用户看到菜单,还需用户重新拉 my-menus(刷新页面)。
鉴权与限流:仅 JWT(登录即可,无权限码——它是权限体系的源头数据)。
curl -s http://127.0.0.1:3040/api/system/my-menus -H "Authorization: Bearer <access_token>"// 200 成功(返回平铺数组,前端 buildTree 组树)
{
"menus": [
{ "id": 1, "parentId": 0, "name": "系统管理", "type": 0, "path": "/system", "component": "", "permission": "", "icon": "setting", "order": 1, "status": 1 },
{ "id": 2, "parentId": 1, "name": "用户管理", "type": 1, "path": "/system/user", "component": "system/user/index", "permission": "system:user:list", "icon": "user", "order": 1, "status": 1 },
{ "id": 20, "parentId": 2, "name": "用户新增", "type": 2, "path": "", "component": "", "permission": "system:user:create", "icon": "", "order": 5, "status": 1 },
{ "id": 26, "parentId": 1, "name": "内嵌文档", "type": 3, "path": "https://cn.vuejs.org", "component": "IFRAME", "permission": "", "icon": "link", "order": 8, "status": 1 },
{ "id": 27, "parentId": 1, "name": "若依官网", "type": 4, "path": "https://ruoyi.vip", "component": "", "permission": "", "icon": "external-link", "order": 9, "status": 1 }
]
}契约注解:type 五类语义 0 目录 / 1 菜单 / 2 按钮 / 3 内嵌 iframe / 4 外链(陷阱 24 的统一结果);type=2 且 permission 非空的记录就是按钮权限码全集——前端 api/routes.ts 过滤后 SET_PERMS 存 user store,网关 getPermissionCodes 聚合的也是同一批记录(只统计 status=1),两路同源。服务端按当前用户角色过滤并迭代补祖先链(system/src/app.controller.ts listMenus 的 while 循环,保证授权了按钮也返回其父菜单维持树形完整)。管理页全量菜单走 /api/system/menus(权限码 system:menu:list),两者共用同一 RPC、按 roleIds 是否为空分流。
鉴权与限流:JWT + 权限码 system:role:assign。
# 查询角色 2 当前授权的菜单
curl -s http://127.0.0.1:3040/api/system/role-menus/2 -H "Authorization: Bearer <access_token>"
# 全量覆盖授权
curl -s -X PUT http://127.0.0.1:3040/api/system/role-menus/2 \
-H "Authorization: Bearer <access_token>" -H 'Content-Type: application/json' \
-d '{"menuIds":[1,2,4,5,6,20,21]}'// GET 200 成功
{ "menuIds": [1, 2, 3, 4, 5, 6] }
// PUT 200 成功
{ "ok": true }契约注解:PUT 是全量覆盖语义(事务内先删后插,AssignRoleMenus),且服务端会先 findMany 过滤掉不存在的 menuId(传错 ID 静默忽略而非报错);roleId 不校验存在性(角色在 user_db 跨库,无外键也无预检——传不存在的 roleId 会写入空授权行集,⚠️ 待确认是否要补校验)。授权生效时序:网关 Ability 缓存最长 60 秒 + 前端下次拉 my-menus,两段延迟叠加,验证新授权时按《11-扩展开发指南》SOP-5 的时序预期。权限码"即菜单记录"的设计使本接口与示例六构成权限体系的读写对。
鉴权与限流:仅 JWT。这是网关唯一允许的跨服务聚合端点。
curl -s http://127.0.0.1:3040/api/system/dashboard-stats -H "Authorization: Bearer <access_token>"// 200 成功(user/system 两路 gRPC + 网关内存会话计数并行聚合)
{
"userCount": 12, // 示意:user_db users 全量计数(GetDashboardStats)
"deptCount": 6, // 示意:depts 计数
"roleCount": 2, // 示意:roles 计数
"menuCount": 33, // seed 菜单种子实际 33 条
"operLogCount": 1584, // 示意:system_db oper_logs 计数(随写流量线性增长)
"loginLogCount": 302, // 示意:login_logs 计数
"onlineCount": 3 // 网关内存 30 分钟活跃窗口内会话数
}契约注解:七项计数三个来源——user/src/app.controller.ts GetDashboardStats(前三项)、system/src/app.controller.ts GetSystemDashboardStats(中间三项)、网关 sessionService.listOnline().length(最后一项)。onlineCount 的口径是"30 分钟内有过请求的会话",与登录总数、会话总数都不同(见《05-核心流程》流程五的"会话与在线是两个概念")。聚合在 gateway/src/system/dashboard.controller.ts 用 Promise.all 一次并行完成、无按源降级——任一 gRPC 源不可用时整个端点报错(异常进过滤器按未映射分支返回 500 并落 system_logs),前端仪表盘页据此保留 0 值展示;是否值得改成按源降级 ⚠️ 待确认(当前教学定位下整体报错更易暴露问题)。
鉴权与限流:JWT + 权限码 monitor:operlog:list。
curl -s "http://127.0.0.1:3040/api/monitor/oper-logs?username=p1user&module=system%3Ausers&status=1&startTime=2026-10-01T00:00:00.000Z&endTime=2026-10-07T00:00:00.000Z&page=1&pageSize=20" \
-H "Authorization: Bearer <access_token>"// 200 成功
{
"logs": [
{
"id": 9527,
"traceId": "3f8a2c1e-9b4d-4a1c-8e2f-6d5b7a9c0e11", // 示意 UUID;与网关日志/system_logs 关联键
"userId": 1,
"username": "p1user",
"module": "system:users", // URL 前两段拼接(/api/system/users → system:users)
"method": "POST",
"url": "/api/system/users",
"ip": "120.85.xx.xx", // 示意:trust proxy 后的真实客户端 IP
"statusCode": 201,
"durationMs": 47,
"createdAt": "2026-10-06T09:00:00.000Z"
}
],
"total": 42
}// 200 无匹配(空列表而非 404)
{ "logs": [], "total": 0 }契约注解:筛选参数语义——username/module 模糊、status 取 1(2xx 成功)/0(非 2xx)/-1 或缺省(不筛,proto3 零值缺省坑的约定解)、时间范围两个 ISO 字符串(gte/lte);分页真分页(findMany+count),与《03-接口设计》§5.1 的分页总约定一致。写入方是网关中间件旁路采集,每个写请求一条(含对日志接口自身的删除——《12-FAQ与故障排查》§5 Q3:清空后多出的一条是采集器记下了删除动作本身)。日志详情用 GET /api/monitor/oper-logs/:id(404"操作日志不存在"是唯一业务错误),批量删除与清空见《15-接口全量参考》§4.1 第 54-57 条。
鉴权与限流:公开(内网)。这是 Consul 每 10 秒探测的端点,也是运维排障的第一入口。
curl -s "http://127.0.0.1:3030/api/v1/health?service=user_grpc"// 200 健康(terminus 标准格式;status:1 即 gRPC SERVING)
{ "user_grpc": { "status": "up" } }
// 404 service key 不在 SERVICE_HEALTHCHECK_ENDPOINTS 清单里
{ "message": "Service Not Found" }
// 500 gRPC 探测失败(超时 2s / TLS 不一致 / 进程未监听)
{ "message": "Server Error" }契约注解:路径带 /v1 段是 health 服务 main.ts 的版本控制缺省(VERSION 缺省取 '1'),与网关的 /api/health(VERSION_NEUTRAL)不同——写 CHECK 环境变量必须带 /v1(《07-模块详解》§4)。端点清单来自 SERVICE_HEALTHCHECK_ENDPOINTS(格式 service_key,grpc_url,packageName,protoName,rpc名,竖线分隔多段),?service= 指定查询其中一个 key。200/404/500 三态与排障决策树的对应关系见《12-FAQ与故障排查》§3:500 且 GRPC_TLS 双端不一致时,实例会在 60 秒后被 Consul 摘除("注册成功又消失"的经典根因)。网关内嵌的同款端点 GET /api/health 行为一致,容器场景两者均可作存活探测。
HTTP 示例之外,给一个网关→user 服务的 gRPC 调用契约全貌,说明"契约先行"在消息层的落地。proto 定义(proto-pkg/proto/user.proto):
// 用户管理(摘自 proto-pkg/proto/user.proto,注释为原文)
message CreateUserRequest {
string username = 1;
string password = 2;
string nickname = 3;
string phone = 4;
string email = 5;
uint32 deptId = 6;
repeated uint32 roleIds = 7;
}
// 应答消息(与 GET /api/system/users 列表元素同形状)
message UserData {
uint32 id = 1;
string username = 2;
int32 status = 3;
uint32 deptId = 4;
string deptName = 5;
repeated uint32 roleIds = 6;
repeated string roleNames = 7;
string createdAt = 8;
string nickname = 9;
string phone = 10;
string email = 11;
string avatar = 12;
}
service UserService {
// ...(其余 25 个 rpc 略)
rpc CreateUser (CreateUserRequest) returns (UserData);
}以示例四的管理员建用户为例,这次 gRPC 调用在链路上的形态是:网关 UserClientService.createUser 经 promisifyServiceMethods 包装的 async 方法发起调用,载荷为 {username:'newbie', password:'secret123', nickname:'新同学', phone:'', email:'', deptId:3, roleIds:[1]}(proto3 中未填字段以类型零值传输——phone/email 空串、数值 0,因此"未传"与"空值"在服务端不可区分,这是筛选参数用 -1 表示不筛的原因);服务端 @GrpcMethod('UserService', 'CreateUser') 处理后返回 UserData 形状(roleNames 为空数组),错误以带 code 的 RpcException 传输(如 ALREADY_EXISTS(6))。三个工程要点值得记忆:其一,网关与业务服务各自经 loadProto('user') 加载同一份 proto 文件(packageDefinition 注入 ClientProxyFactory 与 createMicroservice),契约不可能单侧漂移;其二,运行时对象键保持 proto 原名(keepCase),返回的关联列表键是 UserRole 而非 userRole(陷阱 4);其三,消息重名会直接编译失败(user.proto 与 system.proto 各有 IdRequest/OkResponse 是各自包内的私有定义,跨 proto 共用需 import),M6 的重复 IdRequest 事故(陷阱 22)正是踩在这条线上。消息级契约的完整字段定义以 proto-pkg/proto/*.proto 为准,ts-proto 生成类型在 proto-pkg/src/nestjs-ts/(camelCase),两者对照阅读即可还原任意接口的线格式。
本文 10 个示例全部可用 curl 复现,且彼此存在依赖顺序(先 signin 拿 token,再做带鉴权调用;示例七的授权影响示例六的菜单可见性;示例四的用户新建会在示例三的列表出现)。这个顺序性使它们天然构成一份最小回归脚本的骨架:仓库根的 smoke_fullchain.js 已经把其中三步固化(示例一 signin→示例六 my-menus→示例八 dashboard,另含一个"验证码接口已下线 404"的回归断言,ALL GREEN 收尾,见 HANDOFF §3),接手者可以为本文尚未覆盖的端点按同一模式补齐脚本,把"文档示例"升级为"可执行契约"。写新示例时遵循三条纪律:先 curl 真实接口确认形状再写进文档(陷阱 23 的教训——适配器对形状的假设必须有一手证据);示意值一律标注,防止后人把示例值当成种子数据回灌数据库;错误示例至少覆盖"数据链类 403"与"唯一约束 409"两种,让文档同时承担教学与排障双重角色。契约的最终解释权在 proto 与控制器源码,本文与源码不一致时以源码为准。