跳至主要內容

用户中心-登录注册

holic-x...大约 30 分钟项目用户中心

用户中心-登录注册实现

1.业务逻辑分析

<1>功能说明

功能分析

​ 实现功能:用户登录注册、权限拦截校验、用户注销

<2>细化业务逻辑

提示

​ 细化业务逻辑部分主要就是为了梳理业务流程,然后根据这一流程进行业务实现。在实际场景开发中往往需要先分析功能和流程,必要时还可借助图示或者其他辅助工具帮助更好地理解业务流程开发,从而在此基础上进一步实现业务开发

🔖用户注册

  1. 用户在前端输入账户和密码、以及校验码(todo)
  2. 校验用户的账户、密码、校验密码,是否符合要求
    1. 非空
    2. 账户长度 不小于 4 位
    3. 密码就 不小于 8 位
    4. 账户不能重复
    5. 账户不包含特殊字符
    6. 密码和校验密码相同
  3. 对密码进行加密(禁止明文存储到数据库中,可采用MD5加密方式存储)
  4. 向数据库插入用户数据

🔖用户登录

  1. 校验用户账户和密码是否合法

    1. 非空
    2. 账户长度 不小于 4 位
    3. 密码就 不小于 8 位吧
    4. 账户不包含特殊字符
  2. 校验密码是否输入正确,要和数据库中的密文密码去对比

  3. 用户信息脱敏,隐藏敏感信息,防止数据库中的字段泄露

  4. 要记录用户的登录态(session),将其存到服务器上(用后端 SpringBoot 框架封装的服务器 tomcat 去记录)

    cookie

  5. 返回脱敏后的用户信息

🔖用户注销

​ 后端session清理,前端相关登录数据清理

2.登录注册

<1>概念说明

​ 在项目构建完成的基础上,梳理相应的业务逻辑并实现。在构建项目的基础上,先掌握项目开发的基本流程概念,例如要实现一个功能需要做哪些工作和准备,在此基础上扩展功能需要调整什么内容

​ 此处的代码示例倾向过程说明,主要为了说明一个功能的构建思路和内容,在后续的实践中会结合实际内容进行调整和优化

接口测试

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
    implements UserService{

    @Override
    public long userRegister(String userAccount, String userPassword, String checkPassword) {
        // 1.校验参数信息
        if(StringUtils.isAnyBlank(userAccount,userPassword,checkPassword)){
            return -1;
        }

        // 2.校验用户的账户、密码、校验密码,是否符合要求
        if(userAccount.length()<4){
            return -1;
        }
        if(userPassword.length()<8){
            return -1;
        }
        if(!userPassword.equals(checkPassword)){
            return -1;
        }
        // 特殊字符校验(正则表达式)
        String regEx =  ".*[`~!@#$%^&*()+=|{}':;',\\[\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?\\\\]+.*";
        if(Pattern.compile(regEx).matcher(userAccount).matches()){
            return -1;
        }

        // 账号重复校验(考虑到数据库资源的占用,一般在相关业务校验通过无误之后再进行数据库重复校验)
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("user_account",userAccount); // 用户账号匹配
        if(this.count(queryWrapper)>0){
            return -1;
        }

        // 3.密码加密(MD5单向算法)
        final String SALT = "Noob";
        String pwd = DigestUtils.md5DigestAsHex((SALT+ userPassword).getBytes());


        // 4.数据存储
        User user = new User();
//        user.setId(0L);
        user.setUsername("");
        user.setUserAccount(userAccount);
        user.setAvatarUrl("");
        user.setGender("");
        user.setUserPassword(pwd);
        user.setPhone("");
        user.setEmail("");
        user.setUserStatus(0);
        user.setCreateTime(new Date());
        user.setUpdateTime(new Date());
        user.setIsDelete(0);

        boolean saveResult = this.save(user);
        if(!saveResult){
            return -1;
        }
        return user.getId();
    }
}

提示

​ 鼠标移动到指定方法名,随后按下快捷键alt+enter,选择Generate missed test method便可生成对应的测试方法,随后在此时方法中进行填充即可

# src/test/java目录下相应路径的UserServiceTest.java
@SpringBootTest()
public class UserServiceTest {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private UserService userService;

    @Test
    public void testSelect() {
        System.out.println(("----- selectAll method test ------"));
        List<User> userList = userMapper.selectList(null);

//        User user = new User();
//        user.setId(0L);
//        user.setUsername("");
//        user.setUserAccount("");
//        user.setAvatarUrl("");
//        user.setGender("");
//        user.setUserPassword("");
//        user.setPhone("");
//        user.setEmail("");
//        user.setUserStatus(0);
//        user.setCreateTime(new Date());
//        user.setUpdateTime(new Date());
//        user.setIsDelete(0);

//        Assert.assertEquals(5, userList.size());
        for (User user : userList) {
            System.out.println(user.getUsername());
        }
        userList.forEach(System.out::println);
    }

    @Test
    void userRegister() {
        // 针对不同的情况进行业务校验
        long res = userService.userRegister("","000000","000000");
        Assertions.assertEquals(-1, res);
        res = userService.userRegister("no","000000","000000");
        Assertions.assertEquals(-1, res);
        res = userService.userRegister("noob","000000","000000");
        Assertions.assertEquals(-1, res);
        res = userService.userRegister("noob*","000000","000000");
        Assertions.assertEquals(-1, res);
        res = userService.userRegister("noob","000000","111111");
        Assertions.assertEquals(-1, res);
        res = userService.userRegister("admin","000000","000000");
        Assertions.assertEquals(-1, res);
        res = userService.userRegister("noob","12345678","12345678");
        Assertions.assertEquals(-1, res);
    }

}

