☰
SpringBoot用户登录模块实战:JWT鉴权、BCrypt加密与安全加固全解析
2026/10/19 3:46:40 网站建设 项目流程

用户登录,听起来是个很普通的JavaWeb小功能,但它其实是一套系统的门卫——做得不好,后面的权限、安全、风控全都会跟着出问题。我最近用SpringBoot独立实现了一个完整的用户登录模块,包括注册、登录、退出、密码加密、Token鉴权、全局异常处理和登录日志记录,算是把这些年踩过的坑都重新趟了一遍。这篇文章不打算只贴一堆代码就完事,而是把整个模块的设计思路和实现过程中的关键决策讲透,让正准备做登录模块、或者想优化现有登录逻辑的同学少走一些弯路。

如果你只是想把登录功能跑通,其实一个Controller加三张表加一个加密工具类就够了。但真正要应付日常联调、上线、被攻击,就得想清楚很多细节:密码用什么方式存、登录状态用什么机制、注销之后token怎么处理、拦截器放行哪些路径、错误信息怎么给前端。这些决定了一周内无恙还是上线后手忙脚乱。

1. 需求拆解与方案选型

1.1 这个模块到底要解决什么问题

先别急着写代码。我在接到这个任务的时候,第一时间做的是列需求清单。一个用户登录模块从表面上看的核心只有三个字:登、录、退——注册账号、登录系统、退出登录。但如果把这三个字拓展开,会发现背后还有几条隐性的需求线。

第一条是账号安全。密码不能明文存,登录错误次数多了要有限制,越权访问要有拦截,这直接关系到用户的账号会不会被恶意登录。第二条是状态管理。用户登录之后,系统怎么识别“接下来这个请求是谁发来的”,无状态和有状态两种方式对前后端分离、集群部署的影响完全不同。第三条是异常处理与提示体验。用户名填错、密码填错、账号被封禁,前端看到的提示不能是系统霸屏的异常堆栈,而是清晰友好的说明。

带着这条需求线去设计,功能清单就出来了:用户注册、用户登录、退出登录、获取当前登录用户信息、密码加密存储、Token签发与校验、登录状态拦截、全局异常处理。这就是这个模块的完整边界,少任何一个环节,后面补坑的成本都远高于一开始就做进去。

1.2 为什么选SpringBoot + MyBatis-Plus + JWT的组合

这套组合可能在很多文章里出现过,但它不是跟风选的。先说SpringBoot,理由是它把繁琐的配置压得很低,内嵌Tomcat,打一个jar直接跑,对单体应用来说开发效率最高。相比早期SSH或者SSM时代写一堆XML配置,SpringBoot把数据库、Web、测试这些场景的默认配置都准备好了,我只需要关注业务代码本身。

MyBatis-Plus的选择则是基于团队习惯和数据访问的友好程度。它比JPA更容易看到SQL,也比原生的MyBatis少写很多重复的XML。在这个登录模块里,数据查询基本就是按用户名找用户、按ID找用户这种单表操作,MyBatis-Plus的LambdaQueryWrapper写起来非常顺手,几乎没有SQL拼接的负担。

再说JWT。这个选择需要重点说。现在做用户登录状态管理,主流方案就两个:Session和Token。Session是服务端存储会话,客户端拿一个SessionId,每次请求带过来,服务端查一下自己的内存或者Redis。Token则是把用户身份信息签进一个令牌里,服务端不存会话,客户端每次请求带上令牌,服务端验签后直接拿里面的信息用。

用生活里的例子类比,Session像去商场存包柜存包,你拿一个存包牌,回来凭牌取件,服务端得惦记着你的包。JWT像去景区买了一张带防伪标识的套票,你身上带着票,检票口扫码验证,不需要记住你这个人。前后端分离的项目,更推荐JWT,因为前端可能是小程序、App、浏览器同时接同一套后端,Token放header里,服务端无状态,水平扩容时不需要处理会话同步。

技术方案对比表:

对比维度SessionJWT
存储位置服务端(内存/Redis)客户端(localStorage/header)
扩容能力需要处理会话同步天然无状态,直接水平扩容
跨域支持Cookie跨域麻烦Header方式跨域友好
服务端内存占用资源不占用
主动失效服务端可删session需要黑名单机制
复杂度需要管理会话生命周期需要管理密钥

