用户模块

本节重点

首先开发每个项目基本都具有的用户模块,本节教程可以当做一个 用户管理系统项目 独立学习,适合新手入门,后端和前端部分也可以按需独立学习。

本节大纲:

  • 用户模块需求分析
  • 用户模块方案设计
  • 用户模块后端开发(含 MyBatis Flex + 代码生成器)
  • 用户模块前端开发(手写为主,仅包含一点 Vibe Coding)

友情提示,除了数据访问层框架使用 MyBatis Flex、以及自定义了后端代码生成器之外,本期内容和 编程导航的智能协同云图库项目 很相似,可以按需学习。

一、需求分析

对于用户模块,通常要具有下列功能:

  • 用户注册
  • 用户登录
  • 获取当前登录用户
  • 用户注销
  • 用户权限控制
  • 【管理员】管理用户

具体分析每个需求:

1)用户注册:用户可以通过输入账号、密码、确认密码进行注册

2)用户登录:用户可以通过输入账号和密码登录

3)获取当‍前登录用户:得到当⁡前已经登录的用户信‎息(不用重复登录)

4)用户注销:用户可以退出登录

5)用户权‍限控制:用户又分为⁡普通用户和管理员,‎管理员拥有整个系统‎的最高权限,比如可以管理其他用户

6)用户管‍理:仅管理员可用,⁡可以对整个系统中的‎用户进行管理,比如‎搜索用户、删除用户

二、方案设计

实现用户模块的难度不大,方案设计阶段我们需要确认:

  • 库表设计
  • 用户登录流程
  • 如何对用户权限进行控制?

库表设计

库名:yu_ai_code_mother

表名:user(用户表)

鱼皮会先讲本‍项目需要的核心设计,再补⁡充一些扩展设计,便于大家‎学习。         ‎

1、核心设计

用户表的核‍心是用户登录凭证(⁡账号密码)和个人信‎息,SQL 如下:

-- 用户表
create table if not exists user
(
    id           bigint auto_increment comment 'id' primary key,
    userAccount  varchar(256)                           not null comment '账号',
    userPassword varchar(512)                           not null comment '密码',
    userName     varchar(256)                           null comment '用户昵称',
    userAvatar   varchar(1024)                          null comment '用户头像',
    userProfile  varchar(512)                           null comment '用户简介',
    userRole     varchar(256) default 'user'            not null comment '用户角色:user/admin',
    editTime     datetime     default CURRENT_TIMESTAMP not null comment '编辑时间',
    createTime   datetime     default CURRENT_TIMESTAMP not null comment '创建时间',
    updateTime   datetime     default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    isDelete     tinyint      default 0                 not null comment '是否删除',
    UNIQUE KEY uk_userAccount (userAccount),
    INDEX idx_userName (userName)
) comment '用户' collate = utf8mb4_unicode_ci;

几个注意事项:

  1. editTime 和 updateTime 的区别:editTime 表示用户编辑个人信息的时间(需要业务代码来更新),而 updateTime 表示这条用户记录任何字段发生修改的时间(由数据库自动更新)。
  2. 给唯一值添加唯一键(唯一索引),比如账号 userAccount,利用数据库天然防重复,同时可以增加查询效率。
  3. 给经常用于查询的字段添加索引,比如用户昵称 userName,可以增加查询效率。

💡 建议养成好习惯,将库表设计 SQL 保存到项目的目录中,比如新建 sql/create_table.sql 文件,这样其他开发者就能更快地了解和启动项目。

# 数据库初始化
# @author <a href="<https://github.com/liyupi>">程序员鱼皮</a>
# @from <a href="<https://codefather.cn>">编程导航学习圈</a>

-- 创建库
create database if not exists yu_ai_code_mother;

-- 切换库
use yu_ai_code_mother;

-- 用户表
-- 以下是建表语句

2、扩展设计

1)如果要实现会员功能,可以对表进行如下扩展:

  1. 给 userRole 字段新增枚举值 vip,表示会员用户,可根据该值判断用户权限
  2. 新增会员过期时间字段,可用于记录会员有效期
  3. 新增会员兑换码字段,可用于记录会员的开通方式
  4. 新增会员编号字段,可便于定位用户并提供额外服务,并增加会员归属感

对应的 SQL 如下:

vipExpireTime datetime     null comment '会员过期时间',
vipCode       varchar(128) null comment '会员兑换码',
vipNumber     bigint       null comment '会员编号'

2)如果要实现用户邀请功能,可以对表进行如下扩展:

  1. 新增 shareCode 分享码字段,用于记录每个用户的唯一邀请标识,可拼接到邀请网址后面,比如 https://mianshiya.com/?shareCode=xxx
  2. 新增 inviteUser 字段,用于记录该用户被哪个用户邀请了,可通过这个字段查询某用户邀请的用户列表。

对应的 SQL 如下:

shareCode     varchar(20)  DEFAULT NULL COMMENT '分享码',
inviteUser    bigint       DEFAULT NULL COMMENT '邀请用户 id'

用户登录流程

1)建立初始会话‍:前端与服务器建立连接后,服务器⁡会为该客户端创建一个初始的匿名 ‎Session,并将其状态保‎存下来。这个 Session 的 ID 会作为唯一标识,返回给前端。

2)登录成功,更新会话信息:当‍用户在前端输入正确的账号密码并提交到后端验证成功后,后端会⁡更新该用户的 Session,将用户的登录信息(如用户 I‎D、用户名等)保存到与该 Session 关联的存储中。同‎时,服务器会生成一个 Set-Cookie 的响应头,指示前端保存该用户的 Session ID。

3)前端保存 C‍ookie:前端接收到后端的响应⁡后,浏览器会自动根据 Set-C‎ookie 指令,将 Sessi‎on ID 存储到浏览器的 Cookie 中,与该域名绑定。

4)带 Coo‍kie 的后续请求:当前端再⁡次向相同域名的服务器发送请求‎时,浏览器会自动在请求头中附‎带之前保存的 Cookie,其中包含 Session ID。

5)后端验‍证会话:服务器接收到⁡请求后,从请求头中提‎取 Session ‎ID,找到对应的 Session 数据。

6)获取会话‍中存储的信息:后端通过该⁡ Session 获取之‎前存储的用户信息(如登录‎名、权限等),从而识别用户身份并执行相应的业务逻辑。

image-3779a2c2

如何对用户权限进行控制?

可以将接口分为 4 种权限:

  1. 未登录也可以使用
  2. 登录用户才能使用
  3. 未登录也可以使用,但是登录用户能进行更多操作(比如登录后查看全文)
  4. 仅管理员才能使用

传统的权限‍控制方法是,在每个⁡接口内单独编写逻辑‎:先获取到当前登录‎用户信息,然后判断用户的权限是否符合要求。

这种方法最‍灵活,但是会写很多⁡重复的代码,而且其‎他开发者无法一眼得‎知接口所需要的权限。

权限校验其实是一个比较通用的业务需求,一般会通过 Spring AOP 切面 + 自定义权限校验注解 实现统一的接口拦截和权限校验;如果有特殊的权限校验逻辑,再单独在接口中编码。

💡 如果需要更复杂更灵活的权限控制,可以引入 Shiro / Spring Security / Sa-Token 等专门的权限管理框架。在 编程导航的智能协同云图库项目 中,有 Sa-Token 权限管理的实战。


OK,有了实现方案后,我们先来开发后端接口。

后端开发

以后每次开发一套新接口时,都可以遵循以下流程。

数据库建表

需要在本地‍启动 MySQL(⁡推荐 8.x 版本‎),利用 IDEA‎ 连接 MySQL 数据库:

image-2c40a146

填写配置并检测连接:

image-e2260648

执行 SQL 脚本,创建数据库表:

image-6b433637

MyBatis Flex 代码生成器

1、什么是 MyBatis Flex?

之前编程导航的很多项目中,鱼皮都是使用 MyBatis Plus 作为数据访问层框架,能够通过 Java 快速操作数据库。