<2>后台实现

⭕mapper层

​ 借助mybatis-plus构建mapper层,此处只需要借助逆向工程生成器生成相应的mapper(dao)层数据内容即可,具体步骤可参考项目构建说明中的内容,此处以user为例,生成代码结构说明如下(UserMapper、UserMapper.xml)

​ 后续的mapper定义如果是针对简单的单表操作可以通过调用mybatis-plus提供的api进行使用,针对一些较为复杂的查询定义可以通过自定义的方式实现,便于更好的管理和维护

1)UserMapper.java

@Mapper
public interface UserMapper extends BaseMapper<User> {

}

2)UserMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.noob.mapper.UserMapper">

    <resultMap id="BaseResultMap" type="com.noob.model.domain.User">
            <id property="id" column="id" jdbcType="BIGINT"/>
            <result property="username" column="username" jdbcType="VARCHAR"/>
            <result property="userAccount" column="user_account" jdbcType="VARCHAR"/>
            <result property="avatarUrl" column="avatar_url" jdbcType="VARCHAR"/>
            <result property="gender" column="gender" jdbcType="VARCHAR"/>
            <result property="userPassword" column="user_password" jdbcType="VARCHAR"/>
            <result property="phone" column="phone" jdbcType="VARCHAR"/>
            <result property="email" column="email" jdbcType="VARCHAR"/>
            <result property="userStatus" column="user_status" jdbcType="TINYINT"/>
            <result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
            <result property="updateTime" column="update_time" jdbcType="TIMESTAMP"/>
            <result property="isDelete" column="is_delete" jdbcType="TINYINT"/>
    </resultMap>

    <sql id="Base_Column_List">
        id,username,user_account,
        avatar_url,gender,user_password,
        phone,email,user_status,
        create_time,update_time,is_delete
    </sql>
</mapper>

⭕service层

1>UserService.java
public interface UserService extends IService<User> {

    public long userRegister(String userAccount,String userPassword,String checkPassword);

    public User userLogin(String userAccount, String userPassword, HttpServletRequest request);

}
2>UserServiceImpl.java
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
    implements UserService{

    private final String SALT = "Noob";

    @Resource
    private UserMapper userMapper;

    private Logger log = LoggerFactory.getLogger(UserServiceImpl.class);

    // 用户登录态键值定义(迁移在常量中定义)
    // private static final String USER_LOGIN_STATE="userLoginState";

    @Override
    public long userRegister(String userAccount, String userPassword, String checkPassword) {
        // 1.校验参数信息
        if(StringUtils.isAnyBlank(userAccount,userPassword,checkPassword)){
            return -1;
        }

        // 2.校验用户的账户、密码、校验密码,是否符合要求
        if(userAccount.length()<4){
            return -1;
        }
        if(userPassword.length()<8){
            return -1;
        }
        if(!userPassword.equals(checkPassword)){
            return -1;
        }
        // 特殊字符校验(正则表达式)
        String regEx =  ".*[`~!@#$%^&*()+=|{}':;',\\[\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?\\\\]+.*";
        if(Pattern.compile(regEx).matcher(userAccount).matches()){
            return -1;
        }

        // 账号重复校验(考虑到数据库资源的占用,一般在相关业务校验通过无误之后再进行数据库重复校验)
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("user_account",userAccount); // 用户账号匹配
        if(this.count(queryWrapper)>0){
            return -1;
        }

        // 3.密码加密(MD5单向算法)
        String pwd = DigestUtils.md5DigestAsHex((SALT+ userPassword).getBytes());

        // 4.数据存储
        User user = new User();
//        user.setId(0L);
        user.setUsername("");
        user.setUserAccount(userAccount);
        user.setAvatarUrl("");
        user.setGender("");
        user.setUserPassword(pwd);
        user.setPhone("");
        user.setEmail("");
        user.setUserStatus(0);
        user.setCreateTime(new Date());
        user.setUpdateTime(new Date());
        user.setIsDelete(0);

        boolean saveResult = this.save(user);
        if(!saveResult){
            return -1;
        }
        return user.getId();
    }

    @Override
    public User userLogin(String userAccount, String userPassword, HttpServletRequest request) {
        // 1.校验参数信息
        if(StringUtils.isAnyBlank(userAccount,userPassword)){
            return null;
        }

        // 2.校验用户的账户、密码、校验密码,是否符合要求
        if(userAccount.length()<4){
            return null;
        }
        if(userPassword.length()<8){
            return null;
        }

        // 特殊字符校验(正则表达式)
        String regEx =  ".*[`~!@#$%^&*()+=|{}':;',\\[\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?\\\\]+.*";
        if(Pattern.compile(regEx).matcher(userAccount).matches()){
            return null;
        }

        // 3.密码加密(MD5单向算法)
        String pwd = DigestUtils.md5DigestAsHex((SALT+ userPassword).getBytes());

        // 查找数据库中的数据是否存在
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("user_account",userAccount); // 用户账号匹配
        queryWrapper.eq("user_password",pwd);// 用户密码匹配(加密后的密码校验)
        User findUser = userMapper.selectOne(queryWrapper);
        // 用户不存在
        if(findUser==null){
            log.info("用户登录失败,账号名或者密码错误");
            return null;
        }

        // 4.用户信息脱敏(控制前端数据展示)
        User safeUser = new User();
        safeUser.setId(findUser.getId());
        safeUser.setUsername(findUser.getUsername());
        safeUser.setUserAccount(findUser.getUserAccount());
        safeUser.setAvatarUrl(findUser.getAvatarUrl());
        safeUser.setGender(findUser.getGender());
        safeUser.setPhone(findUser.getPhone());
        safeUser.setEmail(findUser.getEmail());
        safeUser.setUserStatus(findUser.getUserStatus());
        safeUser.setCreateTime(findUser.getCreateTime());

        // 5.记录用户的登录状态(cookie设置)
        request.getSession().setAttribute(UserConstant.USER_LOGIN_STATE,safeUser);
        return safeUser;
    }
}