对于登录这个模块,JWT的优势体现在:不用管session的过期清理,不用被cookie跨域卡住,前后端接口设计也干净。至于JWT服务端无法主动踢人的毛病,在登录模块这个场景下完全可以通过黑名单或者短过期时间兜底。

1.3 功能清单与验收标准

功能清单列在这里,方便对照开发进度:

  • 用户注册:校验用户名唯一、校验密码强度、密码加密入库。
  • 用户登录:校验用户名密码、生成Token并下发、记录最近登录时间。
  • 退出登录:客户端清除Token,服务端可选黑名单处理。
  • 获取当前登录用户信息:根据Token解析用户ID,返回用户基本信息。
  • 登录拦截:放行登录/注册接口,拦截其余接口,未登录返回401状态。
  • 全局异常处理:业务异常、参数校验异常、系统异常统一返回标准JSON。
  • 密码策略:密码至少8位,包含大小写和数字。

验收标准其实就一句话:当一个正常的用户打开前端页面,可以顺利注册、登录、看到自己的信息、退出;当一位恶意用户尝试用错误密码撞库,会被拦截;当系统抛出一个未预料的异常时,前端拿到的仍然是一个结构完整的JSON错误响应,而不是堆栈页。

2. 数据库设计与工程环境搭建

2.1 用户表设计与字段解析

数据库是整个登录模块的地基。我第一次做登录模块时犯过一个错:只建了一张用户名和密码的表,还顺手把密码字段设为VARCHAR(20),导致后来要加手机号登录、加头像、加状态,发现表结构到处都是补丁。这次干脆把基础字段做完整。

CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', username VARCHAR(64) NOT NULL COMMENT '用户名', password VARCHAR(120) NOT NULL COMMENT 'BCrypt加密后的密码', nickname VARCHAR(64) DEFAULT '' COMMENT '昵称', avatar VARCHAR(255) DEFAULT '' COMMENT '头像URL', email VARCHAR(128) DEFAULT NULL COMMENT '邮箱', status TINYINT DEFAULT 1 COMMENT '状态 1正常 0禁用', last_login_time DATETIME DEFAULT NULL COMMENT '最近登录时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

这里有几个字段值得展开说一下。password长度设置为120,不是随便拍的——BCrypt加密之后的结果通常是60个字符左右,但因为算法版本可能升级,留足冗余空间,以后换更长的hash也无需改表。username上加唯一索引,是为了在数据库层面对重名做二次拦截,业务层校验和数据库约束各管一层,缺一不可。status字段很多人以为多余,但用户被封禁、账号被停用的是非常常见的需求,没有这个字段就只能物理删数据,到时候审计记录全都丢了。create_time和update_time是通用必备字段,排查问题时这两个字段能救你一命。

2.2 工程骨架与核心依赖

工程创建比较简单,IDE里选Spring Initializr,或者直接上官网生成一个基础项目。我这边的核心依赖长这样:

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

注意这里我没有引入spring-boot-starter-security。很多人有个误区:做登录就一定要上Spring Security。其实Security是个功能很强的双刃剑,它的过滤器链默认拦截所有请求,默认开启CSRF,新手上手配置成本很高,很多情况下你只是想做个登录模块,却被Security的配置折腾到怀疑人生。我这里用的是SpringBoot的拦截器配合JWT手动验签,控制力更强、逻辑也直白,等后面这套系统真的需要细粒度权限控制,再集成Security也不迟。

另外,密码加密用到的BCrypt实现来自spring-security-crypto模块,这是我从Security体系中单独拆出来的一个轻量依赖,我们只想要它的密码加密工具,不想要那一整套过滤器链。然后在配置类里声明一个Bean:

@Configuration public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }

2.3 统一响应结构与统一语义

前后端联调最容易出现的问题就是响应格式五花八门。登录成功返回一个Map,登录失败返回一个字符串,服务端异常直接返回Spring默认错误页,前端拿到这些数据类型不一致,就只能在代码里层层判断,极易出错。所以我在动手写业务之前先定义了一个统一响应体。

@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }

这个类看起来简单,但价值很大。code字段我直接沿用了HTTP状态码的语义,200表示成功,401表示未认证,403表示无权限,500表示系统异常,前端拿到code之后不需要再单独维护一套错误码字典。实际开发中,有些人喜欢自定义一套“0000成功、0001失败”之类的业务码,我不反对,但如果能跟HTTP语义对齐,前后端沟通成本会低很多。

3. 登录流程核心实现

3.1 注册接口:密码加密入库的正确姿势

注册接口的逻辑很清晰:接收用户名、密码、昵称,先查一下用户名是否存在,存在就抛业务异常;不存在就把密码加密后插入用户表。这里的核心是密码加密,不能有任何商量的余地——明文存储密码在真实场景里等同于裸奔。

BCrypt算法的特点是用随机盐参与加密,每次加密同一个密码,得到的hash值都不一样。有人看到这可能很疑惑:既然每次都不一样,数据库里存的是密文,之后怎么验证密码对不对?答案是BCrypt的验证不是简单比较密文,而是把用户输入的密码和存储的密文一起放进校验算法,由算法自己识别盐值和摘要去比对。这就是为什么BCryptPasswordEncoder提供的是encode和matches两个方法,而不是一个compare方法。用代码写就是下面这样:

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private IUserService userService; @Autowired private PasswordEncoder passwordEncoder; @PostMapping("/register") public Result<Void> register(@RequestBody @Valid RegisterRequest request) { userService.register(request); return Result.success(null); } }

ServiceImpl里对应的注册逻辑:

public Long register(RegisterRequest request) { LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysUser::getUsername, request.getUsername()); if (this.count(wrapper) > 0) { throw new BizException("用户名已存在"); } SysUser user = new SysUser(); user.setUsername(request.getUsername()); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setNickname(request.getNickname()); this.save(user); return user.getId(); }

这里我特意在业务层而不是Controller里写用户名重复校验,是为了让注册逻辑可以被多个入口复用,比如以后开发后台管理员手动创建用户的功能,同样可以走这套校验。

关于密码强度的校验,Spring的validation注解能满足一部分,但我更推荐在注册请求里加自定义正则:

@Data public class RegisterRequest { @NotBlank(message = "用户名不能为空") @Size(min = 3, max = 20, message = "用户名长度必须在3到20个字符之间") private String username; @NotBlank(message = "密码不能为空") @Size(min = 8, max = 32, message = "密码长度必须在8到32个字符之间") @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", message = "密码必须包含大小写字母和数字") private String password; }

这个正则要求密码必须同时包含大写字母、小写字母和数字,杜绝了那种纯123456的弱密码。正则本身不复杂,但能极大提升账号的安全基线。

3.2 登录接口:查用户、验密码、签发Token三个动作

登录接口的核心逻辑拆解下来就是三步:根据用户名查用户、用BCrypt校验密码、校验通过后签发JWT。

@PostMapping("/login") public Result<LoginResponse> login(@RequestBody @Valid LoginRequest request) { // 1. 根据用户名找用户 SysUser user = userService.getUserByUsername(request.getUsername()); if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { // 不要把"用户不存在"和"密码错误"拆开提示,防止恶意用户枚举账号 throw new BizException("用户名或密码错误"); } // 2. 检查账号状态 if (user.getStatus() == 0) { throw new BizException("账号已被禁用"); } // 3. 生成Token String token = jwtUtil.createToken(user.getId(), user.getUsername()); // 4. 更新最近登录时间 userService.updateLastLoginTime(user.getId()); LoginResponse response = new LoginResponse(); response.setToken(token); response.setUsername(user.getUsername()); response.setNickname(user.getNickname()); return Result.success(response); }

注意这里有一个设计细节:当用户不存在和密码错误时,我返回的是同一个提示“用户名或密码错误”。很多新人不理解为什么,说用户体验很差——用户填错用户名,系统却告诉他密码也可能错了。但从安全角度看,如果接口明确返回“该用户不存在”,任何人都可以通过批量请求探测系统里有哪些真实注册账号,这就是账号枚举漏洞。产品在绝大多数场景下拉一下回归也不是不能忍,安全优先。

JWT的生成和解析我封装在一个工具类里:

@Component public class JwtUtil { // 在实际项目中放到配置文件中,不要写死在代码里 private SecretKey key = Keys.hmacShaKeyFor("your-256-bit-secret-key-change-me!".getBytes(StandardCharsets.UTF_8)); private long expireMillis = 86400000L; // 24小时 public String createToken(Long userId, String username) { Date now = new Date(); Date expireTime = new Date(now.getTime() + expireMillis); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .setIssuedAt(now) .setExpiration(expireTime) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }

关于密钥,这里想强调一个容易踩坑的点:HS256签名算法要求密钥长度至少256位(32字节),如果密钥太短,程序启动时不会报错,但生成或解析Token时会直接抛异常。我在第一次写这个工具类时就是把密钥写成了“secret”这种短字符串,结果一调接口就报错,排查了半天。正式项目里密钥应该放到配置文件,并通过环境变量注入,保证不同环境用不同密钥。

3.3 登出接口与Token处置策略

登出这个功能,很多人认为前端把Token从localStorage里删掉就结束了,这种理解在纯前端的视角下没毛病,但从服务端角度看,Token在到期之前仍然是合法的。如果有人窃取了你的Token,只要没到过期时间,他就能继续以你的身份操作。

所以在服务端,登出通常有两种处理路径。一种是把Token加入黑名单,Redis里维护一个set,key是Token的散列值,value是过期时间,登出时写入,请求进来先查黑名单。另一种是维护一个用户的Token版本号,登出时版本号加一,旧Token直接失效。对于单机小项目,简单一点可以选第一种,代价是需要引入Redis。

这次我采用的是接口层面支持登出,并且把前端清除Token作为主流程,服务端提供了一个可选的登出接口,后续如果要接黑名单,前端调用登出接口时服务端把Token加入Redis黑名单即可。这里不展开Redis的完整配置,但思路是明确的:Token黑名单设计并不是从一开始就必须做,但它一定要在我们的设计里留好位置。

3.4 获取当前登录用户信息:服务端如何识别

用户登录成功后,前端每次请求把Token放在请求头里,服务端通过拦截器统一解析,然后把用户信息放进去。这里我用了一个线程变量来保存当前登录用户的信息,方便后续业务代码在任何位置获取当前用户。

public class UserContext { private static final ThreadLocal<SysUser> USER_HOLDER = new ThreadLocal<>(); public static void set(SysUser user) { USER_HOLDER.set(user); } public static SysUser get() { return USER_HOLDER.get(); } public static void clear() { USER_HOLDER.remove(); } }

ThreadLocal的作用是让同一个线程里的所有代码都能共享这份数据。一个请求从进入拦截器到返回结果,全程是同一个线程处理的,所以从拦截器里把用户信息放进ThreadLocal,Controller层就能直接拿出来用。但要特别注意拦截器完成后必须调用remove方法,否则线程池复用线程时,上一次请求的用户信息会泄漏到下一个请求,这是典型的踩坑点。

4. 安全加固与全局异常处理

4.1 登录拦截器的设计与路径放行

拦截器是登录模块的哨兵。我用Spring的HandlerInterceptor来实现Token的验证逻辑。

@Component public class LoginInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 跨域预检请求直接放行 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { throw new BizException("未登录,请先登录"); } try { Claims claims = jwtUtil.parseToken(token.replace("Bearer ", "")); SysUser user = new SysUser(); user.setId(Long.valueOf(claims.getSubject())); user.setUsername((String) claims.get("username")); UserContext.set(user); return true; } catch (Exception e) { throw new BizException("登录状态已失效,请重新登录"); } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }

然后是注册拦截器:

@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns("/api/auth/login", "/api/auth/register", "/error"); } }

这里的excludePathPatterns特别重要,很多人登录接口被拦截器拦住,调不通,就是因为没有把登录、注册接口加进放行名单。注册接口如果也被拦,那系统里就永远不会有第一个用户了。

另外,拦截器里我对Authorization头做了“Bearer ”前缀的兼容处理。很多HTTP客户端习惯在Authorization头部携带Bearer Token,但也有不少前端直接甩一个裸Token过来,兼容两种方式能少很多联调喷口水的机会。

4.2 CSRF、XSS与Token接口的取舍

