Java框架快速入门:Spring Security+OAuth2之用户注册与唯一性校验实现

概述

在构建安全的后端服务时,用户注册接口往往是第一个需要精心设计的端点。不仅要完成数据持久化,更需要在落地前完成一系列唯一性约束检查,避免数据库冲突,同时赋予新用户合理的默认角色。

本文从零开始,基于 Spring Boot、Spring Security、Spring Data JPA,实现一个健壮的注册流程,涵盖校验、密码编码、角色初始化、异常标准化等关键环节,并提供可直接运行的代码示例。

纲要

  • 需求分解与处理流程
    • 唯一性校验:usernameemailmobile 三项均不得重复
    • DTO 转实体:将 UserDTO 中的字段映射到 User 实体
    • 默认角色分配:自动授予 ROLE_USER
    • 密码加密:使用 PasswordEncoder 安全存储
    • 标准化异常:对重复数据抛出结构化错误
  • 核心组件与职责
    • UserRepository -- 基于方法命名衍生查询
    • UserService -- 业务逻辑与事务管理
    • DuplicateProblem -- 统一异常模型
    • Constants -- 常量管理
    • AccountResource -- REST 控制器
  • 完整代码实现(含目录结构)
  • 流程可视化的 Mermaid 时序图
  • 测试验证
  • 总结

需求与设计

注册接口需要处理以下三步:

  1. 接收客户端提交的 UserDTO(包含 username、password、email、mobile 等)
  2. 检查 username、email、mobile 是否在数据库中已存在,若存在则抛出业务异常,返回明确的冲突信息
  3. 转换 DTO 为实体,设置加密后的密码,赋予默认角色 ROLE_USER,在事务中完成持久化

处理流程可以用下面的时序图表示:
Database UserRepository UserService AccountResource Client Database UserRepository UserService AccountResource Client #mermaid-svg-NfcLKGYIUSHTRel3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NfcLKGYIUSHTRel3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NfcLKGYIUSHTRel3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NfcLKGYIUSHTRel3 .error-icon{fill:#552222;}#mermaid-svg-NfcLKGYIUSHTRel3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NfcLKGYIUSHTRel3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NfcLKGYIUSHTRel3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NfcLKGYIUSHTRel3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NfcLKGYIUSHTRel3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NfcLKGYIUSHTRel3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NfcLKGYIUSHTRel3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NfcLKGYIUSHTRel3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NfcLKGYIUSHTRel3 .marker.cross{stroke:#333333;}#mermaid-svg-NfcLKGYIUSHTRel3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NfcLKGYIUSHTRel3 p{margin:0;}#mermaid-svg-NfcLKGYIUSHTRel3 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NfcLKGYIUSHTRel3 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-NfcLKGYIUSHTRel3 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-NfcLKGYIUSHTRel3 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-NfcLKGYIUSHTRel3 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-NfcLKGYIUSHTRel3 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-NfcLKGYIUSHTRel3 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-NfcLKGYIUSHTRel3 .sequenceNumber{fill:white;}#mermaid-svg-NfcLKGYIUSHTRel3 #sequencenumber{fill:#333;}#mermaid-svg-NfcLKGYIUSHTRel3 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-NfcLKGYIUSHTRel3 .messageText{fill:#333;stroke:none;}#mermaid-svg-NfcLKGYIUSHTRel3 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NfcLKGYIUSHTRel3 .labelText,#mermaid-svg-NfcLKGYIUSHTRel3 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-NfcLKGYIUSHTRel3 .loopText,#mermaid-svg-NfcLKGYIUSHTRel3 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-NfcLKGYIUSHTRel3 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-NfcLKGYIUSHTRel3 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-NfcLKGYIUSHTRel3 .noteText,#mermaid-svg-NfcLKGYIUSHTRel3 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-NfcLKGYIUSHTRel3 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NfcLKGYIUSHTRel3 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NfcLKGYIUSHTRel3 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NfcLKGYIUSHTRel3 .actorPopupMenu{position:absolute;}#mermaid-svg-NfcLKGYIUSHTRel3 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-NfcLKGYIUSHTRel3 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NfcLKGYIUSHTRel3 .actor-man circle,#mermaid-svg-NfcLKGYIUSHTRel3 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-NfcLKGYIUSHTRel3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} altcount \> 0not exist altcount \> 0not exist altcount \> 0not exist POST /register (UserDTO)existsByUsername(username)countByUsername(username)select count(*)counttrue409 Conflict (DuplicateProblem)existsByEmail(email)countByEmail(email)select count(*)counttrue409 Conflict (DuplicateProblem)existsByMobile(mobile)countByMobile(mobile)select count(*)counttrue409 Conflict (DuplicateProblem)register(userDTO)findRoleByName("ROLE_USER")encode passwordsave(User)insertsaved UserUser (or void)201 Created