⭕controller层

@RestController
@RequestMapping("/user")
public class UserController {
		@PostMapping("/userRegister")
    public Long userRegister(@RequestBody UserRegisterRequest userRegisterRequest){
        if(userRegisterRequest == null){
            return null;
        }
        String userAccount = userRegisterRequest.getUserAccount();
        String userPassword = userRegisterRequest.getUserPassword();
        String checkPassword = userRegisterRequest.getCheckPassword();
        if(StringUtils.isAllBlank(userAccount,userPassword,checkPassword)){
            return null;
        }
        return userService.userRegister(userAccount,userPassword,checkPassword);
    }

    @PostMapping("/userLogin")
    public User userLogin(@RequestBody UserRegisterRequest userRegisterRequest, HttpServletRequest request){
        if(userRegisterRequest == null){
            return null;
        }
        String userAccount = userRegisterRequest.getUserAccount();
        String userPassword = userRegisterRequest.getUserPassword();
        if(StringUtils.isAllBlank(userAccount,userPassword)){
            return null;
        }
        return userService.userLogin(userAccount,userPassword,request);
    }
}

⚡接口测试

1>请求接口

​ 在测试一个接口的时候首先要明确几个重要的参数,分别是请求路径、请求方式、请求参数、响应参数:

  • 请求路径:请求url由后台服务配置+接口映射配置进行定义
  • 请求方式:一般是get、post,针对一些restful规范还有put、delete等等
  • 请求参数:一般是json数据,针对get请求也可直接通过参数拼接的方式附加在url后面
  • 响应参数:一般是json数据,有明确统一的响应格式要求

接口文档参考示例

  • 请求路径:/login/toLogin
  • 请求方法:post
  • 请求参数:
参数名参数说明备注
{
	"xxx":"xxx"
}
  • 响应数据:
参数名参数说明备注
{
    "errCode": 0,
    "errMsg": "[响应成功]",
    "extend": {
    }
}
2>请求测试

​ 后台接口测试可借助postman等api调用工具进行测试,此处也可使用idea自带的rest-api接口测试工具进行测试。

Tools-->HTTP Client-->Create Request,创建请求文件,随后编写请求文本内容,并启动项目进行请求测试

application.yml配置

# 服务配置
server:
  port: 8080
  servlet:
    context-path: /user-center-api

​ 通过配置servlet的context-path属性,可确定url请求的基路径,随后可通过访问ip:8080/user-center-api访问应用,如果不指定则默认是”/”

请求参考

POST http://localhost:8080/user-center-api/user/userLogin
Content-Type: application/json

{
  "userAccount": "test",
  "userPassword": "test000000"
}

###
POST http://localhost:8080/user-center-api/user/userRegister
Content-Type: application/json

{
  "userAccount": "test",
  "userPassword": "test000000",
  "checkPassword": "test000000"
}

❓补充内容

1>mybatis逻辑删除概念

​ 逻辑删除概念是针对业务场景中数据删除问题衍生的内容,数据删除分为硬删除和软删除,硬删除是指直接将数据从数据库中剔除,而软删除则是采用逻辑删除的概念保留数据记录并在业务层”隐藏数据”

​ mybatis-plus提供了逻辑删除的配置方式,只需要在application.yml配置文件中配置逻辑删除处理相关的内容,随后在对应的实体中加入注解让mybatis-plus进行识别,其便可管理逻辑处理相关的内容

  • mybatis-plus配置
mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true # 下划线转驼峰配置
  mapper-locations: classpath*:/mapper/**/*.xml # mapper映射文件配置

  global-config:
    db-config:
      logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
  • 以User为例,指定逻辑删除标识为isDelete,在指定字段前加入@TableLogic注解 // 设定逻辑删除
@TableName(value ="user")
@Data
public class User implements Serializable {
	// ...... 其余属性定义 ......
	 /**
     * 是否删除
     */
    @TableLogic // 设定逻辑删除
    private Integer isDelete;
} 
  • 测试

​ 设定某条记录的is_delete值为1,随后在测试中查找这条记录的内容,会发现持久层已经自动过滤掉逻辑删除的记录

2>Serializable序列化

​ 涉及序列化和反序列化相关的概念,右键 generator 自动生成序列化id

3>用户登录

❓如何知道是哪个用户登录?

