weblog/doc/5、整合 SaToken 实现 JWT 登录功能/5.11 用户注册登录接口开发(1).md
2025-02-17 10:05:44 +08:00

14 KiB
Raw Blame History

本小节开始,正式进入到小哈书的用户注册/登录功能开发工作。

流程分析

小伙伴们打开小红书 APP观察一下就会发现小红书其实是没有用户注册页的。即使是新用户也可以直接通过手机号验证码登录成功非常方便新用户登录后系统会自动根据你的手机号帮你把账号注册好。那么我们来理一下登录接口的实现逻辑大致如下图所示

接口定义

接口的业务逻辑分析完毕后,接下来,定义一下此接口的请求地址、入参,以及出参。

接口地址

POST /user/login

入参

{
    "phone": "18011119108", // 手机号
    "code": "218603", // 登录验证码,验证码登录时,需要填写
    "password": "xx", // 密码登录时,需要填写
    "type": 1 // 登录类型1表示手机号验证码登录2表示账号密码登录
}

出参

{
	"success": true,
	"message": null,
	"errorCode": null,
	"data": "xxxxx" // 登录成功后,返回 Token 令牌
}

权限数据准备

由于现在还没有管理后台,我们先把一些的基础权限数据,初始化到数据库中,如普通用户的角色-权限数据。

权限数据

INSERT INTO `xiaohashu`.`t_permission` (`id`, `parent_id`, `name`, `type`, `menu_url`, `menu_icon`, `sort`, `permission_key`, `status`, `create_time`, `update_time`, `is_deleted`) VALUES (1, 0, '发布笔记', 3, '', '', 1, 'app:note:publish', 0, now(), now(), b'0');
INSERT INTO `xiaohashu`.`t_permission` (`id`, `parent_id`, `name`, `type`, `menu_url`, `menu_icon`, `sort`, `permission_key`, `status`, `create_time`, `update_time`, `is_deleted`) VALUES (2, 0, '发布评论', 3, '', '', 2, 'app:comment:publish', 0, now(), now(), b'0');

先新增两条权限:

  • 发布笔记;
  • 发布评论

后续如果还有别的权限需要控制,到时候咱们再添加。

角色数据

INSERT INTO `xiaohashu`.`t_role` (`id`, `role_name`, `role_key`, `status`, `sort`, `remark`, `create_time`, `update_time`, `is_deleted`) VALUES (1, '普通用户', 'common_user', 0, 1, '', now(), now(), b'0');

新增一个普通用户角色。

关联数据

然后是该角色与权限的关联关系:

INSERT INTO `xiaohashu`.`t_role_permission_rel` (`id`, `role_id`, `permission_id`, `create_time`, `update_time`, `is_deleted`) VALUES (1, 1, 1, now(), now(), b'0');
INSERT INTO `xiaohashu`.`t_role_permission_rel` (`id`, `role_id`, `permission_id`, `create_time`, `update_time`, `is_deleted`) VALUES (2, 1, 2, now(), now(), b'0');

删除无用的测试类

开始进入编码工作。先将之前测试用的类全部删除掉,如下图标注的这些:

重新生成 DO 、Mapper 接口、XML 文件

重新通过 MyBatis 代码生成器,生成 t_user 表的 DO 实体类、Mapper 接口,以及 XML 文件。生成完毕后,编辑 UserDO 实体类, 为其添加上 Lombok 注解,以及相关字段类型修正,代码如下;

package com.quanxiaoha.xiaohashu.auth.domain.dataobject;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserDO {
    private Long id;

    private String xiaohashuId;

    private String password;

    private String nickname;

    private String avatar;

    private LocalDateTime birthday;

    private String backgroundImg;

    private String phone;

    private Integer sex;

    private Integer status;

    private String introduction;

    private LocalDateTime createTime;

    private LocalDateTime updateTime;

    private Boolean isDeleted;
}

入参 VO

