--- title: "08-业务讲解-用户登录和退出流程解析" created: 2025-12-12 aliases: - 业务讲解-用户登录和退出流程解析 tags: - 项目 --- # 业务讲解-用户登录和退出流程解析 ## 登录 本文重点讲解关于通过手机号或者邮箱的登录方式,如图: ![[image-42191f7d.webp]] ### 说明 由于将用户表进行了分库分表的操作,在其他操作中,比如查询用户信息,修改用户信息,订单的用户信息等都是是用的userId查询的 这就产生了问题,由于登录支持使用手机号和邮箱登录,那么进行分库分表的分片键要怎么选择呢?上述中提到了很多的都是靠userId作为条件的,那么还是要以userId作为分片键,但是手机号和邮箱怎么处理?为了解决这个问题,我们使用了互联网公司常用的附属表的方案,另外设计了用户手机表和用户邮箱表,这样先用手机号和邮箱查询到userId,然后userId再去操作用户表,这样就解决了分片建的问题。 关于此问题的详细介绍,可查看用户的分库分表相关文档 [[01-分库分表-用户服务-用户表|分库分表-用户服务-用户表]] ### 流程 模块: damai-user-service 入参 ```java @Data @ApiModel(value="UserLoginDto", description ="用户登录") public class UserLoginDto { @ApiModelProperty(name ="code", dataType ="String", value ="渠道code 0001:pc网站", required = true) @NotBlank private String code; @ApiModelProperty(name ="name", dataType ="String", value ="用户手机号") private String mobile; @ApiModelProperty(name ="email", dataType ="String", value ="用户邮箱") private String email; @ApiModelProperty(name ="password", dataType ="String", value ="密码", required = true) @NotBlank private String password; } ``` com.damai.service.UserService#login ```java /** * 登录 * @param userLoginDto 登录入参 * @return 用户信息 * */ public UserLoginVo login(UserLoginDto userLoginDto) { UserLoginVo userLoginVo = new UserLoginVo(); String code = userLoginDto.getCode(); String mobile = userLoginDto.getMobile(); String email = userLoginDto.getEmail(); String password = userLoginDto.getPassword(); //如果手机号和邮箱同时不存在,那么直接抛出异常 if (StringUtil.isEmpty(mobile) && StringUtil.isEmpty(email)) { throw new DaMaiFrameException(BaseCode.USER_MOBILE_AND_EMAIL_NOT_EXIST); } Long userId; if (StringUtil.isNotEmpty(mobile)) { //检查输入的手机号是否达到输入错误次数限制 String errorCountStr = redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR, mobile), String.class); //如果达到限制的阈值则不再往下执行 if (StringUtil.isNotEmpty(errorCountStr) && Integer.parseInt(errorCountStr) >= ERROR_COUNT_THRESHOLD) { throw new DaMaiFrameException(BaseCode.MOBILE_ERROR_COUNT_TOO_MANY); } //如果手机号存在,则用手机号查询用户id LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(UserMobile.class) .eq(UserMobile::getMobile, mobile); UserMobile userMobile = userMobileMapper.selectOne(queryWrapper); if (Objects.isNull(userMobile)) { //如果查询手机号不存在,则放入redis中将手机号输入错误的计数器加1 redisCache.incrBy(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR,mobile),1); redisCache.expire(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR,mobile),1,TimeUnit.MINUTES); throw new DaMaiFrameException(BaseCode.USER_MOBILE_EMPTY); } userId = userMobile.getUserId(); }else { //检查输入的邮箱是否达到输入错误次数限制 String errorCountStr = redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR, email), String.class); //如果达到限制的阈值则不再往下执行 if (StringUtil.isNotEmpty(errorCountStr) && Integer.parseInt(errorCountStr) >= ERROR_COUNT_THRESHOLD) { throw new DaMaiFrameException(BaseCode.EMAIL_ERROR_COUNT_TOO_MANY); } //用邮箱查询用户id LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(UserEmail.class) .eq(UserEmail::getEmail, email); UserEmail userEmail = userEmailMapper.selectOne(queryWrapper); if (Objects.isNull(userEmail)) { //如果查询手机号不存在,则放入redis中将手机号输入错误的计数器加1 redisCache.incrBy(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR,email),1); redisCache.expire(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR,email),1,TimeUnit.MINUTES); throw new DaMaiFrameException(BaseCode.USER_EMAIL_NOT_EXIST); } userId = userEmail.getUserId(); } //从库中查询用户 LambdaQueryWrapper queryUserWrapper = Wrappers.lambdaQuery(User.class) .eq(User::getId, userId).eq(User::getPassword, password); User user = userMapper.selectOne(queryUserWrapper); //用户不存在,抛出异常 if (Objects.isNull(user)) { throw new DaMaiFrameException(BaseCode.NAME_PASSWORD_ERROR); } //将用户信息放到缓存中 redisCache.set(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN,code,user.getId()),user, tokenExpireTime,TimeUnit.MINUTES); userLoginVo.setUserId(userId); //生成token userLoginVo.setToken(createToken(user.getId(),getChannelDataByCode(code).getTokenSecret())); return userLoginVo; } ``` 登录逻辑很简单,每个手机号都有一个错误次数上限,不能用一个不存在的手机号一直尝试登陆。校验完手机号没有超过错误上限,且确实存在改手机号和用户,就验证密码。密码是对的就登录成功了,返回一个token给前端,作为登录凭证。 这里应该需要防止缓存雪崩和穿透吧,大量的错误手机号请求来登录,数据库不就崩了么,验证次数那里,要验证密码输入错误次数 穿透逻辑应该和注册逻辑一样 通过code查询token秘钥 ```java private GetChannelDataVo getChannelDataByCode(String code){ //从redis查询 GetChannelDataVo channelDataVo = getChannelDataByRedis(code); if (Objects.isNull(channelDataVo)) { //调用base-data服务查询 channelDataVo = getChannelDataByClient(code); //放到redis中 setChannelDataRedis(code,channelDataVo); } return channelDataVo; } private GetChannelDataVo getChannelDataByRedis(String code){ return redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.CHANNEL_DATA,code),GetChannelDataVo.class); } private GetChannelDataVo getChannelDataByClient(String code){ GetChannelDataByCodeDto getChannelDataByCodeDto = new GetChannelDataByCodeDto(); getChannelDataByCodeDto.setCode(code); ApiResponse getChannelDataApiResponse = baseDataClient.getByCode(getChannelDataByCodeDto); if (Objects.equals(getChannelDataApiResponse.getCode(), BaseCode.SUCCESS.getCode())) { return getChannelDataApiResponse.getData(); } throw new DaMaiFrameException("没有找到ChannelData"); } ``` 生成token ```java public String createToken(Long userId,String tokenSecret){ Map map = new HashMap<>(4); map.put("userId",userId); return TokenUtil.createToken(String.valueOf(uidGenerator.getUid()), JSON.toJSONString(map),tokenExpireTime * 60 * 1000,tokenSecret); } ``` #### 总结 - 如果手机号存在,则去用户手机表查询用户id - 如果邮箱存在,则去用户邮箱表查询用户i - 然后根据手机号或者邮箱判断是否达到输入次数错误的限制,如果达到则锁定该用户1分钟不允许登录 - 根据用户id,去用户表中查询用户信息 - 将用户信息放入到缓存中作为登录信息,并根据token.expire.time配置来设置过期时间,默认是40分 - 根据code查询到token秘钥 - 根据用户id,token秘钥来生成token,过期时间和token.expire.time配置的相同 - 将数据返回给前端 ### token工具类 生成token的工具类,用于token的生成和解析,并且提供了生成和解析的示例 模块:damai-common com.damai.jwt.TokenUtil ```java @Slf4j public class TokenUtil { /** * 指定签名的时候使用的签名算法,也就是header那部分。 * */ private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256; /** * 用户登录成功后生成Jwt * 使用Hs256算法 * * @param id 标识 * @param info 登录成功的user对象 * @param ttlMillis jwt过期时间 * @param tokenSecret 私钥 * @return */ public static String createToken(String id, String info, long ttlMillis, String tokenSecret) { //生成JWT的时间 long nowMillis = System.currentTimeMillis(); //创建一个JwtBuilder,设置jwt的body JwtBuilder builder = Jwts.builder() //如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的 // .setClaims(claims) //设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。 .setId(id) //iat: jwt的签发时间 .setIssuedAt(new Date(nowMillis)) //代表这个JWT的主体,即它的所有人,这个是一个json格式的字符串。 .setSubject(info) //设置签名使用的签名算法和签名使用的秘钥 .signWith(SIGNATURE_ALGORITHM, tokenSecret); if (ttlMillis >= 0) { //设置过期时间 builder.setExpiration(new Date(nowMillis + ttlMillis)); } return builder.compact(); } /** * Token的解密 * * @param token 加密后的token * @param tokenSecret 私钥 * @return */ public static String parseToken(String token, String tokenSecret) { try { return Jwts.parser() //设置签名的秘钥 .setSigningKey(tokenSecret) //设置需要解析的jwt .parseClaimsJws(token) .getBody() .getSubject(); }catch (ExpiredJwtException jwtException) { log.error("parseToken error",jwtException); throw new DaMaiFrameException(BaseCode.TOKEN_EXPIRE); } } public static void main(String[] args) { String tokenSecret = "CSYZWECHAT"; //生成token的实力 JSONObject jsonObject = new JSONObject(); jsonObject.put("001key", "001value"); jsonObject.put("002key", "001value"); String token1 = TokenUtil.createToken("1", jsonObject.toJSONString(), 10000, tokenSecret); System.out.println("token:" + token1); //解析token的示例 String token2 = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxIiwiaWF0IjoxNjg4NTQyODM3LCJzdWIiOiJ7XCIwMDJrZXlcIjpcIjAwMXZhbHVlXCIsXCIwMDFrZXlcIjpcIjAwMXZhbHVlXCJ9IiwiZXhwIjoxNjg4NTQyODQ3fQ.vIKcAilTn_CR3VYssNE7rBpfuCSCH_RrkmsadLWf664"; String subject = TokenUtil.parseToken(token2, tokenSecret); System.out.println("解析token后的值:" + subject); } } ``` ## 退出 入参 ```java @Data @ApiModel(value="UserLoginDto", description ="用户退出登录") public class UserLogoutDto { @ApiModelProperty(name ="code", dataType ="String", value ="渠道code 0001:pc网站", required = true) @NotBlank private String code; @ApiModelProperty(name ="id", dataType ="Long", value ="用户id", required =true) @NotNull private Long id; } ``` com.damai.service.UserService#logout ```java public void logout(UserLogoutDto userLogoutDto) { User user = userMapper.selectById(userLogoutDto.getId()); if (Objects.isNull(user)) { throw new DaMaiFrameException(BaseCode.USER_EMPTY); } redisCache.del(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN,userLogoutDto.getCode(),user.getId())); } ``` 退出登录的逻辑比较简单,根据用户id和code删除缓存的用户信息 疑问:这里用了jwt还要存入redis的原因只是为了服务端这边对用户登录状态的可见吗? (1)登录跟注册在特性和风险上是不一样的,所以它们采用的“防刷”手段也不一样: - 注册(Register)面向的是未知用户、新手机号/邮箱,完全开放给外网 - 要防止恶意批量注册、缓存穿透、缓存击穿,因为攻击者可以随便拿一大批手机/邮箱撞库。 - 因此大麦在注册环节: - 全局 Lua+Redis 限流 → 判断「是否需要图形验证码」。 - JVM 本地秒级限流 → 把剩余的洪峰也拦在本机,避免打穿后端 Redis/DB。 - 真正执行「是否已注册」校验时,先用布隆过滤器粗筛(避免数据库),再回落到数据库做 100% 准确性验证。 - 登录(Login)面向的是已有用户,调用模式不同 - 用户登录请求是「小范围、可预期」的:只有已经注册过的手机号/邮箱才会发起登录。 - 大麦只需要在登录失败时,防止短时间内密码爆破: - 对单个手机号或邮箱建立 Redis 计数器(expire=1分钟),超过阈值就拒绝,再也不用打数据库。 总结: 注册:高风险、未知 key、全局暴力撞号 → 用全局+本地双层、Bloom + DB 二次校验来保安全。 登录:低风险、已知 key、只需防暴力破解 → 用「每手机号/邮箱秒级计数器」做限速即可。 (2)当前登录逻辑问题? - 只有“用户不存在”才计数 - 真实的「密码输错」分支里是没有 incrBy 的。 - 如果手机号/邮箱查到了,接下来正确性验证(密码对不对)只会 throw new DaMaiFrameException(BaseCode.NAME\_PASSWORD\_ERROR),并不增加错误计数。 - 也就是说,你输对了一个已注册的手机号但密码错 throwNAMEPASSWORDERRORthrow NAME\_PASSWORD\_ERRORthrowNAMEPASSWORDERROR,永远不会被计入那 5 次阈值,只会一直提示「用户名或密码错误」。 - 暴力破解:攻击者只要用一个合法手机号,不断尝试不同密码,就永远不会触发你那段“incrBy+TTL”逻辑,也就不会被限流或者短暂锁定。 - 字典攻击:同理可以用常见密码列表,撞库式试密码,完全没有任何限制。 (3)改进方案 - 密码错误也要计数并短时锁定 - 密码错误到一定次数后,强制冷静期或启用图形验证码 - 登录成功要清零计数 ```text // 5.将用户信息放到缓存中 redisCache.set( RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN,code,user.getId()) , user , tokenExpireTime , TimeUnit.MINUTES ); ``` ![[image-35f87ab2.webp]] 用户缓存也没有续期的逻辑,其它地方也是直接缓存读,没有重建流程, 可以加一个每次操作都刷新缓存的逻辑 但其实不这么做也行,因为如果token被劫取了,劫取人就可以无限制使用了。 --- **企业级项目导航**:⬅️ [[07-业务讲解-用户注册-使用组合模式处理复杂的验证功能|07-业务讲解-用户注册-使用组合模式处理复杂的验证功能]] | 08-业务讲解-用户登录和退出流程解析 | ➡️ [[09-业务讲解-用户私密信息如何加密存储|09-业务讲解-用户私密信息如何加密存储]]