​ 根据session进行校验

  • 客户端连接服务器后会得到一个session状态(匿名会话)返回给前端
  • 用户登录成功后,通过设定该session值(例如存储用户信息),随后返回给前端,前端再将得到的用户信息设置到cookie中
  • 当前端再次请求后端的时候(相同域名),则会在请求头中带上cookie去请求
  • 后端接收请求会进行校验,根据前端指定的cookie找到相应的session,随后再从session中取出登录用户信息

3.用户管理

​ 用户管理相关的实现可参考上述步骤实现,后台接口借助mybatis-plus提供的方法快速构建实现

<1>概念说明

⚡基于代码优化的考虑

1>权限校验

​ 针对用户管理部分,除却前端显示访问入口,在后端还需通过校验的方式限制接口访问。可借助权限校验框架SpringSecurity、shiro等实现,但针对一些比较简单的场景可自行定义方法进行校验,常规的思路有两种:

​ 方式1:普通方法校验,通过校验session中存储的登录用户信息,校验其权限字段

​ 方式2:过滤器或者拦截器校验,将校验方法进行抽离,通过统一定义过滤器或者拦截器指定拦截URL按照既定规则进行过滤

2>常量类封装

​ 针对一些常量数据,可通过定义constant常量包存储常量相关的内容,以供访问。例如此处可参考UserConstant

public class UserConstant {

    // 用户登录态键值定义
    public static final String USER_LOGIN_STATE="userLoginState";

    // 用户角色定义
    public static final int USER_ROLE_ADMIN=1;

}
3>公共代码抽离

​ 针对一些公共的代码尽量将其抽离为公共的方法进行调用,一来是避免重复代码的冗余定义便于统一维护管理,二来考虑一些业务场景需要统一规范(例如对用户数据进行脱敏),此处以项目中的例子进行说明

管理员权限校验

​ 常规的管理员权限校验,可抽离公共的方法进行权限校验

		/**
     * 管理员权限校验
     * @param request
     * @return
     */
    private boolean isAdmin(HttpServletRequest request){
        // 限定管理员权限查询
        User user = (User)request.getSession().getAttribute(USER_LOGIN_STATE);
        if(user==null || user.getUserRole()!= UserConstant.USER_ROLE_ADMIN){
            // 非管理员不允许查看
            return false;
        }
        return true;
    }

用户数据脱敏

​ 在数据展示模块,针对用户数据的脱敏可以确定统一规则,将其封装为公共方法。例如在用户登录后信息返回以及查询用户信息列表这块均需对其进行数据脱敏,将公共的脱敏部分抽离出来(在UserService中定义接口和实现)

1)UserService.java

public interface UserService extends IService<User> {
    User getSafetyUser(User user);
}

2)UserServiceImpl.java

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
    implements UserService{
    @Override
    public User getSafetyUser(User user){
        User safetyUser = new User();
        safetyUser.setId(user.getId());
        safetyUser.setUsername(user.getUsername());
        safetyUser.setUserAccount(user.getUserAccount());
        safetyUser.setAvatarUrl(user.getAvatarUrl());
        safetyUser.setGender(user.getGender());
        safetyUser.setPhone(user.getPhone());
        safetyUser.setEmail(user.getEmail());
        safetyUser.setUserStatus(user.getUserStatus());
        safetyUser.setCreateTime(user.getCreateTime());
        return safetyUser;
    }
}

3)业务调用

# 单个用户信息脱敏
User safetyUser = userService.getSafetyUser(user);

# 批量数据筛选脱敏
userList.stream().map(user->{
	return userService.getSafetyUser(user);
}).collect(Collectors.toList());

⚡交互设计优化

1>响应数据封装

​ 在前面的实现中简单定义Controller接口进行测试,但在实际项目前后端交互过程中往往需要通过约定规范的数据格式进行交互,一方面是为了减少交互的沟通成本,另一方面也是为了更好地进行开发调试、运维工作

​ 在一些业务场景下会直接将http状态码作为交互校验依据,但实际上http状态码默认的值可能无法支撑庞大的业务概念,且为了将业务状态和网络层面的状态进行区分,自定义错误码还是非常必要的

确定交互响应格式

属性说明
code错误码(业务状态码)
message响应信息
data响应数据(要提供给前端内容)
{
    "code": 0,
    "message": "响应正常",
    "data": obj
}

自定义错误码

public enum ErrorCode {

    SUCCESS(0,"ok","响应成功"),
    PARAM_ERROR(40000,"请求参数错误",""),
    NULL_ERROR(40001,"请求参数为空",""),
    NOT_LOGIN(40100,"未登录",""),
    NO_AUTH(40101,"没有权限",""),
  	SYSTEM_ERROR(50000,"系统内部异常","")
    ;

    private final int code;

    private final String message;

    private final String description;

    ErrorCode(int code, String message, String description) {
        this.code = code;
        this.message = message;
        this.description = description;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

    public String getDescription() {
        return description;
    }
}

封装通用返回对象和工具类

  • BaseResponse.java
@Data
public class BaseResponse<T> implements Serializable {
    private int code;

    private  String message;

    private T data;

    private String description;

    public BaseResponse(int code, String message, T data, String description) {
        this.code = code;
        this.message = message;
        this.data = data;
        this.description = description;
    }

    public BaseResponse(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    public BaseResponse(int code, T data) {
        this.code = code;
        this.data = data;
    }

    public BaseResponse(ErrorCode errorCode) {
        this(errorCode.getCode(),errorCode.getMessage(),null,errorCode.getDescription());
    }
}
  • ResultUtil.java(还可自定义扩展其他响应方法,例如error等)
public class ResultUtil {