在前后端分离的架构下,CSRF攻击的核心载体——Cookie自动携带机制——基本已经失效了,因为Token放在Authorization头里,跨站请求没有办法自动带上这个头,这本身就对CSRF免疫了一大半。所以这里不需要再引入Spring Security的CSRF防护,也不需要过滤器来校验Referer。

XSS防护则需要留意。如果我在前端把Token放在sessionStorage里而不是localStorage里,可以降低XSS窃取Token的风险,因为sessionStorage在关闭标签页后会清空,攻击脚本的窗口期更短。服务端同样需要考虑输出内容的转义,尤其是用户在昵称、头像URL等字段里输入的内容,保存到数据库后再展示在管理后台时,如果没有转义,就可能变成存储型XSS的注入点。这个模块里,我在昵称字段上做了HTML转义,并且在全局异常处理中统一返回JSON,避免任何异常信息直接渲染到页面上。

4.3 防暴力破解与登录限速策略

暴力破解听起来很高端,拆开就是不停地试密码。对于这种攻击,最简单有效的防御就是限制失败次数和登录频率。这次的实现里,我引入了Redis来做登录失败计数。

逻辑是这样的:以用户名为key,记录连续登录失败的次数。每次失败,把次数加一,并设置自动过期时间。当失败次数达到5次时,锁定该用户的登录操作15分钟。关键代码片段大致如下:

String failKey = "login:fail:" + username; // 登录失败后 Long failCount = redisTemplate.opsForValue().increment(failKey); if (failCount != null && failCount == 1) { redisTemplate.expire(failKey, 15, TimeUnit.MINUTES); } if (failCount >= 5) { throw new BizException("登录失败次数过多,账号已锁定15分钟"); }

细心的同学可能会发现,这种限速策略绕不开一个问题——攻击者可以故意用别人的用户名触发锁定,导致正常用户也无法登录。这属于典型的账号锁定DoS问题,线上更稳健的做法是对同一个IP、同一种设备指纹也做频次限制,并且锁定后的提示文案引导用户走找回密码或者联系客服,而不是单纯的“账号已锁定”。对于这个模块现在的体量,先做用户名维度的限速,接口预留IP限速的位置,等有真实规模压力时再补全。

4.4 全局异常处理:让前端永远不背锅

一套成熟的登录接口,必须保证返回给前端的永远是结构一致的JSON。我定义了一个核心业务异常类BizException,它继承RuntimeException,构造时传入错误码和错误信息,默认统一使用500作为业务错误码。然后在全局异常处理器里统一拦截:

@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BizException.class) public Result<Void> handleBizException(BizException e) { return Result.error(e.getCode(), e.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public Result<Void> handleValidException(MethodArgumentNotValidException e) { String message = e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining("; ")); return Result.error(400, message); } @ExceptionHandler(Exception.class) public Result<Void> handleException(Exception e) { log.error("系统异常", e); return Result.error(500, "系统繁忙,请稍后重试"); } }

最后一个兜底异常捕获很重要,它把任何未预料的异常都转化成友好提示,同时把堆栈日志留在服务端,方便排查。这样可以避免数据库宕机时,前端看到一屏500的白页面加Tomcat错误堆栈。

5. 实战中的坑与排查思路实录

5.1 BCrypt密码校验永远失败

这是登录模块里最常见的问题。注册时用passwordEncoder.encode保存了密码,登录时用passwordEncoder.matches进行比较,结果总是false。排查方向通常是两点。

检查密码字段的长度是否够长。BCrypt的hash结果大约是60个字符,如果数据库字段设成VARCHAR(20),密码存储时就被截断了,登录时拿到的密文根本不是完整的,自然永远匹配不上。另外,确认你的matches参数顺序没有颠倒——第一个参数是用户输入的明文密码,第二个参数是数据库里的密文,两个写反会出现“看似没报错,实际永远不通过”的诡异效果。

5.2 JWT过期时间与刷新机制

JWT过期时间如果设得太短,用户用着用着就被踢下线,体验很差;设得太长,Token泄露后的风险窗口又太大。目前我的做法是给登录Token设24小时过期,同时预留了一个refreshToken的接口,RefreshToken有效期7天,过期前可以续期,避免用户频繁重新登录。这个方案在简单场景里可以不做,但如果产品要做移动端,RefreshToken几乎是标配。

