feat(system): 按 OAuth2 客户端 platform 过滤菜单,支持业务/物联双前端

问题:业务平台(biz)和物联运维平台(iot)共用一套用户体系和菜单表,但每个前端
只该看到自己域的菜单。原来没有按客户端过滤的机制。

方案:在 OAuth2 客户端维度打 platform 标签(biz/iot/NULL),菜单也打同样标签,
登录时下发菜单按二者匹配过滤。

链路:
- OAuth2AccessTokenCheckRespDTO / LoginUser(framework + gateway)新增 clientId 字段
- TokenAuthenticationFilter(framework + gateway)把 accessToken.clientId 带进 LoginUser
- WebFrameworkUtils.HEADER_CLIENT_ID="X-Client-Id":登录/refresh 等"无 token 入口"
  允许前端声明 client,避免硬编码 default
- AdminAuthServiceImpl.resolveClientId:未传 Header 时回退 OAuth2ClientConstants.CLIENT_ID_DEFAULT
- MenuDO / OAuth2ClientDO 各加 platform 列
- MenuService.filterMenusByPlatform:platform 为空(全平台共用)或匹配即保留

SQL 迁移按字母序编号:
- _01_oauth2_client_platform.sql:加列 + 给 default/iot-client 客户端打标 + 递归标
  IoT 菜单子树(root id=4000)为 iot
- _02_bulk_mark_biz_menus.sql:其余 platform=NULL 的菜单兜底标 biz
- 顺序依赖:_01 标完 iot 后 _02 才动剩余 NULL,避免 _02 把 IoT 菜单错标 biz

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 13:32:26 +08:00
parent 4564eec893
commit cbbb048a4d
15 changed files with 729 additions and 559 deletions

View File