    public static <T> BaseResponse<T> success(T data){
        return new BaseResponse<T>(0,"ok",data);
    }

    public static BaseResponse error(ErrorCode errorCode){
        return new BaseResponse<>(errorCode);
    }

    public static BaseResponse error(int code, String message, String description) {
        return new BaseResponse<>(code,message,null,description);
    }

    public static BaseResponse error(ErrorCode errorCode,String message, String description) {
        return new BaseResponse<>(errorCode.getCode(),message,null,description);
    }

    public static BaseResponse error(ErrorCode errorCode, String description) {
        return new BaseResponse<>(errorCode.getCode(),errorCode.getMessage(),null,description);
    }

}

Controller层改造

​ Controller层的改造主要是将返回给前端的数据再进一步封装:return ResultUtil.success(xxx);

​ 针对需要反复使用的代码,这里有个小技巧,可以以模板的方式进行定义,然后便可通过”快捷键”的形式快速生成一个代码片段(类似sout),参考步骤说明如下所示

  • File -> settings ->Editor ->Live Templates,点击+选择Template Group

  • 选中指定的分组,随后点击+选择Live Template,为自定义分组创建相应的模板(快捷键、注释、代码模板)

image-20220428221749776

​ 其中$END$表示,敲完快捷键后鼠标会被定为的位置,上述配置文成点击apply即可

  • 验证代码模板是否生效,在Java文件中输入快捷键rusc,随后点击Tab

2>封装全局异常处理

自定义业务异常类

​ 自定义业务异常类是基于java构建的异常类,可以自定义构造函数,且支持灵活 / 快捷的设置更多字段

1.自定义业务异常类继承RuntimeException
2.定义code、description
3.定义构造方法(接收ErrorCode封装异常信息)
public class BusinessException extends RuntimeException{

    private final int code;
    private final String description;

    public BusinessException(int code, String message,String description) {
        super(message);
        this.code = code;
        this.description = description;
    }

    public BusinessException(ErrorCode errorCode) {
        this(errorCode.getCode(),errorCode.getMessage(),errorCode.getDescription());
    }

    public int getCode() {
        return code;
    }

    public String getDescription() {
        return description;
    }
}

编写全局异常处理器

❓为什么要引入全局异常处理器

​ 通过自定义业务异常类实现业务异常情况信息封装,其在项目中的引用是通过throw关键字抛出自定义异常,例如 throw new BussinessException(ErrorCode.NO_AUTH) ,但这种处理方式是比较”暴力”的,其虽然在后端抛出异常详情,而前端所接收到的是500状态码的异常提醒,这对前端而言是非常不友好的提示

​ 通过引入全局异常处理器,捕获代码中所有的异常进行内部消化,让前端得到更详细的业务报错 / 信息,并屏蔽掉项目框架本身的异常(而不暴露服务器内部状态),此外还可借助全局处理器集中处理、记录日志等

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public BaseResponse businessExceptionHandler(BusinessException e){
      log.error("businessException"+e.getMessage(),e);
      return ResultUtil.error(e.getCode(),e.getMessage(),e.getDescription());
    }

    @ExceptionHandler(RuntimeException.class)
    public BaseResponse runtimeExceptionHandler(BusinessException e){
        log.error("runtimeException",e);
        return ResultUtil.error(ErrorCode.SYSTEM_ERROR,e.getMessage(),"");
    }
}

<2>后台接口实现

​ 对比原有项目内容,此处借助自定义响应数据封装和全局异常优化交互实现,参考内容如下所示

🔖登录注销

1>实现说明

​ 用户注销,于后端而言是将指定session域数据清理;于前端而言则是清理登录用户和相关的缓存数据信息

2>接口定义

UserService

public boolean userLogout(HttpServletRequest request);

UserServiceImpl

		@Override
    public boolean userLogout(HttpServletRequest request) {
        // 清空指定session数据,即移除登录态
        request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE);
        return true;
    }

UserController

    @PostMapping("/userLogout")
    public BaseResponse<Boolean> userLogout( HttpServletRequest request){
        return ResultUtil.success(userService.userLogout(request));
    }
3>接口测试
POST http://localhost:8080/user-center-api/user/userLogout
Content-Type: application/json

🔖用户查询

1>实现说明

​ 实现用户查询操作(根据用户名进行过滤),对接口进行权限校验

2>接口定义

UserService

public List<User> searchUserByName(String username);

UserServiceImpl

		@Override
    public List<User> searchUserByName(String username) {
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        if (!StringUtils.isEmpty(username)) {
            queryWrapper.eq("username",username);
        }
        return userMapper.selectList(queryWrapper);
    }

UserController

		@PostMapping("/search")
    public List<User> search(String username, HttpServletRequest request){
        // 限定管理员权限查询
        if(!isAdmin(request)){
            // 非管理员不允许查看
            return new ArrayList<>();
        }
        List<User> userList= userService.searchUserByName(username);
        // 过滤(数据脱敏)
        return userList.stream().map(user->{
            // user.setUserPassword(null);
            return userService.getSafetyUser(user);
        }).collect(Collectors.toList());
    }
3>接口测试

分别以未登录、已登录(切换不同身份)状态进行访问测试