5.3 拦截器放行顺序导致请求404

一个很典型的坑:接口路径明明配了拦截器放行,但请求还是进不来。原因往往是excludePathPatterns里的路径和Controller的路由前缀没对齐。比如我把登录接口放在了/api/auth/login下,但放行名单写的是/login,拦截器仍然把请求拦掉了。另一个原因是静态资源被拦截了,导致前端页面打开后CSS、JS加载不出来。排查时先看请求日志,看请求到底被谁处理了,是拦截器拦的还是直接404了。

5.4 JSON序列化导致的时间与类型问题

用户表里有LocalDateTime类型的字段,如果没有配置序列化规则,接口返回的时间会是数组形式或者带T的格式,前端拿到的数据很怪。在application.yml里加上全局的日期格式化配置就好:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

时间时区也容易忽略,如果不设置time-zone,服务器默认的时区可能跟本地环境不一致,导致注册时间、登录时间显示相差8个小时。这个问题在上线时最容易暴露,本地测得好好的,一上服务器时间就不对了。

5.5 跨域配置与前后端联调

前端页面不在同一个端口时,浏览器会发OPTIONS预检请求。如果跨域配置没写好,前端怎么调用接口都报跨域错误。添加一个CorsFilter是必要的,同时要注意跨域配置里的allowCredentials不能和前端的withCredentials冲突。开发环境我通常直接放开所有跨域限制,生产环境则配置白名单域名。

@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }

6. 测试与联调心得

6.1 自测流程与接口链路验证

接口写完不急着联调,先自己把每个接口用测试工具过一遍。我习惯把注册、登录、获取用户信息、登出这四条链路串成一个完整的测试流程。用Postman或者Apifox都可以,登录接口会返回一个Token,在测试工具的全局变量里把Token存下来,后面的接口自动带上Authorization头,这样测试时就不用每个请求都手动贴Token了。

JUnit方面,这个模块至少会mock一层数据库,去验证登录校验逻辑。比如用户不存在时返回什么、密码错误时返回什么、账号禁用时返回什么。别小看这些边界测试,逻辑分支越多的代码越需要测试兜底,登录模块是系统的入口,自测做得越早,联调时被前端打回的次数越少。

6.2 环境差异与部署前的检查

数据库在本地跑的话,建议用Docker起MySQL,配置一行就能解决环境差异,远比本机直接安装MySQL要省心。我有一次因为本地MySQL版本是8.0,而服务器是5.7,驱动、SQL语法略有不同,导致SQL执行报错,当时就是因为没有在本地和服务器之间统一版本约定,硬生生多排查了两个小时。

上线前还有一件重要的事:确认密钥、数据库连接串、Redis连接串不能出现在代码库的配置里。SpringBoot的配置占位符配合环境变量,可以把敏感信息从项目里剥离开。我之前见过有人把生产库密码直接写在application.yml里提交到Git仓库,这种事故一旦发生,轻则删库重来,重则被拖库盗取全部数据,教训太深刻了。

6.3 个人体会与几点建议

用户登录这个功能做下来,回头看最大的体会就是:很多所谓的“坑”不是技术有多难,而是缺少对细节的敬畏。密码加密谁都会说,但有没有人去解释为什么数据库字段要留那么长;JWT谁都会生成,但有没有人注意到密钥长度的限制;拦截器谁都会写,但有没有人记得ThreadLocal在结束后要清理。这些细节点,恰恰是一个登录模块能否从“能跑”变为“扛得住”的分水岭。

我在实现过程中还有一个习惯,就是每写完一个环节,会回到需求清单上打一个勾。这个动作能让人时刻知道自己在做什么、什么还没做,避免写着写着就陷入某个边角料的优化里出不来。如果你也正准备开始做这个功能,建议也先花半天列清单、定方案,再动键盘。磨刀不误砍柴工,这句话在写代码上同样适用。

最后再分享一个小技巧:登录模块的接口设计尽量遵循一个原则——登录、注册这些入口尽量用POST,不要在URL里暴露密码和Token;查询用户信息这些接口,参数优先走Path或者Header,不要拼到查询串里。这些约定看似零碎,但组合在一起,就是后端接口的安全底子,也是前端同事最容易感谢你的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询