# scaffold 项目之用户体系

# 用户体系

系统提供两种用户类型,分别满足后台管理、用户 App 场景。

  1. 后台管理用户,前端访问接口: /admin-api/** RESTful API 接口。
  2. App 用户,前端访问接口: /app-api/** RESTful API 接口。

# 表结构

2 种类型的用户采用不同的存储方式(不同表), 后台管理用户的表是 system_users , app 用户的表则是 member_user , 授权表中通过 user_type 字段来区分

为什么不统一用户还要区分表

确实可以放在同一张表,在用户表添加一个字段 user_type 来区分,但是在实际的项目中,不同类型的用户往往是不同部门开发维护的。

如果表需要关联多个用户类型,列如上述所说的 system_oauth2_access_token 访问令牌表,可以通过 user_type 字段区分,并且 user_type 对应 UserTypeEnum 全局枚举,代码如下:

/**
 * <p> Project: scaffold - UserTypeEnum </p>
 *
 * 全局用户类型枚举
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@AllArgsConstructor
@Getter
public enum UserTypeEnum implements IntArrayValuable {
    /**
     * 面向 c 端,普通用户
     */
    MEMBER(1, "会员"),
    /**
     * 面向 b 端,管理后台
     */
    ADMIN(2, "管理员");
    public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(UserTypeEnum::getValue).toArray();
    /**
     * 类型
     */
    private final Integer value;
    /**
     * 类型名
     */
    private final String name;
    public static UserTypeEnum valueOf(Integer value) {
        return ArrayUtil.firstMatch(userType -> userType.getValue().equals(value), UserTypeEnum.values());
    }
    @Override
    public int[] array() {
        return ARRAYS;
    }
}

# 如何获取当前登陆用户信息

使用 SecurityFrameworkUtils 提供的如下方法,可以获的当前登陆用户信息

# 获取当前用户信息

public static LoginUser getLoginUser()

# 获取当前用户编号(最常用)

public static Long getLoginUserId()

# 账号密码登陆

# 管理后台的实现

使用 username 账号 + password 密码进行登陆,由 AuthController 提供 admin-api/system/auth/login 接口。代码如下:

@PostMapping("/login")
@PermitAll
@Operation(summary = "使用账号密码登录")
@OperateLog(enable = false)
public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {
    return success(authService.login(reqVO));
}
//service:
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);
}

# app 端的实现

使用 username 账号 + password 密码进行登陆,由 AppAuthController 提供 /app-api/member/auth/login 接口。代码如下:

@PostMapping("/login")
@Operation(summary = "使用手机 + 密码登录")
public CommonResult<AppAuthLoginRespVO> login(@RequestBody @Valid AppAuthLoginReqVO reqVO) {
	return success(authService.login(reqVO));
}
    
//service
public AppAuthLoginRespVO login(AppAuthLoginReqVO reqVO) {
    // 使用手机 + 密码,进行登录。
    MemberUserDO user = login0(reqVO.getMobile(), reqVO.getPassword());
    // 如果 socialType 非空,说明需要绑定社交用户
    String openid = null;
    if (reqVO.getSocialType() != null) {
        openid = socialUserApi.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(),
                                                                       reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState()));
    }
    // 创建 Token 令牌,记录登录日志
    return createTokenAfterLoginSuccess(user, reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE, openid);
}

# 手机验证码登陆

# 管理后台的实现

  1. 使用 mobile 手机号获取验证码,由 AuthController 提供 admin-api/system/auth/send-sms-code 接口,获取验证码。代码如下:
@PostMapping("/send-sms-code")
@PermitAll
@Operation(summary = "发送手机验证码")
@OperateLog(enable = false)
public CommonResult<Boolean> sendLoginSmsCode(@RequestBody @Valid AuthSmsSendReqVO reqVO) {
    authService.sendSmsCode(reqVO);
    return success(true);
}
//service
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()));
}
  1. 使用手机号加 验证码 登陆,由 AuthController 提供 admin-api/system/auth/sms-login 接口登陆。代码如下:
