NestJS 入门(4):统一响应与异常处理

📅 2026/8/11 22:15:07
NestJS 入门(4):统一响应与异常处理
上一篇NestJS 入门3Guard 如何挡住未登录请求 讲了鉴权门槛。业务代码里常见这样写thrownewUnauthorizedException(Invalid credentials);但前端拿到的往往不是 Nest 默认的异常结构而是统一信封例如{code:1001,msg:Invalid credentials,data:null}成功时则是{code:0,msg:success,data:{/* 业务数据 */}}这篇文章只讲清楚一件事Interceptor 负责成功包装Exception Filter 负责失败整形——两边约定同一套信封前端才能稳定解析。1. 为什么要统一信封如果每个接口自己返回{ok:true,result:...}{success:1,payload:...}{error:xxx}前端就要写一堆特殊判断。统一成字段含义code业务错误码0表示成功msg给人看的说明data成功时的业务数据失败常为null前端只需if(data.code0){returndata.data;}// 否则按 data.code / data.msg 提示用户HTTP 状态码仍然有用401/403/404但业务语义优先看code。例如同是 401可以细分成「未登录 / token 无效 / 凭证错误」。2. 成功路径全局 Response InterceptorNest 启动时挂上全局拦截器asyncfunctionbootstrap(){constappawaitNestFactory.create(AppModule);app.useGlobalInterceptors(newResponseInterceptor());app.useGlobalFilters(newHttpExceptionFilter());awaitapp.listen(3000);}拦截器大致是这样Injectable()exportclassResponseInterceptorimplementsNestInterceptor{intercept(context:ExecutionContext,next:CallHandler):Observableunknown{constresponsecontext.switchToHttp().getResponse();if(this.shouldSkip(response)){returnnext.handle();// SSE / 文件流不要包}returnnext.handle().pipe(map((data)({code:0,msg:success,data:data??null,})));}privateshouldSkip(response:Recordstring,unknown):boolean{constcontentTypetypeofresponse.getHeaderfunction?response.getHeader(Content-Type):response.contentType;if(typeofcontentTypestring){if(contentType.includes(text/event-stream))returntrue;if(contentType.includes(application/octet-stream))returntrue;}returnfalse;}}Controller 仍然可以「直接 return 业务对象」Get()findAll(){returnthis.projectsService.findAll(userId);// 实际响应会被包成 { code: 0, msg: success, data: [...] }}你不用在每个方法里手写信封。为什么 SSE 要跳过SSE 要持续写data: {event:content,data:你好}\n\n如果也走成功拦截器可能被一次性包成 JSON 信封流就坏了。所以看到text/event-stream或文件下载时直接next.handle()不做map。3. 失败路径全局 Exception Filter业务里抛thrownewUnauthorizedException(Invalid credentials);若没有过滤器Nest 默认也会返回 JSON但字段名、结构和成功信封往往不一致。全局过滤器把所有异常收口成同一形状Catch()exportclassHttpExceptionFilterimplementsExceptionFilter{catch(exception:unknown,host:ArgumentsHost){constctxhost.switchToHttp();constresponsectx.getResponseResponse();letstatusHttpStatus.INTERNAL_SERVER_ERROR;letmessageInternal server error;if(exceptioninstanceofHttpException){statusexception.getStatus();constexceptionResponseexception.getResponse();if(typeofexceptionResponsestring){messageexceptionResponse;}elseif(typeofexceptionResponseobjectexceptionResponse!null){message((exceptionResponseasRecordstring,unknown).messageasstring)||message;}}elseif(exceptioninstanceofError){// 也可把 JWT 相关 Error 映射成 401messageexception.message;}constcodethis.mapStatusToErrorCode(status);response.status(status).json({code,msg:message,data:null,});}privatemapStatusToErrorCode(status:number):number{switch(status){caseHttpStatus.UNAUTHORIZED:return1001;caseHttpStatus.FORBIDDEN:return1003;caseHttpStatus.NOT_FOUND:return1100;caseHttpStatus.BAD_REQUEST:return1503;caseHttpStatus.CONFLICT:return1102;caseHttpStatus.SERVICE_UNAVAILABLE:return1502;default:return1500;}}}关键点Catch()不传参数 抓住所有异常不只是HttpException从异常里取出 HTTP status 与可读 message映射成业务code永远返回{ code, msg, data }这样前端无论成功失败解析路径都一样。4. 请求链路对照成功 Controller return data → ResponseInterceptor map 成 { code:0, msg:success, data } → 前端拿到统一成功包 失败 Service throw UnauthorizedException(...) → 不走成功拦截器的 map异常打断 Observable → HttpExceptionFilter catch → { code:1001, msg:..., data:null } → 前端拿到统一错误包可以记成正常 return 走 Interceptor抛异常走 Filter。两边约定同一信封字段前端只认这一套。5. HTTP 状态码 vs 业务错误码两者分工不同维度HTTP status业务code给谁看网关、浏览器、通用客户端业务前端、运营排障粒度粗401/404/500细1001/1100/1503…例子401 Unauthorized1001 InvalidToken / 1004 InvalidCredentials常见分段示例区间含义0成功1000–1099认证鉴权1100–1199项目相关1200–1299文档相关1500系统 / 校验类Filter 里用mapStatusToErrorCode做「粗映射」够入门更精细时可以在抛异常时直接带业务码自定义异常类Filter 优先读业务码。6. 业务代码怎么写才干净Service抛语义清晰的异常asynclogin(email:string,password:string){constuserthis.findUserByEmail(email);if(!user){thrownewUnauthorizedException(Invalid credentials);}// ...}不要在 Service 里手动拼return{code:1001,msg:...,data:null};// 不推荐和拦截器职责打架Controller继续薄Post(login)login(Body()body:{email:string;password:string}){returnthis.authService.login(body.email,body.password);}成功自动包失败自动整形。前端按信封解包http.interceptors.response.use((response){constdataresponse.data;if(datatypeofdataobjectcodeindata){if(data.code0){returndata.data;// 业务层只看到真正的 data}returnPromise.reject(newError(data.msg||请求失败));}returnresponse;});7. 自定义异常信息时注意getResponse()形态UnauthorizedException(Invalid credentials)时getResponse()可能是字符串也可能是{statusCode:401,message:Invalid credentials,error:Unauthorized}所以 Filter 里要同时处理string和object否则msg可能变成[object Object]或拿不到可读文案。校验类异常如 ValidationPipe的message还可能是字符串数组进阶时可以再归一成一句或列表。8. 和 Guard / JWT 的关系Guard 鉴权失败时底层同样会抛出 HTTP 异常常见 401。只要全局 Filter 在Guard 挡下的请求也会变成统一错误包而不是「有的接口结构不一样」。这正是系列串起来的好处Module / Controller / Service 分层DI 接线Guard 守门Interceptor Filter 统一出口前端感知到的 API始终是同一套语言。9. 小结统一信封{ code, msg, data }成功code 0ResponseInterceptor包装成功返回SSE/文件流要跳过ExceptionFilter把HttpException/ 普通Error收口成同一错误包业务层优先throw new UnauthorizedException(...)不要手写两套返回结构HTTP status 表达传输层语义业务code表达产品语义对照前几篇可以再多一句哪个 Controller 接请求哪个 Service 做业务哪个 Module 组装依赖从哪注入有没有 Guard成功谁包装、失败谁整形前端拿到的信封长什么样下一篇会讲Pipe 与 DTO 校验——为什么Body()进来的脏数据可以在进 Controller 之前就被拦下。系列导航上一篇NestJS 入门3Guard 如何挡住未登录请求第二篇NestJS 入门2依赖注入到底解决了什么问题第一篇NestJS 入门1先搞懂 Module、Controller、Service