POST http://localhost:8080/user-center-api/user/search
Content-Type: application/json
{
  "username": "test"
}

🔖用户删除

1>实现说明

​ 实现用户查询操作(根据用户名进行过滤),对接口进行权限校验

2>接口定义

UserService

public boolean deleteUserById(String userId);

UserServiceImpl

		@PostMapping("/delete")
    public boolean delete(String userId, HttpServletRequest request){
        // 限定管理员权限查询
        if(!isAdmin(request)){
            // 非管理员不允许删除操作
            return false;
        }
        if(StringUtils.isEmpty(userId)){
            return false;
        }
        return userService.deleteUserById(userId);
    }

UserController

		@PostMapping("/delete")
    public boolean delete(String userId, HttpServletRequest request){
        // 限定管理员权限查询
        if(!isAdmin(request)){
            // 非管理员不允许删除操作
            return false;
        }
        if(StringUtils.isEmpty(userId)){
            return false;
        }
        return userService.deleteUserById(userId);
    }
2>接口测试

分别以未登录、已登录(切换不同身份)状态进行访问测试

POST http://localhost:8080/user-center-api/user/delete
Content-Type: application/json
{
  "username": "1"
}

​ 查看数据的处理状态,此处的逻辑删除应为数据更新操作

<3>前台实现

​ 此处将前台实现单独剥离出来进行说明,掌握核心的前端开发技巧,前端部分的功能实现主要在于数据交互和数据展示,其中涉及框架组件的应用

⚡代码规范

1>常量定义

​ 在src下定义常量包constants,创建index.ts,用于存储常量数据,参考内容如下

export const SYSTEM_LOGO = ""

​ 随后则可在其他ts文件中引用内容

# 引入内容
import {SYSTEM_LOGO} from '@/constants'; // 如果是index.ts可省略
# 随后可在该文件中使用该内容
2>前后端交互

​ 前后端通过ajax进行交互,前端借助axios组件封装了ajax内容

​ 注意请求的参数设定(如果要修改参数信息,建议使用快捷键全局进行修改,避免遗漏参数被引用的部分导致程序出错)

MOCK数据模拟 VS 后台接口请求

项目启动(MOCK数据模拟 VS 后台接口请求)

​ 此处需注意,start:dev和start指令的区别,从package.json中的脚本分析可知,start:dev模式下取消了MOCK(模拟数据选项),因此在没有打通后台api的基础上直接访问则会报错;而start模式下则是引用了MOCK数据进而模拟数据交互操作

    "start": "cross-env UMI_ENV=dev umi dev",
    "start:dev": "cross-env REACT_APP_ENV=dev MOCK=none UMI_ENV=dev umi dev",

接口请求

​ 根据全局搜索定位api定义,可以跟踪接口请求的实现

services\ant-design-pro\api.ts:ant-design-pro封装的request请求
config\oneapi.json:模拟数据的json响应数据定义
mock\user.ts:用户管理相关的ts文件(定义交互中使用的函数)

MOCK模拟数据处理

DEV API请求处理

1)API请求代码说明

export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
  return request<API.LoginResult>('/user-center-api/user/userLogin', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    data: body,
    ...(options || {}),
  });
}

# 1.request<API.LoginResult>理解
	类似Java中的模板概念,接收定义的参数类型,随后封装返回对应参数类型的结果,其定义在typings.d.ts中可以查看
	type LoginResult = {
    status?: string;
    type?: string;
    currentAuthority?: string;
  };
  
# 2.api请求URL修改
	默认是以当前前端项目启动根路径作为基路径,当启动访问为http://localhost:8000
	则下述配置接口访问路径为"http://localhost:8000/api/login/account"
	export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
  	return request<API.LoginResult>('/api/login/account', {
      // ......
  	});
  }

- 方式1:硬编码式修改(定义一个BASE前缀,随后将其拼接到URL)
	const const BASE_PREFIX = process.env ? 'http://localhost:8080' : '';
	export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
  	return request<API.LoginResult>(BASE_PREFIX + '/user-center-api/user/userLogin', {
      // ......
  	});
  }

- 方式2:以全局配置的方式定义请求URL前缀(app.tsx的RequestConfig配置)
	import { RequestConfig } from 'umi';
	export const request:RequestConfig = {
    prefix: 'http://localhost:8080',
    timeout: 1000
  }	
  RequestConfig相关的配置可参考umi源码进行配置,注意运行和生产环境的切换

- 方式3:借助请求前拦截器
	借助请求前拦截器对请求URL进行拦截、拼接

2)跨域处理

跨域问题

⚡代理概念

​ 正向代理:替客户端向服务器发送请求,可以解决跨域问题

​ 反向代理:由反向代理服务器替服务器统一接收请求

提示

​ 可结合简单的案例场景进一步理解代理的内容,参考文章

方案1:前端处理跨域

​ 通过F12查看请求URL,在没有做任何配置变动的情况下,默认以项目启动根路径作为基路径拼接接口请求URL(当启动访问为http://localhost:8000),下述接口定义的接口访问路径为:http://localhost:8000/api/login/account

# api.ts
export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
  return request<API.LoginResult>('/api/login/account', {
    // ......
  });
}

​ 因此此处要注意区分两个概念,接口请求路径、代理路径:

1)接口请求路径