@PostMapping("/sms-login")
 @PermitAll
 @Operation(summary = "使用短信验证码登录")
 @OperateLog(enable = false)
     public CommonResult<AuthLoginRespVO> smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) {
     return success(authService.smsLogin(reqVO));
 }
 
 //service
 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);
}

# app 端实现

  1. 使用 mobile 手机号获取验证码,由 AppAuthController 提供 admin-api/system/auth/send-sms-code 接口,获取验证码。代码如下:
@PostMapping("/send-sms-code")
@Operation(summary = "发送手机验证码")
public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AppAuthSmsSendReqVO reqVO) {
    authService.sendSmsCode(getLoginUserId(), reqVO);
    return success(true);
}
//service
public void sendSmsCode(Long userId, AppAuthSmsSendReqVO reqVO) {
    // 情况 1:如果是修改手机场景,需要校验新手机号是否已经注册,说明不能使用该手机了
    if (Objects.equals(reqVO.getScene(), SmsSceneEnum.MEMBER_UPDATE_MOBILE.getScene())) {
        MemberUserDO user = userService.getUserByMobile(reqVO.getMobile());
        if (user != null && !Objects.equals(user.getId(), userId)) {
            throw exception(AUTH_MOBILE_USED);
        }
    }
    // 情况 2:如果是重置密码场景,需要校验手机号是存在的
    if (Objects.equals(reqVO.getScene(), SmsSceneEnum.MEMBER_RESET_PASSWORD.getScene())) {
        MemberUserDO  user= userService.getUserByMobile(reqVO.getMobile());
        if (user == null) {
            throw exception(USER_MOBILE_NOT_EXISTS);
        }
    }
    // 执行发送
    smsCodeApi.sendSmsCode(AuthConvert.INSTANCE.convert(reqVO).setCreateIp(getClientIP()));
}
  1. 使用手机号加 验证码 登陆,由 AppAuthController 提供 admin-api/system/auth/sms-login 接口登陆。代码如下:
@PostMapping("/sms-login")
@Operation(summary = "使用手机 + 验证码登录")
public CommonResult<AppAuthLoginRespVO> smsLogin(@RequestBody @Valid AppAuthSmsLoginReqVO reqVO,
                                                 @RequestHeader Integer terminal) {
    return success(authService.smsLogin(reqVO, terminal));
}
    
//service
@Override
@Transactional
public AppAuthLoginRespVO smsLogin(AppAuthSmsLoginReqVO reqVO, Integer terminal) {
    // 校验验证码
    String userIp = getClientIP();
    smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.MEMBER_LOGIN.getScene(), userIp));
    // 获得获得注册用户
    MemberUserDO user = userService.createUserIfAbsent(reqVO.getMobile(), userIp, terminal);
    Assert.notNull(user, "获取用户失败,结果为空");
    // 如果 socialType 非空,说明需要绑定社交用户
    String openid = null;
    if (reqVO.getSocialType() != null) {
        openid = socialUserApi.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(),
                                                                       reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState()));
    }
    // 创建 Token 令牌,记录登录日志
    return createTokenAfterLoginSuccess(user, reqVO.getMobile(), LoginLogTypeEnum.LOGIN_SMS, openid);
}

如果用户未注册,会自动使用手机号进行注册会员。所以, admin-api/system/auth/sms-login 也提供了用户的注册功能。

# 三方登陆

详细参考文章

# 管理后台的实现

  1. 跳转第三方平台,来获取第三方授权码, 由 AuthController 提供 admin-api/system/auth/social-auth-redirect 接口,代码如下:
@GetMapping("/social-auth-redirect")
@PermitAll
@Operation(summary = "社交授权的跳转")
@Parameters({
    @Parameter(name = "type", description = "社交类型", required = true),
    @Parameter(name = "redirectUri", description = "回调路径")
})
public CommonResult<String> socialLogin(@RequestParam("type") Integer type,
                                        @RequestParam("redirectUri") String redirectUri) {
    return success(socialClientService.getAuthorizeUrl(
        type, UserTypeEnum.ADMIN.getValue(), redirectUri));
}
//service
@Override
public String getAuthorizeUrl(Integer socialType, Integer userType, String redirectUri) {
    // 获得对应的 AuthRequest 实现
    AuthRequest authRequest = buildAuthRequest(socialType, userType);
    // 生成跳转地址
    String authorizeUri = authRequest.authorize(AuthStateUtils.createState());
    return HttpUtils.replaceUrlQuery(authorizeUri, "redirect_uri", redirectUri);
}
  1. 使用 code 第三方授权进行登陆,由 AuthController 提供 admin-api/system/auth/social-login 接口,代码如下:
@PostMapping("/social-login")
@PermitAll
@Operation(summary = "社交快捷登录,使用 code 授权码", description = "适合未登录的用户,但是社交账号已绑定用户")
@OperateLog(enable = false)
public CommonResult<AuthLoginRespVO> socialQuickLogin(@RequestBody @Valid AuthSocialLoginReqVO reqVO) {
    return success(authService.socialLogin(reqVO));
}
//service
@Override
public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) {
    // 使用 code 授权码,进行登录。然后,获得到绑定的用户编号
    SocialUserRespDTO socialUser = socialUserService.getSocialUser(UserTypeEnum.ADMIN.getValue(), reqVO.getType(),
                                                                   reqVO.getCode(), reqVO.getState());
    if (socialUser == 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);
}
  1. 使用 social 三方授权码 + username + password 进行绑定登陆,直接使用 admin-api/system/auth/login 账号密码登陆的接口,区别在于额外添加了 socialType + socialCode + socialState 参数。

# 用户 App 的实现

  1. 跳转第三方平台,来获取第三方授权码, 由 AppAuthController 提供 app-api/member/auth/social-auth-redirect 接口,代码如下:
@GetMapping("/social-auth-redirect")
@Operation(summary = "社交授权的跳转")
@Parameters({
    @Parameter(name = "type", description = "社交类型", required = true),
    @Parameter(name = "redirectUri", description = "回调路径")
})
public CommonResult<String> socialAuthRedirect(@RequestParam("type") Integer type,
                                               @RequestParam("redirectUri") String redirectUri) {
    return CommonResult.success(authService.getSocialAuthorizeUrl(type, redirectUri));
}
//service
@Override
public String getSocialAuthorizeUrl(Integer type, String redirectUri) {
    return socialClientApi.getAuthorizeUrl(type, UserTypeEnum.MEMBER.getValue(), redirectUri);
}
  1. 使用 code 第三方授权进行登陆,由 AppAuthController 提供 app-api/member/auth/social-login 接口,代码如下:
@PostMapping("/social-login")
@Operation(summary = "社交快捷登录,使用 code 授权码", description = "适合未登录的用户,但是社交账号已绑定用户")
public CommonResult<AppAuthLoginRespVO> socialLogin(@RequestBody @Valid AppAuthSocialLoginReqVO reqVO) {
    return success(authService.socialLogin(reqVO));
}
//service
@Override
public AppAuthLoginRespVO socialLogin(AppAuthSocialLoginReqVO reqVO) {
    // 使用 code 授权码,进行登录。然后,获得到绑定的用户编号
    SocialUserRespDTO socialUser = socialUserApi.getSocialUser(UserTypeEnum.MEMBER.getValue(), reqVO.getType(),
                                                               reqVO.getCode(), reqVO.getState());
    if (socialUser == null) {
        throw exception(AUTH_THIRD_LOGIN_NOT_BIND);
    }
    // 自动登录
    MemberUserDO user = userService.getUser(socialUser.getUserId());
    if (user == null) {
        throw exception(USER_NOT_EXISTS);
    }
    // 创建 Token 令牌,记录登录日志
    return createTokenAfterLoginSuccess(user, user.getMobile(), LoginLogTypeEnum.LOGIN_SOCIAL, socialUser.getOpenid());
}
  1. 使用 social 三方授权码 + username + password 进行绑定登陆,直接使用 admin-api/system/auth/login 账号密码登陆的接口,区别在于额外添加了 socialType + socialCode + socialState 参数。