项目代码结构

dir 复制代码
src/
└── main/
    └── java/
        └── com/example/demo/
            ├── config/
            │   └── SecurityConfig.java
            ├── constant/
            │   └── Constants.java
            ├── dto/
            │   └── UserDTO.java
            ├── entity/
            │   ├── Role.java
            │   └── User.java
            ├── exception/
            │   └── DuplicateProblem.java
            ├── repository/
            │   ├── RoleRepository.java
            │   └── UserRepository.java
            ├── service/
            │   └── UserService.java
            └── web/
                └── AccountResource.java

核心实现

常量定义

为了避免魔法值,将默认角色名、异常类型 URI、手机号正则等统一管理。

java 复制代码
package com.example.demo.constant;

public final class Constants {
    private Constants() {}

    public static final String ROLE_USER = "ROLE_USER";
    public static final String PROBLEM_BASE_URL = "https://api.example.com/problem";
    public static final String DUPLICATE_TYPE = PROBLEM_BASE_URL + "/duplicate";
    public static final String MOBILE_REGEX = "^1[3-9]\\d{9}$";
}

实体与 Repository

User 实体 使用 JPA 映射,包含用户名、密码、邮箱、手机号,以及多对多的角色关联。authorities 字段与 Spring Security 默认的 getAuthorities() 对应,便于后续认证鉴权。

java 复制代码
package com.example.demo.entity;

import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.*;
import java.util.stream.Collectors;

@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@With
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(unique = true)
    private String email;

    @Column(unique = true, length = 20)
    private String mobile;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
        name = "user_roles",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    private Set<Role> authorities = new HashSet<>();

    // 忽略账户过期、锁定等细节,均返回 true
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities.stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toList());
    }

    @Override
    public boolean isAccountNonExpired() { return true; }
    @Override
    public boolean isAccountNonLocked() { return true; }
    @Override
    public boolean isCredentialsNonExpired() { return true; }
    @Override
    public boolean isEnabled() { return true; }
}

Role 实体

java 复制代码
package com.example.demo.entity;

import lombok.*;
import javax.persistence.*;

@Entity
@Table(name = "roles")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Role {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String name;
}

UserRepository -- 利用 Spring Data 方法命名衍生查询,轻松完成计数逻辑。

java 复制代码
package com.example.demo.repository;

import com.example.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
    long countByUsername(String username);
    long countByEmail(String email);
    long countByMobile(String mobile);
}

RoleRepository -- 用于加载默认角色。

java 复制代码
package com.example.demo.repository;

import com.example.demo.entity.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface RoleRepository extends JpaRepository<Role, Long> {
    Optional<Role> findByName(String name);
}

标准异常模型

通过继承一个自定义的抽象 Problem 类(此处仿照 Zalando Problem 风格),实现结构化的错误响应。DuplicateProblem 会在重复数据场景下被抛出,配合全局异常处理器自动转换为符合 RFC 7807 的 JSON。

java 复制代码
package com.example.demo.exception;

import org.springframework.http.HttpStatus;
import org.zalando.problem.AbstractThrowableProblem;
import org.zalando.problem.Status;

import java.net.URI;

public class DuplicateProblem extends AbstractThrowableProblem {