​ 也就是上述配置相关的内容, 而在app.tsx中的配置主要就是为了解决请求路径的配置问题,因此直接通过配置请求路径前缀让其自动拼接为完整的”后台接口路径”,这种方式虽然能够正常定位到对应的后台接口,但是却由于服务域的端口不同导致出现跨域问题

​ 举例分析:假设后台接口请求的路径为:"http://localhost:8080/user-center-api/user/userLogin",在app.tsxapi.ts文件下分别配置如下内容,则可定位到对应的后台接口(但存在跨域问题导致无法正常访问后台接口)

	# app.tsx
	import { RequestConfig } from 'umi';
	export const request:RequestConfig = {
    prefix: 'http://localhost:8080/user-center-api',
    timeout: 1000
  }	
  
  # api.ts
  export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
    return request<API.LoginResult>('/user/userLogin', {
      // ......
    });
  }

2)代理路径

​ 代理路径是通过拦截指定规则的请求路径,然后根据规则转发到目标路径。

​ 在没有配置自定义接口请求前缀的时候,请求默认的基路径以当前项目启动发布的为参考(例如前端启动根目录为:"http://localhost:8000"),此时要拦截的对象应该是"/"(或者自定义指定一个前缀/api/

# api.ts
export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
  return request<API.LoginResult>('/api/login/account', {
    // ......
  });
}

# proxy.ts
export default {
  dev: {
    // localhost:8000/api/** -> https://preview.pro.ant.design/api/**
    '/api/': {
      target: 'https://localhost:8080/user-center-api',
      changeOrigin: true, // 依赖 origin 的功能可能需要这个,比如 cookie
    },
  },
}

​ 上述配置请求路径为:http://localhost:8000/api/login/account,前端拦截的是http://localhost:8000/api/**,代理配置会将"/"(也就是http://localhost:8000)转发==>https://localhost:8080,因此测试结果如下所示(虽然target中配置格式为:ip:[port]/context,但可以看到拦截的时候做了截断,仅仅将ip:[port]识别为转发域,而将后面的参数当做是URL的一部分)

​ 如果是在基于自定义接口请求前缀的配置的基础上,此处要对指定路径规则(要拦截的是http://localhost:8000/user-center-api//user/userLogin),代理配置会将”/”(也就是http://localhost:8000)转发==>https://localhost:8080,则相应的访问路径便可定位到https://localhost:8080/user-center-api/**

# app.tsx
export const request:RequestConfig = {
  prefix: '/user-center-api',
  timeout: 10000
}

# api.ts
export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
  return request<API.LoginResult>('/user/userLogin', {
    // ......
  });
}

# proxy.ts
export default {
  dev: {
    // localhost:8000/** -> https://localhost:8080/**
    '/user-center-api/': {
      target: 'https://localhost:8080',
      changeOrigin: true, // 依赖 origin 的功能可能需要这个,比如 cookie
    },
  },
}

3>代码清理

​ 针对一些冗余的功能可以适当清理代码内容,此处结合实际需求调整不做赘述

​ 最简单的一种方式就是通过全局搜索定位代码实现,如果对框架有一定了解的话也可依据代码实现流程对项目代码进行定位

src/pages/user/Login 下定义登录相关的内容

快捷键参考:eclipse

ctrl+H:搜索指定内容

双击shift:快速定位指定文件

alt+shift+R:重命名参数

⚡交互设计优化

1>全局响应处理

​ 和后端类似,前端部分也有相应的全局响应处理,通过抽离公共的响应处理部分,交由全局拦截器进行集中处理,常见的应用场景有用户是否登录、权限管理等内容

​ 具体实现需要参考所使用的的请求封装工具的文档说明,项目中所使用的是umi-requestopen in new window

1)在前端定义通用返回对象(typings.d.ts
  /**
   * 通用返回类定义
   */
  type BaseResponse<T> = {
    code: number,
    data: T,
    message: string,
    description: string
  }
2)接口请求配置调整(api.ts

​ 此处以登录接口为例进行测试

​ 经由上述配置,请求所获得的响应数据如下说明

3)配置全局响应拦截器

​ 后端响应格式已经明确,现需调整前端内容尽量适配后端内容;由后端响应的数据,可通过全局响应拦截器进行统一的数据处理。

方式1:修改.umi/plugin-request/request-ts

  • 测试接口响应,可以通过F12窗口查看拦截器是否正常运作

  • 基于上述配置,拦截器正常运作,随后进一步对响应数据进行处理
	// 自定义全局响应拦截器
  requestConfig.responseInterceptors = [
    async function (response: Response,option: RequestOptionsInit): Response | Promise<Response>{
      const res = await response.clone().json();
      console.log('全局响应拦截器',res);
      if(res.code == 0){
        console.log('处理响应数据',res.data);
        return res.data;
      }
    }
  ]
  • 此处返回的内容为拦截器放行的数据,对应为接口调用(Login/index.tsx)之后所获取的参数内容
const handleSubmit = async (values: API.LoginParams) => {
    try {
      // 接口调用,返回的是响应正常的data数据
      const data = await login({ ...values, type });
      console.log('请求响应结果',data);

      // ------ 业务处理 -------

    } catch (error) {
      const defaultLoginFailureMessage = '登录失败,请重试!';
      message.error(defaultLoginFailureMessage);
    }

  };

方式2:自定义request封装

