上一篇: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;}}}关键点:
@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 Unauthorized | 1001 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 里要同时处理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 === 0 - ResponseInterceptor:包装成功返回;SSE/文件流要跳过
- ExceptionFilter:把
HttpException/ 普通Error收口成同一错误包 - 业务层优先
throw new UnauthorizedException(...),不要手写两套返回结构 - HTTP status 表达传输层语义,业务
code表达产品语义
对照前几篇,可以再多一句:
- 哪个 Controller 接请求?
- 哪个 Service 做业务?
- 哪个 Module 组装?
- 依赖从哪注入?
- 有没有 Guard?
- 成功谁包装、失败谁整形?前端拿到的信封长什么样?
下一篇会讲:Pipe 与 DTO 校验——为什么@Body()进来的脏数据,可以在进 Controller 之前就被拦下。
系列导航
- 上一篇:NestJS 入门(3):Guard 如何挡住未登录请求?
- 第二篇:NestJS 入门(2):依赖注入到底解决了什么问题?
- 第一篇:NestJS 入门(1):先搞懂 Module、Controller、Service