1. 整体设计登录注册在系统里的站位与选型1.1 需求拆解登录注册不只是“两个接口”很多人把用户登录注册想得太简单觉得无非就是一个“前端把用户名密码发过来后端查一下表再返回成功”。真要动手做的时候往往会被一堆细节绊住密码到底怎么存才安全用户登录之后怎么知道他下次请求还是同一个用户退出登录要怎么做才真的有效如果接口被脚本疯狂重放要不要限制我把登录注册相关的功能拆开看至少包含这几个核心点注册时的数据校验、用户名唯一性检查、密码加密存储、登录后的身份标识生成、接口访问时的身份校验、退出登录的状态处理。旁路还涉及密码重置、账号封禁、登录日志等这次先用一套能满足小型项目的方案打通主流程后续再按需扩展。从项目整体角度看登录注册是所有业务接口的入口。用户鉴权一旦做乱了后面所有带“当前用户”的操作都会出问题。我见过不少项目开始图省事直接把用户id放在请求参数里结果被人恶意遍历接口越权到飞起。所以哪怕做一个学习项目也最好从一开始就养成“身份信息必须由服务端从凭证中解析而不是信任客户端传参”的习惯。1.2 技术选型Session还是JWT这是个关键决定登录之后怎么识别用户主流有两条路传统的Session Cookie以及无状态的JWTJSON Web Token。两者没有绝对的对错但适用场景差别很大。Session方式由服务端保存会话数据客户端只保存一个Session ID。优点是服务端可以随时主动踢人、控制会话失效状态集中管理很方便。缺点是如果做了微服务或多节点部署需要额外引入Redis之类的会话共享方案否则A机器登录了请求被负载均衡到B机器就认不出来。对单体项目来说Session很省事但写到“多端登录”“移动端App”时会有点别扭。JWT方式把用户信息签名后发给客户端服务端不保存会话每次请求只要验证签名和有效期就行。好处是天然适合分布式和前后端分离扩展方便。坏处是没法主动让Token失效服务端踢人要额外搞黑名单Token如果太长每个请求都会多带一些字节对带宽有点影响。我这个系列的项目是Spring Boot单体服务但我最终还是选了JWT。不是因为Session不好而是想提前把无状态认证的套路摸熟后面拆微服务也能直接平移。如果你做的是一个纯后端渲染、或者有运维团队能稳定支持Session共享的项目选Session也完全可行。重要的是理解两种方案背后的取舍而不是迷信某个技术。1.3 依赖清单Spring Security还是轻量拦截器说到实现方式网上教程一半用Spring Security一半用拦截器加上手动解析Token。我的做法是折中认证逻辑自己用拦截器写Spring Security只用来做接口层面更复杂的权限控制。这次登录注册的教程里我先用一套轻量拦截器方案把原理讲透不加太多框架魔法这样出了问题你能知道在哪个环节。等到后面需要角色权限、方法级安全注解的时候再把Spring Security完整引进来也不迟。如果你不想用拦截器直接上Spring Security也可以。但要注意Spring Security的配置在Spring Boot 3.x里变化不小很多老教程用的WebSecurityConfigurerAdapter已经废了得用SecurityFilterChain这种新的配置方式。等会儿我会顺带给出Spring Boot 3.x下兼容的配置片段方便两拨同学都能落地。核心依赖方面我项目里用的是框架Spring Boot 3.2.5JDK 17持久层Spring Data JPA也可以用MyBatis-Plus取舍后面讲密码加密spring-security-crypto单独引入BCrypt实现不需要整套SecurityTokenJava JWTio.jsonwebtoken:jjwt:0.12.5这里有个小细节用Spring Boot 3.x时javax包都变成了jakarta很多老代码复制过来会报红。引入依赖时记得统一用jakarta.persistence和jakarta.servlet否则写实体类或过滤器时会一脸懵。2. 数据库设计与用户表落地2.1 一张用户表该有哪些字段登录注册的基础是用户表字段设计直接影响后续功能扩展。我见过很多新手一上来只建一个id、username、password的极简表后面要做用户昵称、头像、手机号时再一张张表加字段迁移起来特别麻烦。我的习惯是第一步就把常用字段规划好。我偏好的用户表结构如下MySQL方言CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, username VARCHAR(50) NOT NULL COMMENT 用户名/登录账号, password VARCHAR(100) NOT NULL COMMENT 密码BCrypt哈希, nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称, email VARCHAR(100) DEFAULT NULL COMMENT 邮箱, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像URL, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1启用 0禁用, create_time DATETIME NOT NULL COMMENT 创建时间, update_time DATETIME DEFAULT NULL COMMENT 更新时间, last_login_time DATETIME DEFAULT NULL COMMENT 最后登录时间, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除0未删除 1已删除, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT用户表;几个设计点说一下。password字段长度不是常见的varchar(32)因为BCrypt加密后的字符串固定60个字符留varchar(100)是为以后算法升级留空间。username加唯一索引防止并发下出现重复账号。email也搞唯一索引因为很多人喜欢扫码登录前的邮箱注册这块先占坑。deleted字段是逻辑删除的标志用户注销时不去真正删记录免得把历史订单、日志这些关联数据搞成孤儿。status字段用来做封禁登录时如果发现状态不是1直接拒绝。最后注意字符集一定是utf8mb4不是utf8否则微信昵称里带个emoji表情插入时就报Incorrect string value错误。这个坑我踩过后来所有表都统一utf8mb4。2.2 实体类与数据访问层JPA与MyBatis-Plus二选一我用Spring Data JPA的用户挺多的但国内公司用MyBatis-Plus也不少。两种写法思路类似。我这次用JPA做演示因为它不需要手写XML逻辑简单能更聚焦在登录注册的认证流程上。如果你项目里用的是MyBatis-Plus代码大差不差后面我会标注差异点。对应实体类import jakarta.persistence.*; import java.time.LocalDateTime; Entity Table(name sys_user) public class SysUser { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, unique true, length 50) private String username; Column(nullable false, length 100) private String password; private String nickname; private String email; private String phone; private String avatar; Column(nullable false) private Integer status 1; Column(name create_time, nullable false) private LocalDateTime createTime; Column(name update_time) private LocalDateTime updateTime; Column(name last_login_time) private LocalDateTime lastLoginTime; private Integer deleted 0; // getter/setter 省略实际开发用Lombok Data }Repository接口import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface SysUserRepository extends JpaRepositorySysUser, Long { OptionalSysUser findByUsername(String username); boolean existsByUsername(String username); OptionalSysUser findByUsernameAndDeletedFalse(String username); }这里返回Optional而不是直接返回SysUser是我从项目代码规范里学到的习惯。查询可能查不到时用Optional能强迫调用方考虑空值情况避免一不留神在登录逻辑里搞出空指针。如果改用MyBatis-Plus实体类上加TableName(sys_user)Mapper接口继承BaseMapperSysUser然后类似selectOne(new LambdaQueryWrapperSysUser().eq(SysUser::getUsername, username))。思想一样封装代理帮我们省了SQL。3. 注册与登录的后端实现细节3.1 注册流程校验、BCrypt加密、入库注册接口的核心流程是参数校验 → 检查用户名是否已存在 → 密码加密 → 保存用户 → 返回结果。听起来简单细节却不少。先看Service代码import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; Service public class AuthService { private final SysUserRepository userRepository; private final BCryptPasswordEncoder passwordEncoder; public AuthService(SysUserRepository userRepository) { this.userRepository userRepository; this.passwordEncoder new BCryptPasswordEncoder(); } Transactional public SysUser register(RegisterRequest request) { // 1. 参数校验 if (request.getUsername() null || request.getUsername().trim().isEmpty()) { throw new BizException(用户名不能为空); } if (request.getPassword() null || request.getPassword().length() 6) { throw new BizException(密码长度不能少于6位); } // 2. 用户名唯一性检查 if (userRepository.existsByUsername(request.getUsername())) { throw new BizException(用户名已存在); } // 3. 密码加密并落库 SysUser user new SysUser(); user.setUsername(request.getUsername().trim()); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setNickname(request.getNickname()); user.setEmail(request.getEmail()); user.setStatus(1); user.setCreateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now()); return userRepository.save(user); } }这里花最多心思的是密码加密。为什么不能用MD5、SHA-256这类哈希算法直接存密码因为它们是“快速哈希”专门为计算效率设计攻击者可以用彩虹表或GPU暴力破解。BCrypt是“慢哈希”一条密码要迭代计算很多次而且每次生成的盐是随机的所以同一个密码加密两次得到的字符串都不一样能有效抵挡彩虹表和暴力破解。BCrypt的另一个特性是验证时不能直接比字符串必须用passwordEncoder.matches(明文, 数据库哈希)来做。我刚学的时候不知道直接把user.getPassword().equals(passwordEncoder.encode(input))拿出来比较结果永远返回false因为这个encode每次都会重新生成盐。这个坑后面专门写。数据库那边createTime和updateTime最好都由代码显式赋值别依赖数据库的CURRENT_TIMESTAMP这样将来换数据库方言不会出幺蛾子。注册成功后按理说应该自动返回Token帮用户免登录但我习惯让用户登录后再发Token“注册即登录”和“注册后手动登录”只是产品取舍没有标准答案。教程里我选择注册只返回用户基础信息登录接口单独走一套。3.2 登录流程先找到用户再验密码最后发Token登录要比注册多两个动作验证密码、生成身份凭证。核心代码长这样Transactional public LoginResult login(LoginRequest request) { // 1. 根据用户名找用户 SysUser user userRepository .findByUsernameAndDeletedFalse(request.getUsername()) .orElseThrow(() - new BizException(用户名或密码错误)); // 2. 校验账号状态 if (user.getStatus() ! 1) { throw new BizException(账号已被禁用请联系管理员); } // 3. 校验密码 if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new BizException(用户名或密码错误); } // 4. 更新最后登录时间 user.setLastLoginTime(LocalDateTime.now()); userRepository.save(user); // 5. 生成JWT Token String token jwtUtil.generateToken(user.getId(), user.getUsername()); return new LoginResult(token, user); }这里有个故意设计的细节用户名不存在和密码错误我返回的是同一个提示“用户名或密码错误”。为什么因为如果接口提示“用户不存在”攻击者就可以遍历用户名批量探测哪些账号注册过对撞库攻击来说等于白送字典。统一提示能减少信息泄露这是安全审计里非常基础的要求。登录成功后把最后登录时间更新一下。这个操作虽然不起眼但做用户列表时能派上大用场谁活跃谁僵尸一目了然。JWT生成工具类我用的是JJWT核心方法如下import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire-hours}) private Long expireHours; private SecretKey getKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Long userId, String username) { Date now new Date(); Date expiryDate new Date(now.getTime() expireHours * 3600 * 1000); return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getKey()) .compact(); } }这里secret一定不要写在代码里要放在application.yml里并且不同环境用不同配置。秘钥长度也有要求HS256算法下秘钥字节数至少32字节否则JJWT会直接抛WeakKeyException。我手里这个项目一开始用的秘钥是secret马上被拦了改成一段64字节的随机字符串才通过。生产环境建议把秘钥长度弄到64字节以上这种靠application.yml外置配好的方式将来换秘钥也不用改代码。3.3 退出登录无状态Token的三种处理姿势Session方案踢人很简单把Session销毁就行。JWT是无状态的服务端不记得“已经发过的Token”所以退出登录变成了一个需要“想清楚”的问题。常见的姿势有三种。第一种最简单前端登录成功后把Token存内存或localStorage退出时直接删掉后端什么都不用管。问题在于如果Token已经被某些请求发出去了后端在到期前依然认它。对大多数内部项目来说这个“缺陷”可以接受但严格的安全要求下就不够看了。第二种是维护一个Token黑名单退出时把Token的jtiJWT ID或者原始字符串丢到Redis里设置一个和Token剩余有效期相同的TTL。每次请求在拦截器里先查Redis一旦发现当前Token在黑名单就直接拒绝。这等于把“服务端无状态”退回到了“半有状态”但换来了可控性生产环境很多项目都这么做。第三种是干脆把Token的有效期调得很短比如2小时同时对客户端做定时刷新通过一个单独的refresh_token接口换新Token。退出时只需删掉客户端的刷新令牌旧Token没过多久就自行失效。我在中小项目里的默认方案是第一种前端负责清理后端通过短有效期兜底。Tutorial里可以先把这套跑通等有空再升级成Redis黑名单。记住一句话没有万能的认证方案只有适合当前团队维护水平的方案。3.4 控制器接口注册、登录的REST风格写法Controller层要保持薄只做参数接收和结果返回。我用一个AuthController同时提供注册和登录接口import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/auth) public class AuthController { private final AuthService authService; public AuthController(AuthService authService) { this.authService authService; } PostMapping(/register) public ResultSysUser register(RequestBody Valid RegisterRequest request) { return Result.success(authService.register(request)); } PostMapping(/login) public ResultLoginResult login(RequestBody Valid LoginRequest request) { return Result.success(authService.login(request)); } PostMapping(/logout) public ResultVoid logout(RequestHeader(value Authorization, required false) String token) { // 前端删除Token后端如果维护黑名单在这里处理 return Result.success(); } }注意REST风格里不要把动词写进URL比如/api/register这种。用POST/api/auth/register这样的路径符合“资源 动作”的习惯后续在页面上调试也方便。为了让接口参数校验更规范我建议在RegisterRequest上用jakarta.validation注解比如NotBlank、Size(min 6, max 20)Controller里再加Valid。这样能省掉大量手写if判断。返回体Result是统一响应结构我通常定义成{code, message, data}三件套。别小看这个统一格式接口多了之后前端能基于同一个结构做全局拦截比如401统一跳登录页省很多事。4. 接口安全与身份认证拦截4.1 轻量拦截器解析Token把用户塞进上下文登录注册做完业务接口怎么知道“当前是哪个用户”我选择写一个拦截器在preHandle里解析请求头里的Token并往ThreadLocal里塞入用户信息。这样所有Controller都能通过一个工具类拿到当前用户ID不用每个方法都手写解析逻辑。拦截器代码如下import io.jsonwebtoken.Claims; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; Component public class JwtInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; public JwtInterceptor(JwtUtil jwtUtil) { this.jwtUtil jwtUtil; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 处理预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { throw new BizException(401, 未登录或Token缺失); } String token authHeader.substring(7); Claims claims jwtUtil.parseToken(token); // 解析失败会抛异常 Long userId claims.get(userId, Long.class); String username claims.getSubject(); UserContext.set(userId, username); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); // 必须清理ThreadLocal防止内存泄漏 } }UserContext的实现非常简单一个ThreadLocal变量搞定public class UserContext { private static final ThreadLocalLong USER_ID new ThreadLocal(); private static final ThreadLocalString USERNAME new ThreadLocal(); public static void set(Long userId, String username) { USER_ID.set(userId); USERNAME.set(username); } public static Long getUserId() { return USER_ID.get(); } public static String getUsername() { return USERNAME.get(); } public static void clear() { USER_ID.remove(); USERNAME.remove(); } }这里有一个很多教程都不会强调的细节线程池异步任务里子线程拿不到父线程设置的ThreadLocal值Spring Boot的异步Async也一样。如果你在异步方法里要用当前用户ID必须通过参数显式传过去而不是指望UserContext。还有就是afterCompletion里一定要remove()否则容器复用线程时上一次请求的用户信息可能泄露到下一次请求里酿成越权事故。这种问题非常隐蔽一般在压测或乱序访问时才暴露。4.2 注册到WebMvcConfig哪些接口要放行拦截器写好了还要告诉Spring Boot哪些路径需要拦截、哪些放行。注册和登录接口肯定要放行其它接口默认拦截。配置文件import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.*; Configuration public class WebMvcConfig implements WebMvcConfigurer { private final JwtInterceptor jwtInterceptor; public WebMvcConfig(JwtInterceptor jwtInterceptor) { this.jwtInterceptor jwtInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns( /api/auth/register, /api/auth/login, /error ); } Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }CORS配置是前后端分离项目不可或缺的。不加这一段前端页面在localhost:8081后端在localhost:8080一请求就报跨域。注意allowedOriginPatterns(*)配合allowCredentials(true)在Spring Boot 3里直接写allowedOrigins(*)会被拒绝因为携带Cookie凭证时不允许通配源。虽然JWT一般不依赖Cookie但养成写好allowedOriginPatterns的习惯总没错。4.3 引入Spring Security后的替代写法如果你不想用拦截器而是准备上Spring SecuritySpring Boot 3.x的配置大概是这样import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/register, /api/auth/login).permitAll() .anyRequest().authenticated() ); // 后续再加JWT过滤器到过滤链 return http.build(); } }这套配置的核心思路是关闭CSRF、关闭Session、放行认证接口、其余请求必须认证。再配合一个OncePerRequestFilter把Token解析结果放进SecurityContext整个流程才能跑通。和拦截器方案相比Spring Security把过滤器的执行顺序、异常处理、方法级权限都做了很好的封装适合复杂的权限体系但学习曲线确实陡一些。我建议新手先按拦截器方案把流程跑通理解了之后再平滑迁移到Spring Security这两种方案不是非此即彼而是递进关系。4.4 登录防爆破加锁、限流、验证码写完主流程如果你做的不是内网小工具我强烈建议把登录防爆破加上。最简单的思路是记录单一用户名或IP的登录失败次数超出阈值就临时锁定几分钟。可以用ConcurrentHashMap维护一个内存计数器也能用Redis做分布式计数看你的部署规模。验证码是另一个有效的拦路手段。图形验证码后端生成后把答案存缓存前端提交时一起校验滑块验证码则更多依赖第三方服务。加了验证码之后脚本批量撞库的成本立刻变高但这个环节和业务体验有冲突需要产品层面权衡。我一般会在注册和登录接口预留一个captchaId和captchaCode字段先不强制校验等真要接验证码时前端不用大改。5. 联调测试与常见问题排查5.1 用Postman和curl把整个流程跑一遍代码写完先别急着写前端用工具把后端接口验证一遍。我习惯用curl做快速冒烟测试能顺手检查状态码和响应体。注册接口curl -X POST http://localhost:8080/api/auth/register \ -H Content-Type: application/json \ -d {username:zhangsan,password:123456,nickname:张三,email:zhangsanexample.com}预期响应{ code: 200, message: success, data: { id: 1, username: zhangsan, nickname: 张三, status: 1 } }注意响应里不能把password字段也返回去。如果实体类的getter被JSON序列化时带上了密码要在字段上标JsonIgnore否则等于把密码哈希送给了前端。我见过有人把数据库整个用户对象直接返回的那不是一般的危险。登录接口curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:zhangsan,password:123456}预期响应里带Token{ code: 200, message: success, data: { token: eyJhbGciOiJIUzI1NiJ9..., user: { id: 1, username: zhangsan, nickname: 张三 } } }然后拿Token请求一个需要鉴权的接口curl -X GET http://localhost:8080/api/user/profile \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...流程跑通后再顺手验证几个异常场景重复注册、密码错误、Token过期、不带Token访问。这些用例应该自动化地沉淀到接口测试集合里别每次改完代码都靠手动点一遍。5.2 我踩过的几个高频坑第一个坑BCrypt.matches永远不通。如果你在代码里用user.getPassword().equals(encode(input))来判断密码结果必然失败。BCrypt每次生成的哈希前缀都不一样它是把随机盐一起揉进了结果里所以验证必须用passwordEncoder.matches(raw, hash)方法它内部会从哈希里提取盐再重新计算比较。这个问题几乎每个刚接触BCrypt的同学都会遇到别问我怎么知道的。第二个坑JWT过期时间边界问题。JJWT解析时如果Token刚好在过期时间的同一秒不同版本对和的处理可能有细微差别。更常见的是服务器时钟和签发时钟不一致导致明明没到过期时间却提示过期。多节点部署时务必用NTP同步机器时间别再让某台机器的时钟慢十分钟。第三个坑数据库字段名撞了MySQL保留字。把用户表字段命名为password没问题但如果你用了name、order、group这类容易撞保留字的列名不加反引号时SQL直接报错。JPA里可以显式指定Column(name order)不过名称能避则避我的习惯是统一给列名加user_之类的前缀比如user_name、user_password从源头上不踩保留字。第四个坑Jackson序列化LocalDateTime。Spring Boot默认情况下对Java 8日期时间类型支持其实还可以但如果你手动配了Jackson的自定义序列化器没给LocalDateTime加JsonFormat返回给前端的时间可能变成一串数组非常难看。我通常在application.yml里配spring.jackson.date-format: yyyy-MM-dd HH:mm:ss和spring.jackson.time-zone: GMT8统一处理。第五个坑拦截器里的异常响应格式。拦截器抛出的异常如果直接落到容器默认错误页前端拿不到你定义的Result结构。解决办法是写一个RestControllerAdvice配合ExceptionHandler把BizException统一转为JSON响应。要是拦截器里抛的异常发生在进入Controller之前ControllerAdvice通常也能接住但要注意Spring Boot对某些容器级异常的处理顺序必要时在preHandle里直接写response.setStatus并手动输出JSON。第六个坑数据库唯一索引与业务校验的竞态。existsByUsername检查完再插入如果两个请求并发同时通过检查数据库唯一索引会直接报Duplicate entry看起来像500错误。要么捕获这个异常转换为友好的“用户名已存在”要么直接依赖数据库唯一索引来兜底。我的做法是在Service中先做业务校验插入时再捕获DataIntegrityViolationException双保险。5.3 安全建议与后续优化方向登录注册只是认证的起点安全方面还有一堆事可以做。生产环境必须启用HTTPS否则Token在网络上明文流转跟把密码写在明信片上没什么区别。前端保存Token也别用localStorageXSS攻击一旦得手Token随手就能被偷走更稳的是放内存变量每次刷新页面重新登录或者用httpOnly的Cookie配合CSRF防护。这个取舍要结合业务对“记住我”的需求来决定。密码策略上至少要求6位以上最好强制包含字母和数字但别搞太复杂逼着用户把密码写在便利贴上。密码重试锁定、异地登录提醒、登录日志审计这些都可以在用户量起来后逐步加。还有一个被很多团队忽略的点日志输出时一定要把密码和Token字段脱敏我用Logback的Converter对含有password、token的日志消息做掩码处理避免测试环境里一抓日志全是用户敏感信息。后续功能扩展可以往这几个方向走引入Spring Security强化角色权限控制、用Redis做Token黑名单实现可控制的退出、集成第三方OAuth2登录、给注册接口加图片验证码和滑动验证。每一步都不会太难因为核心认证链路我们已经摸透了。这一整套做完之后你会发现登录注册并不是一道“会写就行”的题而是一道“如何安全、可靠、优雅地写”的题。希望这篇文章能让你少走一些弯路一次就把登录注册跑通畅。