​ 上述方式是直接修改.umi下的内容,但.umi是项目框架生成的文件,很有可能在不经意的情况下被覆盖掉,因此此处需要通过自定义request.ts覆盖.umi的实现,让自定义的内容发挥作用

​ 参考官网对全局响应拦截器的定义说明open in new windowumi-requestopen in new window

  • 创建文件src/plugins/globalRequest.ts,填充下述内容
/**
 * 配置request请求时的默认参数
 */
const request = extend({
  // 代理配置
  prefix: '/user-center-api',
  timeout: 100000,
  credentials: 'include', // 默认请求是否带上cookie
  // requestType: 'form',
});

/**
 * 所有请求拦截器
 */
request.interceptors.request.use((url, options): any => {
  // 自定义逻辑处理,对每次请求进行拦截
  console.log('请求拦截',url);
  return {
    url,
    options: {
      ...options,
      headers: {
        // Authorization: getAccessToken(),
      },
    },
  };
});

/**
 * 所有响应拦截器
 */
request.interceptors.response.use(async (response, options): Promise<any> => {
  const res = await response.clone().json();
  console.log('自定义全局响应拦截器',res);
  if(res.code === 0){
    console.log('自定义处理响应数据',res.data);
    return res.data;
  }
  // 用户未登录
  if(res.code===40100){
    message.error(res.description);
    history.replace({
      pathname: '/user/login',
      search: stringify({
        redirect: location.pathname,
      })
    })
  }else{
    // 其余情况统一处理异常
    message.error(res.description);
  }
  return res;
});

export default request;
  • api.ts中对umi的引用调整为自定义的request方法
// import { request } from 'umi';
import request from '@/plugins/globalRequest';

  • 开发过程问题排查

​ 问题1:当启用自定义的request切换测试的时候发现程序启动报错,且页面刷新无法加载内容

​ 上述问题可能存在于项目路径移动src/.umi缓存文件找不到原来的路径导致,可以通过删除src/.umi然后重新yarn/npm install运行项目;还有一种方式是取消msfu配置(在了解msfu的基础上对其进行操作,否则不建议采取该方案)

扩展:msfuopen in new window是一种基于 webpack5 新特性 Module Federation 的打包提速方案

​ 问题2:访问接口响应结果是html文本内容,查看url配置发现接口代理没有正常转发(这与前面的代理配置相关),由于之前使用的是umi-request,且在app.tsx中对request进行了配置扩展。相应地,对应的自定义request中内容也需要做适配调整

const request = extend({
  // 代理配置
  prefix: '/user-center-api',
  timeout: 100000,
  credentials: 'include', // 默认请求是否带上cookie
  // requestType: 'form',
});

✨业务功能实现

1>用户登录

​ 在上述内容构建的基础上,业务功能的实现可以主要拆分为视图、请求这两部分,视图也就是页面定义(包括组件定义、校验等内容)、请求(包括请求方法、URL、参数、响应等内容),由这两部分构成一个功能的实现。

​ 以用户登录为例,对应内容修改参考如下。

index.tsx

src/pages/user/Login/index.tsx定义了用户登录页面的基本视图,以及请求调用的内容和响应处理,而要实现登录功能则要封装参数、定义请求URL、处理响应结果

1)组件定义

​ 组件定义主要有三部分:用户名、密码、登录按钮,其中用户名、密码中的name属性和typings.d.ts中的LoginParams定义保持一致;而登录按钮的事件触发则由handleSubmit方法进行处理

2)api.ts

​ 调整url请求路径,以及响应结果LoginResult

export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
  // return request<API.LoginResult>('/user/userLogin', {
  return request<API.BaseResponse<API.LoginResult>>('/user/userLogin', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    data: body,
    ...(options || {}),
  });
}
  • typings.d.ts:LoginResult
  # 原有配置
  type LoginResult = {
    status?: string;
    type?: string;
    currentAuthority?: string;
  };
  
  # 根据后台进行适配(或者适当调整后台响应内容)
  
  
2>用户注销

代码分析

​ 后台部分的用户注销是将对应session的用户登录态取消,而相应地当注销请求响应成功之后清理掉前端缓存的用户信息和数据

​ 分析app.tsx文件,每次刷新页面或刚进入新页面时,最先执行的getInitialState方法,在这个方法中定义了一些初始化操作(例如获取已登录用户信息存放到InitialState中,相应地如果要实现注销操作则需要将InitialState中已存在的用户信息清空)

3>用户注册

​ 参考Login的实现,将Login文件夹内容粘贴至user文件夹下并设置为Register作为注册页面开发,并重命名组件相关内容(避免与原有的Login内容冲突),清理一些未引用的或者飘红的配置

1)确定注册地址(访问路由、组件定义)

​ 在routes.ts中配置注册组件访问路由配置

2)代码清理

image-20220328225716939

3)app.tsx

​ 登录拦截说明:app.tsx 是前端项目的全局文件,里面定义了刚进入一个页面时要执行的逻辑,例如登录信息拦截校验,因此需要设立相应的白名单机制,针对一些无需登录校验的页面进行校验、放行。参考的调整内容如下所示

刚进入页面要获取用户信息,如果没获取到用户信息,就重定向到登录页让你登录,那同时,因为使用的这个框架,更偏向于后台管理系统,所以说每个页面都会去校验。如果说用户没登录,没有登录状态,那就把它重定向到登录页

5.业务测试

​ 完成接口和页面层级的开发,则可进一步进行业务测试

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3