SpringBoot+MyBatis实战:从零构建安全高效的登录注册系统
1. 项目起航为什么选择SpringBoot和MyBatis大家好我是老张一个在后端开发领域摸爬滚打了十多年的老码农。最近带实习生做项目发现很多新手朋友一听到要自己动手搭建一个完整的登录注册系统心里就有点发怵。要么觉得框架太复杂要么担心安全问题处理不好。其实啊这事儿没想象中那么难关键是要选对工具理清思路。今天我就用最接地气的方式带大家用SpringBoot和MyBatis这两个黄金搭档从零开始手把手构建一个既安全又高效的登录注册模块。这不仅是新手入门的绝佳练手项目也是很多中小型项目快速上线的核心基础。那么为什么是SpringBoot和MyBatis呢我给大家打个比方。SpringBoot就像是一个已经帮你配好了发动机、底盘和内饰的“汽车组装套件”。你不需要再自己去研究怎么造轮子、怎么连接管线它把Spring框架那些繁琐的XML配置都“约定俗成”地搞定了你只需要关注怎么把这辆车开起来开到你想去的地方。而MyBatis呢它就像是你和车库数据库之间的一位超级靠谱的“管家”。你不用自己写又臭又长的JDBC代码去跟车库沟通只需要告诉这位管家“嘿帮我把这辆红色的跑车一条用户数据存进去”或者“把车牌号是XXX的车用户信息给我找出来”它就能帮你办得妥妥帖帖而且效率很高。这套组合拳最大的好处就是“快”和“清晰”。SpringBoot让你几分钟就能跑起来一个Web服务MyBatis让你用写SQL的方式直观地操作数据库两者结合开发效率直接拉满。接下来咱们就抛开那些晦涩的理论直接进入实战。我会把我当年踩过的坑、总结的最佳实践都揉碎了讲给你听保证你跟着做一遍就能掌握这套企业级开发的核心流程。2. 万丈高楼平地起项目初始化与环境搭建2.1 创建你的第一个SpringBoot工程工欲善其事必先利其器。咱们的第一步就是创建一个SpringBoot项目。现在最省事的方法就是用Spring Initializr你可以把它理解为官方的一键生成器。我习惯用IDEA直接在里面选择New Project-Spring Initializr就行。如果你用别的工具访问start.spring.io这个网站效果是一样的。在配置项目信息时有几个关键点要注意Project 选 Maven。Gradle也行但国内用Maven的更多资料也丰富。Language Java。Spring Boot 选一个稳定的版本比如 2.7.x 或者 3.x。我建议新手先用 2.7.x兼容性更好。Project Metadata 这里的Group和Artifact是你的项目坐标简单理解就是项目在“仓库”里的唯一身份证。比如com.example作为Grouplogin-demo作为Artifact。Packaging 选 Jar。现在SpringBoot内置了Tomcat打成一个可执行的Jar包部署起来方便得不得了。Java 选你本地安装的JDK版本8、11、17都行推荐11或17。接下来是最重要的环节——选择依赖。在Dependencies里我们勾选这几个Spring Web 这是必须的它包含了构建Web应用包括RESTful API的核心组件。MyBatis Framework 持久层框架的本尊。MySQL Driver 因为我们要连接MySQL数据库所以这个驱动必不可少。点击生成一个标准的SpringBoot项目骨架就下载到本地了。用IDEA打开你会看到一个结构清晰的目录。我特别喜欢SpringBoot这种“开箱即用”的感觉所有依赖都通过pom.xml文件管理好了省去了过去手动配jar包到怀疑人生的痛苦。2.2 连接数据库让应用“活”起来项目有了接下来得让它能跟数据库对话。我见过不少新手在这里卡住问题大多出在配置文件的细节上。咱们打开src/main/resources目录下的application.properties文件你也可以用application.yml看个人喜好我习惯用properties更直观。把下面的配置贴进去但切记要改成你自己的数据库信息# 应用名称 spring.application.namelogin-registration-demo # 数据源配置 - 核心 spring.datasource.urljdbc:mysql://localhost:3306/user_db?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneAsia/Shanghai spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver spring.datasource.usernameroot # 你的数据库用户名 spring.datasource.passwordyourpassword # 你的数据库密码 # MyBatis 配置 # 指定Mapper.xml文件的位置MyBatis会去这里找SQL映射文件 mybatis.mapper-locationsclasspath:mapper/*.xml # 开启驼峰命名自动映射。数据库字段是 user_name实体类属性是 userName它能自动对应上非常方便。 mybatis.configuration.map-underscore-to-camel-casetrue # 在控制台打印执行的SQL语句调试神器上线前记得关掉。 mybatis.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl这里我解释几个容易出错的地方数据库URL 我加了serverTimezoneAsia/Shanghai。这是为了解决时区问题不然插入时间字段时可能会报错。useSSLfalse在本地开发环境可以用如果是线上生产环境一定要设置为true并配置证书。驱动类 注意是com.mysql.cj.jdbc.Driver不是老的com.mysql.jdbc.Driver。用新的。数据库 配置里指向的user_db数据库你需要提前在MySQL里用CREATE DATABASE user_db;命令创建好。配置写完启动一下你的应用主类就是那个带SpringBootApplication注解的类。如果控制台没有报出一大堆红色的错误而是看到Tomcat启动在8080端口的日志恭喜你基础环境搭建成功了3. 核心骨架五层架构设计与实体建模3.1 理解分层各司其职代码清晰很多朋友刚开始写代码喜欢把所有逻辑都堆在Controller里这样短期内是快但项目稍微一大改起来就是灾难。咱们遵循一个经典的分层架构让每一层只干自己该干的活这样代码好维护也好和别人协作。这个架构通常分为五层实体层Entity 也叫POJO或Model。它的唯一职责就是定义数据长什么样和数据库表字段一一对应。它不包含任何业务逻辑。数据访问层Mapper 这一层负责所有和数据库打交道的“脏活累活”。它的接口定义我们要做什么比如根据账号查用户对应的XML或注解写具体怎么做SQL语句。业务逻辑层Service 这是大脑是核心。它负责处理复杂的业务规则。比如“注册时不仅要存用户还要发欢迎邮件”、“登录失败5次后锁定账号”。它调用Mapper层获取数据进行加工处理。控制层Controller 它是接待前端的“服务员”。接收前端发来的HTTP请求比如/user/login把参数交给Service层去处理然后把处理结果包装好返回给前端通常是JSON格式。配置层Config 存放各种全局配置比如我们后面要讲的跨域配置、拦截器、事务管理等。这样分层之后代码就像有了清晰的交通规则数据从Controller进经过Service处理通过Mapper存取数据库再沿着原路返回井然有序。3.2 创建实体类定义你的数据蓝图现在我们来创建第一个实体类。在com.example.logindemo.entity包下包名请对应你的项目创建User类。这个类就对应数据库里的user表。import lombok.Data; import java.util.Date; Data public class User { private Long id; // 主键我习惯用Long类型自增 private String username; // 用户名建议唯一 private String password; // 密码注意这里先存明文后面我们会加密 private String email; // 邮箱 private String nickname; // 昵称 private Date createTime; // 创建时间 private Date updateTime; // 更新时间 // 还可以根据业务需要添加状态字段如private Integer status; // 0-正常1-禁用 }这里我用了Data注解这是Lombok提供的它会自动帮我们生成getter、setter、toString等方法让代码非常简洁。记得在pom.xml里添加Lombok依赖。实体类字段的设计要和你的数据库表设计保持一致。我建议每个表都加上create_time和update_time来记录数据生命周期这在排查问题时非常有用。光有User实体还不够我们通常需要一个通用的返回结果类Result或叫ApiResponse,R来统一前后端交互的数据格式。这样前端拿到数据就知道怎么解析了。import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; Data NoArgsConstructor AllArgsConstructor public class ResultT { private Integer code; // 状态码比如200成功400参数错误500系统异常 private String message; // 提示信息 private T data; // 返回的具体数据用泛型T表示可以是User也可以是ListUser // 快速生成成功响应的静态方法 public static T ResultT success(T data) { return new Result(200, 操作成功, data); } public static T ResultT success(String message, T data) { return new Result(200, message, data); } // 快速生成失败响应的静态方法 public static T ResultT error(String message) { return new Result(400, message, null); } public static T ResultT error(Integer code, String message) { return new Result(code, message, null); } }有了这个Result类我们在Controller里返回Result.success(user)或者Result.error(用户名已存在)前端一看code不是200就直接弹窗提示message逻辑非常清晰。4. 数据桥梁MyBatis Mapper与SQL映射4.1 编写Mapper接口声明你的数据操作实体是蓝图Mapper就是施工队。我们在com.example.logindemo.mapper包下创建UserMapper接口。import com.example.logindemo.entity.User; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; Mapper // 关键注解告诉MyBatis这是需要管理的Mapper接口 public interface UserMapper { /** * 插入一个新用户 * param user 用户实体 * return 受影响的行数 */ int insert(User user); /** * 根据用户名查询用户用于登录和校验重复 * param username 用户名 * return 用户实体 */ User selectByUsername(Param(username) String username); /** * 根据邮箱查询用户可用于邮箱登录或校验 * param email 邮箱 * return 用户实体 */ User selectByEmail(Param(email) String email); /** * 根据用户ID查询用户 * param id 用户ID * return 用户实体 */ User selectById(Param(id) Long id); }注意Param注解当方法有多个参数或者参数要在XML中引用时用它来指定参数名这样在XML里就能用#{username}来引用了避免参数绑定错误。4.2 编写XML映射文件让SQL落地MyBatis的精华之一就是SQL与代码分离SQL写在XML里维护起来特别方便。在src/main/resources下新建mapper文件夹然后创建UserMapper.xml文件。?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.logindemo.mapper.UserMapper !-- 这里namespace必须对应Mapper接口的全限定名 -- resultMap idBaseResultMap typecom.example.logindemo.entity.User id columnid propertyid/ result columnusername propertyusername/ result columnpassword propertypassword/ result columnemail propertyemail/ result columnnickname propertynickname/ result columncreate_time propertycreateTime/ result columnupdate_time propertyupdateTime/ /resultMap sql idBase_Column_List id, username, password, email, nickname, create_time, update_time /sql insert idinsert parameterTypecom.example.logindemo.entity.User useGeneratedKeystrue keyPropertyid INSERT INTO user (username, password, email, nickname, create_time, update_time) VALUES (#{username}, #{password}, #{email}, #{nickname}, now(), now()) /insert select idselectByUsername resultMapBaseResultMap SELECT include refidBase_Column_List/ FROM user WHERE username #{username} LIMIT 1 /select select idselectByEmail resultMapBaseResultMap SELECT include refidBase_Column_List/ FROM user WHERE email #{email} LIMIT 1 /select select idselectById resultMapBaseResultMap SELECT include refidBase_Column_List/ FROM user WHERE id #{id} /select /mapper我来解释一下里面的几个技巧resultMap 手动定义了数据库字段column和实体类属性property的映射关系。虽然我们开启了驼峰自动映射但显式定义更清晰尤其应对复杂查询。sqlinclude 把常用的字段列表定义成一个SQL片段其他地方用include引用。这样改字段时只需要改一个地方避免重复和出错。useGeneratedKeystrue keyPropertyid 这是插入后获取自增主键的神器。插入完成后传入的User对象的id属性会被自动赋上数据库生成的值。now() 直接在SQL里使用MySQL的now()函数生成时间比在Java代码里new Date()再传进去更简洁。写完这个MyBatis部分的核心就完成了。你可以写个简单的单元测试注入UserMapper调用一下insert和select方法看看数据库里是不是真的有数据了。这一步的成就感是最强的5. 业务逻辑Service层的安全与健壮性设计5.1 定义Service接口与实现Service层是业务规则的守护者。我们先在com.example.logindemo.service包下创建UserService接口定义业务契约。import com.example.logindemo.entity.User; import com.example.logindemo.entity.Result; public interface UserService { /** * 用户注册 * param user 用户信息包含用户名、密码等 * return 统一结果封装 */ Result register(User user); /** * 用户登录 * param username 用户名 * param password 密码 * return 统一结果封装成功则携带用户信息不包含密码 */ Result login(String username, String password); }接着创建实现类UserServiceImpl。这里才是真正的业务逻辑主场。import com.example.logindemo.entity.User; import com.example.logindemo.entity.Result; import com.example.logindemo.mapper.UserMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.Date; Service // 声明这是一个Spring管理的Service Bean Slf4j // Lombok的注解自动提供log变量用于记录日志 public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override public Result register(User user) { // 1. 参数基础校验 if (!StringUtils.hasText(user.getUsername())) { return Result.error(用户名不能为空); } if (!StringUtils.hasText(user.getPassword())) { return Result.error(密码不能为空); } if (user.getPassword().length() 6) { return Result.error(密码长度不能少于6位); } // 2. 校验用户名是否已存在 User existUser userMapper.selectByUsername(user.getUsername()); if (existUser ! null) { log.warn(注册失败用户名已存在{}, user.getUsername()); return Result.error(用户名已被占用); } // 3. 校验邮箱是否已存在如果提供了邮箱 if (StringUtils.hasText(user.getEmail())) { existUser userMapper.selectByEmail(user.getEmail()); if (existUser ! null) { return Result.error(邮箱已被注册); } } // 4. 密码加密重中之重 // 绝对不能在数据库里存明文密码这里先留个位置下一节专门讲加密。 String encryptedPassword user.getPassword(); // 临时用明文待替换 user.setPassword(encryptedPassword); // 5. 设置创建/更新时间 user.setCreateTime(new Date()); user.setUpdateTime(new Date()); // 6. 执行插入 try { int rows userMapper.insert(user); if (rows 0) { // 插入成功后清空密码再返回保护用户隐私 user.setPassword(null); log.info(用户注册成功{}, user.getUsername()); return Result.success(注册成功, user); } else { return Result.error(注册失败请稍后重试); } } catch (Exception e) { log.error(用户注册时发生数据库异常, e); return Result.error(系统繁忙请稍后重试); } } Override public Result login(String username, String password) { // 1. 参数校验 if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) { return Result.error(用户名和密码不能为空); } // 2. 根据用户名查询用户 User user userMapper.selectByUsername(username); if (user null) { // 这里可以统一返回“用户名或密码错误”避免提示过于具体被恶意利用 log.warn(登录失败用户不存在{}, username); return Result.error(用户名或密码错误); } // 3. 校验密码这里先直接比对明文待替换为加密比对 if (!password.equals(user.getPassword())) { // 待替换 log.warn(登录失败密码错误{}, username); return Result.error(用户名或密码错误); } // 4. 可以在这里检查用户状态比如是否被禁用如果实体类有status字段 // if (user.getStatus() 1) { // return Result.error(账号已被禁用请联系管理员); // } // 5. 登录成功生成Token这里先返回用户信息会话管理下一节讲 user.setPassword(null); // 切记清除密码 log.info(用户登录成功{}, username); return Result.success(登录成功, user); } }这段代码里我埋了两个重要的“坑”密码明文存储和登录状态管理。这是新手最容易忽略的安全隐患。别急我们马上来填坑。5.2 密码安全加密存储是底线把用户密码明文存数据库相当于把家门钥匙挂在门上。一旦数据库泄露用户在所有使用相同密码的网站都会遭殃。所以加密是必须的。现在最推荐的是BCrypt算法它是专门为密码存储设计的每次加密出来的密文都不一样而且验证速度慢能有效抵御暴力破解。首先在pom.xml里引入Spring Security的密码加密工具我们不用它做安全框架只用它的加密功能。dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency然后我们创建一个密码工具类PasswordEncoder。import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; Component public class PasswordEncoder { private final BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); /** * 加密原始密码 * param rawPassword 明文密码 * return 加密后的密文 */ public String encode(String rawPassword) { return encoder.encode(rawPassword); } /** * 验证密码是否正确 * param rawPassword 前端传来的明文密码 * param encodedPassword 数据库存储的加密密码 * return 是否匹配 */ public boolean matches(String rawPassword, String encodedPassword) { return encoder.matches(rawPassword, encodedPassword); } }现在回到UserServiceImpl注入这个工具类并修改注册和登录的逻辑Service Slf4j public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Autowired private PasswordEncoder passwordEncoder; // 注入密码编码器 Override public Result register(User user) { // ... 前面的校验逻辑不变 ... // 密码加密替换之前的临时代码 String encryptedPassword passwordEncoder.encode(user.getPassword()); user.setPassword(encryptedPassword); // ... 后续逻辑不变 ... } Override public Result login(String username, String password) { // ... 前面的校验逻辑不变 ... // 校验密码替换之前的明文比对 if (!passwordEncoder.matches(password, user.getPassword())) { log.warn(登录失败密码错误{}, username); return Result.error(用户名或密码错误); } // ... 后续逻辑不变 ... } }这样一来数据库里存的是一串像$2a$10$N9qo8uLOickgx2ZMRZoMye.MKjK8qC7cF5d5U/3KJQnS9JZzYqW1C这样的BCrypt密文即使数据泄露攻击者也极难反推出原始密码。这是构建安全系统的第一道也是最重要的防线。6. 控制中枢Controller与API设计6.1 编写RESTful风格的ControllerController是前后端的桥梁设计得好前端调用起来就舒服。我们采用RESTful风格来设计API。在com.example.logindemo.controller包下创建UserController。import com.example.logindemo.entity.Result; import com.example.logindemo.entity.User; import com.example.logindemo.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; RestController // 等于 Controller ResponseBody直接返回JSON RequestMapping(/api/user) // 统一API路径前缀/api表示这是后端接口 public class UserController { Autowired private UserService userService; /** * 用户注册接口 * POST /api/user/register * param user 用户注册信息JSON格式 * return 注册结果 */ PostMapping(/register) public Result register(RequestBody Validated User user) { // Validated 用于后续参数校验 return userService.register(user); } /** * 用户登录接口 * POST /api/user/login * param loginRequest 登录请求体这里用一个专门的类接收更好 * return 登录结果包含用户基本信息不含密码或Token */ PostMapping(/login) public Result login(RequestBody LoginRequest loginRequest) { return userService.login(loginRequest.getUsername(), loginRequest.getPassword()); } /** * 获取当前用户信息需要登录后才能访问 * GET /api/user/info * return 用户信息 */ GetMapping(/info) public Result getUserInfo() { // 这里需要从Token或Session中获取当前登录用户的ID // 暂时模拟返回下一节实现完整的认证流程后会修改 // Long currentUserId getCurrentUserIdFromToken(); // User user userService.getUserById(currentUserId); // return Result.success(user); return Result.success(获取用户信息接口待实现Token验证); } // 内部类用于接收登录参数。这样比直接用Map或User更清晰安全。 static class LoginRequest { private String username; private String password; // getter and setter ... } }这里我做了几个优化路径设计 使用/api作为接口前缀方便与静态资源区分。动词用POST创建资源-注册、GET获取资源-用户信息。请求体 登录接口没有复用User实体而是创建了专用的LoginRequest。因为登录可能只需要用户名密码而注册需要更多字段。这样更符合“单一职责”也避免了前端传多余字段。Validated 为后续使用JSR-303注解校验参数如NotBlank做准备可以让校验逻辑更简洁。接口规划 预留了/info接口这是实际项目中获取当前登录用户信息的常用接口。6.2 处理跨域问题让前端顺利调用现在前后端分离是主流前端项目比如Vue、React运行在localhost:3000或localhost:8081后端运行在localhost:8080端口不同浏览器出于安全考虑会阻止这种“跨域”请求。所以我们需要配置CORS跨域资源共享。创建一个配置类com.example.logindemo.config.WebConfig。import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) // 对所有/api开头的路径生效 .allowedOrigins(http://localhost:3000, http://localhost:8081) // 允许哪些前端源 .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) // 允许的HTTP方法 .allowedHeaders(*) // 允许所有请求头 .allowCredentials(true) // 允许携带Cookie等凭证 .maxAge(3600); // 预检请求的缓存时间秒减少OPTIONS请求 } }这个配置告诉浏览器“来自localhost:3000的页面可以访问我/api/下的所有接口并且可以带Cookie。” 这样前端就能正常调用我们的登录注册接口了。上线时需要把allowedOrigins换成真实的前端域名。7. 进阶加固会话管理、参数校验与全局异常处理7.1 会话管理从Session到Token用户登录成功后如何维持登录状态传统Web应用用HttpSession但在前后端分离和分布式环境下Session有局限性比如服务器内存压力、集群同步问题。更流行的方式是使用Token尤其是JWT (JSON Web Token)。JWT是一个自包含的令牌服务器生成后发给客户端客户端后续请求都在Header里带上这个Token服务器验证Token的合法性即可识别用户。它无需服务器存储非常适合分布式系统。我们先引入JWT依赖以jjwt为例dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency然后创建一个JWT工具类import io.jsonwebtoken.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.Date; import java.util.HashMap; import java.util.Map; Component public class JwtUtil { Value(${jwt.secret}) // 从application.properties读取密钥 private String secret; Value(${jwt.expiration}) // 过期时间毫秒 private Long expiration; /** * 生成Token * param username 用户名 * param userId 用户ID * return Token字符串 */ public String generateToken(String username, Long userId) { MapString, Object claims new HashMap(); claims.put(username, username); claims.put(userId, userId); return Jwts.builder() .setClaims(claims) // 自定义载荷 .setSubject(username) // 主题 .setIssuedAt(new Date()) // 签发时间 .setExpiration(new Date(System.currentTimeMillis() expiration)) // 过期时间 .signWith(SignatureAlgorithm.HS256, secret) // 签名算法和密钥 .compact(); } /** * 从Token中解析用户名 */ public String getUsernameFromToken(String token) { return getClaimsFromToken(token).getSubject(); } /** * 从Token中解析用户ID */ public Long getUserIdFromToken(String token) { return getClaimsFromToken(token).get(userId, Long.class); } /** * 验证Token是否有效 */ public boolean validateToken(String token) { try { getClaimsFromToken(token); return true; } catch (JwtException | IllegalArgumentException e) { // Token过期、签名错误、格式错误等 return false; } } private Claims getClaimsFromToken(String token) { return Jwts.parserBuilder() .setSigningKey(secret) .build() .parseClaimsJws(token) .getBody(); } }在application.properties中添加配置jwt.secretYourSuperSecretKeyHereMakeItLongAndComplex # 密钥要足够复杂 jwt.expiration86400000 # Token有效期这里设24小时毫秒接着修改登录成功后的逻辑返回Token而不是完整的用户信息// 在UserServiceImpl的login方法中登录成功后 String token jwtUtil.generateToken(user.getUsername(), user.getId()); MapString, Object loginResult new HashMap(); loginResult.put(token, token); // 可以再放一些简单的用户信息如昵称、头像 loginResult.put(userInfo, user); // user的密码已被清空 return Result.success(登录成功, loginResult);前端拿到这个Token后需要把它存起来比如放在localStorage或Cookie里并在后续每次请求的Header中带上Authorization: Bearer your_token_here。7.2 拦截器统一验证Token我们需要一个拦截器在请求到达Controller之前验证Token的有效性。创建拦截器com.example.logindemo.interceptor.JwtInterceptorimport org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; Component public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 如果不是映射到方法直接通过比如请求静态资源 if (!(handler instanceof HandlerMethod)) { return true; } // 从请求头中获取Token String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { // 可以在这里直接返回401但为了灵活我们通常放行在需要的方法上再用注解控制 // 这里我们只是把用户信息放到request中具体权限控制由注解完成 request.setAttribute(USER_ID, null); return true; } String token authHeader.substring(7); // 去掉Bearer 前缀 try { if (jwtUtil.validateToken(token)) { Long userId jwtUtil.getUserIdFromToken(token); request.setAttribute(USER_ID, userId); // 将用户ID存入请求属性 return true; } } catch (Exception e) { // Token无效 } request.setAttribute(USER_ID, null); return true; // 依然放行让注解去判断 } }然后注册这个拦截器并配置它拦截哪些路径通常在WebConfig中Configuration public class WebConfig implements WebMvcConfigurer { Autowired private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) // 拦截所有/api开头的请求 .excludePathPatterns(/api/user/login, /api/user/register); // 排除登录注册接口 } // ... 之前的CORS配置 ... }这样除了登录注册其他所有/api/下的请求都会经过拦截器验证Token。验证通过后我们就可以在Controller的方法中通过RequestAttribute(USER_ID)获取当前登录用户的ID了。当然更优雅的方式是自定义一个注解如CurrentUserId这里限于篇幅不展开。这套流程下来一个基于Token的无状态认证体系就搭建好了安全性和扩展性都比简单的Session强很多。7.3 参数校验与全局异常处理最后我们来让系统更健壮。使用Validated注解和NotBlank、Email等注解可以优雅地校验参数。同时用一个全局异常处理器来捕获所有异常返回统一的错误格式避免把堆栈信息直接抛给前端。首先在User实体和LoginRequest的字段上添加校验注解import javax.validation.constraints.NotBlank; import javax.validation.constraints.Email; import javax.validation.constraints.Size; public class User { NotBlank(message 用户名不能为空) private String username; NotBlank(message 密码不能为空) Size(min 6, message 密码长度至少6位) private String password; Email(message 邮箱格式不正确) private String email; // ... 其他字段 } // LoginRequest 内部类 static class LoginRequest { NotBlank(message 用户名不能为空) private String username; NotBlank(message 密码不能为空) private String password; }然后创建一个全局异常处理类import com.example.logindemo.entity.Result; import lombok.extern.slf4j.Slf4j; import org.springframework.validation.BindException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import javax.validation.ConstraintViolationException; Slf4j RestControllerAdvice // 捕获所有Controller抛出的异常 public class GlobalExceptionHandler { /** * 处理参数校验异常Validated触发的 */ ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) public Result handleValidException(Exception e) { String message 参数错误; if (e instanceof MethodArgumentNotValidException) { message ((MethodArgumentNotValidException) e).getBindingResult().getFieldError().getDefaultMessage(); } else if (e instanceof BindException) { message ((BindException) e).getBindingResult().getFieldError().getDefaultMessage(); } log.warn(参数校验失败: {}, message); return Result.error(400, message); } /** * 处理业务逻辑异常可以自定义一个BusinessException来抛 */ ExceptionHandler(RuntimeException.class) // 这里示例实际应捕获更具体的异常 public Result handleRuntimeException(RuntimeException e) { log.error(业务运行时异常: , e); // 生产环境可以返回更友好的提示而不是异常信息本身 return Result.error(500, 系统繁忙请稍后重试); } /** * 处理所有其他未捕获的异常 */ ExceptionHandler(Exception.class) public Result handleException(Exception e) { log.error(系统未知异常: , e); return Result.error(500, 系统内部错误); } }这样当前端传过来的用户名为空时Controller层会因为Validated校验失败抛出MethodArgumentNotValidException然后被我们的全局处理器捕获返回一个格式统一的{code:400, message:用户名不能为空, data:null}。系统里任何未处理的异常也不会直接暴露给用户而是被优雅地捕获并记录日志返回友好的提示信息。这对于线上问题的排查和用户体验都至关重要。走到这里一个具备基础安全防护密码加密、Token认证、良好代码结构五层架构、健壮性参数校验、全局异常处理和可维护性的登录注册系统就基本完成了。当然真实的企业级系统还会涉及更多内容比如短信/邮箱验证码、第三方登录、权限管理RBAC、限流防刷、操作日志等。但万变不离其宗掌握了今天这套核心流程和设计思想你就有能力去应对那些更复杂的挑战了。记住编程是门实践的手艺多写、多调、多思考遇到问题就查文档、搜社区你踩过的每一个坑都会成为你未来简历上最硬的通货。