接着,创建登录接口的入参 VO 类,在 /vo 包下,新增 /user 包,并创建 UserLoginReqVO 入参实体类:

代码如下:

package com.quanxiaoha.xiaohashu.auth.model.vo.user;

import com.quanxiaoha.framework.common.validator.PhoneNumber;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @author: 犬小哈
 * @date: 2024/4/7 15:17
 * @version: v1.0.0
 * @description: 用户登录(支持密码或验证码两种方式)
 **/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserLoginReqVO {

    /**
     * 手机号
     */
    @NotBlank(message = "手机号不能为空")
    @PhoneNumber
    private String phone;

    /**
     * 验证码
     */
    private String code;

    /**
     * 密码
     */
    private String password;

    /**
     * 登录类型:手机号验证码,或者是账号密码
     */
    @NotNull(message = "登录类型不能为空")
    private Integer type;
}

登录类型枚举类

在接口的入参类中,定义了一个 type 字段,用于表示登录类型。这里我们创建一个枚举类,方便后续获取 type 值,代码如下:

package com.quanxiaoha.xiaohashu.auth.enums;

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.Objects;

/**
 * @author: 犬小哈
 * @url: www.quanxiaoha.com
 * @date: 2023-08-15 10:33
 * @description: 登录类型
 **/
@Getter
@AllArgsConstructor
public enum LoginTypeEnum {
    // 验证码
    VERIFICATION_CODE(1),
    // 密码
    PASSWORD(2);

    private final Integer value;

    public static LoginTypeEnum valueOf(Integer code) {
        for (LoginTypeEnum loginTypeEnum : LoginTypeEnum.values()) {
            if (Objects.equals(code, loginTypeEnum.getValue())) {
                return loginTypeEnum;
            }
        }
        return null;
    }

}

验证码错误状态码

接着,在 ResponseCodeEnum 全局业务状态码枚举类中,新增一个验证码错误的枚举值,代码如下,等会在业务层中,如果用户提交的验证码与保存在 Redis 中的验证码不一致,则抛出该错误提示信息:

package com.quanxiaoha.xiaohashu.auth.enums;

import com.quanxiaoha.framework.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {

	// 省略...
	
    // ----------- 业务异常状态码 -----------
	// 省略...
    VERIFICATION_CODE_ERROR("AUTH-20001", "验证码错误"),
    ;

    // 省略...

}

mapper 查询方法

另外,编辑 UserDOMapper 接口,定义一个根据手机号查询记录的方法,业务层等会判断用户是否已经注册,需要用到此方法,代码如下、:

package com.quanxiaoha.xiaohashu.auth.domain.mapper;

import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;

public interface UserDOMapper {

	// 省略...

    /**
     * 根据手机号查询记录
     * @param phone
     * @return
     */
    UserDO selectByPhone(String phone);

	// 省略...
}

方法声明完毕后,编辑 UserDOMapper.xml 文件,编写对应的 sql , 代码如下:

  // 省略...
  
  <select id="selectByPhone" resultMap="BaseResultMap">
    select id, password from t_user where phone = #{phone}
  </select>
  
  // 省略...

编写 service 业务层

前置工作都完成后,在 /service 包下,创建 UserService 业务接口,并声明一个登录与注册的方法:

package com.quanxiaoha.xiaohashu.auth.service;

import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;

/**
 * @author: 犬小哈
 * @date: 2024/4/7 15:41
 * @version: v1.0.0
 * @description: TODO
 **/
public interface UserService {

    /**
     * 登录与注册
     * @param userLoginReqVO
     * @return
     */
    Response<String> loginAndRegister(UserLoginReqVO userLoginReqVO);
}

然后,在 /impl 包下,创建其实现类,代码如下:

package com.quanxiaoha.xiaohashu.auth.service.impl;

import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.auth.enums.LoginTypeEnum;
import com.quanxiaoha.xiaohashu.auth.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
import com.quanxiaoha.xiaohashu.auth.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Objects;