    public DuplicateProblem(String field, String message) {
        super(
                URI.create(com.example.demo.constant.Constants.DUPLICATE_TYPE + "/" + field),
                "Duplicate Data",
                Status.CONFLICT,
                message
        );
    }
}

注意:示例中引入了 zalando-problem-spring-web 依赖,若不想依赖第三方库,可改用 @ResponseStatus 注解的自定义 RuntimeException,并配合 @ControllerAdvice 统一处理。

业务服务层

UserService 封装了唯一性校验逻辑与注册事务。密码通过 PasswordEncoder 进行 BCrypt 编码,默认角色从数据库加载。当角色不存在时,建议在系统初始化时通过数据迁移脚本预置。

java 复制代码
package com.example.demo.service;

import com.example.demo.constant.Constants;
import com.example.demo.dto.UserDTO;
import com.example.demo.entity.Role;
import com.example.demo.entity.User;
import com.example.demo.exception.DuplicateProblem;
import com.example.demo.repository.RoleRepository;
import com.example.demo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Set;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    public boolean existsByUsername(String username) {
        return userRepository.countByUsername(username) > 0;
    }

    public boolean existsByEmail(String email) {
        return userRepository.countByEmail(email) > 0;
    }

    public boolean existsByMobile(String mobile) {
        return userRepository.countByMobile(mobile) > 0;
    }

    @Transactional
    public User register(UserDTO dto) {
        // 默认角色必须存在,否则抛异常
        Role defaultRole = roleRepository.findByName(Constants.ROLE_USER)
                .orElseThrow(() -> new IllegalStateException("Default role ROLE_USER not found in database"));

        User userToSave = User.builder()
                .username(dto.getUsername())
                .password(passwordEncoder.encode(dto.getPassword()))
                .email(dto.getEmail())
                .mobile(dto.getMobile())
                .build()
                .withAuthorities(Set.of(defaultRole));

        return userRepository.save(userToSave);
    }
}

DTO 与控制器

UserDTO 仅承担数据传递,避免直接暴露实体结构。

java 复制代码
package com.example.demo.dto;

import lombok.Data;
import javax.validation.constraints.*;

@Data
public class UserDTO {
    @NotBlank
    private String username;
    @NotBlank
    private String password;
    @Email
    @NotBlank
    private String email;
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String mobile;
}

AccountResource 控制器处理 /register 请求,按顺序校验唯一性,通过后委托 UserService 执行注册。

java 复制代码
package com.example.demo.web;

import com.example.demo.constant.Constants;
import com.example.demo.dto.UserDTO;
import com.example.demo.exception.DuplicateProblem;
import com.example.demo.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.net.URI;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AccountResource {

    private final UserService userService;

    @PostMapping("/register")
    public ResponseEntity<Void> register(@Valid @RequestBody UserDTO userDTO) {
        if (userService.existsByUsername(userDTO.getUsername())) {
            throw new DuplicateProblem("username", "用户名已存在");
        }
        if (userService.existsByEmail(userDTO.getEmail())) {
            throw new DuplicateProblem("email", "电子邮件地址已存在");
        }
        if (userService.existsByMobile(userDTO.getMobile())) {
            throw new DuplicateProblem("mobile", "手机号已存在");
        }
        userService.register(userDTO);
        return ResponseEntity.created(URI.create("/api/users/" + userDTO.getUsername())).build();
    }
}

安全配置

为使得 PasswordEncoder 可用,并开放注册端点,需要提供一个基础的 Spring Security 配置类。

java 复制代码
package com.example.demo.config;

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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeRequests()
                .antMatchers("/api/register").permitAll()
                .anyRequest().authenticated()
            .and()
            .httpBasic();
        return http.build();
    }
}

全局异常处理(补充示例)

若使用 zalando-problem,Spring Boot 会自动将 AbstractThrowableProblem 的子类转换为标准 Problem JSON。若未引入,可添加如下 @ControllerAdvice

java 复制代码
package com.example.demo.exception;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.zalando.problem.Problem;
import org.zalando.problem.Status;