为了帮大家丰富技术栈,这次我们来学一个新的数据访问层框架 MyBatis Flex,它的作用和 MyBatis Plus 一样,都是对传统操作数据库的 MyBatis 框架进行增强。它的 3 大优势:

  • 更轻量:MyBatis-Flex 除了 MyBatis 本身,再无任何第三方依赖,因此会带来更高的自主性、把控性和稳定性。在任何一个系统中,依赖越多,稳定性越差。
  • 更灵活:MyBatis-Flex 提供了非常灵活的 QueryWrapper,支持关联查询、多表查询、多主键、逻辑删除、乐观锁更新、数据填充、数据脱敏等等。
  • 更高的性能:MyBatis-Flex 通过独特的架构,没有任何 MyBatis 拦截器、在 SQL 执行的过程中,没有任何的 SQL Parse,因此会带来指数级的性能增长。

官方文档 中提到了和 MyBatis Plus 的功能对比,我认为比较有竞争力的是它 支持多表查询

image-cfe95274

此外,在 Mybatis Flex 中,有了一个名称为 mybatis-flex-codegen 的模块,提供了可以通过数据库表,生成代码的功能。当我们把数据库表设计完成后, 就可以使用其快速生成 Entity、Mapper、Service、Controller 代码,能大幅提高我们的开发效率。

2、使用代码生成器

下面让我们参考 官方文档 使用代码生成器。

1)引入 ‍MyBatis F⁡lex 依赖,注意‎是 Spring ‎Boot3 的:

<dependency>
    <groupId>com.mybatis-flex</groupId>
    <artifactId>mybatis-flex-spring-boot3-starter</artifactId>
    <version>1.11.0</version>
</dependency>

还需要引入代码生成相关依赖:

<!-- 代码生成模块 -->
<dependency>
    <groupId>com.mybatis-flex</groupId>
    <artifactId>mybatis-flex-codegen</artifactId>
    <version>1.11.0</version>
</dependency>
<!-- 数据库连接池 -->
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
</dependency>

2)编写数据库连接配置:

spring:
  # mysql
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/yu_ai_code_mother
    username: root
    password: 123456

3)开发生成器

在根包下新建 ‍generator 包,新⁡建 MyBatisCode‎Generator 类,编‎写代码生成器。先从官方复制示例代码,然后按需修改即可。

在 Myb‍atis Flex ⁡的代码生成器中,支持‎如下 8 种类型的的‎产物生成,我们只需要关注前 6 个就好:

  • Entity 实体类 ✅
  • Mapper 映射类 ✅
  • Service 服务类 ✅
  • ServiceImpl 服务实现类 ✅
  • Controller 控制类 ✅
  • MapperXml 文件 ✅
  • TableDef 表定义辅助类
  • package-info.java 文件

代码如下:

public class MyBatisCodeGenerator {

    // 需要生成的表名
    private static final String[] TABLE_NAMES = {"user"};

    public static void main(String[] args) {
        // 获取数据源信息
        Dict dict = YamlUtil.loadByPath("application.yml");
        Map<String, Object> dataSourceConfig = dict.getByPath("spring.datasource");
        String url = String.valueOf(dataSourceConfig.get("url"));
        String username = String.valueOf(dataSourceConfig.get("username"));
        String password = String.valueOf(dataSourceConfig.get("password"));
        // 配置数据源
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(url);
        dataSource.setUsername(username);
        dataSource.setPassword(password);

        // 创建配置内容
        GlobalConfig globalConfig = createGlobalConfig();

        // 通过 datasource 和 globalConfig 创建代码生成器
        Generator generator = new Generator(dataSource, globalConfig);

        // 生成代码
        generator.generate();
    }

    // 详细配置见:<https://mybatis-flex.com/zh/others/codegen.html>
    public static GlobalConfig createGlobalConfig() {
        // 创建配置内容
        GlobalConfig globalConfig = new GlobalConfig();

        // 设置根包,建议先生成到一个临时目录下,生成代码后,再移动到项目目录下
        globalConfig.getPackageConfig()
                .setBasePackage("com.yupi.yuaicodemother.genresult");

        // 设置表前缀和只生成哪些表,setGenerateTable 未配置时,生成所有表
        globalConfig.getStrategyConfig()
                .setGenerateTable(TABLE_NAMES)
                // 设置逻辑删除的默认字段名称
                .setLogicDeleteColumn("isDelete");

        // 设置生成 entity 并启用 Lombok
        globalConfig.enableEntity()
                .setWithLombok(true)
                .setJdkVersion(21);

        // 设置生成 mapper
        globalConfig.enableMapper();
        globalConfig.enableMapperXml();

        // 设置生成 service
        globalConfig.enableService();
        globalConfig.enableServiceImpl();

        // 设置生成 controller
        globalConfig.enableController();

        // 设置生成时间和字符串为空,避免多余的代码改动
        globalConfig.getJavadocConfig()
                .setAuthor("<a href=\\"<https://github.com/liyupi\\">程序员鱼皮</a>">)
                .setSince("");
        return globalConfig;
    }
}

上述代码中‍,我们从配置文件中⁡获取数据库连接信息‎,用来初始化数据源‎。然后自定义了一些生成配置,包括:

  • 指定逻辑删除字段
  • 使用的 JDK 版本
  • 生成的代码放到哪个包(建议选择一个独立的包,不要跟老代码混一起)
  • 要生成哪些文件
  • 生成什么代码注释

4)执行代码生成器,查看生成的结果。如图:

image-5940ddc2

将生成的代码文件移动到对应的包下,移动后:

image-335dca4b

5)主类添加扫描 Mapper 文件的注解:

@SpringBootApplication
@MapperScan("com.yupi.yuaicodemother.mapper")
public class YuAiCodeMotherApplication {
    public static void main(String[] args) {
        SpringApplication.run(YuAiCodeMotherApplication.class, args);
    }
}

然后就可以启动项目,验证生成代码的效果了。

扩展思路 - 自定义代码生成

可以参考 官方文档,通过自定义模板的方式更灵活地控制代码生成的内容,比如设置主键自增策略、增加 Controller 层生成的接口等等。

数据模型开发

1、实体类

生成的代码‍也许不能完全满足我⁡们的要求,比如数据‎库实体类,我们可以‎手动更改其字段配置,指定主键生成策略。

id 默认是连续生成的,容易被爬虫抓取,所以更换策略为 ASSIGN_ID 雪花算法生成。

修改 User 类的代码如下:

@Table("user")
public class User implements Serializable {

    @Id(keyType = KeyType.Generator, value = KeyGenerators.snowFlakeId)
    private Long id;
}

💡 使用框架的过程中有任何疑问,都可以在官方文档查阅,比如 了解 MyBatis Flex 的主键注解

2、枚举类

对于用户角‍色这样值的数量有限的⁡、可枚举的字段,最好‎定义一个枚举类,便于‎在项目中获取值、减少枚举值输入错误的情况。

model.enums 包下新建 UserRoleEnum:

@Getter
public enum UserRoleEnum {

    USER("用户", "user"),
    ADMIN("管理员", "admin");

    private final String text;

    private final String value;

    UserRoleEnum(String text, String value) {
        this.text = text;
        this.value = value;
    }

    /**
     * 根据 value 获取枚举
     *
     * @param value 枚举值的value
     * @return 枚举值
     */
    public static UserRoleEnum getEnumByValue(String value) {
        if (ObjUtil.isEmpty(value)) {
            return null;
        }
        for (UserRoleEnum anEnum : UserRoleEnum.values()) {
            if (anEnum.value.equals(value)) {
                return anEnum;
            }
        }
        return null;
    }
}

其中,ge‍tEnumByVa⁡lue 是通过 v‎alue 找到具体的枚‎举对象。

💡 如果‍枚举值特别多,可以⁡ Map 缓存所有‎枚举值来加速查找,‎而不是遍历列表。


下面依次进行各功能接口的开发。

用户注册

1、数据模型

model.dto.user 下新建用于接受请求参数的类:

@Data
public class UserRegisterRequest implements Serializable {

