构建一个用户模块,少不了注册、登录、权限拦截这些常规功能。下面从头梳理一下 Spring Boot 项目里的实现思路,涵盖敏感字段加密、JWT 令牌、短信验证码、强制登录拦截,以及后台管理的用户列表。
敏感字段加密
用户注册时,密码和手机号都不能明文存库。
- 密码:用加盐 SHA-256。注册时给每个用户生成随机盐值,把密码和盐拼起来哈希,只存结果,不可逆。
- 手机号:业务上偶尔需要明文(比如发短信),所以用 AES 对称加密。存的时候加密,用的时候解密。
家国工具类我习惯用 Hutool,加解密、验证码生成这些都能搞定。
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
Hutool 官方文档:https://hutool.cn/
Maven 仓库:https://mvnrepository.com/artifact/cn.hutool
注册接口
注册参数包含姓名、邮箱、手机号、密码、身份(普通用户或管理员)。前端表单校验后发起 POST,后端用 @Validated 做基本校验。
请求体:
{
"name": "张三",
"mail": "[email protected]",
"phoneNumber": "13188888888",
"password": "123456789",
"identity": "ADMIN"
}
返回用户 ID:
{
"code": 200,
"data": { "userId": 22 },
"msg": ""
}
Controller 层很简单,接收 UserRegisterParam,调 Service 拿到 UserRegisterDTO 再转成返回对象:
@RestController
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
@Autowired
private UserService userService;
@Autowired
private VerificationCodeService verificationCodeService;
@PostMapping("/register")
public CommonResult<UserRegisterResult> userRegister(@Validated @RequestBody UserRegisterParam param) {
logger.info("userRegister UserRegisterParam 用户注册:{}", JacksonUtil.writeValueAsString(param));
UserRegisterDTO userRegisterDTO = userService.register(param);
return CommonResult.success(convertToUserRegisterResult(userRegisterDTO));
}
private UserRegisterResult convertToUserRegisterResult(UserRegisterDTO userRegisterDTO) {
UserRegisterResult result = new UserRegisterResult();
if (null == userRegisterDTO) {
throw new ControllerException(ControllerErrorCodeConstants.REGISTER_ERROR);
}
result.setUserId(userRegisterDTO.getUserId());
return result;
}
}
如果用的是 Spring Boot 2.3 以上,要额外引入 validation 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Service 层里做实质性校验:邮箱格式、手机号格式、身份合法、密码长度至少6位、邮箱和手机号唯一性。然后加密手机号、对密码做 SHA-256,再入库。
@Slf4j
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private VerificationCodeService verificationCodeService;
@Override
public UserRegisterDTO register(UserRegisterParam param) {
checkRegisterInfo(param);
UserDO userDO = new UserDO();
userDO.setUserName(param.getName());
userDO.setEmail(param.getMail());
userDO.setPhoneNumber(newEncrypt(param.getPhoneNumber()));
userDO.setIdentity(param.getIdentity());
if (StringUtils.hasLength(param.getPassword())) {
userDO.setPassword(DigestUtil.sha256Hex(param.getPassword()));
}
userMapper.insert(userDO);
UserRegisterDTO dto = new UserRegisterDTO();
dto.setUserId(userDO.getId());
return dto;
}
private void checkRegisterInfo(UserRegisterParam param) {
if (null == param) throw new ServiceException(ServiceErrorCodeConstants.REGISTER_INFO_IS_EMPTY);
if (!RegexUtil.checkMail(param.getMail())) throw new ServiceException(ServiceErrorCodeConstants.MAIL_ERROR);
if (!RegexUtil.checkMobile(param.getPhoneNumber())) throw new ServiceException(ServiceErrorCodeConstants.PHONE_NUMBER_ERROR);
if (null == UserIdentityEnum.forName(param.getIdentity())) throw new ServiceException(ServiceErrorCodeConstants.IDENTITY_ERROR);
if (param.getIdentity().equalsIgnoreCase(UserIdentityEnum.ADMIN.name()) && !StringUtils.hasLength(param.getPassword()))
throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_IS_EMPTY);
if (StringUtils.hasLength(param.getPassword()) && !RegexUtil.checkPassword(param.getPassword()))
throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_ERROR);
if (checkMailUsed(param.getMail())) throw new ServiceException(ServiceErrorCodeConstants.MAIL_USED);
if (checkPhoneNumberUsed(param.getPhoneNumber())) throw new ServiceException(ServiceErrorCodeConstants.PHONE_NUMBER_USED);
}
private boolean checkPhoneNumberUsed(String phoneNumber) {
return userMapper.countByPhone(newEncrypt(phoneNumber)) > 0;
}
private boolean checkMailUsed(String mail) {
return userMapper.countByMail(mail) > 0;
}
}
Dao 层用 MyBatis,UserMapper 负责插入和唯一性查询。UserDO 映射用户表,继承 BaseDO 包含主键、创建时间、更新时间。
手机号自动加解密:TypeHandler
因为手机号存库前要加密、读出来要解密,每个地方手动处理很烦。MyBatis 的 TypeHandler 可以让我们只在字段上标记一下,框架自动处理。
- 定义一个
Encrypt类型包装要加密的字符串。 - 写一个
EncryptTypeHandler实现BaseTypeHandler<Encrypt>,重写setNonNullParameter(加密)和getNullableResult(解密)。 - 在
application.properties里配好 TypeHandler 的包路径。
这样 Service 层就能忽略加解密细节了。
控制层通用异常处理
用 @RestControllerAdvice + @ExceptionHandler 统一处理异常,避免每个方法都 try-catch。这里分别捕获 Exception、ServiceException、ControllerException,记录日志后返回统一的 CommonResult 错误格式。
短信验证码登录
因为没有企业认证,用的是阿里云短信服务的测试模板。配置里填好 AccessKeyId、AccessKeySecret 和签名。
CaptchaUtil 用 Hutool 生成随机数字验证码:
public class CaptchaUtil {
public static String getCaptcha(int length) {
RandomGenerator randomGenerator = new RandomGenerator("0123456789", length);
LineCaptcha lineCaptcha = cn.hutool.captcha.CaptchaUtil.createLineCaptcha(200, 100);
lineCaptcha.setGenerator(randomGenerator);
lineCaptcha.createCode();
return lineCaptcha.getCode();
}
}
发送短信的接口:GET /verification-code/send?phoneNumber=13199999999,返回 true/false。
Service 层实现:
- 校验手机号格式。
- 生成验证码。
- 调用
SMSUtil发短信。 - 把验证码存进 Redis,设置 60 秒过期。
@Service
public class VerificationCodeServiceImpl implements VerificationCodeService {
@Autowired
private SMSUtil smsUtil;
@Autowired
private RedisUtil redisUtil;
@Override
public void sendVerificationCode(String phoneNumber) {
if (!RegexUtil.checkMobile(phoneNumber)) {
throw new ServiceException(ServiceErrorCodeConstants.PHONE_NUMBER_ERROR);
}
String code = CaptchaUtil.getCaptcha(Constants.CODE_LENGTH);
smsUtil.sendVerifyCode(phoneNumber, code);
redisUtil.set(Constants.VERIFICATION_CODE_PREFIX + phoneNumber, code, Constants.VERIFICATION_CODE_TIMEOUT);
}
}
Redis 工具类
为避免序列化乱码,基于 StringRedisTemplate 封装了 RedisUtil,提供常见的 get、set(支持过期时间)、hasKey、del 等方法。
@Configuration
public class RedisUtil {
private static final Logger logger = LoggerFactory.getLogger(RedisUtil.class);
@Autowired
private StringRedisTemplate stringRedisTemplate;
public boolean set(String key, String value, Long time) {
try {
stringRedisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
return true;
} catch (Exception e) {
logger.error("RedisUtil error,set({}, {}, {})", key, value, time, e);
return false;
}
}
public String get(String key) {
try {
return StringUtils.hasText(key) ? stringRedisTemplate.opsForValue().get(key) : null;
} catch (Exception e) {
logger.error("RedisUtil error,get({})", key, e);
return null;
}
}
}
JWT 令牌
用 JWT 实现无状态登录,集群环境下不需要管 Session 共享。JWT 由 Header、Payload、Signature 三部分组成,签名能防止篡改,但内容本身是 Base64 编码的,不要往里面放敏感信息。
JWTUtil 封装了生成和解析方法。密钥从 Base64 字符串生成 HMAC SHA 密钥,过期时间设为 24 小时。
public class JWTUtil {
private static final Logger logger = LoggerFactory.getLogger(JWTUtil.class);
private static final String SECRET = "********";
private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET));
private static final long EXPIRATION = 24 * 60 * 60 * 1000;
public static String genJwt(Map<String, Object> claim) {
return Jwts.builder()
.setClaims(claim)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(SECRET_KEY)
.compact();
}
public static Claims parseJWT(String jwt) {
if (!StringUtils.hasLength(jwt)) return null;
try {
return Jwts.parserBuilder().setSigningKey(SECRET_KEY).build().parseClaimsJws(jwt).getBody();
} catch (Exception e) {
logger.error("解析令牌错误,jwt:{}", jwt, e);
return null;
}
}
}
管理员登录
支持两种方式:电话+密码,或者电话+验证码。登录成功返回 JWT 和用户身份。
密码登录接口:POST /password/login
{
"loginName": "13199999999",
"password": "123456",
"mandatoryIdentity": "ADMIN"
}
验证码登录接口:POST /message/login
{
"loginMobile": "13199999999",
"verificationCode": "0475",
"mandatoryIdentity": "ADMIN"
}
响应体:
{
"code": 200,
"data": { "token": "eyJhbGci...", "identity": "ADMIN" },
"msg": ""
}
Service 层要区分登录类型:
- 密码登录:校验登录名(邮箱或手机号),查出用户,验证身份和密码,然后签发令牌。
- 验证码登录:校验手机号,查出用户,校验身份,从 Redis 取出验证码对比,通过后签发令牌。
Dao 层提供 selectByEmail 和 selectByPhoneNumber 两个查询方法。
前端登录页用 tab 切换两种方式,表单校验、倒计时重发验证码这些都在前端处理。登录成功把 token 和身份存 localStorage,然后跳转后台首页。
强制登录拦截
未登录的用户访问受保护页面时,自动拦住跳登录页。
前端在所有 AJAX 请求头里带上 user_token(从 localStorage 取),后端解析令牌,无效就返回 401。前端 ajaxSend 里统一处理 401,把页面重定向到登录页(包括 iframe 嵌套的情况)。
后端拦截器 LoginInterceptor 实现 HandlerInterceptor:
@Slf4j
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("user_token");
log.info("获取 Token: {}", token);
Claims claims = JWTUtil.parseJWT(token);
if (null == claims) {
log.error("解析 JWT 令牌失败!");
response.setStatus(401);
return false;
}
return true;
}
}
AppConfig 配置拦截器,放行登录、注册、验证码发送等接口,以及静态资源:
@Configuration
public class AppConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
private final List<String> excludes = Arrays.asList(
"/**.html", "/css/**", "/js/**", "/pic/**", "/*.jpg", "/*.png",
"/favicon.ico", "/**/login", "/register", "/verification-code/send",
"/winning-records/show"
);
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(excludes);
}
}
用户管理后台
后台页面 admin.html 通过 iframe 加载子页,侧边栏切换功能。管理员新增用户和前端注册流程差不多,只是新增完直接跳转到用户列表(通过 URL 参数控制)。
用户列表接口:GET /base-user/find-list,可选 identity 参数筛选身份。返回数组里包含 userId、userName、identity。
Service 层 findUserList 方法根据身份可选查询,按 ID 降序;Dao 层对应 selectUserList。
前端拿到数据后渲染表格,未登录时同样会触发拦截跳转。
就这样,一个基础的用户模块就搭起来了。从加密、注册、登录到 JWT 和拦截器,各层代码都比较清晰。实际项目中还可以加上角色权限细分、Token 刷新等,但核心逻辑大差不差。