# 注册

# 管理后台的实现

管理后台不支持用户在页面上注册,而是通过在 [系统管理 -> 用户管理] 菜单,进行添加新用户,由 UserController 提供 /admin-api/system/user/create 接口。代码如下:

@PostMapping("/create")
@Operation(summary = "新增用户")
@PreAuthorize("@ss.hasPermission('system:user:create')")
public CommonResult<Long> createUser(@Valid @RequestBody UserCreateReqVO reqVO) {
    Long id = userService.createUser(reqVO);
    return success(id);
}
//service
@Override
@Transactional(rollbackFor = Exception.class)
public Long createUser(UserCreateReqVO reqVO) {
    // 校验账户配合
    tenantService.handleTenantInfo(tenant -> {
        long count = userMapper.selectCount();
        if (count >= tenant.getAccountCount()) {
            throw exception(USER_COUNT_MAX, tenant.getAccountCount());
        }
    });
    // 校验正确性
    validateUserForCreateOrUpdate(null, reqVO.getUsername(), reqVO.getMobile(), reqVO.getEmail(),
                                  reqVO.getDeptId(), reqVO.getPostIds());
    // 插入用户
    AdminUserDO user = UserConvert.INSTANCE.convert(reqVO);
    // 默认开启
    user.setStatus(CommonStatusEnum.ENABLE.getStatus());
    // 加密密码
    user.setPassword(encodePassword(reqVO.getPassword()));
    userMapper.insert(user);
    // 插入关联岗位
    if (CollectionUtil.isNotEmpty(user.getPostIds())) {
        userPostMapper.insertBatch(convertList(user.getPostIds(),
                                               postId -> new UserPostDO().setUserId(user.getId()).setPostId(postId)));
    }
    return user.getId();
}

# 用户 App 实现

手机验证码登陆时候,如果用户未注册,会自动使用手机号进行注册会员。所以, admin-api/system/auth/sms-login 也提供了用户的注册功能。

# 用户登出

用户登出功能,统一使用 Spring Security 框架,通过删除用户 token 的方式来实现删除。代码如下:

//admin
@PostMapping("/logout")
@PermitAll
@Operation(summary = "登出系统")
@OperateLog(enable = false)
public CommonResult<Boolean> logout(HttpServletRequest request) {
    String token = SecurityFrameworkUtils.obtainAuthorization(request,
                                                              securityProperties.getTokenHeader(), securityProperties.getTokenParameter());
    if (StrUtil.isNotBlank(token)) {
        authService.logout(token, LoginLogTypeEnum.LOGOUT_SELF.getType());
    }
    return success(true);
}
//service
@Override
public void logout(String token, Integer logType) {
    // 删除访问令牌
    OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token);
    if (accessTokenDO == null) {
        return;
    }
    // 删除成功,则记录登出日志
    createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType);
}
//app
@PostMapping("/logout")
@PermitAll
@Operation(summary = "登出系统")
public CommonResult<Boolean> logout(HttpServletRequest request) {
    String token = SecurityFrameworkUtils.obtainAuthorization(request,
                                                              securityProperties.getTokenHeader(), securityProperties.getTokenParameter());
    if (StrUtil.isNotBlank(token)) {
        authService.logout(token);
    }
    return success(true);
}
//service
@Override
public void logout(String token) {
    // 删除访问令牌
    OAuth2AccessTokenRespDTO accessTokenRespDTO = oauth2TokenApi.removeAccessToken(token);
    if (accessTokenRespDTO == null) {
        return;
    }
    // 删除成功,则记录登出日志
    createLogoutLog(accessTokenRespDTO.getUserId());
}

# 扩展

  1. 是否允许用户多个设备登陆的配置
  2. 多台设备登陆的情况下最多允许多少台设备的配置