    private static final long serialVersionUID = 3191241716373120793L;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 密码
     */
    private String userPassword;

    /**
     * 确认密码
     */
    private String checkPassword;
}

💡 在 Java ‍接口开发中,为每个接口定义一个专门的类来⁡接收请求参数,可以提高代码的可读性和维护‎性,便于对参数进行统一验证和扩展,同时减‎少接口方法参数过多导致的复杂性,有助于在复杂场景下更清晰地管理和传递数据。

2、服务开发

service 包的 UserService 中增加方法声明:

/**
 * 用户注册
 *
 * @param userAccount   用户账户
 * @param userPassword  用户密码
 * @param checkPassword 校验密码
 * @return 新用户 id
 */
long userRegister(String userAccount, String userPassword, String checkPassword);

在 Use‍rServiceI⁡mpl 中增加实现‎代码,注意多补充一‎些校验条件:

@Override
public long userRegister(String userAccount, String userPassword, String checkPassword) {
    // 1. 校验
    if (StrUtil.hasBlank(userAccount, userPassword, checkPassword)) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
    }
    if (userAccount.length() < 4) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短");
    }
    if (userPassword.length() < 8 || checkPassword.length() < 8) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短");
    }
    if (!userPassword.equals(checkPassword)) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
    }
    // 2. 检查是否重复
    QueryWrapper queryWrapper = new QueryWrapper();
    queryWrapper.eq("userAccount", userAccount);
    long count = this.mapper.selectCountByQuery(queryWrapper);
    if (count > 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复");
    }
    // 3. 加密
    String encryptPassword = getEncryptPassword(userPassword);
    // 4. 插入数据
    User user = new User();
    user.setUserAccount(userAccount);
    user.setUserPassword(encryptPassword);
    user.setUserName("无名");
    user.setUserRole(UserRoleEnum.USER.getValue());
    boolean saveResult = this.save(user);
    if (!saveResult) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
    }
    return user.getId();
}

注意,上述‍代码中,我们需要将⁡用户密码加密后进行‎存储。可以封装一个‎方法,便于后续复用:

@Override
public String getEncryptPassword(String userPassword) {
    // 盐值,混淆密码
    final String SALT = "yupi";
    return DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
}

3、接口开发

controller 包中新建 UserController,新增用户注册接口:

@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private UserService userService;

    /**
     * 用户注册
     *
     * @param userRegisterRequest 用户注册请求
     * @return 注册结果
     */
    @PostMapping("register")
    public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest) {
        ThrowUtils.throwIf(userRegisterRequest == null, ErrorCode.PARAMS_ERROR);
        String userAccount = userRegisterRequest.getUserAccount();
        String userPassword = userRegisterRequest.getUserPassword();
        String checkPassword = userRegisterRequest.getCheckPassword();
        long result = userService.userRegister(userAccount, userPassword, checkPassword);
        return ResultUtils.success(result);
    }
}

4、测试

每开发完一‍个接口,都可以使用⁡ Swagger ‎接口文档来测试:

image-335dca4b

💡 测试‍时要尤其注意边界值⁡和特殊值,不能只测‎试正常的情况。

用户登录

1、数据模型

model.dto.user 下新建用于接受请求参数的类:

@Data
public class UserLoginRequest implements Serializable {

    private static final long serialVersionUID = 3191241716373120793L;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 密码
     */
    private String userPassword;
}

2、数据脱敏

无论是用户注册‍还是用户登录接口,都应该返回⁡已登录的用户信息,而且一定要‎对返回结果进行脱敏处理,不能‎直接将数据库查到的所有信息都返回给了前端(包括密码)。

model.vo 包下新建 LoginUserVO 类,表示脱敏后的登录用户信息:

@Data
public class LoginUserVO implements Serializable {

    /**
     * 用户 id
     */
    private Long id;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 用户头像
     */
    private String userAvatar;

    /**
     * 用户简介
     */
    private String userProfile;

    /**
     * 用户角色:user/admin
     */
    private String userRole;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;

    private static final long serialVersionUID = 1L;
}

在 Use‍rService ⁡中新增获取脱敏后的‎已登录用户信息方法‎:

/**
 * 获取脱敏的已登录用户信息
 *
 * @return
 */
LoginUserVO getLoginUserVO(User user);

编写方法对‍应的实现类,其实就是⁡将 User 类的属‎性复制到 Login‎UserVO 中,不存在的字段就被过滤掉了:

@Override
public LoginUserVO getLoginUserVO(User user) {
    if (user == null) {
        return null;
    }
    LoginUserVO loginUserVO = new LoginUserVO();
    BeanUtil.copyProperties(user, loginUserVO);
    return loginUserVO;
}

3、服务开发

service 包的 UserService 中增加方法声明:

/**
 * 用户登录
 *
 * @param userAccount  用户账户
 * @param userPassword 用户密码
 * @param request
 * @return 脱敏后的用户信息
 */
LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request);

在 UserS‍erviceImpl 中增⁡加实现代码,注意多补充一些‎校验条件,在用户登录成功后‎,将用户信息存储在当前的 Session 中。代码如下:

@Override
public LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request) {
    // 1. 校验
    if (StrUtil.hasBlank(userAccount, userPassword)) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
    }
    if (userAccount.length() < 4) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号错误");
    }
    if (userPassword.length() < 8) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码错误");
    }
    // 2. 加密
    String encryptPassword = getEncryptPassword(userPassword);
    // 查询用户是否存在
    QueryWrapper queryWrapper = new QueryWrapper();
    queryWrapper.eq("userAccount", userAccount);
    queryWrapper.eq("userPassword", encryptPassword);
    User user = this.mapper.selectOneByQuery(queryWrapper);
    // 用户不存在
    if (user == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在或密码错误");
    }
    // 3. 记录用户的登录态
    request.getSession().setAttribute(USER_LOGIN_STATE, user);
    // 4. 获得脱敏后的用户信息
    return this.getLoginUserVO(user);
}

注意,由于注‍册用户时存入数据库的密码⁡是加密后的,查询用户信息‎时,也要对用户输入的密码‎进行同样算法的加密,才能跟数据库的信息对应上。

可以把上述的 Session 理解为一个 Map,可以给 Map 设置 key 和 value,每个不同的 SessionID 对应的 Session 存储都是不同的,不用担心会污染。所以上述代码中,给 Session 设置了固定的 key(USER_LOGIN_STATE),可以将这个 key 值提取为常量,便于后续获取。

constant 包下新建 UserConstant 类,统一声明用户相关的常量:

public interface UserConstant {

    /**
     * 用户登录态键
     */
    String USER_LOGIN_STATE = "user_login";

    //  region 权限

    /**
     * 默认角色
     */
    String DEFAULT_ROLE = "user";

    /**
     * 管理员角色
     */
    String ADMIN_ROLE = "admin";

    // endregion
}

4、接口开发

在 UserController 中新增用户登录接口:

@PostMapping("/login")
public BaseResponse<LoginUserVO> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) {
    ThrowUtils.throwIf(userLoginRequest == null, ErrorCode.PARAMS_ERROR);
    String userAccount = userLoginRequest.getUserAccount();
    String userPassword = userLoginRequest.getUserPassword();
    LoginUserVO loginUserVO = userService.userLogin(userAccount, userPassword, request);
    return ResultUtils.success(loginUserVO);
}

获取当前登录用户

可以从 r‍equest 请求对⁡象对应的 Sessi‎on 中直接获取到之‎前保存的登录用户信息,无需其他请求参数。

1、服务开发

service 包的 UserService 中增加方法声明:

/**
 * 获取当前登录用户
 *
 * @param request
 * @return
 */
User getLoginUser(HttpServletRequest request);

在 UserSe‍rviceImpl 中增加实现代⁡码,此处为了保证获取到的数据始终‎是最新的,先从 Session ‎中获取登录用户的 id,然后从数据库中查询最新的结果。代码如下:

@Override
public User getLoginUser(HttpServletRequest request) {
    // 先判断是否已登录
    Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
    User currentUser = (User) userObj;
    if (currentUser == null || currentUser.getId() == null) {
        throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
    }
    // 从数据库查询(追求性能的话可以注释,直接返回上述结果)
    long userId = currentUser.getId();
    currentUser = this.getById(userId);
    if (currentUser == null) {
        throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
    }
    return currentUser;
}

2、接口开发

在 UserController 中新增获取当前登录用户接口,注意一定要 返回脱敏后的用户信息!

@GetMapping("/get/login")
public BaseResponse<LoginUserVO> getLoginUser(HttpServletRequest request) {
    User loginUser = userService.getLoginUser(request);
    return ResultUtils.success(userService.getLoginUserVO(loginUser));
}

用户注销

可以从 re‍quest 请求对象对⁡应的 Session ‎中直接获取到之前保存的‎登录用户信息,来完成注销,无需其他请求参数。

1、服务开发

service 包的 UserService 中增加方法声明:

/**
 * 用户注销
 *
 * @param request
 * @return
 */
boolean userLogout(HttpServletRequest request);

在 Use‍rServiceI⁡mpl 中增加实现‎代码,从 Sess‎ion 中移除掉当前用户的登录态即可:

@Override
public boolean userLogout(HttpServletRequest request) {
    // 先判断是否已登录
    Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
    if (userObj == null) {
        throw new BusinessException(ErrorCode.OPERATION_ERROR, "未登录");
    }
    // 移除登录态
    request.getSession().removeAttribute(USER_LOGIN_STATE);
    return true;
}

2、接口开发

在 UserController 中新增用户注销接口:

@PostMapping("/logout")
public BaseResponse<Boolean> userLogout(HttpServletRequest request) {
    ThrowUtils.throwIf(request == null, ErrorCode.PARAMS_ERROR);
    boolean result = userService.userLogout(request);
    return ResultUtils.success(result);
}

用户权限控制

权限校验其实是一个比较通用的业务需求,一般会通过 Spring AOP 切面 + 自定义权限校验注解 实现统一的接口拦截和权限校验。如果有特殊的权限校验逻辑,再单独在接口中编码。

1、权限校验注解

首先编写权限校验注解,放到 annotation 包下:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {

    /**
     * 必须有某个角色
     */
    String mustRole() default "";
}

2、权限校验切面

编写权限校验 AOP,采用环绕通知,在 打上该注解的方法 执行前后进行一些额外的操作,比如校验权限。

代码如下,放到 aop 包下:

@Aspect
@Component
public class AuthInterceptor {

    @Resource
    private UserService userService;

    /**
     * 执行拦截
     *
     * @param joinPoint 切入点
     * @param authCheck 权限校验注解
     */
    @Around("@annotation(authCheck)")
    public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
        String mustRole = authCheck.mustRole();
        RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
        // 当前登录用户
        User loginUser = userService.getLoginUser(request);
        UserRoleEnum mustRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
        // 不需要权限,放行
        if (mustRoleEnum == null) {
            return joinPoint.proceed();
        }
        // 以下为:必须有该权限才通过
        // 获取当前用户具有的权限
        UserRoleEnum userRoleEnum = UserRoleEnum.getEnumByValue(loginUser.getUserRole());
        // 没有权限,拒绝
        if (userRoleEnum == null) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
        }
        // 要求必须有管理员权限,但用户没有管理员权限,拒绝
        if (UserRoleEnum.ADMIN.equals(mustRoleEnum) && !UserRoleEnum.ADMIN.equals(userRoleEnum)) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
        }
        // 通过权限校验,放行
        return joinPoint.proceed();
    }
}

3、使用注解

只要给方法添加了 @AuthCheck 注解,就必须要登录,否则会抛出异常。

可以设置 ‍mustRole ⁡为管理员,这样仅管‎理员才能使用该接口‎:

@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)

对于不需要登录就能使用的接口,不需要使用该注解。

💡 如果要学习更复杂的企业级权限控制方案和 Sa-Token 权限校验框架,可以看 编程导航的智能协同云图库项目

用户管理

用户管理需求具体可以拆分为:

  • 【管理员】创建用户
  • 【管理员】根据 id 删除用户
  • 【管理员】更新用户
  • 【管理员】分页获取用户列表(需要脱敏)
  • 【管理员】根据 id 获取用户(未脱敏)
  • 根据 id 获取用户(脱敏)

1、数据模型

1)每个操作都需要提供一个请求类,都放在 dto.user 包下。

image-ce2aa0c8

用户创建请求:

@Data
public class UserAddRequest implements Serializable {

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 用户头像
     */
    private String userAvatar;

    /**
     * 用户简介
     */
    private String userProfile;

    /**
     * 用户角色: user, admin
     */
    private String userRole;

    private static final long serialVersionUID = 1L;
}

用户更新请求:

@Data
public class UserUpdateRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 用户头像
     */
    private String userAvatar;

    /**
     * 简介
     */
    private String userProfile;

    /**
     * 用户角色:user/admin
     */
    private String userRole;

    private static final long serialVersionUID = 1L;
}

用户查询请求,需要继承公共包中的 PageRequest 来支持分页查询:

@EqualsAndHashCode(callSuper = true)
@Data
public class UserQueryRequest extends PageRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 简介
     */
    private String userProfile;

    /**
     * 用户角色:user/admin/ban
     */
    private String userRole;

    private static final long serialVersionUID = 1L;
}

2)由于要‍提供获取用户信息的⁡接口,需要和获取当‎前登录用户接口一样‎对用户信息进行脱敏。

model.vo 包下新建 UserVO,表示脱敏后的用户:

@Data
public class UserVO implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 用户头像
     */
    private String userAvatar;

    /**
     * 用户简介
     */
    private String userProfile;

    /**
     * 用户角色:user/admin
     */
    private String userRole;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    private static final long serialVersionUID = 1L;
}

2、服务开发

1)在 U‍serServic⁡e 中编写获取脱敏‎后的单个用户信息、‎获取脱敏后的用户列表方法:

@Override
public UserVO getUserVO(User user) {
    if (user == null) {
        return null;
    }
    UserVO userVO = new UserVO();
    BeanUtil.copyProperties(user, userVO);
    return userVO;
}

@Override
public List<UserVO> getUserVOList(List<User> userList) {
    if (CollUtil.isEmpty(userList)) {
        return new ArrayList<>();
    }
    return userList.stream().map(this::getUserVO).collect(Collectors.toList());
}

2)除了上述方法外,‍对于分页查询接口,需要根据用户传入的参数⁡来构造 SQL 查询。由于使用 MyBa‎tis Flex 框架,不用自己拼接 S‎QL 了,而是通过构造 QueryWrapper 对象来生成 SQL 查询。

可以在 U‍serServic⁡e 中编写一个方法‎,专门用于将查询请‎求转为 QueryWrapper 对象:

@Override
public QueryWrapper getQueryWrapper(UserQueryRequest userQueryRequest) {
    if (userQueryRequest == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数为空");
    }
    Long id = userQueryRequest.getId();
    String userAccount = userQueryRequest.getUserAccount();
    String userName = userQueryRequest.getUserName();
    String userProfile = userQueryRequest.getUserProfile();
    String userRole = userQueryRequest.getUserRole();
    String sortField = userQueryRequest.getSortField();
    String sortOrder = userQueryRequest.getSortOrder();
    return QueryWrapper.create()
            .eq("id", id)
            .eq("userRole", userRole)
            .like("userAccount", userAccount)
            .like("userName", userName)
            .like("userProfile", userProfile)
            .orderBy(sortField, "ascend".equals(sortOrder));
}

更多 QueryWrapper 的详细用法可以查看 MyBatis Flex 的官方文档,支持关联查询,很方便。

3、接口开发

上述功能其实都是样板代码,俗称 “增删改查”。

代码实现比‍较简单,注意添加对⁡应的权限注解、做好‎参数校验即可:

/**
 * 创建用户
 */