@RestControllerAdvice
public class ExceptionHandling {

    @ExceptionHandler(DuplicateProblem.class)
    public ResponseEntity<Problem> handleDuplicate(DuplicateProblem ex, WebRequest request) {
        Problem problem = Problem.builder()
                .withType(ex.getType())
                .withTitle(ex.getTitle())
                .withStatus(ex.getStatus())
                .withDetail(ex.getMessage())
                .build();
        return new ResponseEntity<>(problem, ex.getStatus().getStatusCode());
    }
}

测试验证

启动应用后,发送 POST 请求至 /api/register,携带 JSON:

json 复制代码
{
    "username": "john_doe",
    "password": "secret123",
    "email": "john@example.com",
    "mobile": "13800138000"
}

首次请求将返回 201 Created,并在数据库中新增用户记录,user_roles 表中会插入 ROLE_USER 关联。再次发送相同请求,会收到 409 Conflict,响应体类似于:

json 复制代码
{
    "type": "https://api.example.com/problem/duplicate/username",
    "title": "Duplicate Data",
    "status": 409,
    "detail": "用户名已存在"
}

将 username 修改后再发送,但保持 email 不变,将触发电子邮件重复异常;同理验证手机号。密码会被 BCrypt 加密存储,原始明文不会入库。

设计要点与演进

  • 唯一性校验的时机 :必须在事务外提前检查,否则可能因为约束冲突导致 DataIntegrityViolationException,失去业务语义。采用 countByXxx 配合提前抛异常,可以让错误信息更清晰。
  • 默认角色初始化 :示例中要求角色表中必须存在 ROLE_USER,推荐通过 data.sqlCommandLineRunner 在启动时初始化。
  • @With 的使用 :Lombok 的 @With 在已有对象基础上生成一个修改了指定属性的新对象,符合不可变风格。注册时先构建基础 User,再通过 withAuthorities 添加角色集合。
  • 事务边界register 方法标注 @Transactional,保证密码编码后的用户与角色关联在同一个事务中提交。
  • 手机号格式校验 :正则表达式放在 DTO 的 @Pattern 注解中,与控制层的 @Valid 配合完成第一道校验,唯一性检查作为第二道业务防线。

总结

本文从机器翻译的视频文稿出发,重建了一个完整的用户注册模块:利用 Spring Data 衍生查询完成三项唯一性检查,定义标准化异常模型提升 API 可读性,结合 Spring Security 的 PasswordEncoder 与默认角色实现安全落库。

所提供的代码经过结构化重组,可直接复制到 Spring Boot 项目中运行。

注册流程虽小,却涵盖了企业级应用中数据校验、异常处理、安全编码的典型范式。

相关推荐
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之多因子认证与TOTP实战
java·spring·ui
跨境生态圈1 小时前
2026谷歌SEO快速排名深度解析:合规起量、避坑指南与实战落地策略
数据库·人工智能·爬虫·搜索引擎·chatgpt
滕州市燕猫虎计算机科技工作室个体工商户1 小时前
Java面试题汇总
java·开发语言
JavaPub-rodert2 小时前
Docker 安装 MySQL 完整教程:从零部署数据库,到生产环境持久化配置
数据库·mysql·docker
隔窗听雨眠2 小时前
MySQL整库迁移至KaiwuDB完全指南:从数据类型映射到生产切换的系统性实践
数据库·mysql
xhbh6662 小时前
中小企业 Redis 运维,如何安全归档 RDB、AOF 备份文件?
运维·数据库·缓存·数据备份·文件备份·同步备份·号码备份
2301_32241428043 小时前
活力孕康复APP 47737- 原创(免费领源码+部署教程+开发环境)
java·vue.js·spring boot·mysql·微信小程序·idea·微信开发者工具
勤奋的树懒3 小时前
从手写 SQL 到 Windows 工具:致远 OA 文件清理实践
数据库·sql·windows server·致远oa
君顾13 小时前
外卖CPS软件开发实战:从系统架构到部署全流程指南
java·开发语言·外卖