ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/8/11 22:24:43 拓冰建站 浏览量
NestJS 入门(4):统一响应与异常处理

上一篇:NestJS 入门(3):Guard 如何挡住未登录请求? 讲了鉴权门槛。
业务代码里常见这样写:

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.code===0){returndata.data;}// 否则按 data.code / data.msg 提示用户

HTTP 状态码仍然有用(401/403/404),但业务语义优先看code
例如同是 401,可以细分成「未登录 / token 无效 / 凭证错误」。


2. 成功路径:全局 Response Interceptor

Nest 启动时挂上全局拦截器:

asyncfunctionbootstrap(){constapp=awaitNestFactory.create(AppModule);app.useGlobalInterceptors(newResponseInterceptor());app.useGlobalFilters(newHttpExceptionFilter());awaitapp.listen(3000);}

拦截器大致是这样:

@Injectable()exportclassResponseInterceptorimplementsNestInterceptor{intercept(context:ExecutionContext,next:CallHandler):Observable<unknown>{constresponse=context.switchToHttp().getResponse();if(this.shouldSkip(response)){returnnext.handle();// SSE / 文件流不要包}returnnext.handle().pipe(map((data)=>({code:0,msg:'success',data:data??null,})));}privateshouldSkip(response:Record<string,unknown>):boolean{constcontentType=typeofresponse.getHeader==='function'?response.getHeader('Content-Type'):response.contentType;if(typeofcontentType==='string'){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){constctx=host.switchToHttp();constresponse=ctx.getResponse<Response>();letstatus=HttpStatus.INTERNAL_SERVER_ERROR;letmessage='Internal server error';if(exceptioninstanceofHttpException){status=exception.getStatus();constexceptionResponse=exception.getResponse();if(typeofexceptionResponse==='string'){message=exceptionResponse;}elseif(typeofexceptionResponse==='object'&&exceptionResponse!==null){message=((exceptionResponseasRecord<string,unknown>).messageasstring)||message;}}elseif(exceptioninstanceofError){// 也可把 JWT 相关 Error 映射成 401message=exception.message;}constcode=this.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;}}}

关键点:

  1. @Catch()不传参数 = 抓住所有异常(不只是HttpException
  2. 从异常里取出 HTTP status 与可读 message
  3. 映射成业务code
  4. 永远返回{ 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){constuser=this.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)=>{constdata=response.data;if(data&&typeofdata==='object'&&'code'indata){if(data.code===0){returndata.data;// 业务层只看到真正的 data}returnPromise.reject(newError(data.msg||'请求失败'));}returnresponse;});

7. 自定义异常信息时注意getResponse()形态

UnauthorizedException('Invalid credentials')时,getResponse()可能是字符串,也可能是:

{"statusCode":401,"message":"Invalid credentials","error":"Unauthorized"}

所以 Filter 里要同时处理stringobject,否则msg可能变成[object Object]或拿不到可读文案。

校验类异常(如 ValidationPipe)的message还可能是字符串数组,进阶时可以再归一成一句或列表。


8. 和 Guard / JWT 的关系

Guard 鉴权失败时,底层同样会抛出 HTTP 异常(常见 401)。
只要全局 Filter 在,Guard 挡下的请求也会变成统一错误包,而不是「有的接口结构不一样」。

这正是系列串起来的好处:

  1. Module / Controller / Service 分层
  2. DI 接线
  3. Guard 守门
  4. Interceptor + Filter 统一出口

前端感知到的 API,始终是同一套语言。


9. 小结

  • 统一信封:{ code, msg, data },成功code === 0
  • ResponseInterceptor:包装成功返回;SSE/文件流要跳过
  • ExceptionFilter:把HttpException/ 普通Error收口成同一错误包
  • 业务层优先throw new UnauthorizedException(...),不要手写两套返回结构
  • HTTP status 表达传输层语义,业务code表达产品语义

对照前几篇,可以再多一句:

  1. 哪个 Controller 接请求?
  2. 哪个 Service 做业务?
  3. 哪个 Module 组装?
  4. 依赖从哪注入?
  5. 有没有 Guard?
  6. 成功谁包装、失败谁整形?前端拿到的信封长什么样?

下一篇会讲:Pipe 与 DTO 校验——为什么@Body()进来的脏数据,可以在进 Controller 之前就被拦下。

系列导航

  • 上一篇:NestJS 入门(3):Guard 如何挡住未登录请求?
  • 第二篇:NestJS 入门(2):依赖注入到底解决了什么问题?
  • 第一篇:NestJS 入门(1):先搞懂 Module、Controller、Service