java分组接口及校验注解用法
分组接口(Group Interface)是 Bean Validation(JSR 380) 提供的一种机制,用于在参数校验时动态选择需要校验的字段或规则。通过分组接口,可以将校验逻辑按业务场景划分,从而实现更灵活的参数校验。
以下是分组接口的技术细节和实现原理:
1. 分组接口的定义
分组接口是一个普通的 Java 接口,没有任何方法。它的作用仅仅是作为一个标记(Marker Interface),用于标识不同的校验场景。
public interface GroupA {}
public interface GroupB {}
2. 在字段上指定分组
在校验注解(如 @NotEmpty
、@Size
等)中,可以通过 groups
属性指定该注解所属的分组。
public class User {
@NotEmpty(message = "用户名不能为空", groups = GroupA.class)
private String username;
@Size(min = 6, max = 20, message = "密码长度必须在6到20之间", groups = GroupB.class)
private String password;
}
username
字段的@NotEmpty
校验规则属于GroupA
分组。password
字段的@Size
校验规则属于GroupB
分组。
3. 在 Controller 中使用分组校验
在 Spring MVC 中,可以通过 @Validated
注解指定校验分组。
@RestController
public class UserController {
@PostMapping("/createUser")
public ResponseEntity<?> createUser(@Validated(GroupA.class) @RequestBody User user) {
// 只校验属于 GroupA 分组的字段
return ResponseEntity.ok("用户创建成功");
}
@PostMapping("/updateUser")
public ResponseEntity<?> updateUser(@Validated({GroupA.class, GroupB.class}) @RequestBody User user) {
// 校验属于 GroupA 和 GroupB 分组的字段
return ResponseEntity.ok("用户更新成功");
}
}
- 在
createUser
方法中,只会校验username
字段(因为username
属于GroupA
分组)。 - 在
updateUser
方法中,会同时校验username
和password
字段(因为它们分别属于GroupA
和GroupB
分组)。
4. 默认分组
如果没有指定 groups
属性,校验注解默认属于 javax.validation.groups.Default
分组。
@NotEmpty(message = "用户名不能为空") // 默认属于 Default 分组
private String username;
5. 分组继承
分组接口可以继承其他接口,从而实现分组的组合。
public interface GroupA {}
public interface GroupB extends GroupA {}
- 如果一个字段的校验注解属于
GroupB
分组,那么它也会被GroupA
分组校验。
6. 动态分组
分组校验可以根据业务逻辑动态选择。例如,根据系统配置决定是否启用某些校验规则。
@PostMapping("/login")
public ResponseEntity<?> login(@Validated({Default.class, CodeEnableGroup.class}) @RequestBody AuthLoginReqVO reqVO) {
// 根据系统配置动态选择分组
if (enableCaptcha) {
// 启用验证码校验
validator.validate(reqVO, CodeEnableGroup.class);
}
return ResponseEntity.ok("登录成功");
}
7. 自定义校验器与分组
如果你实现了自定义校验器(通过 ConstraintValidator
),可以在校验器中获取当前的分组信息。
public class CustomValidator implements ConstraintValidator<CustomAnnotation, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 获取当前的分组信息
Set<Class<?>> groups = context.getConstraintDescriptor().getGroups();
if (groups.contains(GroupA.class)) {
// 执行 GroupA 分组的校验逻辑
}
return true;
}
}
8. 分组校验的实现原理
Spring Validation 是基于 Bean Validation(Hibernate Validator)实现的。分组校验的核心原理如下:
-
注解解析:
- Spring 会解析
@Validated
注解中指定的分组。 - 根据分组信息,筛选出需要校验的字段和规则。
- Spring 会解析
-
校验器调用:
- Spring 调用 Bean Validation 的校验器(如 Hibernate Validator),并传入分组信息。
- 校验器根据分组信息执行相应的校验逻辑。
-
校验结果处理:
- 如果校验失败,Spring 会抛出
MethodArgumentNotValidException
异常,并返回错误信息。
- 如果校验失败,Spring 会抛出
9. 分组校验的优缺点
优点:
- 灵活性:可以根据业务场景动态选择校验规则。
- 解耦:将校验逻辑与业务逻辑分离,代码更清晰。
- 复用性:同一个字段可以在不同的分组中复用,避免重复定义。
缺点:
- 复杂度增加:如果分组过多,可能会导致代码复杂度增加。
- 维护成本:需要仔细管理分组接口,避免分组冲突或遗漏。
10. 最佳实践
-
按业务场景划分分组:
- 将分组接口与具体的业务场景对应,例如
CreateGroup
、UpdateGroup
、LoginGroup
等。
- 将分组接口与具体的业务场景对应,例如
-
避免过度分组:
- 不要为每个字段都创建单独的分组,而是将相关的字段划分到同一个分组中。
-
使用默认分组:
- 对于通用的校验规则,可以使用
Default
分组,避免重复定义。
- 对于通用的校验规则,可以使用
-
分组继承:
- 通过分组继承实现分组的组合,减少重复代码。
总结
分组接口是 Bean Validation 提供的一种强大机制,用于实现动态、灵活的参数校验。通过分组接口,可以根据业务场景选择需要校验的字段和规则,从而提高代码的可维护性和复用性。在实际开发中,合理使用分组接口可以显著提升参数校验的灵活性和可读性。
package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
@Schema(description = "管理后台 - 账号密码登录 Request VO,如果登录并绑定社交用户,需要传递 social 开头的参数")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuthLoginReqVO {
@Schema(description = "账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudaoyuanma")
@NotEmpty(message = "登录账号不能为空")
@Length(min = 4, max = 16, message = "账号长度为 4-16 位")
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
private String username;
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao")
@NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password;
// ========== 图片验证码相关 ==========
@Schema(description = "验证码,验证码开启时,需要传递", requiredMode = Schema.RequiredMode.REQUIRED,
example = "PfcH6mgr8tpXuMWFjvW6YVaqrswIuwmWI5dsVZSg7sGpWtDCUbHuDEXl3cFB1+VvCC/rAkSwK8Fad52FSuncVg==")
@NotEmpty(message = "验证码不能为空", groups = CodeEnableGroup.class)
private String captchaVerification;
// ========== 绑定社交登录时,需要传递如下参数 ==========
@Schema(description = "社交平台的类型,参见 SocialTypeEnum 枚举值", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
@InEnum(SocialTypeEnum.class)
private Integer socialType;
@Schema(description = "授权码", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private String socialCode;
@Schema(description = "state", requiredMode = Schema.RequiredMode.REQUIRED, example = "9b2ffbc1-7425-4155-9894-9d5c08541d62")
private String socialState;
/**
* 开启验证码的 Group
*/
public interface CodeEnableGroup {}
@AssertTrue(message = "授权码不能为空")
public boolean isSocialCodeValid() {
return socialType == null || StrUtil.isNotEmpty(socialCode);
}
@AssertTrue(message = "授权 state 不能为空")
public boolean isSocialState() {
return socialType == null || StrUtil.isNotEmpty(socialState);
}
}
package cn.iocoder.yudao.module.system.service.auth;
import cn.hutool.core.util.ObjectUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.util.monitor.TracerUtils;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
import cn.iocoder.yudao.module.system.api.logger.dto.LoginLogCreateReqDTO;
import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO;
import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO;
import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
import cn.iocoder.yudao.module.system.convert.auth.AuthConvert;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginResultEnum;
import cn.iocoder.yudao.module.system.enums.oauth2.OAuth2ClientConstants;
import cn.iocoder.yudao.module.system.enums.sms.SmsSceneEnum;
import cn.iocoder.yudao.module.system.service.logger.LoginLogService;
import cn.iocoder.yudao.module.system.service.member.MemberService;
import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
import cn.iocoder.yudao.module.system.service.social.SocialUserService;
import cn.iocoder.yudao.module.system.service.user.AdminUserService;
import com.google.common.annotations.VisibleForTesting;
import com.xingyuv.captcha.model.common.ResponseModel;
import com.xingyuv.captcha.model.vo.CaptchaVO;
import com.xingyuv.captcha.service.CaptchaService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.validation.Validator;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP;
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
/**
* Auth Service 实现类
*
* @author 芋道源码
*/
@Service
@Slf4j
public class AdminAuthServiceImpl implements AdminAuthService {
@Resource
private AdminUserService userService;
@Resource
private LoginLogService loginLogService;
@Resource
private OAuth2TokenService oauth2TokenService;
@Resource
private SocialUserService socialUserService;
@Resource
private MemberService memberService;
@Resource
private Validator validator;
@Resource
private CaptchaService captchaService;
@Resource
private SmsCodeApi smsCodeApi;
/**
* 验证码的开关,默认为 true
*/
@Value("${yudao.captcha.enable:true}")
private Boolean captchaEnable;
@Override
public AdminUserDO authenticate(String username, String password) {
final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME;
// 校验账号是否存在
AdminUserDO user = userService.getUserByUsername(username);
if (user == null) {
createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
}
if (!userService.isPasswordMatch(password, user.getPassword())) {
createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
}
// 校验是否禁用
if (CommonStatusEnum.isDisable(user.getStatus())) {
createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);
throw exception(AUTH_LOGIN_USER_DISABLED);
}
return user;
}
@Override
public AuthLoginRespVO login(AuthLoginReqVO reqVO) {
// 校验验证码
validateCaptcha(reqVO);
// 使用账号密码,进行登录
AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());
// 如果 socialType 非空,说明需要绑定社交用户
if (reqVO.getSocialType() != null) {
socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(),
reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState()));
}
// 创建 Token 令牌,记录登录日志
return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
}
@Override
public void sendSmsCode(AuthSmsSendReqVO reqVO) {
// 登录场景,验证是否存在
if (userService.getUserByMobile(reqVO.getMobile()) == null) {
throw exception(AUTH_MOBILE_NOT_EXISTS);
}
// 发送验证码
smsCodeApi.sendSmsCode(AuthConvert.INSTANCE.convert(reqVO).setCreateIp(getClientIP()));
}
@Override
public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) {
// 校验验证码
smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP()));
// 获得用户信息
AdminUserDO user = userService.getUserByMobile(reqVO.getMobile());
if (user == null) {
throw exception(USER_NOT_EXISTS);
}
// 创建 Token 令牌,记录登录日志
return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE);
}
private void createLoginLog(Long userId, String username,
LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) {
// 插入登录日志
LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO();
reqDTO.setLogType(logTypeEnum.getType());
reqDTO.setTraceId(TracerUtils.getTraceId());
reqDTO.setUserId(userId);
reqDTO.setUserType(getUserType().getValue());
reqDTO.setUsername(username);
reqDTO.setUserAgent(ServletUtils.getUserAgent());
reqDTO.setUserIp(ServletUtils.getClientIP());
reqDTO.setResult(loginResult.getResult());
loginLogService.createLoginLog(reqDTO);
// 更新最后登录时间
if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) {
userService.updateUserLogin(userId, ServletUtils.getClientIP());
}
}
@Override
public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) {
// 使用 code 授权码,进行登录。然后,获得到绑定的用户编号
SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(), reqVO.getType(),
reqVO.getCode(), reqVO.getState());
if (socialUser == null || socialUser.getUserId() == null) {
throw exception(AUTH_THIRD_LOGIN_NOT_BIND);
}
// 获得用户
AdminUserDO user = userService.getUser(socialUser.getUserId());
if (user == null) {
throw exception(USER_NOT_EXISTS);
}
// 创建 Token 令牌,记录登录日志
return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL);
}
@VisibleForTesting
void validateCaptcha(AuthLoginReqVO reqVO) {
// 如果验证码关闭,则不进行校验
if (!captchaEnable) {
return;
}
// 校验验证码
ValidationUtils.validate(validator, reqVO, AuthLoginReqVO.CodeEnableGroup.class);
CaptchaVO captchaVO = new CaptchaVO();
captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification());
ResponseModel response = captchaService.verification(captchaVO);
// 验证不通过
if (!response.isSuccess()) {
// 创建登录失败日志(验证码不正确)
createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR);
throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg());
}
}
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {
// 插入登陆日志
createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
// 创建访问令牌
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),
OAuth2ClientConstants.CLIENT_ID_DEFAULT, null);
// 构建返回结果
return AuthConvert.INSTANCE.convert(accessTokenDO);
}
@Override
public AuthLoginRespVO refreshToken(String refreshToken) {
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT);
return AuthConvert.INSTANCE.convert(accessTokenDO);
}
@Override
public void logout(String token, Integer logType) {
// 删除访问令牌
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token);
if (accessTokenDO == null) {
return;
}
// 删除成功,则记录登出日志
createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType);
}
private void createLogoutLog(Long userId, Integer userType, Integer logType) {
LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO();
reqDTO.setLogType(logType);
reqDTO.setTraceId(TracerUtils.getTraceId());
reqDTO.setUserId(userId);
reqDTO.setUserType(userType);
if (ObjectUtil.equal(getUserType().getValue(), userType)) {
reqDTO.setUsername(getUsername(userId));
} else {
reqDTO.setUsername(memberService.getMemberUserMobile(userId));
}
reqDTO.setUserAgent(ServletUtils.getUserAgent());
reqDTO.setUserIp(ServletUtils.getClientIP());
reqDTO.setResult(LoginResultEnum.SUCCESS.getResult());
loginLogService.createLoginLog(reqDTO);
}
private String getUsername(Long userId) {
if (userId == null) {
return null;
}
AdminUserDO user = userService.getUser(userId);
return user != null ? user.getUsername() : null;
}
private UserTypeEnum getUserType() {
return UserTypeEnum.ADMIN;
}
@Override
public AuthLoginRespVO register(AuthRegisterReqVO registerReqVO) {
// 1. 校验验证码
validateCaptcha(registerReqVO);
// 2. 校验用户名是否已存在
Long userId = userService.registerUser(registerReqVO);
// 3. 创建 Token 令牌,记录登录日志
return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
}
@VisibleForTesting
void validateCaptcha(AuthRegisterReqVO reqVO) {
// 如果验证码关闭,则不进行校验
if (!captchaEnable) {
return;
}
// 校验验证码
ValidationUtils.validate(validator, reqVO, AuthLoginReqVO.CodeEnableGroup.class);
CaptchaVO captchaVO = new CaptchaVO();
captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification());
ResponseModel response = captchaService.verification(captchaVO);
// 验证不通过
if (!response.isSuccess()) {
throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg());
}
}
}