/**
 * @author: 犬小哈
 * @date: 2024/4/7 15:41
 * @version: v1.0.0
 * @description: TODO
 **/
@Service
@Slf4j
public class UserServiceImpl implements UserService {

    @Resource
    private UserDOMapper userDOMapper;
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 登录与注册
     *
     * @param userLoginReqVO
     * @return
     */
    @Override
    public Response<String> loginAndRegister(UserLoginReqVO userLoginReqVO) {
        String phone = userLoginReqVO.getPhone();
        Integer type = userLoginReqVO.getType();

        LoginTypeEnum loginTypeEnum = LoginTypeEnum.valueOf(type);

        Long userId = null;

        // 判断登录类型
        switch (loginTypeEnum) {
            case VERIFICATION_CODE: // 验证码登录
                String verificationCode = userLoginReqVO.getCode();

                // 校验入参验证码是否为空
                if (StringUtils.isBlank(verificationCode)) {
                    return Response.fail(ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode(), "验证码不能为空");
                }

                // 构建验证码 Redis Key
                String key = RedisKeyConstants.buildVerificationCodeKey(phone);
                // 查询存储在 Redis 中该用户的登录验证码
                String sentCode = (String) redisTemplate.opsForValue().get(key);

                // 判断用户提交的验证码,与 Redis 中的验证码是否一致
                if (!StringUtils.equals(verificationCode, sentCode)) {
                    throw new BizException(ResponseCodeEnum.VERIFICATION_CODE_ERROR);
                }

                // 通过手机号查询记录
                UserDO userDO = userDOMapper.selectByPhone(phone);

                log.info("==> 用户是否注册, phone: {}, userDO: {}", phone, JsonUtils.toJsonString(userDO));

                // 判断是否注册
                if (Objects.isNull(userDO)) {
                    // 若此用户还没有注册,系统自动注册该用户
                    // todo
                    
                } else {
                	// 已注册,则获取其用户 ID
                    userId = userDO.getId();
                }
                break;
            case PASSWORD: // 密码登录
                // todo

                break;
            default:
                break;
        }

        // SaToken 登录用户,并返回 token 令牌
        // todo

        return Response.success("");
    }

}

解释一波业务逻辑:

  • 拿到入参实体类中的 type 字段,通过 LoginTypeEnum.valueOf() 方法,获取具体的类型枚举值;
  • 对枚举进行 switch 判断,若是手机号验证码登录;
    • 获取提交上来的验证码,并与存储在 Redis 中的验证码进行比对;
    • 若不一致,返回验证码错误提示信息;
    • 否则,通过手机号查询数据库;
    • userDO 为空,说明是新用户,系统需要自动为该用户注册用户信息。这里代码块中,先写个 todo , 后面小节中,再写具体的逻辑;
    • userDO 不为空,则说明是老用户,获取其用户 ID;
  • 若是账号密码登录,校验密码是否正确;
    • 代码块中,先写个 todo , 后面小节中,再写具体的逻辑;
  • SaToken 登录用户,并返回 token 令牌,暂时先写个 todo

至此,登录接口的业务大体逻辑骨架就完成了。

controller

最后,在 /controller 包下,创建 UserController 控制器,新增 /user/login 接口,代码如下:

package com.quanxiaoha.xiaohashu.auth.controller;

import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
import com.quanxiaoha.xiaohashu.auth.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author: 犬小哈
 * @date: 2024/5/29 15:32
 * @version: v1.0.0
 * @description: TODO
 **/
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

    @Resource
    private UserService userService;

    @PostMapping("/login")
    @ApiOperationLog(description = "用户登录/注册")
    public Response<String> loginAndRegister(@Validated @RequestBody UserLoginReqVO userLoginReqVO) {
        return userService.loginAndRegister(userLoginReqVO);
    }

}

本小节中,登录接口的业务逻辑还有缺失,就先不测试了,等下面小节中,完全开发完毕后,再来自测一波,看看接口功能是否正常。

本小节源码下载

https://t.zsxq.com/TaNKa