@PostMapping("/add")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Long> addUser(@RequestBody UserAddRequest userAddRequest) {
    ThrowUtils.throwIf(userAddRequest == null, ErrorCode.PARAMS_ERROR);
    User user = new User();
    BeanUtil.copyProperties(userAddRequest, user);
    // 默认密码 12345678
    final String DEFAULT_PASSWORD = "12345678";
    String encryptPassword = userService.getEncryptPassword(DEFAULT_PASSWORD);
    user.setUserPassword(encryptPassword);
    boolean result = userService.save(user);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
    return ResultUtils.success(user.getId());
}

/**
 * 根据 id 获取用户(仅管理员)
 */
@GetMapping("/get")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<User> getUserById(long id) {
    ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
    User user = userService.getById(id);
    ThrowUtils.throwIf(user == null, ErrorCode.NOT_FOUND_ERROR);
    return ResultUtils.success(user);
}

/**
 * 根据 id 获取包装类
 */
@GetMapping("/get/vo")
public BaseResponse<UserVO> getUserVOById(long id) {
    BaseResponse<User> response = getUserById(id);
    User user = response.getData();
    return ResultUtils.success(userService.getUserVO(user));
}

/**
 * 删除用户
 */
@PostMapping("/delete")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> deleteUser(@RequestBody DeleteRequest deleteRequest) {
    if (deleteRequest == null || deleteRequest.getId() <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    boolean b = userService.removeById(deleteRequest.getId());
    return ResultUtils.success(b);
}

/**
 * 更新用户
 */
@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> updateUser(@RequestBody UserUpdateRequest userUpdateRequest) {
    if (userUpdateRequest == null || userUpdateRequest.getId() == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    User user = new User();
    BeanUtil.copyProperties(userUpdateRequest, user);
    boolean result = userService.updateById(user);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
    return ResultUtils.success(true);
}

/**
 * 分页获取用户封装列表(仅管理员)
 *
 * @param userQueryRequest 查询请求参数
 */
@PostMapping("/list/page/vo")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<UserVO>> listUserVOByPage(@RequestBody UserQueryRequest userQueryRequest) {
    ThrowUtils.throwIf(userQueryRequest == null, ErrorCode.PARAMS_ERROR);
    long pageNum = userQueryRequest.getPageNum();
    long pageSize = userQueryRequest.getPageSize();
    Page<User> userPage = userService.page(Page.of(pageNum, pageSize),
            userService.getQueryWrapper(userQueryRequest));
    // 数据脱敏
    Page<UserVO> userVOPage = new Page<>(pageNum, pageSize, userPage.getTotalRow());
    List<UserVO> userVOList = userService.getUserVOList(userPage.getRecords());
    userVOPage.setRecords(userVOList);
    return ResultUtils.success(userVOPage);
}

💡 有同‍学可能会有疑惑:不⁡是说不要在 Con‎troller 中‎写业务逻辑代码么?

我的建议是 开发时要灵活一些,我们要保证 Controller 的精简没错,但不代表什么代码都不在 Controller 里写。对于我们上述的代码,根本就没有复杂的业务逻辑,而且也已经调用 Service 来实现了。如果非要抽一层 Service 方法也不是不行,但会更麻烦一些。

功能测试

最后,使用 S‍wagger 接口文档依次⁡对上述接口进行测试,尤其是‎测试用户权限和分页接口。可‎以先注册用户,然后到数据库中修改权限为管理员,便于测试。

image-67a97169

数据精度修复

在测试中,如‍果你打开 F12 开发⁡者工具,利用预览来查看‎响应数据,就会发现一个‎问题:id 的最后两位好像都变成 0 了!

image-86f0d4ca

但是在响应中、以及 Swagger 中查看,却是正常的:

image-34e83ebf

这是由于前端 ‍JS 的精度范围有限,我们⁡后端返回的 id 范围过大‎,导致前端解析 JSON ‎时出现精度丢失,会影响前端页面获取到的数据结果。

为了解决这个问题,可以在后端 config 包下新建一个全局 JSON 配置,将整个后端 Spring MVC 接口返回值的长整型数字转换为字符串进行返回,从而集中解决问题。

/**
 * Spring MVC Json 配置
 */
@JsonComponent
public class JsonConfig {

    /**
     * 添加 Long 转 json 精度丢失的配置
     */
    @Bean
    public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        SimpleModule module = new SimpleModule();
        module.addSerializer(Long.class, ToStringSerializer.instance);
        module.addSerializer(Long.TYPE, ToStringSerializer.instance);
        objectMapper.registerModule(module);
        return objectMapper;
    }
}

重启项目进行测试,这次看到的 id 值就正常了:

image-f4ee1e67

至此,用户‍相关的后端接口开发⁡完毕,大家可以按需‎完善上述代码。

前端开发

新建页面和路由

按照下图的‍结构新建页面文件,⁡将所有页面按照 u‎rl 层级进行创建‎,并且页面名称尽量做到 “见名知意”。

image-7f2b355b

其中,/user/login 地址就对应了 UserLoginPage,以此类推。

修改 router/index.ts 的路由配置:

routes: [
  {
    path: '/',
    name: '主页',
    component: HomePage,
  },
  {
    path: '/user/login',
    name: '用户登录',
    component: UserLoginPage,
  },
  {
    path: '/user/register',
    name: '用户注册',
    component: UserRegisterPage,
  },
  {
    path: '/admin/userManage',
    name: '用户管理',
    component: UserManagePage,
  },
],

记得执行一‍下 openapi⁡ 工具生成接口对应‎的请求代码,每次后‎端改动时都需要这么做。

获取当前登录用户 - 全局状态管理

由于之后很‍多页面都需要获取到当⁡前登录用户信息,需要‎把这个数据作为全局状‎态来管理,而不是每个页面分别编写获取逻辑。

全局状态管理能力可以让 所有页面全局共享变量,而不是局限在某一个页面中。

已登录用户‍信息就很适合作为全⁡局状态的数据,因为‎每个页面几乎都要用‎。

此处我们选择主流的状态管理库 Pinia,相比于 Vuex 来说使用更简单,可参考 入门文档 进行引入。

1、引入 Pinia

此处由于创建项目时使用的 create-vue 脚手架已经帮我们整合了 Pinia,无需手动引入,直接使用即可。

2、定义状态

在 src/stores 目录下新建 loginUser.ts 文件,定义了登录用户的存储、远程获取、修改逻辑:

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getLoginUser } from '@/api/userController.ts'

export const useLoginUserStore = defineStore('loginUser', () => {
  // 默认值
  const loginUser = ref<API.LoginUserVO>({
    userName: '未登录',
  })

  // 获取登录用户信息
  async function fetchLoginUser() {
    const res = await getLoginUser()
    if (res.data.code === 0 && res.data.data) {
      loginUser.value = res.data.data
    }
  }
  // 更新登录用户信息
  function setLoginUser(newLoginUser: any) {
    loginUser.value = newLoginUser
  }

  return { loginUser, setLoginUser, fetchLoginUser }
})

3、使用状态

可以直接使用 store 中导出的状态变量和函数。

在首次进入到页面时,一般我们会尝试获取登录用户信息。所以修改入口文件 App.vue,编写远程获取数据代码:

import { useLoginUserStore } from '@/stores/loginUser.ts'

const loginUserStore = useLoginUserStore()
loginUserStore.fetchLoginUser()

在任何页面‍中都可以使用数据,⁡比如 Global‎Header 全局‎顶部栏组件中直接展示:

// HTML 展示数据
{{ JSON.stringify(loginUserStore.loginUser) }}

// JS 中引入 Store
import { useLoginUserStore } from '@/stores/loginUser.ts'
const loginUserStore = useLoginUserStore()

修改全局顶部栏组件,在右侧展示登录用户信息:

<div class="user-login-status">
  <div v-if="loginUserStore.loginUser.id">
    <a-space>
      <a-avatar :src="loginUserStore.loginUser.userAvatar" />
      {{ loginUserStore.loginUser.userName ?? '无名' }}
    </a-space>
  </div>
  <div v-else>
    <a-button type="primary" href="/user/login">登录</a-button>
  </div>