@@ -63,6 +63,9 @@ public class MenuRespVO {
@Schema(description = "是否总是显示", example = "false")
private Boolean alwaysShow;
@Schema(description = "平台标识biz-业务平台iot-物联运维平台null-两个平台都展示", example = "biz")
private String platform;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式")
private LocalDateTime createTime;

View File

@@ -61,4 +61,7 @@ public class MenuSaveVO {
@Schema(description = "是否总是显示", example = "false")
private Boolean alwaysShow;
@Schema(description = "平台标识biz-业务平台iot-物联运维平台null-两个平台都展示", example = "biz")
private String platform;
}

View File

@@ -105,5 +105,12 @@ public class OAuth2ClientDO extends BaseDO {
* 附加信息JSON 格式
*/
private String additionalInformation;
/**
* 平台标识,决定该客户端登录后能看到的菜单子集
*
* 例如:{@code biz} 业务平台 / {@code iot} 物联运维平台。NULL 表示不做按客户端的菜单过滤。
* 与 {@link com.viewsh.module.system.dal.dataobject.permission.MenuDO#getPlatform()} 对齐。
*/
private String platform;
}

View File

@@ -105,5 +105,12 @@ public class MenuDO extends BaseDO {
* 如果为 false 时,当该菜单只有一个子菜单时,不展示自己,直接展示子菜单
*/
private Boolean alwaysShow;
/**
* 平台标识biz-业务平台iot-物联运维平台NULL-两个平台都展示
*
* 与 {@link com.viewsh.module.system.dal.dataobject.oauth2.OAuth2ClientDO#getPlatform()} 对齐:
* 用户登录的 OAuth2 客户端的 platform 等于本字段,或本字段为 NULL则该菜单展示。
*/
private String platform;
}

View File

@@ -1,361 +1,379 @@
package com.viewsh.module.system.service.auth;
import cn.hutool.core.util.ObjectUtil;
import com.viewsh.framework.common.enums.CommonStatusEnum;
import com.viewsh.framework.common.enums.UserTypeEnum;
import com.viewsh.framework.common.util.monitor.TracerUtils;
import com.viewsh.framework.common.util.object.BeanUtils;
import com.viewsh.framework.common.util.servlet.ServletUtils;
import com.viewsh.framework.common.util.validation.ValidationUtils;
import com.viewsh.framework.datapermission.core.annotation.DataPermission;
import com.viewsh.module.system.api.logger.dto.LoginLogCreateReqDTO;
import com.viewsh.module.system.api.sms.SmsCodeApi;
import com.viewsh.module.system.api.sms.dto.code.SmsCodeUseReqDTO;
import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO;
import com.viewsh.module.system.api.social.dto.SocialUserRespDTO;
import com.viewsh.module.system.controller.admin.auth.vo.*;
import com.viewsh.module.system.convert.auth.AuthConvert;
import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import com.viewsh.module.system.dal.dataobject.user.AdminUserDO;
import com.viewsh.module.system.enums.logger.LoginLogTypeEnum;
import com.viewsh.module.system.enums.logger.LoginResultEnum;
import com.viewsh.module.system.enums.oauth2.OAuth2ClientConstants;
import com.viewsh.module.system.enums.sms.SmsSceneEnum;
import com.viewsh.module.system.service.logger.LoginLogService;
import com.viewsh.module.system.service.member.MemberService;
import com.viewsh.module.system.service.social.SocialClientService;
import com.viewsh.module.system.service.oauth2.OAuth2TokenService;
import com.viewsh.module.system.service.social.SocialUserService;
import com.viewsh.module.system.service.user.AdminUserService;
import com.anji.captcha.model.common.ResponseModel;
import com.anji.captcha.model.vo.CaptchaVO;
import com.anji.captcha.service.CaptchaService;
import com.google.common.annotations.VisibleForTesting;
import jakarta.annotation.Resource;
import jakarta.validation.Validator;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.hutool.core.lang.Assert;
import com.viewsh.module.system.enums.social.SocialTypeEnum;
import java.util.Objects;
import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.viewsh.framework.common.util.servlet.ServletUtils.getClientIP;
import static com.viewsh.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 SocialClientService socialClientService;
@Resource
private Validator validator;
@Resource
private CaptchaService captchaService;
@Resource
private SmsCodeApi smsCodeApi;
/**
* 验证码的开关,默认为 true
*/
@Value("${viewsh.captcha.enable:true}")
@Setter // 为了单测:开启或者关闭验证码
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
@DataPermission(enable = false)
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 (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) {
ResponseModel response = doValidateCaptcha(reqVO);
if (!response.isSuccess()) {
throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg());
}
}
// 登录场景,验证是否存在
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())).checkError();
// 获得用户信息
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);
}
@Override
@Transactional(rollbackFor = Exception.class)
public AuthLoginRespVO weixinMiniAppLogin(AuthWeixinMiniAppLoginReqVO reqVO) {
// 1. 通过 phoneCode 获取手机号
WxMaPhoneNumberInfo phoneNumberInfo = socialClientService.getWxMaPhoneNumberInfo(
UserTypeEnum.ADMIN.getValue(), reqVO.getPhoneCode());
Assert.notNull(phoneNumberInfo, "获得手机信息失败,结果为空");
String mobile = phoneNumberInfo.getPurePhoneNumber();
// 2. 通过手机号查找管理员
AdminUserDO user = userService.getUserByMobile(mobile);
if (user == null) {
throw exception(AUTH_WEIXIN_MINI_APP_PHONE_NOT_FOUND);
}
if (CommonStatusEnum.isDisable(user.getStatus())) {
throw exception(AUTH_LOGIN_USER_DISABLED);
}
// 3. 通过 loginCode 获取社交用户(含 openid
SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(
UserTypeEnum.ADMIN.getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(),
reqVO.getLoginCode(), reqVO.getState());
Assert.notNull(socialUser, "社交用户不能为空");
// 4. 绑定冲突检测
// 4a. 当前 openid 是否已绑定其他管理员
if (socialUser.getUserId() != null && !socialUser.getUserId().equals(user.getId())) {
throw exception(AUTH_WEIXIN_MINI_APP_WECHAT_BINDTO_OTHER);
}
// 4b. 目标管理员是否已绑定其他微信
SocialUserRespDTO existByUser = socialUserService.getSocialUserByUserId(
UserTypeEnum.ADMIN.getValue(), user.getId(),
SocialTypeEnum.WECHAT_MINI_PROGRAM.getType());
if (existByUser != null && !existByUser.getOpenid().equals(socialUser.getOpenid())) {
throw exception(AUTH_WEIXIN_MINI_APP_BINDTO_OTHER_WECHAT);
}
// 5. 绑定社交用户(无冲突且未绑定时)
if (existByUser == null) {
socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(),
getUserType().getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(),
reqVO.getLoginCode(), reqVO.getState()));
}
// 6. 创建 Token 令牌,记录登录日志
return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL);
}
@VisibleForTesting
void validateCaptcha(AuthLoginReqVO reqVO) {
ResponseModel response = doValidateCaptcha(reqVO);
// 校验验证码
if (!response.isSuccess()) {
// 创建登录失败日志(验证码不正确)
createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR);
throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg());
}
}
private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) {
// 如果验证码关闭,则不进行校验
if (!captchaEnable) {
return ResponseModel.success();
}
ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class);
CaptchaVO captchaVO = new CaptchaVO();
captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification());
return captchaService.verification(captchaVO);
}
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 BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class);
}
@Override
public AuthLoginRespVO refreshToken(String refreshToken) {
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT);
return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class);
}
@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) {
ResponseModel response = doValidateCaptcha(reqVO);
// 验证不通过
if (!response.isSuccess()) {
throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg());
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void resetPassword(AuthResetPasswordReqVO reqVO) {
AdminUserDO userByMobile = userService.getUserByMobile(reqVO.getMobile());
if (userByMobile == null) {
throw exception(USER_MOBILE_NOT_EXISTS);
}
smsCodeApi.useSmsCode(new SmsCodeUseReqDTO()
.setCode(reqVO.getCode())
.setMobile(reqVO.getMobile())
.setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene())
.setUsedIp(getClientIP())
).checkError();
userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword());
}
}
package com.viewsh.module.system.service.auth;
import cn.hutool.core.util.ObjectUtil;
import com.viewsh.framework.common.enums.CommonStatusEnum;
import com.viewsh.framework.common.enums.UserTypeEnum;
import com.viewsh.framework.common.util.monitor.TracerUtils;
import com.viewsh.framework.common.util.object.BeanUtils;
import com.viewsh.framework.common.util.servlet.ServletUtils;
import com.viewsh.framework.common.util.validation.ValidationUtils;
import com.viewsh.framework.web.core.util.WebFrameworkUtils;
import cn.hutool.core.util.StrUtil;
import jakarta.servlet.http.HttpServletRequest;
import com.viewsh.framework.datapermission.core.annotation.DataPermission;
import com.viewsh.module.system.api.logger.dto.LoginLogCreateReqDTO;
import com.viewsh.module.system.api.sms.SmsCodeApi;
import com.viewsh.module.system.api.sms.dto.code.SmsCodeUseReqDTO;
import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO;
import com.viewsh.module.system.api.social.dto.SocialUserRespDTO;
import com.viewsh.module.system.controller.admin.auth.vo.*;
import com.viewsh.module.system.convert.auth.AuthConvert;
import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import com.viewsh.module.system.dal.dataobject.user.AdminUserDO;
import com.viewsh.module.system.enums.logger.LoginLogTypeEnum;
import com.viewsh.module.system.enums.logger.LoginResultEnum;
import com.viewsh.module.system.enums.oauth2.OAuth2ClientConstants;
import com.viewsh.module.system.enums.sms.SmsSceneEnum;
import com.viewsh.module.system.service.logger.LoginLogService;
import com.viewsh.module.system.service.member.MemberService;
import com.viewsh.module.system.service.social.SocialClientService;
import com.viewsh.module.system.service.oauth2.OAuth2TokenService;
import com.viewsh.module.system.service.social.SocialUserService;
import com.viewsh.module.system.service.user.AdminUserService;
import com.anji.captcha.model.common.ResponseModel;
import com.anji.captcha.model.vo.CaptchaVO;
import com.anji.captcha.service.CaptchaService;
import com.google.common.annotations.VisibleForTesting;
import jakarta.annotation.Resource;
import jakarta.validation.Validator;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.hutool.core.lang.Assert;
import com.viewsh.module.system.enums.social.SocialTypeEnum;
import java.util.Objects;
import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.viewsh.framework.common.util.servlet.ServletUtils.getClientIP;
import static com.viewsh.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 SocialClientService socialClientService;
@Resource
private Validator validator;
@Resource
private CaptchaService captchaService;
@Resource
private SmsCodeApi smsCodeApi;
/**
* 验证码的开关,默认为 true
*/
@Value("${viewsh.captcha.enable:true}")
@Setter // 为了单测:开启或者关闭验证码
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
@DataPermission(enable = false)
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 (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) {
ResponseModel response = doValidateCaptcha(reqVO);
if (!response.isSuccess()) {
throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg());
}
}
// 登录场景,验证是否存在
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())).checkError();
// 获得用户信息
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);
}
@Override
@Transactional(rollbackFor = Exception.class)
public AuthLoginRespVO weixinMiniAppLogin(AuthWeixinMiniAppLoginReqVO reqVO) {
// 1. 通过 phoneCode 获取手机号
WxMaPhoneNumberInfo phoneNumberInfo = socialClientService.getWxMaPhoneNumberInfo(
UserTypeEnum.ADMIN.getValue(), reqVO.getPhoneCode());
Assert.notNull(phoneNumberInfo, "获得手机信息失败,结果为空");
String mobile = phoneNumberInfo.getPurePhoneNumber();
// 2. 通过手机号查找管理员
AdminUserDO user = userService.getUserByMobile(mobile);
if (user == null) {
throw exception(AUTH_WEIXIN_MINI_APP_PHONE_NOT_FOUND);
}
if (CommonStatusEnum.isDisable(user.getStatus())) {
throw exception(AUTH_LOGIN_USER_DISABLED);
}
// 3. 通过 loginCode 获取社交用户(含 openid
SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(
UserTypeEnum.ADMIN.getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(),
reqVO.getLoginCode(), reqVO.getState());
Assert.notNull(socialUser, "社交用户不能为空");
// 4. 绑定冲突检测
// 4a. 当前 openid 是否已绑定其他管理员
if (socialUser.getUserId() != null && !socialUser.getUserId().equals(user.getId())) {
throw exception(AUTH_WEIXIN_MINI_APP_WECHAT_BINDTO_OTHER);
}
// 4b. 目标管理员是否已绑定其他微信
SocialUserRespDTO existByUser = socialUserService.getSocialUserByUserId(
UserTypeEnum.ADMIN.getValue(), user.getId(),
SocialTypeEnum.WECHAT_MINI_PROGRAM.getType());
if (existByUser != null && !existByUser.getOpenid().equals(socialUser.getOpenid())) {
throw exception(AUTH_WEIXIN_MINI_APP_BINDTO_OTHER_WECHAT);
}
// 5. 绑定社交用户(无冲突且未绑定时)
if (existByUser == null) {
socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(),
getUserType().getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(),
reqVO.getLoginCode(), reqVO.getState()));
}
// 6. 创建 Token 令牌,记录登录日志
return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL);
}
@VisibleForTesting
void validateCaptcha(AuthLoginReqVO reqVO) {
ResponseModel response = doValidateCaptcha(reqVO);
// 校验验证码
if (!response.isSuccess()) {
// 创建登录失败日志(验证码不正确)
createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR);
throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg());
}
}
private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) {
// 如果验证码关闭,则不进行校验
if (!captchaEnable) {
return ResponseModel.success();
}
ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class);
CaptchaVO captchaVO = new CaptchaVO();
captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification());
return captchaService.verification(captchaVO);
}
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {
// 插入登陆日志
createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
// 创建访问令牌client_id 优先取请求头 X-Client-Id缺省回退 default
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),
resolveClientId(), null);
// 构建返回结果
return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class);
}
@Override
public AuthLoginRespVO refreshToken(String refreshToken) {
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, resolveClientId());
return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class);
}
/**
* 解析当前请求声明的 OAuth2 客户端编号。
*
* 用于密码登录、refresh-token 等"还没有 token 的入口"——前端通过请求头 X-Client-Id 声明自己身份,
* 决定 token 绑定到哪个客户端,进而影响 {@code AuthController#getPermissionInfo} 按 platform 过滤的菜单。
*
* 缺省回退到 {@link OAuth2ClientConstants#CLIENT_ID_DEFAULT}"default"),即原生业务平台客户端;
* 在 DB 层通过 system_oauth2_client.platform='biz' 标识。
*/
private String resolveClientId() {
HttpServletRequest request = WebFrameworkUtils.getRequest();
String clientId = WebFrameworkUtils.getClientId(request);
return StrUtil.isNotBlank(clientId) ? clientId : OAuth2ClientConstants.CLIENT_ID_DEFAULT;
}
@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) {
ResponseModel response = doValidateCaptcha(reqVO);
// 验证不通过
if (!response.isSuccess()) {
throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg());
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void resetPassword(AuthResetPasswordReqVO reqVO) {
AdminUserDO userByMobile = userService.getUserByMobile(reqVO.getMobile());
if (userByMobile == null) {
throw exception(USER_MOBILE_NOT_EXISTS);
}
smsCodeApi.useSmsCode(new SmsCodeUseReqDTO()
.setCode(reqVO.getCode())
.setMobile(reqVO.getMobile())
.setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene())
.setUsedIp(getClientIP())
).checkError();
userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword());
}
}

