Spring Boot用户模块与全局异常处理器:打造稳定接口层的实战指南

发布时间:2026/10/15 4:23:18
Spring Boot用户模块与全局异常处理器:打造稳定接口层的实战指南
做AI头条这类内容型产品用户模块往往是第一个从零到一完整落地的业务闭环。注册、登录、用户资料、关注偏好这些能力看着不起眼但一旦进入前后端联调阶段或者并发量稍微上来一点最容易暴露问题的反而不是业务逻辑本身而是异常处理这块皮。项目二把用户模块和全局异常处理器一起实现就是想从一开始就把接口层的稳定性、可维护性、以及前端协作的契约固定下来。这篇文章就从这个角度把用户模块的搭建思路、全局异常处理器的设计细节、以及我在实际联调中踩过的坑完整拆开讲。如果你正准备做类似的资讯类或内容类项目建议把全局异常处理器的优先级提到跟用户注册登录同等的位置。很多团队习惯先把接口写完再去补异常处理结果越补越乱。项目二的做法是先定好响应结构再写业务代码会清爽非常多。下面按实际项目的推进顺序展开。1. 用户模块的整体设计与技术落点1.1 用户模块在这个项目里的定位AI头条类产品的用户模块本质上是一个“身份基础设施”。表面上只需要做注册、登录、退出、改密码但后续的所有业务都挂在这个基础上内容推荐需要知道用户偏好评论需要用户ID收藏需要用户ID甚至个性化推送也要依赖用户唯一标识。因此用户模块在项目二里不单是几个CRUD接口而是一个稳定提供“当前登录用户”语义的能力层。模块拆分时我建议把用户模块单独做成一个垂直闭环而不是跟内容模块混在同一个Controller下。项目二的分法是用户账号中心注册、登录、用户信息查询、修改用户偏好中心关注频道、屏蔽标签、阅读历史认证令牌中心JWT签发、刷新处理、退出登录这三个子模块各自独立但底层共用同一张用户主表和同一套认证切面。实际开发中关注频道和阅读历史可以晚一步做但注册登录和令牌鉴权必须在“项目二”这个阶段一次性闭环否则后续接口全部裸奔。1.2 为什么用了Spring Boot这套技术栈技术选型上项目二沿用最常见的Spring Boot MyBatis-Plus MySQL Redis组合。原因很简单用户模块没有什么复杂的分布式事务也没有高频计算的场景核心要求是“稳定地完成数据落库和接口鉴权”这个组合足够而且团队里有新人也能快速接手。MyBatis-Plus相比JPA在这类项目里的优势是可控性强。用户表字段比较多账号密码、昵称、头像、手机号、邮箱、状态位、最后登录时间JPA的实体关联容易在一个不留神的情况下多查出一堆不必要的数据MyBatis-Plus的LambdaQueryWrapper写起来直观排查SQL也方便。举个例子Long count userMapper.selectCount( Wrappers.UserlambdaQuery() .eq(User::getEmail, dto.getEmail()) );这一句非常直白就是“查一下邮箱是否存在”。项目二里的用户核心逻辑基本都用这种写法代码风格统一后续接需求的人不会因为ORM用法五花八门而头疼。1.3 目录结构怎么分才不会乱用户模块的目录划分直接决定后面全局异常处理器放在哪里、能不能被其他模块复用。项目二的分层如下com.example.aiheadlines ├── controller // 接口层 ├── service // 业务层 ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── model │ ├── dto // 入参 │ └── vo // 出参 ├── common │ ├── Result.java // 统一响应体 │ ├── ErrorCode.java // 错误码枚举 │ ├── exception // 自定义异常 │ └── handler // 全局异常处理器 └── config // 配置类注意common包不和user包放在一起而是提升到项目顶层。因为全局异常处理器、统一响应体、错误码枚举是整个项目共享的内容模块要复用评论模块也要复用。如果放在user模块里后面其他模块引用时会出现很别扭的依赖关系。这个分层在项目早期就要打好否则越写越乱。2. 用户模块的核心实现细节2.1 注册流程与密码加密的取舍注册接口是用户模块的门面参数校验、业务校验、落库三步看起来简单但每一步都有坑。参数校验我推荐用JSR 303注解比如NotBlank、Email、Size。但真正要留意的是业务校验和参数校验的区别参数校验是“格式是否正确”业务校验是“这个邮箱是否已经被注册”。前者在Controller层处理后者在Service层处理。把业务校验写在Service层的好处是不会被绕过。假如以后有后台批量导入用户的接口同样可以复用这层校验逻辑。密码加密这块我见过太多项目直接用MD5加一个固定盐甚至有人直接把密文存进去。这样做的风险不只是破解而是无法解释“为什么同一个密码在不同系统里密文一致”。项目二用的是Spring Security里的BCryptPasswordEncoder核心代码如下Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Autowired private BCryptPasswordEncoder encoder; Override Transactional(rollbackFor Exception.class) public Long register(RegisterDTO dto) { Long count userMapper.selectCount( Wrappers.UserlambdaQuery() .eq(User::getEmail, dto.getEmail()) ); if (count 0) { throw new BizException(ErrorCode.EMAIL_EXIST); } User user new User(); user.setEmail(dto.getEmail()); user.setUsername(dto.getUsername()); user.setPassword(encoder.encode(dto.getPassword())); userMapper.insert(user); return user.getId(); } }为什么不用自己拼接盐因为BCrypt的盐会被编码到hash结果里校验的时候框架能自己解析出来。你不需要额外维护一张盐表也不需要考虑盐落库的字段长度。这个方案是目前性价比最高的哪怕项目只有几千个用户也不应该在这上面省功夫。2.2 登录与JWT签发的正确姿势登录接口的核心动作有两个校验账号密码、签发令牌。项目二的JWT结构是header.payload.signatureheader指定算法payload存userId、exp、nicknamesignature用密钥签名。生成令牌的代码封装在工具类里大致如下String token Jwts.builder() .setSubject(user.getId().toString()) .claim(nickname, user.getNickname()) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact();两个容易忽略的点。第一payload里不要放手机号、邮箱这类敏感信息。JWT的payload只是base64编码谁都能解码看内容唯一安全的是签名能防止篡改但不防窥探。第二过期时间不要拍脑袋定。如果用户经常使用移动端24小时会逼着用户频繁登录但七天又增加了令牌泄露的窗口。项目二的做法是登录令牌2小时过期配合Redis维护一个refreshToken刷新机制后面可以单独扩展。最小闭环阶段先做到令牌过期自动要求重新登录已经能满足绝大多数场景。2.3 用户信息查询与参数校验的联动用户信息查询接口除了根据userId查库还要注意一个细节入参的合法性不能被忽略。我见过很多接口明明是查询用户却把token里解析出的userId直接拿来用根本没有校验参数。项目二的做法是Controller上标注ValidatedDTO字段上用校验注解比如RestController RequestMapping(/api/user) Validated public class UserController { GetMapping(/profile) public ResultUserVO profile(RequestParam(userId) NotNull(message userId不能为空) Long userId) { return Result.ok(userService.getProfile(userId)); } }这里特别提醒如果你要在RequestParam上做校验Controller类上必须加Validated这是新手最容易踩的坑。只加方法参数上的注解是不够的Spring只有在类级别启用校验时才会处理方法参数上的约束注解。项目二踩过一次这个坑现象就是参数明明没传前端却收到“系统繁忙”因为异常掉进兜底分支了前端完全看不懂。3. 全局异常处理器的设计与落地3.1 为什么必须做一个全局异常处理器没有全局异常处理器的时候用户模块的代码会是什么样子每个Controller方法都要try-catch然后手动构造一个错误Map返回。写三个接口还好写十个接口之后就变成灾难。更麻烦的是Spring Boot默认的错误页是一段HTML前端拿到的响应不是JSON联调时完全没法用。举一个生活化的例子给每个接口单独写try-catch就像给每个房间单独配灭火器看起来每个房间都有保障但真着火的时候你会发现自己根本分不清哪个灭火器能用哪个已经过期了。全局异常处理器就是楼里的消防系统集中处理所有控制器的异常出口规则统一流程一致。3.2 响应体在哪一层定义决定协作契约项目二在编码之前先定义了统一响应体ResultT。这里有一个容易忽略的细节Result一定要带泛型和timestamp否则前端没法做统一的数据解析。Data public class ResultT implements Serializable { private Integer code; private String message; private T data; private Long timestamp; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(ErrorCode.SUCCESS.getCode()); result.setMessage(ErrorCode.SUCCESS.getMessage()); result.setData(data); result.setTimestamp(System.currentTimeMillis()); return result; } public static T ResultT fail(ErrorCode errorCode) { ResultT result new Result(); result.setCode(errorCode.getCode()); result.setMessage(errorCode.getMessage()); result.setTimestamp(System.currentTimeMillis()); return result; } }timestamp这个字段看起来不起眼但实际上对排障非常有用。当前端截图说“接口报错了”你让他把整个响应体发过来一眼就能看到具体时间点不用再去翻日志定位是哪个请求。真实开发里这种体验非常重要。3.3 错误码枚举的划分策略错误码不能随手写魔法值。项目二把错误码定义在ErrorCode枚举中划分规则是按模块分段。1000开头是用户模块2000开头是内容模块3000开头是评论模块公共错误码统一用0、400、401、500这类短码。public enum ErrorCode { SUCCESS(0, 成功), PARAM_INVALID(400, 参数校验失败), UNAUTHORIZED(401, 未登录或登录已过期), SYSTEM_ERROR(500, 系统繁忙请稍后再试), EMAIL_EXIST(1001, 该邮箱已注册), EMAIL_NOT_EXIST(1002, 该邮箱未注册), PASSWORD_WRONG(1003, 密码错误), USER_NOT_FOUND(1004, 用户不存在); private final Integer code; private final String message; ErrorCode(Integer code, String message) { this.code code; this.message message; } }为什么业务错误码不用HTTP状态码直接替代因为HTTP状态码的语义太稀疏而且很多网关层会劫持非2xx请求做统一处理。更关键的是参数校验错误有多个字段时你需要在message里一次性告诉用户所有问题这是纯状态码做不到的。所以项目二选择“HTTP状态码基本都用200业务处理结果看code字段”的方案。当然这不是唯一标准但团队内部必须一致。3.4 核心处理器实现与各种异常分支全局异常处理器的核心类就是RestControllerAdvice。这个注解会把类内的ExceptionHandler方法注册到Spring MVC的异常解析链路上当接口抛出指定异常时自动匹配最精确的那个方法。项目二里的处理器覆盖了这几类RestControllerAdvice Slf4j public class GlobalExceptionHandler { ExceptionHandler(BizException.class) public ResultVoid handleBizException(BizException e) { log.warn(业务异常: code{}, message{}, e.getErrorCode().getCode(), e.getMessage()); return Result.fail(e.getErrorCode()); } ExceptionHandler(MethodArgumentNotValidException.class) public ResultVoid handleMethodArgumentNotValid(MethodArgumentNotValidException e) { String message e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining(; )); log.warn(参数校验异常: {}, message); return Result.fail(ErrorCode.PARAM_INVALID, message); } ExceptionHandler(ConstraintViolationException.class) public ResultVoid handleConstraintViolation(ConstraintViolationException e) { String message e.getConstraintViolations().stream() .map(ConstraintViolation::getMessage) .collect(Collectors.joining(; )); return Result.fail(ErrorCode.PARAM_INVALID, message); } ExceptionHandler(BindException.class) public ResultVoid handleBindException(BindException e) { String message e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining(; )); return Result.fail(ErrorCode.PARAM_INVALID, message); } ExceptionHandler(DataIntegrityViolationException.class) public ResultVoid handleDataIntegrityViolation(DataIntegrityViolationException e) { log.error(数据库约束冲突, e); return Result.fail(ErrorCode.SYSTEM_ERROR); } ExceptionHandler(Exception.class) public ResultVoid handleException(Exception e) { log.error(系统未捕获异常, e); return Result.fail(ErrorCode.SYSTEM_ERROR); } }这里为什么要有MethodArgumentNotValidException、ConstraintViolationException、BindException三个不同的方法因为它们看似都是参数校验来源场景完全不同。MethodArgumentNotValidException是RequestBody参数校验失败ConstraintViolationException是RequestParam、PathVariable参数校验失败BindException是表单绑定失败。漏掉任何一个都会掉到最后的兜底Exception分支前端看到的提示就从具体的“邮箱不能为空”变成“系统繁忙”。顺序上BizException放在最前面Exception放最后。Spring在处理时会选最匹配的异常处理器所以不存在“被兜底抢先”的情况。但我还是建议代码顺序也按从具体到宽泛来排可读性好后维护的人不容易看懵。3.5 为什么兜底异常不能把堆栈直接返回给客户端这是个原则问题。当系统发生未预期异常时堆栈信息里大概率包含SQL语句、Redis key、服务器目录结构甚至配置信息。这些内容如果原样返回给前端一方面会暴露系统内部结构另一方面前端也看不懂。比如数据源连接失败时异常信息里会带数据库IP和端口这是绝对不能暴露的。正确的做法是日志里记录完整堆栈响应体里只返回固定文案“系统繁忙请稍后再试”。线上排查问题时看日志而不是依赖接口返回的message。项目二里的兜底处理就是这样做的用log.error(系统未捕获异常, e)打印完整堆栈然后统一返回SYSTEM_ERROR。这条经验是在一次线上事故里用惨痛教训换来的。4. 前后端联调中的协作要点与踩坑记录4.1 接口返回格式约定的实际样例前后端联调最怕各说各话。项目二在接口文档里固定了几种典型返回格式前端照着解析就行。登录成功时的返回体长这样{ code: 0, message: 成功, data: { token: eyJhbGciOiJIUzI1NiJ9.xxx, userId: 1001 }, timestamp: 1710000000000 }参数校验失败时返回体长这样{ code: 400, message: email不能为空; password长度不能小于6, data: null, timestamp: 1710000000000 }注意这里的code400并不是HTTP状态码400而是业务码。HTTP状态码仍然是200。这个方案的好处是前端拦截器不用区分“网络错误”和“业务错误”只需要看code即可。如果网关层做了重试或者熔断也不会因为非2xx状态码导致入口处先报错业务错误连到前端提示的机会都没有。但要跟团队成员说清楚不要用HTTP状态码400表示业务错误、又用code400表示参数错误这样两层容易混淆。项目二从一开始就定死“HTTP状态码只管传输层业务状态看code”前端和后端开发都没有产生过歧义。4.2 登录拦截器里的token异常如何进全局处理器用户模块的接口不是全都公开必须有一套鉴权逻辑。项目二用的是Spring MVC拦截器HandlerInterceptor而不是Servlet Filter。这里要说明一个重要区别过滤器在DispatcherServlet之前执行如果过滤器里抛出异常RestControllerAdvice是捞不到的拦截器在DispatcherServlet之内执行全局异常处理器能够正常拦截。拦截器里的核心逻辑是这样写的public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); if (!StringUtils.hasText(token) || !jwtUtil.verify(token)) { throw new BizException(ErrorCode.UNAUTHORIZED); } Long userId jwtUtil.parseUserId(token); request.setAttribute(currentUserId, userId); return true; } }这样做的直接收益是token不存在、token过期、token签名错误前端收到的响应体依然是统一的JSON格式而不是一段HTML错误页。前端在全局axios拦截器里判断code 401自动跳转登录页非常顺滑。如果你非要用Filter实现统一鉴权那就必须额外处理“异常进不了全局处理器”的问题。项目二没有在这个问题上纠结直接用拦截器方案绕开了它。这是更合理的技术选型。4.3 联调过程中踩过的三个真实坑第一个坑是Validated注解位置不对。之前说过Controller类上不写Validated方法参数上的校验注解就不生效。踩坑后的现象是前端传了个空邮箱后端接口直接进了逻辑层最后在数据库层报错又因为没有具体异常处理器前端只看到“系统繁忙”。排查了半天才发现是校验根本没触发问题不在于数据库层。第二个坑是参数异常时字段错误信息拼不出来。MethodArgumentNotValidException里拿到的是FieldError如果字段是嵌套对象field名称会嵌套前缀。处理时不要图省事直接getDefaultMessage()要结合getField()拼接完整字段路径否则前端不知道是哪个字段出错。第三个坑是日志里看不到真实原因。最初我把兜底异常写成log.error(系统异常: {}, e.getMessage())结果线上出现了问题日志里只有一句“系统异常: null”完全无从排查。正确的写法是把整个异常对象传给日志框架像log.error(系统未捕获异常, e)这样堆栈才会完整打出来。这个细节我后来每次都会提醒团队里的新人。5. 异常处理速查表与实战心得5.1 常见问题速查表异常类型典型原因处理方式返回的是HTML错误页异常没有进入RestControllerAdvice比如Filter中抛异常改用拦截器或自定义ErrorController参数校验不生效Controller类上缺少Validated或DTO字段没加校验注解类上补Validated并确认DTO字段注解参数校验起了作用但提示不具体缺少MethodArgumentNotValidException的处理器增加ExceptionHandler(MethodArgumentNotValidException.class)token过期却看不到统一JSON鉴权逻辑放在Filter里或者拦截器抛出的是非业务异常移到拦截器并抛BizException(ErrorCode.UNAUTHORIZED)数据库唯一键冲突返回500没有处理DataIntegrityViolationException增加对应处理器或提前在Service层做唯一性校验日志中看不到完整错误堆栈使用e.getMessage()而非传整个异常对象改成log.error(描述, e)这张表基本覆盖了项目二从头到尾遇到的异常处理坑。放在团队Wiki里当参考资料比重新读一遍代码高效得多。5.2 异常处理器要不要覆盖所有模块强烈建议从一开始就全局统一。项目二在用户模块阶段就把全局异常处理器放在common包下后面做内容模块和评论模块时新接口只要不主动抛异常所有DB异常、参数异常、JSON序列化异常都会自动走同一套逻辑。与其说这是“给用户模块做的”不如说这是给整个项目提前打好的地基。有一种做法是把只在用户模块生效的异常定义放在用户模块内比如“邮箱已注册”但BizException、Result、GlobalExceptionHandler这类基础设施必须全局共享。否则后面每个模块各自写一套响应结构前端还得分模块判断返回值的不同格式这种内耗完全可以避免。5.3 根据个人经验总结的三个建议第一业务异常一定要有对应的错误码不要所有失败都返回“系统繁忙”。前端拿到500码时只适合弹一个全局提示但用户更想知道的是“这个邮箱已经注册过了”错误码的细化直接决定用户体验。第二全局异常处理器里的每个方法都可以加响应日志但要区分级别。业务异常用warn系统未捕获异常用error参数校验异常用warn。日志一多如果不分级排查起来会非常痛苦。第三不要为了偷懒让Result的message字段复用异常对象的getMessage()。应该始终使用ErrorCode枚举里预定义的文案除非是参数校验这类需要动态拼接的场景。否则一旦你在Service层写了一个“未知错误”的中文提示前端会收到一堆毫无参考价值的文案排障的时候只能干瞪眼。说实话这轮把全局异常处理器做稳之后后续开发效率的提升是很直观的。新接口的研发不用再考虑错误返回的格式只管正常返回和抛业务异常。这个设计本身不复杂但它在项目里承担的作用远比那几十行代码看起来重要。如果你也在做类似的项目建议至少把用户模块的注册、登录、鉴权、全局异常处理器这条线完整打通不要急着堆功能。地基打得稳后面住着才踏实。