</div>

效果如图:

image-7761c3b6

用户登录页面

开发 UserLoginPage.vue,可以基于 Ant Design Vue 的表单组件 快速开发登录页面。

先开发基础页面,可以按照需要增加一些前端校验,代码如下:

<template>
  <div id="userLoginPage">
    <h2 class="title">鱼皮 AI 应用生成 - 用户登录</h2>
    <div class="desc">不写一行代码,生成完整应用</div>
    <a-form :model="formState" name="basic" autocomplete="off" @finish="handleSubmit">
      <a-form-item name="userAccount" :rules="[{ required: true, message: '请输入账号' }]">
        <a-input v-model:value="formState.userAccount" placeholder="请输入账号" />
      </a-form-item>
      <a-form-item
        name="userPassword"
        :rules="[
          { required: true, message: '请输入密码' },
          { min: 8, message: '密码不能小于 8 位' },
        ]"
      >
        <a-input-password v-model:value="formState.userPassword" placeholder="请输入密码" />
      </a-form-item>
      <div class="tips">
        没有账号?
        <RouterLink to="/user/register">去注册</RouterLink>
      </div>
      <a-form-item>
        <a-button type="primary" html-type="submit" style="width: 100%">登录</a-button>
      </a-form-item>
    </a-form>
  </div>
</template>

定义一个响‍应式变量来接受表单⁡输入的值,当这个值‎改变时,页面展示的‎元素也会进行更新:

const formState = reactive<API.UserLoginRequest>({
  userAccount: '',
  userPassword: '',
})

编写 CSS 样式,美化页面:

#userLoginPage {
  max-width: 360px;
  margin: 0 auto;
}

.title {
  text-align: center;
  margin-bottom: 16px;
}

.desc {
  text-align: center;
  color: #bbb;
  margin-bottom: 16px;
}

.tips {
  margin-bottom: 16px;
  color: #bbb;
  font-size: 13px;
  text-align: right;
}

编写表单提‍交后执行的函数,登⁡陆成功后需要在全局‎状态中记录当前登录‎用户的信息,并跳转到主页:

const router = useRouter()
const loginUserStore = useLoginUserStore()

/**
 * 提交表单
 * @param values
 */
const handleSubmit = async (values: any) => {
  const res = await userLogin(values)
  // 登录成功,把登录态保存到全局状态中
  if (res.data.code === 0 && res.data.data) {
    await loginUserStore.fetchLoginUser()
    message.success('登录成功')
    router.push({
      path: '/',
      replace: true,
    })
  } else {
    message.error('登录失败,' + res.data.message)
  }
}

页面效果如图:

image-e6b711c2

用户注册页面 - Vibe Coding

同样使用表单‍组件,在用户登录页面的基⁡础上调整即可,涉及到更多‎表单项的填写。     ‎

这次,我们直接使用 AI 生成,输入提示词:

请仿照用户登录页面 @UserLoginPage.vue 的样式和代码风格,帮我完善用户注册页面 @UserRegisterPage.vue 的代码,请求代码和接口信息在 @/api 目录下。要求注册成功后自动跳转到登录页面。

最好利用 @ 关联到对应的文件:

image-e2e627d5

生成的完整代‍码如下,这里我惊讶地发⁡现竟然跟我之前自己手写‎的神似!       ‎

<template>
  <div id="userRegisterPage">
    <h2 class="title">鱼皮 AI 应用生成 - 用户注册</h2>
    <div class="desc">不写一行代码,生成完整应用</div>
    <a-form :model="formState" name="basic" autocomplete="off" @finish="handleSubmit">
      <a-form-item name="userAccount" :rules="[{ required: true, message: '请输入账号' }]">
        <a-input v-model:value="formState.userAccount" placeholder="请输入账号" />
      </a-form-item>
      <a-form-item
        name="userPassword"
        :rules="[
          { required: true, message: '请输入密码' },
          { min: 8, message: '密码不能小于 8 位' },
        ]"
      >
        <a-input-password v-model:value="formState.userPassword" placeholder="请输入密码" />
      </a-form-item>
      <a-form-item
        name="checkPassword"
        :rules="[
          { required: true, message: '请确认密码' },
          { min: 8, message: '密码不能小于 8 位' },
          { validator: validateCheckPassword },
        ]"
      >
        <a-input-password v-model:value="formState.checkPassword" placeholder="请确认密码" />
      </a-form-item>
      <div class="tips">
        已有账号?
        <RouterLink to="/user/login">去登录</RouterLink>
      </div>
      <a-form-item>
        <a-button type="primary" html-type="submit" style="width: 100%">注册</a-button>
      </a-form-item>
    </a-form>
  </div>
</template>


import { useRouter } from 'vue-router'
import { userRegister } from '@/api/userController.ts'
import { message } from 'ant-design-vue'
import { reactive } from 'vue'

const router = useRouter()

const formState = reactive<API.UserRegisterRequest>({
  userAccount: '',
  userPassword: '',
  checkPassword: '',
})

/**
 * 验证确认密码
 * @param rule
 * @param value
 * @param callback
 */
const validateCheckPassword = (rule: unknown, value: string, callback: (error?: Error) => void) => {
  if (value && value !== formState.userPassword) {
    callback(new Error('两次输入密码不一致'))
  } else {
    callback()
  }
}

/**
 * 提交表单
 * @param values
 */
const handleSubmit = async (values: API.UserRegisterRequest) => {
  const res = await userRegister(values)
  // 注册成功,跳转到登录页面
  if (res.data.code === 0) {
    message.success('注册成功')
    router.push({
      path: '/user/login',
      replace: true,
    })
  } else {
    message.error('注册失败,' + res.data.message)
  }
}



#userRegisterPage {
  max-width: 360px;
  margin: 0 auto;
}

.title {
  text-align: center;
  margin-bottom: 16px;
}

.desc {
  text-align: center;
  color: #bbb;
  margin-bottom: 16px;
}

.tips {
  margin-bottom: 16px;
  color: #bbb;
  font-size: 13px;
  text-align: right;
}

页面效果如图:

image-a7db64df

用户注销

一般鼠标悬‍浮在右上角用户头像⁡时,会展示包含用户‎注销(退出登录)功‎能的下拉菜单。

先开发页面结构:

<div v-if="loginUserStore.loginUser.id">
  <a-dropdown>
    <a-space>
      <a-avatar :src="loginUserStore.loginUser.userAvatar" />
      {{ loginUserStore.loginUser.userName ?? '无名' }}
    </a-space>
    <template #overlay>
      <a-menu>
        <a-menu-item @click="doLogout">
          <LogoutOutlined />
          退出登录
        </a-menu-item>
      </a-menu>
    </template>
  </a-dropdown>
</div>

编写用户注销事件函数,退出登录后跳转到登录页:

import { LogoutOutlined } from '@ant-design/icons-vue'

// 用户注销
const doLogout = async () => {
  const res = await userLogout()
  if (res.data.code === 0) {
    loginUserStore.setLoginUser({
      userName: '未登录',
    })
    message.success('退出登录成功')
    await router.push('/user/login')
  } else {
    message.error('退出登录失败,' + res.data.message)
  }
}

效果如图:

image-a21c7e2d

用户管理页面

需求:允许‍管理员查看已注册的⁡用户信息,能够根据‎用户名称搜索用户、‎并删除非法用户。

需要注意,‍要防止普通用户也能看⁡到用户信息,所以要增‎加一定的权限控制,可‎以分为页面开发和权限控制两个步骤来实现。

编写页面:上方搜索栏,下方表格,表格需要支持分页。

我们可以拆分为几个步骤来开发:

1、表格

1)利用 Ant Design Vue 的表格组件,找到需要的组件进行复制,先展示全部用户信息。

image-978ca850

2)只需要根‍据自己的数据表,编写 c⁡olumns 表格列,并‎传入获取到的 data ‎数据,组件就能自动帮我们展示出表格,非常方便。