View File

@@ -67,6 +67,15 @@ public interface MenuService {
*/
List<MenuDO> filterDisableMenus(List<MenuDO> list);
/**
* 按平台标识过滤菜单。保留 platform 为空(全平台共用)或与入参相同的菜单
*
* @param list 菜单列表
* @param platform 平台标识(来自 OAuth2 客户端的 platform 字段)。为空时不过滤,原样返回
* @return 过滤后的菜单列表
*/
List<MenuDO> filterMenusByPlatform(List<MenuDO> list, String platform);
/**
* 筛选菜单列表
*

View File

@@ -136,6 +136,20 @@ public class MenuServiceImpl implements MenuService {
return menus;
}
@Override
public List<MenuDO> filterMenusByPlatform(List<MenuDO> menuList, String platform) {
if (CollUtil.isEmpty(menuList) || StrUtil.isBlank(platform)) {
return menuList;
}
List<MenuDO> result = new ArrayList<>(menuList.size());
for (MenuDO menu : menuList) {
if (StrUtil.isBlank(menu.getPlatform()) || platform.equals(menu.getPlatform())) {
result.add(menu);
}
}
return result;
}
@Override
public List<MenuDO> filterDisableMenus(List<MenuDO> menuList) {
if (CollUtil.isEmpty(menuList)){