定义表格列:

const columns = [
  {
    title: 'id',
    dataIndex: 'id',
  },
  {
    title: '账号',
    dataIndex: 'userAccount',
  },
  {
    title: '用户名',
    dataIndex: 'userName',
  },
  {
    title: '头像',
    dataIndex: 'userAvatar',
  },
  {
    title: '简介',
    dataIndex: 'userProfile',
  },
  {
    title: '用户角色',
    dataIndex: 'userRole',
  },
  {
    title: '创建时间',
    dataIndex: 'createTime',
  },
  {
    title: '操作',
    key: 'action',
  },
]

3)从后端获取数据:

// 数据
const data = ref<API.UserVO[]>([])
const total = ref(0)

// 搜索条件
const searchParams = reactive<API.UserQueryRequest>({
  pageNum: 1,
  pageSize: 10,
})

// 获取数据
const fetchData = async () => {
  const res = await listUserVoByPage({
    ...searchParams,
  })
  if (res.data.data) {
    data.value = res.data.data.records ?? []
    total.value = res.data.data.totalRow ?? 0
  } else {
    message.error('获取数据失败,' + res.data.message)
  }
}

// 页面加载时请求一次
onMounted(() => {
  fetchData()
})

注意,上述‍代码中,使用 onM⁡ounted 包裹获‎取数据的函数,可以使‎得在页面加载时仅发送一次请求,避免重复获取。

运行项目,可以看到数据被正常加载,但是显然展示效果并不好。

image-99081f2b

4)对于图‍片、用户角色、创建⁡时间、更新时间之类‎的数据,我们可以有‎更好的展示方式。

表格组件支‍持我们使用 Vue⁡ 的插槽自定义列的‎展示,参考 Dem‎o 有样学样修改即可。

<template #bodyCell="{ column, record }">
  <template v-if="column.dataIndex === 'userAvatar'">
    <a-image :src="record.userAvatar" :width="120" />
  </template>
  <template v-else-if="column.dataIndex === 'userRole'">
    <div v-if="record.userRole === 'admin'">
      <a-tag color="green">管理员</a-tag>
    </div>
    <div v-else>
      <a-tag color="blue">普通用户</a-tag>
    </div>
  </template>
  <template v-else-if="column.dataIndex === 'createTime'">
    {{ dayjs(record.createTime).format('YYYY-MM-DD HH:mm:ss') }}
  </template>
  <template v-else-if="column.key === 'action'">
    <a-button danger>删除</a-button>
  </template>
</template>

效果如图:

image-5749643e

2、分页

1)表格组件 支持分页,首先编写一个分页变量,指定当前页号、页面大小、数据总数、展示总数的文案等参数:

// 分页参数
const pagination = computed(() => {
  return {
    current: searchParams.pageNum ?? 1,
    pageSize: searchParams.pageSize ?? 10,
    total: total.value,
    showSizeChanger: true,
    showTotal: (total: number) => `共 ${total} 条`,
  }
})

注意,由于这些‍参数都是动态变化的,需要使⁡用 Vue 的 compu‎ted 计算属性,否则当 ‎searchParams 改变时,分页参数并不会更新。

2)编写 d‍oTableChange⁡ 函数,当用户切换页号和‎页面大小时,需要更新 s‎earchParams 搜索条件的值,并触发搜索:

// 表格变化处理
const doTableChange = (page: any) => {
  searchParams.pageNum = page.current
  searchParams.pageSize = page.pageSize
  fetchData()
}

3)给表格组件绑定分页参数和表格切换事件:

<a-table
  :columns="columns"
  :data-source="data"
  :pagination="pagination"
  @change="doTableChange"
>

效果如图:

image-21f4e8e9

3、搜索

利用 Ant Design Vue 的搜索组件,实现对数据的搜索。

1)其实搜‍索本质上就是让用户⁡填写搜索条件表单,‎此处我们需要根据账‎号和用户昵称搜索,编写表单代码:

<div id="userManagePage">
  <!-- 搜索表单 -->
  <a-form layout="inline" :model="searchParams" @finish="doSearch">
    <a-form-item label="账号">
      <a-input v-model:value="searchParams.userAccount" placeholder="输入账号" />
    </a-form-item>
    <a-form-item label="用户名">
      <a-input v-model:value="searchParams.userName" placeholder="输入用户名" />
    </a-form-item>
    <a-form-item>
      <a-button type="primary" html-type="submit">搜索</a-button>
    </a-form-item>
  </a-form>
  <a-divider />
  <!-- 表格 -->
</div>

使用 sea‍rchParams 接⁡受用户的输入,相当于所‎有的搜索条件全部存到了‎ searchParams 中,便于统一维护。

2)点击搜‍索按钮时会触发表单⁡提交,表单提交时需‎要将页号重置为 1‎,并获取数据。编写对应的函数:

// 获取数据
const doSearch = () => {
  // 重置页码
  searchParams.pageNum = 1
  fetchData()
}

效果如图,可以验证下搜索功能:

image-182cac00

4、操作

比如开发删除功能,先编写点击删除按钮后的处理函数:

// 删除数据
const doDelete = async (id: string) => {
  if (!id) {
    return
  }
  const res = await deleteUser({ id })
  if (res.data.code === 0) {
    message.success('删除成功')
    // 刷新数据
    fetchData()
  } else {
    message.error('删除失败')
  }
}

然后给删除按钮绑定事件:

<a-button danger @click="doDelete(record.id)">删除</a-button>

用户权限控制

虽然后端获取用户列表的接口做了权限校验,防止非管理员用户获取到。但一般情况下,为了保证系统安全和提升用户体验,前端也需要对权限进行控制。

有 2 种实现方式:单页面控制权限,或者全局控制权限。

思路都是一致‍的,在进入某个页面时判断⁡用户是否具有该页面的权限‎,无非是把权限校验相关的‎代码写在单个页面内,还是写到一个独立的文件中罢了。

  • *建议编写独立的全局权限控制文件。**可以利用 Vue Router 的路由守卫实现,每次切换并进入页面前,都会检查一下当前用户是否具有特定页面的权限。

在 src 下编写 access.ts 权限校验文件,可以自己定义逻辑,比如用页面前缀来统一判断:

import { useLoginUserStore } from '@/stores/loginUser'
import { message } from 'ant-design-vue'
import router from '@/router'

// 是否为首次获取登录用户
let firstFetchLoginUser = true

/**
 * 全局权限校验
 */
router.beforeEach(async (to, from, next) => {
  const loginUserStore = useLoginUserStore()
  let loginUser = loginUserStore.loginUser
  // 确保页面刷新,首次加载时,能够等后端返回用户信息后再校验权限
  if (firstFetchLoginUser) {
    await loginUserStore.fetchLoginUser()
    loginUser = loginUserStore.loginUser
    firstFetchLoginUser = false
  }
  const toUrl = to.fullPath
  if (toUrl.startsWith('/admin')) {
    if (!loginUser || loginUser.userRole !== 'admin') {
      message.error('没有权限')
      next(`/user/login?redirect=${to.fullPath}`)
      return
    }
  }
  next()
})

注意,上述代码中,我们为了确‍保页面刷新时,从后端拿到用户信息后再进行权限校验,使用⁡ await 等待后端接口返回,并重新赋值给 logi‎nUser。同时,为了防止每次切换路由都从远程获取用户‎信息,定义了 firstFetchLoginUser 变量,用于控制在刷新页面后只会请求后端一次。

由于在 access.ts 中已经获取到了登录用户信息,可以移除掉之前在 App.vue 中获取登录用户信息的逻辑,避免重复请求。

在 main.ts(全局入口文件)中引入:

import '@/access'

用一个未登‍录的用户来测试,尝⁡试访问用户管理页面‎,会报权限错误并跳‎转到登录页:

image-42176b12

虽然已经满足了需‍求,但是如果用户不是管理员,其⁡实不应该看到 “用户管理” ‎菜单。因此我们还要修改 Glob‎alHeader 菜单项配置,根据权限决定是否展示某些菜单项。

编写一个过滤菜单项的函数:

// 菜单配置项
const originItems = [
  {
    key: '/',
    icon: () => h(HomeOutlined),
    label: '主页',
    title: '主页',
  },
  {
    key: '/admin/userManage',
    label: '用户管理',
    title: '用户管理',
  },
  {
    key: 'others',
    label: h('a', { href: '<https://www.codefather.cn>', target: '_blank' }, '编程导航'),
    title: '编程导航',
  },
]

// 过滤菜单项
const filterMenus = (menus = [] as MenuProps['items']) => {
  return menus?.filter((menu) => {
    const menuKey = menu?.key as string
    if (menuKey?.startsWith('/admin')) {
      const loginUser = loginUserStore.loginUser
      if (!loginUser || loginUser.userRole !== 'admin') {
        return false
      }
    }
    return true
  })
}

// 展示在菜单的路由数组
const menuItems = computed<MenuProps['items']>(() => filterMenus(originItems))

测试效果,未登录时不会看到 “用户管理” 菜单:

image-94712996

已登录的管理员可以看到菜单:

image-94712996

扩展思路

1、编辑用户信息

1)目前的用户管理页面还不支持编辑功能,前端可以利用 组件库文档 的编辑行能力 + AI 快速完成。

2)目前用‍户无法编辑个人的信⁡息,可以在右上角增‎加 “个人中心 /‎ 个人设置”,点击后进入编辑用户信息的表单。

2、全局权限管理(提供实现方案)

在本节 “用户权限‍控制” 部分,我们使用了一种较为简单⁡的方式实现,但是,后续补充页面和权限‎校验逻辑时,我们要同时修改权限管理文‎件和导航栏文件,相当于维护了 2 个文件,比较麻烦,有没有更好的办法呢?

需求:能够通过维护 一个配置文件,灵活配置每个页面所需要的用户权限,由全局权限管理系统自动校验和拦截,而不需要在每个页面中编写权限校验代码,提高开发效率。此外,还能根据该配置文件自动隐藏没权限的菜单项的展示。

实现方案:

  1. 在路由配置文件,利用 Vue Router 的 meta 附加参数,定义某个路由的访问权限
  2. 使用全局路由监听器,每次访问页面时,根据用户要访问页面的路由权限信息,判断用户是否有对应的访问权限,并进行相应的拦截处理。

需要先自行‍新建 NoAuth⁡ 无权限页面,内容‎随便写,比如显示 ‎“你没有权限”。

新建 ac‍cess 目录,所⁡有权限管理相关的代‎码都放在该目录下,‎模块化。只要不引入,就不会生效。

1)定义权限枚举文件 accessEnum.ts:

/**
 * 权限定义
 */
const ACCESS_ENUM = {
  NOT_LOGIN: "notLogin",
  USER: "user",
  ADMIN: "admin",
};

export default ACCESS_ENUM;

2)修改路由配置文件,在 meta 中补充需要的权限:

{
  path: '/admin/userManage',
  name: 'adminUserManage',
  component: UserManagePage,
  meta: {
    access: ACCESS_ENUM.ADMIN,
  },
},

3)编写通用的权限校验方法。

问:为什么要这么做?

答:因为菜‍单组件中要判断权限⁡来过滤展示的菜单项‎、权限拦截也要用到‎权限判断功能,所以抽离成公共模块。

checkAccess.ts 文件:

import ACCESS_ENUM from "@/access/accessEnum";

/**
 * 检查权限(判断当前登录用户是否具有某个权限)
 * @param loginUser 当前登录用户
 * @param needAccess 需要有的权限
 * @return boolean 有无权限
 */
const checkAccess = (loginUser: any, needAccess = ACCESS_ENUM.NOT_LOGIN) => {
  // 获取当前登录用户具有的权限(如果没有 loginUser,则表示未登录)
  const loginUserAccess = loginUser?.userRole ?? ACCESS_ENUM.NOT_LOGIN;
  if (needAccess === ACCESS_ENUM.NOT_LOGIN) {
    return true;
  }
  // 如果用户登录才能访问
  if (needAccess === ACCESS_ENUM.USER) {
    // 如果用户没登录,那么表示无权限
    if (loginUserAccess === ACCESS_ENUM.NOT_LOGIN) {
      return false;
    }
  }
  // 如果需要管理员权限
  if (needAccess === ACCESS_ENUM.ADMIN) {
    // 如果不为管理员,表示无权限
    if (loginUserAccess !== ACCESS_ENUM.ADMIN) {
      return false;
    }
  }
  return true;
};

export default checkAccess;

4)编写全局权限校验核心文件 access/index.ts

逻辑如下:

  1. 首先判断页面是否需要登录权限,如果不需要,直接放行。
  2. 如果页面需要登录权限
  3. 如果用户未登录,则跳转到登录页面。
  4. 如果已登录,判断登录用户的权限是否符合要求,否则跳转到 401 无权限页面。

实现代码如下:

import router from '@/router'
import { useLoginUserStore } from '@/stores/useLoginUserStore'
import ACCESS_ENUM from './accessEnum'
import checkAccess from './checkAccess'

router.beforeEach(async (to, from, next) => {
  const loginUserStore = useLoginUserStore()
  let loginUser = loginUserStore.loginUser
  console.log('登陆用户信息', loginUser)
  const needAccess = (to.meta?.access as string) ?? ACCESS_ENUM.NOT_LOGIN
  // 要跳转的页面必须要登陆
  if (needAccess !== ACCESS_ENUM.NOT_LOGIN) {
    // 如果没登陆,跳转到登录页面
    if (!loginUser || !loginUser.userRole || loginUser.userRole === ACCESS_ENUM.NOT_LOGIN) {
      next(`/user/login?redirect=${to.fullPath}`)
      return
    }
    // 如果已经登陆了,但是权限不足,那么跳转到无权限页面
    if (!checkAccess(loginUser, needAccess)) {
      next('/noAuth')
      return
    }
  }
  next()
})

注意,必须保证 pinia 初始化在这段代码执行前,所以需要将 useLoginUserStore() 函数放到 router.beforeEach 参数里。参考文档:https://pinia.vuejs.org/core-concepts/outside-component-usage.html

在 main.ts 中引入,即可生效权限校验:

import "@/access";

5)支持全局自动登录。如果是 首次 进入页面,状态为未登陆,则自动登录。

如何区别是否为首次进入页面(还没尝试过获取登录用户)呢?

默认的 log‍inUser 是没有 us⁡erRole 的,只要获取‎过,哪怕未登录,也可以给设‎置一个 userRole 为 “notLogin”。

access/index.ts 开头补充自动登录逻辑

// // 如果之前没登陆过,自动登录
if (!loginUser || !loginUser.userRole) {
  // 加 await 是为了等用户登录成功之后,再执行后续的代码
  await loginUserStore.fetchLoginUser();
  loginUser = loginUserStore.loginUser;
}

之后记得移除 App.vue 中获取登录信息的调用。

6)根据权限控制菜单显隐。

需求:只有具有权限的菜单,才对用户可见

原理:

  1. 遍历原菜单项列表,针对菜单 key 对应的 path 路径,找到路由中对应的 meta 权限配置。只要判断用户没有这个权限,就直接过滤掉。
  2. 还可以通过在路由配置的 meta 中添加 hideInMenu,灵活控制菜单的显隐。

修改 Gl‍obalHeade⁡r 全局导航栏(通‎用菜单)组件,补充‎根据权限来过滤菜单的逻辑,可自行实现。

// 过滤菜单项
const items = menus.filter((menu) => {
  // todo 需要自己实现 menu 到路由 item 的转化
  const item = menuToRouteItem(menu);
  if (item.meta?.hideInMenu) {
    return false;
  }
  // 根据权限过滤菜单,有权限则返回 true,则保留该菜单
  return checkAccess(loginUserStore.loginUser, item.meta?.access as string);
});

项目分区导航:⬅️ 10-应用模块 | 11-用户模块 | ➡️ 00-参赛