用户模块
一、需求分析
对于用户模块,通常要具有下列功能:
- 用户注册
- 用户登录
- 获取当前登录用户
- 用户注销
- 用户权限控制
- 【管理员】管理用户
具体分析每个需求:
1)用户注册:用户可以通过输入账号、密码、确认密码进行注册
2)用户登录:用户可以通过输入账号和密码登录
3)获取当前登录用户:得到当前已经登录的用户信息(不用重复登录)
4)用户注销:用户可以退出登录
5)用户权限控制:用户又分为普通用户和管理员,管理员拥有整个系统的最高权限,比如可以管理其他用户
6)用户管理:仅管理员可用,可以对整个系统中的用户进行管理,比如搜索用户、删除用户
二、方案设计
实现用户模块的难度不大,方案设计阶段我们需要确认:
- 库表设计
- 用户登录流程
- 如何对用户权限进行控制?
库表设计
库名:yu_picture
表名: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 '用户昵称',
userGender tinyint null comment '性别',
userAvatar varchar(1024) null comment '用户头像',
userProfile varchar(512) null comment '用户简介',
userRole varchar(256) default 'user' not null comment '用户角色:user/admin/vip',
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 文件,这样其他开发者就能更快地了解项目。
2、扩展设计
1)如果要实现会员功能,可以对表进行如下扩展:
- 给 userRole 字段新增枚举值
vip,表示会员用户,可根据该值判断用户权限 - 新增会员过期时间字段,可用于记录会员有效期
- 新增会员兑换码字段,可用于记录会员的开通方式
- 新增会员编号字段,可便于定位用户并提供额外服务,并增加会员归属感
对应的 SQL 如下:
vipExpireTime datetime null comment '会员过期时间',
vipCode varchar(128) null comment '会员兑换码',
vipNumber bigint null comment '会员编号'
2)如果要实现用户邀请功能,可以对表进行如下扩展:
- 新增 shareCode 分享码字段,用于记录每个用户的唯一邀请标识,可拼接到邀请网址后面,比如 https://mianshiya.com/?shareCode=xxx
- 新增 inviteUser 字段,用于记录该用户被哪个用户邀请了,可通过这个字段查询某用户邀请的用户列表。
对应的 SQL 如下:
shareCode varchar(20) DEFAULT NULL COMMENT '分享码',
inviteUser bigint DEFAULT NULL COMMENT '邀请用户 id'
RBAC
create database if not exists yun_picture;
use yun_picture;
-- 用户表
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 '密码',
salt varchar(128) DEFAULT NULL COMMENT '动态盐值',
userName varchar(256) null comment '用户昵称',
userGender varchar(20) null comment '性别',
userAvatar varchar(1024) DEFAULT 'https://q6.itc.cn/q_70/images03/20240613/ee600a212edb436785cece5554261729.jpeg' COMMENT '用户头像',
userPhone varchar(20) null comment '电话',
userEmail varchar(255) null comment '邮箱',
userProfile varchar(512) null comment '用户简介',
shareCode varchar(20) null comment '邀请码',
inviteUserId bigint null comment '邀请用户ID',
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 '更新时间',
userStatus varchar(10) DEFAULT 'ACTIVE' not null comment '用户状态',
isDelete tinyint default 0 not null comment '是否删除',
UNIQUE KEY uk_userAccount (userAccount),
UNIQUE KEY uk_userPhone (userPhone),
UNIQUE KEY uk_userEmail (userEmail),
UNIQUE KEY uk_shareCode (shareCode),
INDEX idx_userName (userName),
foreign key (inviteUserId) references user (id)
) comment '用户' collate = utf8mb4_unicode_ci;
-- 角色表
create table if not exists role
(
id bigint auto_increment comment '角色ID' primary key,
name varchar(256) not null comment '角色名称'
) comment '角色' collate = utf8mb4_unicode_ci;
-- 权限表
create table if not exists permission
(
id bigint auto_increment comment '权限ID' primary key,
name varchar(256) not null comment '权限名称',
code varchar(256) not null comment '权限代码'
) comment '权限' collate = utf8mb4_unicode_ci;
-- 用户角色关联表
create table if not exists user_role
(
userId bigint not null comment '用户ID',
roleId bigint not null comment '角色ID',
primary key (userId, roleId),
foreign key (userId) references user (id),
foreign key (roleId) references role (id)
) comment '用户角色关联' collate = utf8mb4_unicode_ci;
-- 角色权限关联表
create table if not exists role_permission
(
roleId bigint not null comment '角色ID',
permissionId bigint not null comment '权限ID',
primary key (roleId, permissionId),
foreign key (roleId) references role (id),
foreign key (permissionId) references permission (id)
) comment '角色权限关联' collate = utf8mb4_unicode_ci;
-- 会员表
create table if not exists vip
(
userId bigint not null comment '用户ID',
vipExpireTime datetime null comment '会员过期时间',
vipCode varchar(128) null comment '会员兑换码',
vipNumber bigint null comment '会员编号',
primary key (userId),
foreign key (userId) references user (id)
) comment '会员信息' collate = utf8mb4_unicode_ci;
用户登录流程
1)建立初始会话:前端与服务器建立连接后,服务器会为该客户端创建一个初始的匿名 Session,并将其状态保存下来。这个 Session 的 ID 会作为唯一标识,返回给前端。
2)登录成功,更新会话信息:当用户在前端输入正确的账号密码并提交到后端验证成功后,后端会更新该用户的 Session,将用户的登录信息(如用户 ID、用户名等)保存到与该 Session 关联的存储中。同时,服务器会生成一个 Set-Cookie 的响应头,指示前端保存该用户的 Session ID。
3)前端保存 Cookie:前端接收到后端的响应后,浏览器会自动根据 Set-Cookie 指令,将 Session ID 存储到浏览器的 Cookie 中,与该域名绑定。
4)带 Cookie 的后续请求:当前端再次向相同域名的服务器发送请求时,浏览器会自动在请求头中附带之前保存的 Cookie,其中包含 Session ID。
5)后端验证会话:服务器接收到请求后,从请求头中提取 Session ID,找到对应的 Session 数据。
6)获取会话中存储的信息:后端通过该 Session 获取之前存储的用户信息(如登录名、权限等),从而识别用户身份并执行相应的业务逻辑。
如何对用户权限进行控制?
可以将接口分为 4 种权限:
- 未登录也可以使用
- 登录用户才能使用
- 未登录也可以使用,但是登录用户能进行更多操作(比如登录后查看全文)
- 仅管理员才能使用
传统的权限控制方法是,在每个接口内单独编写逻辑:先获取到当前登录用户信息,然后判断用户的权限是否符合要求。
这种方法最灵活,但是会写很多重复的代码,而且其他开发者无法一眼得知接口所需要的权限。
权限校验其实是一个比较通用的业务需求,一般会通过 **Spring AOP 切面 + 自定义权限校验注解 **实现统一的接口拦截和权限校验;如果有特殊的权限校验逻辑,再单独在接口中编码。
💡 如果需要更复杂更灵活的权限控制,可以引入 Shiro / Spring Security / Sa-Token 等专门的权限管理框架。
三个注解
package com.zwnsyw.yunpicturebackend.annotation;
import java.lang.annotation.*;
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Anonymous
{
}
package com.zwnsyw.yunpicturebackend.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreAuthorize {
String value();
}
package com.zwnsyw.yunpicturebackend.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreAuthorizeRole {
String value();
}
两个拦截器
package com.zwnsyw.yunpicturebackend.config;
import com.zwnsyw.yunpicturebackend.interceptor.AuthInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**"); // 拦截所有路径
// .excludePathPatterns("/login", "/logout"); // 排除登录和登出路径
}
}
package com.zwnsyw.yunpicturebackend.config.properties;
import com.zwnsyw.yunpicturebackend.annotation.Anonymous;
import org.apache.commons.lang3.RegExUtils;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Pattern;
@Configuration
public class PermitAllUrlProperties implements InitializingBean, ApplicationContextAware {
private static final Pattern PATTERN = Pattern.compile("\\{(.*?)\\}");
private ApplicationContext applicationContext;
private List<String> urls = new ArrayList<>();
private String ASTERISK = "*";
@Override
public void afterPropertiesSet() {
RequestMappingHandlerMapping mapping = applicationContext.getBean(RequestMappingHandlerMapping.class);
Map<RequestMappingInfo, HandlerMethod> map = mapping.getHandlerMethods();
map.keySet().forEach(info -> {
HandlerMethod handlerMethod = map.get(info);
// 获取方法上边的注解 替代path variable 为 *
Anonymous method = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Anonymous.class);
Optional.ofNullable(method).ifPresent(anonymous -> Objects.requireNonNull(info.getPatternsCondition().getPatterns())
.forEach(url -> urls.add(RegExUtils.replaceAll(url, PATTERN, ASTERISK))));
// 获取类上边的注解, 替代path variable 为 *
Anonymous controller = AnnotationUtils.findAnnotation(handlerMethod.getBeanType(), Anonymous.class);
Optional.ofNullable(controller).ifPresent(anonymous -> Objects.requireNonNull(info.getPatternsCondition().getPatterns())
.forEach(url -> urls.add(RegExUtils.replaceAll(url, PATTERN, ASTERISK))));
});
}
@Override
public void setApplicationContext(ApplicationContext context) throws BeansException {
this.applicationContext = context;
}
public List<String> getUrls() {
return urls;
}
public void setUrls(List<String> urls) {
this.urls = urls;
}
}
权限校验器
package com.zwnsyw.yunpicturebackend.interceptor;
import com.zwnsyw.yunpicturebackend.annotation.PreAuthorize;
import com.zwnsyw.yunpicturebackend.annotation.PreAuthorizeRole;
import com.zwnsyw.yunpicturebackend.config.properties.PermitAllUrlProperties;
import com.zwnsyw.yunpicturebackend.exception.ErrorCode;
import com.zwnsyw.yunpicturebackend.exception.BusinessException;
import com.zwnsyw.yunpicturebackend.model.vo.LoginUserVO;
import com.zwnsyw.yunpicturebackend.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.List;
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
@Lazy
private PermitAllUrlProperties permitAllUrlProperties;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
// 检查是否为匿名访问
if (isAnonymousAccess(request)) {
return true;
}
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
if (preAuthorize != null) {
String permission = preAuthorize.value();
if (!hasPermission(request, permission)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "权限不足");
}
}
PreAuthorizeRole preAuthorizeRole = method.getAnnotation(PreAuthorizeRole.class);
if (preAuthorizeRole != null) {
String role = preAuthorizeRole.value();
if (!hasRole(request, role)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "角色不足");
}
}
}
return true;
}
private boolean isAnonymousAccess(HttpServletRequest request) {
String requestURI = request.getRequestURI();
List<String> permitAllUrls = permitAllUrlProperties.getUrls();
for (String url : permitAllUrls) {
if (requestURI.matches(url)) {
return true;
}
}
return false;
}
private boolean hasPermission(HttpServletRequest request, String permission) {
LoginUserVO loginUser = userService.getLoginUser(request);
if (loginUser == null || loginUser.getPermissions() == null) {
return false;
}
if (loginUser.getPermissions().contains("*:*:*")) {
return true;
}
return loginUser.getPermissions().contains(permission);
}
private boolean hasRole(HttpServletRequest request, String role) {
LoginUserVO loginUser = userService.getLoginUser(request);
if (loginUser == null || loginUser.getRoles() == null) {
return false;
}
if (loginUser.getRoles().contains("ADMIN")) {
return true;
}
return loginUser.getRoles().contains(role);
}
}
数据填充:
INSERT INTO permission (name, code)
VALUES ('用户查询', 'system:user:query'),
('用户新增', 'system:user:add'),
('用户修改', 'system:user:edit'),
('用户删除', 'system:user:remove'),
('用户导出', 'system:user:export'),
('用户导入', 'system:user:import'),
('重置密码', 'system:user:resetPwd'),
('角色查询', 'system:role:query'),
('角色新增', 'system:role:add'),
('角色修改', 'system:role:edit'),
('角色删除', 'system:role:remove'),
('角色导出', 'system:role:export');
-- 分配权限给user角色
INSERT INTO role_permission (roleId, permissionId) VALUES (1, 1); -- 用户查询
-- 分配权限给vip角色
INSERT INTO role_permission (roleId, permissionId) VALUES (2, 1); -- 用户查询
INSERT INTO role_permission (roleId, permissionId) VALUES (2, 6); -- 用户导出
-- 分配权限给admin角色
INSERT INTO role_permission (roleId, permissionId)
VALUES (3, 1), -- 用户查询
(3, 2), -- 用户新增
(3, 3), -- 用户修改
(3, 4), -- 用户删除
(3, 5), -- 用户导出
(3, 6), -- 用户导入
(3, 7), -- 重置密码
(3, 8), -- 角色查询
(3, 9), -- 角色新增
(3, 10), -- 角色修改
(3, 11), -- 角色删除
(3, 12); -- 角色导出
使用:
所有的权限和身份都会返回给前端 前端也可以校验权限 进行dom隐藏
OK,有了实现方案后,我们先来开发后端接口。
三、后端开发
以后每次开发接口时,都可以遵循以下流程。
数据访问层代码生成
首先利用 IDEA 连接 MySQL 数据库:
执行 SQL 脚本,创建数据库表:
数据访问层的代码一般包括实体类、MyBatis 的 Mapper 类和 XML 等。比起手动编写,建议使用 MyBatisX 代码生成插件,可以快速得到这些文件。
选中数据库的表,右键选择 MybatisX 生成器:
按照下图进行配置:

可以看到生成的代码,包括实体类、Mapper、Service:
我们需要将这些代码移动到项目对应的位置,比如将 Mapper 移动到 mapper 包、User 移动到 model.entity 包、Service 移动到 service 包。记得将有用的文件添加到 Git 进行托管。
移动之后,注意修改 UserMapper.xml 等文件的包名:
数据模型开发
1、实体类
生成的代码也许不能完全满足我们的要求,比如数据库实体类,我们可以手动更改其字段配置,指定主键策略和逻辑删除。
- id 默认是连续生成的,容易被爬虫抓取,所以更换策略为
ASSIGN_ID雪花算法生成。 - 数据删除时默认为彻底删除记录,如果出现误删,将难以恢复,所以采用逻辑删除 —— 通过修改 isDelete 字段为 1 表示已失效的数据。
修改的代码如下:
@TableName(value ="user")
@Data
public class User implements Serializable {
/** * id(要指定主键策略) */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
// ...
/** * 是否删除(逻辑删除) */
@TableLogic private Integer isDelete;
}
使用框架的过程中,有任何疑问,都可以在官方文档查阅,比如了解 MyBatis Plus 的主键生成注解:https://baomidou.com/reference/annotation/#tableid
2、枚举类
对于用户角色这样值的数量有限的、可枚举的字段,最好定义一个枚举类,便于在项目中获取值、减少枚举值输入错误的情况。
在 model.enums 包下新建 UserRoleEnum:
package com.zwnsyw.yunpicturebackend.model.enums.UserEnums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import com.zwnsyw.yunpicturebackend.model.enums.BaseEnum;
import lombok.Getter;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;
public enum UserRoleEnum implements BaseEnum<String> {
USER("USER", "普通用户"),
ADMIN("ADMIN", "管理员"),
VIP("VIP", "会员");
@EnumValue
@Getter
private final String code;
@JsonValue
@Getter
private final String description;
UserRoleEnum(String code, String description) {
this.code = code;
this.description = description;
}
// 静态缓存以提高 fromCode 的查找效率
private static final Map<String, UserRoleEnum> CODE_MAP = Arrays.stream(UserRoleEnum.values())
.collect(Collectors.toMap(UserRoleEnum::getCode, role -> role));
public static UserRoleEnum fromCode(String code) {
if (code == null) {
throw new IllegalArgumentException("角色代码不能为空");
}
UserRoleEnum role = CODE_MAP.get(code);
if (role == null) {
throw new IllegalArgumentException(
String.format("未知的用户角色代码:%s,合法的角色代码有:%s", code, CODE_MAP.keySet())
);
}
return role;
}
}
其中,getEnumByValue 是通过 value 找到具体的枚举对象。
💡 如果枚举值特别多,可以 Map 缓存所有枚举值来加速查找,而不是遍历列表。
下面依次进行各功能接口的开发。
用户注册
1、数据模型
在 model.dto.user 下新建用于接受请求参数的类:
package com.zwnsyw.yunpicturebackend.model.dto.UserRequest;
import com.zwnsyw.yunpicturebackend.model.dto.utils.ValidUserAccount;
import lombok.Data;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.io.Serializable;
@Data
public class UserRegisterRequest implements Serializable {
private static final long serialVersionUID = 3191241716373120793L;
/**
* 用户账号
*/
@NotBlank(message = "账号不能为空")
@Size(max = 20, message = "账号长度不能超过20")
@ValidUserAccount
private String userAccount;
/**
* 用户密码
*/
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 20, message = "密码长度必须在8到20之间")
private String userPassword;
/**
* 校验密码
*/
@NotBlank(message = "重复密码不能为空")
@Size(min = 8, max = 20, message = "重复密码长度必须在8到20之间")
private String checkPassword;
/**
* 邀请码 (可选项)
*/
private String inviteCode;
/**
* 确认密码
*/
@AssertTrue(message = "密码和确认密码不一致")
public boolean isPasswordMatching() {
return this.userPassword.equals(this.checkPassword);
}
}
自定义注解 校验账号合法性
package com.zwnsyw.yunpicturebackend.model.dto.utils;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UserAccountValidator.class)
public @interface ValidUserAccount {
String message() default "账号包含特殊字符";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
package com.zwnsyw.yunpicturebackend.model.dto.utils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class UserAccountValidator implements ConstraintValidator<ValidUserAccount, String> {
private static final String VALID_PATTERN = "[`~!@#$%^&*()+=|{}':;',\\\\[\\\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?]";
@Override
public void initialize(ValidUserAccount constraintAnnotation) {
// 可以初始化一些配置项
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true; // 空值交给@NotBlank等注解处理
}
// 使用正则表达式匹配特殊字符
Matcher matcher = Pattern.compile(VALID_PATTERN).matcher(value);
return !matcher.find(); // 如果找到了特殊字符,则返回false
}
}
使用注解验证需要加上这两条依赖
<!-- dto校验注解 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
</dependency>
💡 在 Java 接口开发中,为每个接口定义一个专门的类来接收请求参数,可以提高代码的可读性和维护性,便于对参数进行统一验证和扩展,同时减少接口方法参数过多导致的复杂性,有助于在复杂场景下更清晰地管理和传递数据。
2、服务开发
在 service 包的 UserService 中增加方法声明:
/**
* 用户注册
*
* @param userAccount 用户账户
* @param userPassword 用户密码
* @param checkPassword 校验密码
* @param inviteCode 邀请码
* @return 新用户 id
*/
long userRegister(String userAccount, String userPassword, String checkPassword, String inviteCode);
在 UserServiceImpl 中增加实现代码:
private String generateRandomSalt() {
byte[] salt = new byte[16];
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(salt);
return Base64.getEncoder().encodeToString(salt);
}
private void assignRole(Long userId, UserRoleEnum roleEnum) {
Long roleId = getRoleIdByRoleName(roleEnum.getCode());
if (roleId == null) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "角色不存在: " + roleEnum.getDescription());
}
UserRole userRole = new UserRole();
userRole.setUserId(userId);
userRole.setRoleId(roleId);
userRoleMapper.insert(userRole);
}
private Long getRoleIdByRoleName(String roleName) {
QueryWrapper<Role> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("name", roleName);
Role role = roleMapper.selectOne(queryWrapper);
return role != null ? role.getId() : null;
}
@Override
public List<UserRolePermissionVO> getUserRolePermission(Long userId) {
return baseMapper.getUserRolePermission(userId);
}
@Override
public long userRegister(String userAccount, String userPassword, String checkPassword, String inviteCode) {
ThrowUtils.throwIf(isAccountExist(userAccount), ErrorCode.PARAMS_ERROR, "账户已存在");
Long inviteUserId = null;
if (inviteCode != null && !inviteCode.isEmpty()) {
User invitedUser = userMapper.selectByShareCode(inviteCode);
ThrowUtils.throwIf(invitedUser == null, ErrorCode.PARAMS_ERROR, "邀请码不存在");
inviteUserId = invitedUser.getId();
}
String dynamicSalt = generateRandomSalt();
String encryptedPassword = passwordUtils.encryptPassword(userPassword, dynamicSalt);
User user = new User();
user.setUserAccount(userAccount);
user.setUserPassword(encryptedPassword);
user.setSalt(dynamicSalt);
user.setUserName(generateRandomUsername());
user.setInviteUserId(inviteUserId);
user.setShareCode(generateRandomShareCode());
boolean saveResult = this.save(user);
ThrowUtils.throwIf(!saveResult, ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
assignRole(user.getId(), UserRoleEnum.USER);
return user.getId();
}
private boolean isAccountExist(String userAccount) {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount", userAccount);
return userMapper.selectCount(queryWrapper) > 0;
}
private String generateRandomUsername() {
return "TK_" + RandomUtil.randomString(8);
}
private String generateRandomShareCode() {
return RandomUtil.randomString(10);
}
注意,上述代码中,我们需要将用户密码加密后进行存储。可以封装一个方法,便于后续复用:
使用encrypt加密需要加入依赖:
<!-- BCrypt for password hashing -->
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
package com.zwnsyw.yunpicturebackend.service.impl.utils;
import org.mindrot.jbcrypt.BCrypt;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class PasswordUtils {
// 定义静态盐值,仅供内部使用
@Value("${security.salt}")
private String STATIC_SALT;
/**
* 使用动态盐值和静态盐对密码进行加密
*
* @param password 用户输入的明文密码
* @param dynamicSalt 动态盐值
* @return 加密后的密码
*/
public String encryptPassword(String password, String dynamicSalt) {
// 拼接静态盐、动态盐和明文密码
String enhancedPassword = STATIC_SALT + dynamicSalt + password;
// 使用 BCrypt 加密
return BCrypt.hashpw(enhancedPassword, BCrypt.gensalt());
}
/**
* 验证密码是否正确
*
* @param rawPassword 用户输入的明文密码
* @param storedPassword 数据库中存储的加密密码
* @param dynamicSalt 数据库中存储的动态盐值
* @return 是否匹配
*/
public boolean verifyPassword(String rawPassword, String storedPassword, String dynamicSalt) {
// 拼接静态盐、动态盐和明文密码
String enhancedPassword = STATIC_SALT + dynamicSalt + rawPassword;
// 使用 BCrypt 进行验证
return BCrypt.checkpw(enhancedPassword, storedPassword);
}
}
盐值配置在yml里面 方便修改
security:
salt: ${USER_SALT:default_salt_value}
3、接口开发
在 controller 包中新建 UserController,新增用户注册接口:
@PostMapping("/register")
public BaseResponse<Long> userRegister(@RequestBody @Valid UserRegisterRequest userRegisterRequest) {
ThrowUtils.throwIf(userRegisterRequest == null, ErrorCode.PARAMS_ERROR, "请求参数不能为空");
long result = userService.userRegister(
userRegisterRequest.getUserAccount(),
userRegisterRequest.getUserPassword(),
userRegisterRequest.getCheckPassword(),
userRegisterRequest.getInviteCode()
);
return ResultUtils.success(result);
}
4、测试
每开发完一个接口,都可以使用 Swagger 接口文档来测试:
💡 测试时要尤其注意边界值和特殊值,不能只测试正常的情况。
用户登录
1、数据模型
在 model.dto.user 下新建用于接受请求参数的类:
package com.zwnsyw.yunpicturebackend.model.dto.UserRequest;
import com.zwnsyw.yunpicturebackend.model.dto.utils.ValidUserAccount;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.io.Serializable;
@Data
public class UserLoginRequest implements Serializable {
private static final long serialVersionUID = 3191241716373120793L;
/**
* 账号
*/
@NotBlank(message = "账号不能为空")
@Size(max = 20, message = "账号长度不能超过20")
@ValidUserAccount
private String userAccount;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 20, message = "密码长度必须在8到20之间")
private String userPassword;
}
2、服务开发
在 service 包的 UserService 中增加方法声明:
/**
* 用户登录
* @param userAccount 用户账户
* @param userPassword 用户密码
* @param request
* @return 脱敏后的用户信息
*/
LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request);
在 UserServiceImpl 中增加实现代码,在用户登录成功后,将用户信息存储在当前的 Session 中。
代码如下:
private String getUserRole(Long userId) {
QueryWrapper<UserRole> userRoleQueryWrapper = new QueryWrapper<>();
userRoleQueryWrapper.eq("userId", userId);
UserRole userRole = userRoleMapper.selectOne(userRoleQueryWrapper);
if (userRole == null) {
return null;
}
QueryWrapper<Role> roleQueryWrapper = new QueryWrapper<>();
roleQueryWrapper.eq("id", userRole.getRoleId());
Role role = roleMapper.selectOne(roleQueryWrapper);
return role != null ? role.getName().getDescription() : null;
}
private Long getInviteUserNumber(Long userId) {
QueryWrapper<User> inviteUserQueryWrapper = new QueryWrapper<>();
inviteUserQueryWrapper.eq("inviteUserId", userId);
return userMapper.selectCount(inviteUserQueryWrapper);
}
@Override
public LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request) {
User user = getUserByAccount(userAccount);
boolean isPasswordValid = passwordUtils.verifyPassword(userPassword, user.getUserPassword(), user.getSalt());
ThrowUtils.throwIf(!isPasswordValid, ErrorCode.PARAMS_ERROR, "密码错误");
String userRole = getUserRole(user.getId());
Long inviteUserNumber = getInviteUserNumber(user.getId());
LoginUserVO LoginuserVO = LoginUserVO.fromUser(user, userRole, inviteUserNumber);
request.getSession().setAttribute(USER_LOGIN_STATE, LoginuserVO);
return LoginuserVO;
}
private User getUserByAccount(String userAccount) {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount", userAccount);
User user = userMapper.selectOne(queryWrapper);
ThrowUtils.throwIf(user == null, ErrorCode.PARAMS_ERROR, "账户不存在");
return user;
}
注意,由于注册用户时存入数据库的密码是加密后的,查询用户信息时,也要对用户输入的密码进行同样算法的加密,才能跟数据库的信息对应上。
可以把上述的 Session 理解为一个 Map,可以给 Map 设置 key 和 value,每个不同的 SessionID 对应的 Session 存储都是不同的,不用担心会污染。所以上述代码中,给 Session 设置了固定的 key(USER_LOGIN_STATE),可以将这个 key 值提取为常量,便于后续获取。
在 constant 包下新建 UserConstant 类,统一声明用户相关的常量:
package com.zwnsyw.yunpicturebackend.contant;
public interface UserConstant {
/**
* 用户登录态键
*/
String USER_LOGIN_STATE = "userLoginState";
//--------------权限-------------
/**
* 默认权限
*/
String DEFAULT_ROLE = "普通用户";
/**
* 管理员权限
*/
String ADMIN_ROLE = "管理员";
/**
* VIP权限
*/
String VIP_ROLE = "VIP用户";
}
3、接口开发
在 UserController 中新增用户登录接口:
@PostMapping("/login")
public BaseResponse<LoginUserVO> userLogin(
@RequestBody @Valid UserLoginRequest userLoginRequest,
HttpServletRequest request,
HttpServletResponse response) {
ThrowUtils.throwIf(userLoginRequest == null, ErrorCode.PARAMS_ERROR, "请求参数不能为空");
LoginUserVO loginUserVO = userService.userLogin(userLoginRequest.getUserAccount(),
userLoginRequest.getUserPassword(),
request);
ThrowUtils.throwIf(loginUserVO == null, ErrorCode.PARAMS_ERROR, "登录失败,用户信息为空");
// 配置 HttpOnly Cookie,用于存储 `SessionID`
Cookie sessionCookie = new Cookie("SESSIONID", request.getSession().getId());
sessionCookie.setHttpOnly(true); // 防止前端 JS 获取,提升安全性
sessionCookie.setPath("/"); // 设置全路径,适用于整个域
sessionCookie.setMaxAge(7 * 24 * 60 * 60); // 设置过期时间为 7 天
// sessionCookie.setSecure(true); // 推荐开启,仅在 HTTPS 环境生效
response.addCookie(sessionCookie);
return ResultUtils.success(loginUserVO);
}
获取当前登录用户
可以从 request 请求对象对应的 Session 中直接获取到之前保存的登录用户信息,无需其他请求参数。
1、服务开发
在 service 包的 UserService 中增加方法声明:
/**
* 获取当前登录用户信息
*
* @param request 请求
* @return 当前用户
*/
LoginUserVO getLoginUser(HttpServletRequest request);
在 UserServiceImpl 中增加实现代码,此处为了保证获取到的数据始终是最新的,先从 Session 中获取登录用户的 id,然后从数据库中查询最新的结果。代码如下:
@Override
public LoginUserVO getLoginUser(HttpServletRequest request) {
if (request == null) {
return null;
}
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
ThrowUtils.throwIf(userObj == null, ErrorCode.NOT_LOGIN_ERROR, "未登录或会话已过期");
return (LoginUserVO) userObj;
}
2、接口开发
在 UserController 中新增获取当前登录用户接口:
@GetMapping("/current")
public BaseResponse<LoginUserVO> getLoginUser(HttpServletRequest request) {
ThrowUtils.throwIf(request == null, ErrorCode.PARAMS_ERROR, "请求不能为空");
return ResultUtils.success(userService.getLoginUser(request));
}
3、数据脱敏
在 model.vo 包下新建 LoginUserVO 类,表示脱敏后的登录用户信息:
package com.zwnsyw.yunpicturebackend.model.vo;
import com.zwnsyw.yunpicturebackend.model.entity.User;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
import java.util.Set;
@Data
public class LoginUserVO implements Serializable {
private long id; // 用户ID
private String userAccount; // 账号
private String userName; // 用户昵称
private String userGender; // 性别(转换为描述性字符串)
private String userAvatar; // 用户头像
private String userPhone; // 脱敏后的电话
private String userEmail; // 脱敏后的邮箱
private String shareCode; // 邀请码
private Long inviteUserNumber; // 邀请用户数量
private Date createTime; // 创建时间
private Date updateTime; // 更新时间
private List<String> roles; //身份列表
private Set<String> permissions; // 权限列表
private String userStatus; // 用户状态(转换为描述性字符串)
private static final long serialVersionUID = 1L;
// 图床基础路径(作为完整 URL 拼接的前缀)
// todo 引入在线图床
private static final String BASE_IMAGE_URL = "http://localhost:8123/api";
public static LoginUserVO fromUser(User user, Long inviteUserNumber,List<String> roles, Set<String> permissions) {
LoginUserVO vo = new LoginUserVO();
vo.setId(user.getId());
vo.setUserAccount(user.getUserAccount());
vo.setUserName(user.getUserName());
vo.setUserGender(user.getUserGender() != null ? user.getUserGender().getDescription() : null);
if (user.getUserAvatar() != null) {
if (isFullUrl(user.getUserAvatar())) {
vo.setUserAvatar(user.getUserAvatar());
} else {
vo.setUserAvatar(BASE_IMAGE_URL + user.getUserAvatar());
}
}
vo.setUserPhone(maskPhone(user.getUserPhone()));
vo.setUserEmail(maskEmail(user.getUserEmail()));
vo.setShareCode(user.getShareCode());
vo.setInviteUserNumber(inviteUserNumber);
vo.setCreateTime(user.getCreateTime());
vo.setUpdateTime(user.getUpdateTime());
vo.setRoles(roles);
vo.setPermissions(permissions);
vo.setUserStatus(user.getUserStatus().getDescription());
return vo;
}
// 判断是否为完整 URL(如以 http:// 或 https:// 开头)
private static boolean isFullUrl(String url) {
return url.startsWith("http://") || url.startsWith("https://");
}
// 脱敏逻辑封装 for Phone
private static String maskPhone(String phone) {
return phone != null ? phone.replaceAll("(?<=\\d{3})\\d{4}(?=\\d{4})", "****") : null;
}
// 脱敏逻辑封装 for Email
private static String maskEmail(String email) {
return email != null ? email.replaceAll("(?<=\\w{2})\\w+(?=@)", "****") : null;
}
}
用户注销
可以从 request 请求对象对应的 Session 中直接获取到之前保存的登录用户信息,来完成注销,无需其他请求参数。
1、服务开发
在 service 包的 UserService 中增加方法声明:
/**
* 用户注销
* @param request 请求
* @return 注销成功与否
*/
boolean userLogout(HttpServletRequest request);
在 UserServiceImpl 中增加实现代码,从 Session 中移除掉当前用户的登录态即可:
@Override
public boolean userLogout(HttpServletRequest request) {
if(getLoginUser(request)!=null)
request.getSession().removeAttribute(USER_LOGIN_STATE);
return true;
}
2、接口开发
在 UserController 中新增用户注销接口:
@PostMapping("/logout")
@Anonymous
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)
对于不需要登录就能使用的接口,不需要使用该注解。
用户管理
//todo 管理员 修改用户状态
//todo 管理员 删除用户
//todo 管理员 重置用户密码
//todo 用户 修改个人信息(昵称 性别 头像 手机号 邮箱 简介) 根据id(管理员也有权修改) //todo 用户 修改密码 //todo 用户 使用激活码 激活vip
controller
@PutMapping("/status/{userId}")
@PreAuthorizeRole("ADMIN")
public BaseResponse<Boolean> updateUserStatus(@PathVariable Long userId, @RequestParam String status) {
UserStatusEnum userStatusEnum = UserStatusEnum.fromCode(status);
return ResultUtils.success(userService.updateUserStatus(userId, userStatusEnum));
}
@DeleteMapping("/delete/{userId}")
@PreAuthorizeRole("ADMIN")
public BaseResponse<Boolean> deleteUser(@PathVariable Long userId) {
ThrowUtils.throwIf(userId == null || userId <= 0, ErrorCode.PARAMS_ERROR, "用户ID不能为空或无效");
boolean result = userService.deleteUser(userId);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "用户删除失败");
return ResultUtils.success(true);
}
@PutMapping("/reset-password/{userId}")
@PreAuthorizeRole("ADMIN")
public BaseResponse<Boolean> resetUserPassword(@PathVariable Long userId) {
String defaultPassword = "TK123456";
boolean result = userService.resetUserPassword(userId, defaultPassword);
return ResultUtils.success(result);
}
@PostMapping("/update")
@PreAuthorize("system:user:edit")
public BaseResponse<LoginUserVO> updateUser(@RequestBody @Validated(UpdateGroup.class) UserUpdateRequest userUpdateRequest, HttpServletRequest request) {
ThrowUtils.throwIf(userUpdateRequest == null, ErrorCode.PARAMS_ERROR, "用户更新信息不能为空");
LoginUserVO loginUser = userService.getLoginUser(request);
boolean isAdmin = loginUser.getRoles().contains(ADMIN_ROLE);
ThrowUtils.throwIf(!isAdmin && !loginUser.getId().equals(userUpdateRequest.getId()), ErrorCode.FORBIDDEN_ERROR, "无权修改其他用户信息");
LoginUserVO updatedUserVO = userService.updateUser(userUpdateRequest);
// 更新成功后,重新设置 session 中的用户信息,确保 session 中保存的是最新的用户数据
if (!isAdmin) {
request.getSession().setAttribute(USER_LOGIN_STATE, updatedUserVO);
}
return ResultUtils.success(updatedUserVO);
}
@PostMapping("/update-password")
@PreAuthorize("system:user:edit")
public BaseResponse<Boolean> updatePassword(@RequestBody @Valid UserPasswordUpdateRequest userPasswordUpdateRequest, HttpServletRequest request) {
ThrowUtils.throwIf(userPasswordUpdateRequest == null, ErrorCode.PARAMS_ERROR, "请求参数不能为空");
boolean result = userService.updatePassword(
userPasswordUpdateRequest.getOldPassword(),
userPasswordUpdateRequest.getNewPassword(),
request
);
return ResultUtils.success(result);
}
@PostMapping("/set-role")
@PreAuthorizeRole("ADMIN")
public BaseResponse<Boolean> setRole(@RequestBody @Valid UserRoleRequest userRoleRequest) {
ThrowUtils.throwIf(userRoleRequest == null, ErrorCode.PARAMS_ERROR, "请求参数不能为空");
return ResultUtils.success(userService.assignRole(userRoleRequest.getUserId(), userRoleRequest.getRoleName()));
}
//todo 用户 使用激活码 激活vip
service
/**
* 设置角色
*
* @param userId 用户id
* @param roleName 角色名
* @return
*/
Boolean assignRole(Long userId, String roleName);
/**
* 更新用户状态
*
* @param userId 用户id
* @param status 用户状态
* @return 更新成功与否
*/
boolean updateUserStatus(Long userId, UserStatusEnum status);
/**
* 逻辑删除用户
*
* @param userId 用户id
* @return 删除成功与否
*/
boolean deleteUser(Long userId);
/**
* 管理员重置用户密码
*
* @param userId 用户id
* @param defaultPassword 默认密码
* @return 重置成功与否
*/
boolean resetUserPassword(Long userId, String defaultPassword);
/**
* 更新用户信息
*
* @param userUpdateRequest 用户更新请求
* @return 更新成功与否
*/
LoginUserVO updateUser(UserUpdateRequest userUpdateRequest);
/**
* 更新用户密码。
*
* @param oldPassword 用户输入的原密码
* @param newPassword 用户输入的新密码
* @param request 当前的 HTTP 请求,包含用户的会话信息
* @return 更新结果,操作成功返回 true,失败返回 false
*/
boolean updatePassword(String oldPassword, String newPassword, HttpServletRequest request);
impl
private Long getRoleIdByRoleName(String roleName) {
QueryWrapper<Role> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("name", roleName);
Role role = roleMapper.selectOne(queryWrapper);
return role != null ? role.getId() : null;
}
@Override
@Transactional
public Boolean assignRole(Long userId, String roleName) {
Long roleId = getRoleIdByRoleName(roleName);
ThrowUtils.throwIf(roleId == null, ErrorCode.SYSTEM_ERROR, "角色不存在: " + roleName);
User user = userMapper.selectById(userId);
ThrowUtils.throwIf(user == null, ErrorCode.NOT_FOUND_ERROR, "用户不存在");
// 检查用户是否已经拥有该角色
QueryWrapper<UserRole> userRoleQueryWrapper = new QueryWrapper<>();
userRoleQueryWrapper.eq("userId", userId).eq("roleId", roleId);
UserRole existingUserRole = userRoleMapper.selectOne(userRoleQueryWrapper);
ThrowUtils.throwIf(existingUserRole != null, ErrorCode.PARAMS_ERROR, "用户已经拥有该角色");
UserRole userRole = new UserRole();
userRole.setUserId(userId);
userRole.setRoleId(roleId);
int result = userRoleMapper.insert(userRole);
return result > 0;
}
@Override
public boolean updateUserStatus(Long userId, UserStatusEnum status) {
UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("id", userId).set("userStatus", status);
return update(updateWrapper);
}
@Override
public boolean deleteUser(Long userId) {
LambdaUpdateWrapper<User> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(User::getId, userId)
.set(User::getIsDelete, 1); // 设置 isDelete 为 1 表示逻辑删除
return update(updateWrapper);
}
@Override
public boolean resetUserPassword(Long userId, String defaultPassword) {
String dynamicSalt = generateRandomSalt();
String encryptedPassword = passwordUtils.encryptPassword(defaultPassword, dynamicSalt);
UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("id", userId)
.set("userPassword", encryptedPassword)
.set("salt", dynamicSalt);
return update(updateWrapper);
}
private String normalizeGender(String gender) {
if (gender == null || gender.trim().isEmpty()) {
return "UNKNOWN";
}
Map<String, String> genderMapping = new HashMap<>();
genderMapping.put("男", "MALE");
genderMapping.put("male", "MALE");
genderMapping.put("男性", "MALE");
genderMapping.put("0", "MALE");
genderMapping.put("女", "FEMALE");
genderMapping.put("female", "FEMALE");
genderMapping.put("女性", "FEMALE");
genderMapping.put("1", "FEMALE");
genderMapping.put("未知", "UNKNOWN");
genderMapping.put("保密", "UNKNOWN");
genderMapping.put("unknown", "UNKNOWN");
genderMapping.put("2", "UNKNOWN");
return genderMapping.getOrDefault(gender.toLowerCase().trim(), gender);
}
@Override
public LoginUserVO updateUser(UserUpdateRequest userUpdateRequest) {
User oldUser = userMapper.selectById(userUpdateRequest.getId());
ThrowUtils.throwIf(oldUser == null, ErrorCode.NOT_FOUND_ERROR, "用户不存在");
Map<String, Object> updateFields = new HashMap<>();
if (org.springframework.util.StringUtils.hasText(userUpdateRequest.getUserName()) && !userUpdateRequest.getUserName().equals(oldUser.getUserName())) {
updateFields.put("userName", userUpdateRequest.getUserName());
}
if (org.springframework.util.StringUtils.hasText(userUpdateRequest.getUserAvatar()) && !userUpdateRequest.getUserAvatar().equals(oldUser.getUserAvatar())) {
updateFields.put("userAvatar", userUpdateRequest.getUserAvatar());
}
if (org.springframework.util.StringUtils.hasText(userUpdateRequest.getUserGender())) {
UserGenderEnum newGender = UserGenderEnum.fromCode(normalizeGender(userUpdateRequest.getUserGender()));
UserGenderEnum currentGender = oldUser.getUserGender();
if (!newGender.equals(currentGender)) {
updateFields.put("userGender", newGender);
}
}
if (org.springframework.util.StringUtils.hasText(userUpdateRequest.getUserPhone()) && !userUpdateRequest.getUserPhone().equals(oldUser.getUserPhone())) {
updateFields.put("userPhone", userUpdateRequest.getUserPhone());
}
if (org.springframework.util.StringUtils.hasText(userUpdateRequest.getUserEmail()) && !userUpdateRequest.getUserEmail().equals(oldUser.getUserEmail())) {
updateFields.put("userEmail", userUpdateRequest.getUserEmail());
}
if (org.springframework.util.StringUtils.hasText(userUpdateRequest.getUserProfile()) && !userUpdateRequest.getUserProfile().equals(oldUser.getUserProfile())) {
updateFields.put("userProfile", userUpdateRequest.getUserProfile());
}
ThrowUtils.throwIf(updateFields.isEmpty(), ErrorCode.PARAMS_ERROR, "没有需要更新的字段");
User updatedUser = new User();
updatedUser.setId(userUpdateRequest.getId());
// 使用反射机制更新字段
updateFields.forEach((key, value) -> {
try {
// 使用反射给 User 对象的字段赋值
Field field = User.class.getDeclaredField(key);
field.setAccessible(true);
field.set(updatedUser, value);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "字段更新失败:" + key);
}
});
int result = userMapper.updateById(updatedUser);
ThrowUtils.throwIf(result <= 0, ErrorCode.SYSTEM_ERROR, "用户信息更新失败,请稍后重试");
User NewUser = userMapper.selectById(userUpdateRequest.getId());
return convertToLoginUserVO(NewUser);
}
@Override
public boolean updatePassword(String oldPassword, String newPassword, HttpServletRequest request) {
LoginUserVO loginUser = getLoginUser(request);
User currentUser = this.getById(loginUser.getId());
ThrowUtils.throwIf(currentUser == null, ErrorCode.NOT_FOUND_ERROR, "用户不存在");
ThrowUtils.throwIf(
!passwordUtils.verifyPassword(oldPassword, currentUser.getUserPassword(), currentUser.getSalt()),
ErrorCode.OPERATION_ERROR,
"旧密码不正确");
ThrowUtils.throwIf(
passwordUtils.verifyPassword(newPassword, currentUser.getUserPassword(), currentUser.getSalt()),
ErrorCode.OPERATION_ERROR,
"新密码不能与旧密码相同");
String dynamicSalt = generateRandomSalt();
String encryptedPassword = passwordUtils.encryptPassword(newPassword, dynamicSalt);
currentUser.setUserPassword(encryptedPassword);
currentUser.setSalt(dynamicSalt);
boolean updateResult = this.updateById(currentUser);
ThrowUtils.throwIf(!updateResult, ErrorCode.SYSTEM_ERROR, "密码更新失败");
//修改用户密码可以不用清除缓存 不会影响推荐等前端展示 不过登录态可能需要移除 直接执行一次退出登入即可
userLogout(request);
return true;
}
分页功能修复
使用 Swagger 接口文档依次对上述接口进行测试,发现 listUserVOByPage 接口有一些问题!
分页好像没有生效,还是查出了全部数据:
由于我们用的是 MyBatis Plus 来操作数据库,所以需要通过 官方文档 来查询解决方案。
查阅后发现,原来必须要配置一个分页插件。必须要注意,本项目使用的 v3.5.9 版本引入分页插件的方式和之前不同!v3.5.9 版本后需要独立安装分页插件依赖!!!
在 pom.xml 中引入分页插件依赖:
<!-- MyBatis Plus 分页插件 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
</dependency>
光引入这一条,大概率是无法成功下载依赖的,还要在 pom.xml 的依赖管理配置中补充 mybatis-plus-bom:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-bom</artifactId>
<version>3.5.9</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
依赖下载成功后,在 config 包下新建 MyBatis Plus 拦截器配置,添加分页插件:
package com.zwnsyw.yunpicturebackend.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.zwnsyw.yunpicturebackend.mapper")
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
重启项目,这次就能正常完成分页了~
数据精度修复
但是,在测试中,如果你打开 F12 控制台,利用预览来查看响应数据,就会发现另一个问题:id 的最后两位好像都变成 0 了!
但是在响应中、以及 Swagger 中查看,却是正常的:
这是由于前端 JS 的精度范围有限,我们后端返回的 id 范围过大,导致前端精度丢失,会影响前端页面获取到的数据结果。
为了解决这个问题,可以在后端 config 包下新建一个全局 JSON 配置,将整个后端 Spring MVC 接口返回值的长整型数字转换为字符串进行返回,从而集中解决问题。
package com.zwnsyw.yunpicturebackend.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.context.annotation.Bean;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
/**
* 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 值就正常了:
至此,用户相关的后端接口开发完毕,大家可以按需完善上述代码。
四、前端开发
新建页面和路由
按照下图的结构新建页面文件:
修改 router/index.ts 的路由配置:
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import UserLoginPage from '@/pages/user/UserLoginPage.vue'
import HomePage from '@/pages/HomePage.vue'
import UserRegisterPage from '@/pages/user/UserRegisterPage.vue'
import UserManagePage from '@/pages/admin/UserManagePage.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/',
name: '主页',
component: HomePage
},
{
path: '/user/login',
name: '用户登录',
component: UserLoginPage
},
{
path: '/user/register',
name: '用户注册',
component: UserRegisterPage
},
{
path: '/admin/userManage',
name: '用户管理',
component: UserManagePage
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue')
}
]
})
记得执行一下 openapi 命令生成接口对应的请求代码,每次后端改动时都需要这么做。
获取当前登录用户
在之前的教程中,已经创建了前端登录用户的状态管理文件 useLoginUserStore.ts。现在后端提供了获取当前登录用户的接口,直接修改 fetchLoginUser 函数即可:
/**
* 获取登录用户
*/
async function fetchLoginUser() {
const res = await currentUserUsingGet()
if (res.data.code === 0 && res.data.data) {
loginUser.value = res.data.data
}
}
可以顺便给 loginUser 增加 TypeScript 类型,这样在后续开发时就会有字段提示:
const loginUser = ref<API.LoginUserVO>({ userName: '未登录', })
之前在 GlobalHeader 中已经编写好显示当前登录用户信息的页面代码:
<div class="user-login-status">
<div v-if="loginUserStore.loginUser.id">
{{ loginUserStore.loginUser.userName ?? '无名' }}
</div>
<div v-else>
<a-button type="primary" href="#root/QzGBY7Py1iOo">登录</a-button>
</div>
</div>
用户登录页面
开发 UserLoginPage.vue,可以基于 Ant Design 的表单组件 快速开发登录页面。
先开发基础页面,可以按照需要增加一些前端校验,代码如下:
<template>
<div id="userLoginPage">
<h2 class="title">云图库 - 用户登录</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, max: 20, message: '密码长度必须在8到20之间' },
]"
>
<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()
const handleSubmit = async (values: any) => {
const res = await userLoginUsingPost(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)
}
}
页面效果如图:
完整代码:
<template>
<div id="userLoginPage">
<h2 class="title">云图库 - 用户登录</h2>
<div class="desc">智能协同云图库</div>
<a-form :model="formState" name="basic" autocomplete="off" @finish="handleSubmit">
<a-form-item
name="userAccount"
:rules="[
{ required: true, message: '请输入账号' },
{ max: 20, message: '账号长度不能超过20' },
]"
>
<a-input v-model:value="formState.userAccount" placeholder="请输入账号" />
</a-form-item>
<a-form-item
name="userPassword"
:rules="[
{ required: true, message: '请输入密码' },
{ min: 8, max: 20, message: '密码长度必须在8到20之间' },
]"
>
<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>
import { reactive } from 'vue'
import { userLoginUsingPost } from '@/api/userController.ts'
import { useLoginUserStore } from '@/stores/useLoginUserStore.ts'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
const formState = reactive<API.UserLoginRequest>({
userAccount: '',
userPassword: '',
})
const router = useRouter()
const loginUserStore = useLoginUserStore()
const handleSubmit = async (values: API.UserLoginRequest) => {
try {
const res = await userLoginUsingPost(values)
if (res.data.code === 0 && res.data.data) {
await loginUserStore.fetchLoginUser()
message.success('登录成功')
router.push({ path: '/', replace: true })
} else {
message.error(`登录失败,${res.data.description || '请稍后再试'}`)
}
} catch (error) {
message.error('网络请求失败,请检查网络连接')
}
}
#userLoginPage {
max-width: 360px;
margin: 100px 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;
}
用户注册页面
同样使用表单组件,在用户登录页面的基础上调整即可,涉及到更多表单项的填写:
<template>
<div id="userRegisterPage">
<h2 class="title">云图库 - 用户注册</h2>
<div class="desc">企业级智能协同云图库</div>
<a-form
:model="formState"
name="basic"
label-align="left"
autocomplete="off"
@finish="handleSubmit"
>
<a-form-item
name="userAccount"
:rules="[
{ required: true, message: '请输入账号' },
{ max: 20, message: '账号长度不能超过20' },
]"
>
<a-input v-model:value="formState.userAccount" placeholder="请输入账号" />
</a-form-item>
<a-form-item
name="userPassword"
:rules="[
{ required: true, message: '请输入密码' },
{ min: 8, max: 20, message: '密码长度必须在8到20之间' },
]"
>
<a-input-password v-model:value="formState.userPassword" placeholder="请输入密码" />
</a-form-item>
<a-form-item
name="checkPassword"
:rules="[
{ required: true, message: '请输入确认密码' },
{ min: 8, max: 20, message: '重复密码长度必须在8到20之间' },
]"
>
<a-input-password v-model:value="formState.checkPassword" placeholder="请输入确认密码" />
</a-form-item>
<a-form-item name="inviteCode">
<a-input v-model:value="formState.inviteCode" 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 { reactive } from 'vue'
import { userRegisterUsingPost } from '@/api/userController.ts'
import { useLoginUserStore } from '@/stores/useLoginUserStore.ts'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
const formState = reactive({
userAccount: '',
userPassword: '',
checkPassword: '',
inviteCode: '',
})
const router = useRouter()
const loginUserStore = useLoginUserStore()
const handleSubmit = async (values: any) => {
if (values.userPassword !== values.checkPassword) {
message.error('两次输入的密码不一致')
return
}
try {
const res = await userRegisterUsingPost(values)
if (res.data.code === 0 && res.data.data) {
await loginUserStore.fetchLoginUser()
message.success('注册成功')
router.push({ path: '/user/login', replace: true })
} else {
message.error(`注册失败,${res.data.description || '请稍后再试'}`)
}
} catch (error) {
message.error('网络请求失败,请检查网络连接')
}
}
#userRegisterPage {
max-width: 360px;
margin: 100px 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;
}
页面效果如图:
用户注销
一般鼠标悬浮在右上角用户头像时,会展示包含用户注销(退出登录)功能的下拉菜单。
先开发页面结构:
<a-col flex="200px">
<div class="user-login-status">
<div v-if="loginUserStore.loginUser.id">
<a-dropdown>
<ASpace>
<a-avatar :src="api/images/cXldbTivoxOM/loginUserStore.loginUser.userAvatar" />
{{ loginUserStore.loginUser.userName ?? '无名' }}
</ASpace>
<template #overlay>
<a-menu>
<a-menu-item @click="doLogout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div v-else>
<a-button type="primary" href="#root/QzGBY7Py1iOo">登录</a-button>
</div>
</div>
</a-col>
编写用户注销事件函数,退出登录后跳转到登录页:
// 用户注销
const doLogout = async () => {
const res = await userLogoutUsingPost()
console.log(res)
if (res.data.code === 0) {
loginUserStore.setLoginUser({ userName: '未登录' })
message.success('退出登录成功')
await router.push('/user/login')
} else {
message.error('退出登录失败,' + res.data.description)
}
}
效果如图:
用户管理页面
需求:允许管理员查看已注册的用户信息,能够根据用户名称搜索用户、并删除非法用户。
需要注意,要防止普通用户也能看到用户信息,所以要增加一定的权限控制,可以分为页面开发和权限控制两个步骤来实现。
编写页面:上方搜索栏,下方表格,表格需要支持分页。
我们可以拆分为几个步骤来开发:
1、表格
利用 Ant Design 的表格组件,找到需要的组件进行复制,先展示全部用户信息。
<template>
<div id="userManagePage">
<!-- 搜索表单 -->
<a-form layout="inline">
<a-form-item label="搜索">
<a-input v-model:value="searchParams.keyword" placeholder="请输入搜索关键词" allow-clear />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" @click="doSearch">搜索</a-button>
</a-form-item>
</a-form>
<div style="margin-bottom: 16px" />
<a-table
:columns="columns"
:data-source="dataList"
:pagination="pagination"
@change="doTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'userAvatar'">
<a-image :src="api/images/1RTxMDJKKWhH/getSafeImageUrl(record.userAvatar)" :width="120" />
</template>
<template v-else-if="column.dataIndex === 'userRole'">
<a-tag :color="getUserRoleColor(record.roles)">{{ getUserRoleText(record.roles) }}</a-tag>
</template>
<template v-if="column.dataIndex === 'createTime'">
{{ dayjs(record.createTime).format('YYYY-MM-DD HH:mm:ss') }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="dashed" @click="openSetRoleModal(record.id)">设置角色</a-button>
<a-button type="dashed" @click="confirmResetPassword(record.id)">重置密码</a-button>
<a-switch
:checked="record.userStatus === 'ACTIVE'"
@change="toggleUserStatus(record.id, record.userStatus)"
:title="record.userStatus === 'ACTIVE' ? '禁用用户' : '启用用户'"
/>
<a-button danger @click="confirmDelete(record.id)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 设置角色的模态框 -->
<a-modal
v-model:visible="setRoleModalVisible"
title="设置角色"
@ok="handleSetRoleOk"
@cancel="handleSetRoleCancel"
:centered="true"
:wrapClassName="customModalClass"
>
<a-checkbox-group v-model:value="selectedRoles">
<a-checkbox :value="role.id" v-for="role in roles" :key="role.id">
{{ role.text }}
</a-checkbox>
</a-checkbox-group>
</a-modal>
</div>
</template>
import { computed, onMounted, reactive, ref } from 'vue'
import {
deleteUserUsingDelete,
getAllUsersUsingGet,
searchUsersUsingGet,
resetUserPasswordUsingPut,
updateUserStatusUsingPut,
} from '@/api/userController.ts'
import { message, Modal } from 'ant-design-vue'
import dayjs from 'dayjs'
import { createUserRoleUsingPost, deleteUserRoleUsingDelete } from '@/api/userRoleController.ts'
const columns = [
{
title: 'ID',
dataIndex: 'id',
align: 'center',
},
{
title: '账号',
dataIndex: 'userAccount',
align: 'center',
},
{
title: '用户名',
dataIndex: 'userName',
align: 'center',
},
{
title: '头像',
dataIndex: 'userAvatar',
align: 'center',
},
{
title: '简介',
dataIndex: 'userProfile',
align: 'center',
},
{
title: '用户角色',
dataIndex: 'userRole',
align: 'center',
},
{
title: '创建时间',
dataIndex: 'createTime',
align: 'center',
},
{
title: '操作',
key: 'action',
align: 'center',
},
]
const dataList = ref<API.LoginUserVO[]>([])
const total = ref(0)
const searchParams = reactive<API.searchUsersUsingGETParams>({
page: 1,
pageSize: 10,
sortField: 'createTime',
sortOrder: 'ascend',
keyword: '',
})
// 获取用户数据
const fetchUsers = async (params?: API.searchUsersUsingGETParams) => {
const res = await (params ? searchUsersUsingGet(params) : getAllUsersUsingGet())
if (res.data.code === 0 && res.data.data && res.data.data.records) {
dataList.value = res.data.data.records.map((record) => ({
...record,
userStatus: mapUserStatusToFrontend(record.userStatus),
}))
total.value = res.data.data.total ?? 0
} else {
message.error('获取数据失败,' + res.data.description)
}
}
// 页面加载时获取所有用户数据
onMounted(() => {
fetchUsers()
})
// 分页参数
const pagination = computed(() => {
return {
current: searchParams.page,
pageSize: searchParams.pageSize,
total: total.value,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
}
})
// 表格变化之后,重新获取数据
const doTableChange = (page: any) => {
searchParams.page = page.current
searchParams.pageSize = page.pageSize
fetchUsers(searchParams.keyword ? searchParams : undefined)
}
// 搜索数据
const doSearch = () => {
// 重置页码
searchParams.page = 1
fetchUsers(searchParams)
}
// 删除数据
const doDelete = async (id: number) => {
if (!id) {
message.error('无效的用户ID')
return
}
try {
const res = await deleteUserUsingDelete({ userId: id })
if (res.data.code === 0) {
message.success('删除成功')
fetchUsers(searchParams.keyword ? searchParams : undefined)
} else {
message.error('删除失败,' + res.data.description)
}
} catch (error) {
console.error('删除用户时发生错误:', error)
message.error('删除用户时发生错误,请稍后再试')
}
}
// 确认删除
const confirmDelete = (id: number) => {
Modal.confirm({
title: '确认删除',
content: '此操作将永久删除该用户,是否继续?',
onOk: () => {
doDelete(id)
},
onCancel() {
// 用户取消操作
},
})
}
// 重置密码
const doResetPassword = async (id: number) => {
if (!id) {
message.error('无效的用户ID')
return
}
try {
const res = await resetUserPasswordUsingPut({ userId: id })
if (res.data.code === 0) {
message.success('重置密码成功')
} else {
message.error('重置密码失败,' + res.data.description)
}
} catch (error) {
console.error('重置密码时发生错误:', error)
message.error('重置密码时发生错误,请稍后再试')
}
}
// 确认重置密码
const confirmResetPassword = (id: number) => {
Modal.confirm({
title: '确认重置密码',
content: '此操作将重置该用户的密码,是否继续?',
onOk: () => {
doResetPassword(id)
},
onCancel() {
// 用户取消操作
},
})
}
// 更新状态
const toggleUserStatus = async (id: number, currentStatus: string) => {
if (!id) {
message.error('无效的用户ID')
return
}
const confirmTitle = currentStatus === 'ACTIVE' ? '是否禁用该用户?' : '是否启用该用户?'
Modal.confirm({
title: confirmTitle,
content: '此操作将更改用户的状态。',
onOk: async () => {
const newStatus = currentStatus === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
try {
const res = await updateUserStatusUsingPut({ userId: id, status: newStatus })
if (res.data.code === 0) {
message.success('更新状态成功')
const user = dataList.value.find((user) => user.id === id)
if (user) {
user.userStatus = newStatus
}
} else {
message.error('更新状态失败,' + res.data.description)
}
} catch (error) {
console.error('更新用户状态时发生错误:', error)
message.error('更新用户状态时发生错误,请稍后再试')
}
},
onCancel() {
// 用户取消操作
},
})
}
// 设置角色
// todo debug 删除角色会清理所有角色 待修复 另外 这些逻辑应该移到后端 提供方便的接口
const selectedUserId = ref<number | null>(null)
const selectedRoles = ref<number[]>([])
const setRoleModalVisible = ref(false)
const roles = ref([
{ id: 1, text: '普通用户' },
{ id: 2, text: 'VIP用户' },
{ id: 3, text: '管理员' },
])
const openSetRoleModal = (userId: number) => {
selectedUserId.value = userId
const user = dataList.value.find((user) => user.id === userId)
if (user && user.roles) {
selectedRoles.value = user.roles.map((roleName) => getRoleIdByName(roleName))
} else {
selectedRoles.value = []
}
setRoleModalVisible.value = true
}
const handleSetRoleOk = async () => {
if (!selectedUserId.value) {
message.error('请选择用户')
return
}
const user = dataList.value.find((user) => user.id === selectedUserId.value)
if (!user) {
message.error('用户不存在')
return
}
const currentRoles = user.roles ? user.roles.map((roleName) => getRoleIdByName(roleName)) : []
// 创建新的角色分配
const newRoles = selectedRoles.value.filter((roleId) => !currentRoles.includes(roleId))
for (const roleId of newRoles) {
const res = await createUserRoleUsingPost({ userId: selectedUserId.value, roleId: roleId })
if (res.data.code !== 0) {
message.error('设置角色失败,' + res.data.description)
return
}
}
// 删除旧的角色分配
const oldRoles = currentRoles.filter((roleId) => !selectedRoles.value.includes(roleId))
for (const roleId of oldRoles) {
const res = await deleteUserRoleUsingDelete({ userId: selectedUserId.value, roleId: roleId })
if (res.data.code !== 0) {
message.error('删除角色失败,' + res.data.description)
return
}
}
message.success('设置角色成功')
setRoleModalVisible.value = false
fetchUsers(searchParams.keyword ? searchParams : undefined)
}
const handleSetRoleCancel = () => {
setRoleModalVisible.value = false
}
// 辅助函数:根据 roleName 获取 roleId
const getRoleIdByName = (roleName: string): number => {
switch (roleName) {
case 'USER':
return 1
case 'VIP':
return 2
case 'ADMIN':
return 3
default:
return 0
}
}
// 辅助函数:根据 roleId 获取 roleName
const getRoleNameById = (roleId: number): string => {
switch (roleId) {
case 1:
return 'USER'
case 2:
return 'VIP'
case 3:
return 'ADMIN'
default:
return ''
}
}
// 辅助函数:将后端状态映射到前端状态
const mapUserStatusToFrontend = (backendStatus: string): string => {
switch (backendStatus) {
case '正常':
return 'ACTIVE'
case '禁用':
return 'DISABLED'
default:
return 'UNKNOWN'
}
}
// 辅助函数:获取安全的图片URL
const getSafeImageUrl = (url: string): string => {
// todo 这里可以添加更复杂的验证逻辑
return url.startsWith('http') ? url : ''
}
// 辅助函数:获取用户角色颜色
const getUserRoleColor = (roles: string[]): string => {
if (roles.includes('ADMIN')) return 'green'
if (roles.includes('VIP')) return 'yellow'
return 'blue'
}
// 辅助函数:获取用户角色文本
const getUserRoleText = (roles: string[]): string => {
if (roles.includes('ADMIN')) return '管理员'
if (roles.includes('VIP')) return 'VIP用户'
return '普通用户'
}
// 自定义模态框类名
const customModalClass = 'custom-modal'
#userManagePage {
padding: 24px;
}
/* 确保模态框居中 */
.custom-modal .ant-modal {
top: 50%;
transform: translateY(-50%);
}
2、分页
1)表格组件 支持分页,首先编写一个分页变量,指定当前页号、页面大小、数据总数、展示总数的文案等参数:
// 分页参数
const pagination = computed(() => {
return {
current: searchParams.page,
pageSize: searchParams.pageSize,
total: total.value,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
}
})
注意,由于这些参数都是动态变化的,需要使用 Vue 的 computed 计算属性,否则当 searchParams 改变时,分页参数并不会更新。
2)编写 doTableChange 函数,当用户切换页号和页面大小时,需要更新 searchParams 搜索条件的值,并触发搜索:
// 表格变化之后,重新获取数据
const doTableChange = (page: any) => {
searchParams.page = page.current
searchParams.pageSize = page.pageSize
fetchUsers(searchParams.keyword ? searchParams : undefined)
}
3)给表格组件绑定分页参数和表格切换事件:
<a-table
:columns="columns"
:data-source="dataList"
:pagination="pagination"
@change="doTableChange"
>
3、搜索
利用 Ant Design 的搜索组件,实现对数据的搜索。
1)其实搜索本质上就是让用户填写搜索条件表单,此处我们需要根据账号和用户昵称搜索,编写表单代码:
<!-- 搜索表单 -->
<a-form layout="inline">
<a-form-item label="搜索">
<a-input v-model:value="searchParams.keyword" placeholder="请输入搜索关键词" allow-clear />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" @click="doSearch">搜索</a-button>
</a-form-item>
</a-form>
<div style="margin-bottom: 16px" />
使用 searchParams 接受用户的输入,相当于所有的搜索条件全部存到了 seachParams 中,便于统一维护。
2)点击搜索按钮时会触发表单提交,表单提交时需要将页号重置为 1,并获取数据。编写对应的函数:
// 搜索数据
const doSearch = () => {
// 重置页码
searchParams.page = 1
fetchUsers(searchParams)
}
用户权限控制
虽然后端获取用户列表的接口做了权限校验,防止非管理员用户获取到。但一般情况下,为了保证系统安全和提升用户体验,前端也需要对权限进行控制。
有 2 种实现方式:单页面控制权限,或者全局控制权限。
思路都是一致的,在进入某个页面时判断用户是否具有该页面的权限,无非是把权限校验相关的代码写在单个页面内,还是写到一个独立的文件中罢了。
**建议编写独立的全局权限控制文件。**可以利用 Vue Router 的路由守卫实现,每次切换并进入页面前,都会检查一下当前用户是否具有特定页面的权限。
在 src 下编写 access.ts 权限校验文件,可以自己定义逻辑,比如用页面前缀来统一判断:
import { useLoginUserStore } from '@/stores/useLoginUserStore'
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.roles.contains('admin') {
message.error('没有权限')
next(`/user/login?redirect=${to.fullPath}`)
return
}
}
next()
})
注意,上述代码中,我们为了确保页面刷新时,从后端拿到用户信息后再进行权限校验,使用 await 等待后端接口返回,并重新赋值给 loginUser。同时,为了防止每次切换路由都从远程获取用户信息,定义了 firstFetchLoginUser 变量,用于控制在刷新页面后只会请求后端一次。
💡 由于在 access.ts 中已经获取到了登录用户信息,可以移除掉之前在 App.vue 中获取登录用户信息的逻辑,避免重复请求。
在 main.ts(全局入口文件)中引入:
import '@/access'
RBAC
控件级别的权限控制 使用自定义指令 v-permission 控制按钮和其他元素的显示和隐藏。 在 main.ts 中注册自定义指令。 在组件中使用 v-permission 指令来控制按钮的显示和隐藏。 页面级别的权限控制 在 router/index.ts 中定义路由,并添加 meta 字段来指定页面所需的权限或角色。 在 router.beforeEach 中进行权限检查,确保用户只能访问其有权限的页面。 创建一个 Forbidden.vue 页面,用于显示权限不足的提示。
src/directives/permission.ts
import type { Directive, DirectiveBinding } from 'vue'
import { useLoginUserStore } from '@/stores/useLoginUserStore'
const permissionDirective: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { value } = binding
const loginUserStore = useLoginUserStore()
const permissions = loginUserStore.loginUser?.permissions || []
const roles = loginUserStore.loginUser?.roles || []
if (roles.includes('ADMIN')) {
return
}
if (!permissions.includes(value)) {
el.parentNode?.removeChild(el)
}
},
}
export default permissionDirective
main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import permissionDirective from './directives/permission'
const app = createApp(App)
app.directive('permission', permissionDirective)
app.use(createPinia())
app.use(router)
app.use(Antd)
app.mount('#app')
页面级别控制:
router index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useLoginUserStore } from '@/stores/useLoginUserStore'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'home',
component: () => import('@/pages/HomePage.vue')
},
{
path: '/user/login',
name: '用户登录',
component: () => import('@/pages/user/UserLoginPage.vue')
},
{
path: '/user/register',
name: '用户注册',
component: () => import('@/pages/user/UserRegisterPage.vue')
},
{
path: '/admin/userManage',
name: '用户管理',
component: () => import('@/pages/admin/UserManagePage.vue'),
meta: { requiresAuth: true, roles: ['ADMIN'] }
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
meta: { requiresAuth: true, roles: ['ADMIN','VIP'] }
},
{
path: '/403',
name: '403 Forbidden',
component: () => import('@/views/Forbidden.vue')
},
{
path: '/:pathMatch(.*)*',
redirect: '/404'
},
{
path: '/404',
name: '404 Not Found',
component: () => import('@/views/NotFound.vue')
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
router.beforeEach(async (to, from, next) => {
const loginUserStore = useLoginUserStore()
const loginUser = loginUserStore.loginUser
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!loginUser) {
next({ path: '/user/login', query: { redirect: to.fullPath } })
} else {
if (to.matched.some(record => record.meta.roles)) {
const requiredRoles = to.matched.flatMap(record => record.meta.roles || [])
const userRoles = loginUser.roles || []
const hasRoles = requiredRoles.some(role => userRoles.includes(role))
if (!hasRoles) {
next('/403') // 确保传递的是字符串
} else {
next()
}
} else {
next()
}
}
} else {
next()
}
})
export default router
控件级别权限控制
<a-button v-permission="'system:user:edit'" >button1</a-button>
<a-button v-permission="'ADMIN'" type="dashed">button2</a-button>
虽然已经满足了需求,但是如果用户不是管理员,其实也不应该看到 “用户管理” 菜单。因此我们还要修改 GlobalHeader 菜单项配置,根据权限决定是否展示某些菜单项。
编写一个过滤菜单项的函数:
<template>
<div id="globalHeader">
<a-row :wrap="false">
<a-col flex="200px">
<RouterLink to="/">
<div class="title-bar">
<img class="logo" src="api/images/5b7MdgRHcRPX/logo.svg" alt="logo" />
<div class="title">云图库</div>
</div>
</RouterLink>
</a-col>
<a-col flex="auto">
<a-menu
v-model:selectedKeys="current"
mode="horizontal"
:items="filteredItems"
@click="doMenuClick"
/>
</a-col>
<a-col flex="150px">
<div class="user-login-status">
<div v-if="loginUserStore.loginUser?.id">
<a-dropdown>
<a-space>
<a-avatar :src="api/images/cXldbTivoxOM/loginUserStore.loginUser.userAvatar" />
<span>{{ loginUserStore.loginUser.userName ?? '无名' }}</span>
</a-space>
<template #overlay>
<a-menu>
<a-menu-item @click="doLogout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div v-else>
<a-button type="primary" @click="navigateToLogin">登录</a-button>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
import { h, ref, onMounted, onUnmounted, computed } from 'vue'
import { HomeOutlined, LogoutOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useLoginUserStore } from '@/stores/useLoginUserStore.ts'
import { userLogoutUsingPost } from '@/api/userController.ts'
import type { MenuProps } from 'ant-design-vue/lib'
const loginUserStore = useLoginUserStore()
loginUserStore.fetchLoginUser()
const current = ref<string[]>(['home'])
const items = ref<MenuProps['items']>([
{
key: '/',
icon: () => h(HomeOutlined),
label: '主页',
title: '主页',
},
{
key: '/about',
label: '关于',
title: '关于'
},
{
key: '/admin/userManage',
label: '后台管理',
title: '后台管理',
},
{
key: 'others',
label: h('a', { href: 'http://www.zwnsyw.top', target: '_blank' }, '听凭风引'),
title: '听凭风引',
},
])
// 过滤菜单项
const filterMenus = (menus?: MenuProps['items']) => {
return (menus || []).filter((menu) => {
if (typeof menu.key === 'string' && menu.key.startsWith('/admin')) {
const loginUser = loginUserStore.loginUser
return loginUser && Array.isArray(loginUser.roles) && loginUser.roles.includes('ADMIN')
}
return true
})
}
// 展示在菜单的路由数组
const filteredItems = computed<MenuProps['items']>(() => filterMenus(items.value))
const router = useRouter()
// 路由跳转事件
const doMenuClick = async ({ key }: { key: string }) => {
try {
await router.push({ path: key })
} catch (error) {
console.error('路由跳转失败:', error)
message.error('路由跳转失败,请稍后再试')
}
}
// 监听路由变化,更新当前选中菜单高亮
const routeGuard = router.afterEach((to) => {
current.value = [to.path]
})
// 用户注销
const doLogout = async () => {
const res = await userLogoutUsingPost()
console.log(res)
if (res.data.code === 0) {
loginUserStore.setLoginUser({ userName: '未登录' })
message.success('退出登录成功')
await router.push('/user/login')
} else {
message.error('退出登录失败,' + res.data.description)
}
}
// 导航到登录页面
const navigateToLogin = () => {
router.push({ name: 'login' })
}
// 清除路由监听器
onUnmounted(() => {
routeGuard()
})
.title-bar {
display: flex;
align-items: center;
}
.title {
color: black;
font-size: 18px;
margin-left: 16px;
}
.logo {
height: 48px;
}
测试效果,未登录时不会看到 “用户管理” 菜单:
扩展
1、编辑用户信息
1)目前的用户管理页面还不支持编辑功能,前端可以利用 组件库文档 的编辑行能力快速完成。
2)目前用户无法编辑个人的信息,可以在右上角增加 “个人中心 / 个人设置”,点击后进入编辑用户信息的表单。
2、全局权限管理
在本节 “用户权限控制” 部分,我们使用了一种较为简单的方式实现,但是,后续补充页面和权限校验逻辑时,我们要同时修改权限管理文件和导航栏文件,相当于维护了 2 个文件,比较麻烦,有没有更好的办法呢?
需求:能够通过维护 一个配置文件,灵活配置每个页面所需要的用户权限,由全局权限管理系统自动校验和拦截,而不需要在每个页面中编写权限校验代码,提高开发效率。此外,还能根据该配置文件自动隐藏没权限的菜单项的展示。
实现方案:
- 在路由配置文件,利用 Vue Router 的 meta 附加参数,定义某个路由的访问权限
- 使用全局路由监听器,每次访问页面时,根据用户要访问页面的路由权限信息,判断用户是否有对应的访问权限,并进行相应的拦截处理。
需要先自行新建 NoAuth 无权限页面,内容随便写,比如显示 “你没有权限”。
新建 access 目录,所有权限管理相关的代码都放在该目录下,模块化。只要不引入,就不会生效。
1)定义权限枚举文件 accessEnum.ts:
const ACCESS_ENUM = {
NOT_LOGIN: "NOTLOGIN",
USER: "USER",
VIP: "VIP",
ADMIN: "ADMIN",
};
export default ACCESS_ENUM;
2)修改路由配置文件,在 meta 中补充需要的权限:
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useLoginUserStore } from '@/stores/useLoginUserStore'
import ACCESS_ENUM from '@/access/accessEnum'
import checkAccess from '@/access/checkAccess'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'home',
component: () => import('@/pages/HomePage.vue')
},
{
path: '/user/login',
name: '用户登录',
component: () => import('@/pages/user/UserLoginPage.vue')
},
{
path: '/user/register',
name: '用户注册',
component: () => import('@/pages/user/UserRegisterPage.vue')
},
{
path: '/admin/userManage',
name: '用户管理',
component: () => import('@/pages/admin/UserManagePage.vue'),
meta: { access: ACCESS_ENUM.ADMIN, requiresAuth: true }
},
{
path: '/about',
name: 'about',
component: () => import('@/views/AboutView.vue'),
meta: { access: ACCESS_ENUM.VIP, requiresAuth: true }
},
{
path: '/403',
name: '403 Forbidden',
component: () => import('@/views/Forbidden.vue')
},
{
path: '/:pathMatch(.*)*',
redirect: '/404'
},
{
path: '/404',
name: '404 Not Found',
component: () => import('@/views/NotFound.vue')
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
router.beforeEach(async (to, from, next) => {
const loginUserStore = useLoginUserStore()
const loginUser = loginUserStore.loginUser
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!loginUser) {
next({ path: '/user/login', query: { redirect: to.fullPath } })
} else {
const needAccess = (to.meta?.access as string) ?? ACCESS_ENUM.NOT_LOGIN
if (!checkAccess(loginUser, needAccess)) {
next('/403')
} else {
next()
}
}
} else {
next()
}
})
export default router
3)编写通用的权限校验方法。
Q:为什么?
A:因为菜单组件中要判断权限来过滤展示的菜单项、权限拦截也要用到权限判断功能,所以抽离成公共模块。
checkAccess.ts 文件:
import ACCESS_ENUM from '@/access/accessEnum'
const checkAccess = (loginUser: any, needAccess = ACCESS_ENUM.NOT_LOGIN) => {
const loginUserRoles = loginUser?.roles || []
if (loginUserRoles.includes(ACCESS_ENUM.ADMIN)) {
return true
}
switch (needAccess) {
case ACCESS_ENUM.NOT_LOGIN:
return true
case ACCESS_ENUM.USER:
return loginUserRoles.length > 0
case ACCESS_ENUM.VIP:
return loginUserRoles.includes(ACCESS_ENUM.VIP)
case ACCESS_ENUM.ADMIN:
return loginUserRoles.includes(ACCESS_ENUM.ADMIN)
default:
return false
}
}
export default checkAccess
4)编写全局权限校验核心文件 access/index.ts。
逻辑如下:
- 首先判断页面是否需要登录权限,如果不需要,直接放行。
- 如果页面需要登录权限
- 如果用户未登录,则跳转到登录页面。
- 如果已登录,判断登录用户的权限是否符合要求,否则跳转到 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
const needAccess = (to.meta?.access as string) ?? ACCESS_ENUM.NOT_LOGIN
if (needAccess !== ACCESS_ENUM.NOT_LOGIN) {
if (!loginUser || !loginUser.roles || loginUser.roles.length === 0) {
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)支持全局自动登录。如果是 **首次 **进入页面,状态为未登陆,则自动登录。
如何区别是否为首次进入页面(还没尝试过获取登录用户)呢?
默认的 loginUser 是没有 userRole 的,只要获取过,哪怕未登录,也可以给设置一个 userRole 为 "notLogin"。
在 access/index.ts 开头补充自动登录逻辑
//如果之前没登陆过,自动登录
if (!loginUser || !loginUser.userRole) {
// 加 await 是为了等用户登录成功之后,再执行后续的代码
await loginUserStore.fetchLoginUser();
loginUser = loginUserStore.loginUser;
}
之后记得移除 App.vue 中获取登录信息的调用。
6)根据权限控制菜单显隐。
需求:只有具有权限的菜单,才对用户可见
原理:
- 遍历原菜单项列表,针对菜单 key 对应的 path 路径,找到路由中对应的 meta 权限配置。只要判断用户没有这个权限,就直接过滤掉。
- 还可以通过在路由配置的 meta 中添加 hideInMenu,灵活控制菜单的显隐。
修改 GlobalHeader 全局导航栏(通用菜单)组件,补充根据权限来过滤菜单的逻辑,可自行实现。
<template>
<div id="globalHeader">
<a-row :wrap="false">
<a-col flex="200px">
<RouterLink to="/">
<div class="title-bar">
<img class="logo" src="api/images/5b7MdgRHcRPX/logo.svg" alt="logo" />
<div class="title">云图库</div>
</div>
</RouterLink>
</a-col>
<a-col flex="auto">
<a-menu
v-model:selectedKeys="current"
mode="horizontal"
:items="filteredItems"
@click="doMenuClick"
/>
</a-col>
<a-col flex="150px">
<div class="user-login-status">
<div v-if="loginUserStore.loginUser?.id">
<a-dropdown>
<a-space>
<a-avatar :src="api/images/cXldbTivoxOM/loginUserStore.loginUser.userAvatar" />
<span>{{ loginUserStore.loginUser.userName ?? '无名' }}</span>
</a-space>
<template #overlay>
<a-menu>
<a-menu-item @click="doLogout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div v-else>
<a-button type="primary" @click="navigateToLogin">登录</a-button>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
import { h, ref, onMounted, onUnmounted, computed } from 'vue'
import { HomeOutlined, LogoutOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useLoginUserStore } from '@/stores/useLoginUserStore.ts'
import { userLogoutUsingPost } from '@/api/userController.ts'
import type { MenuProps } from 'ant-design-vue/lib'
import router from '@/router'
import checkAccess from '@/access/checkAccess'
const loginUserStore = useLoginUserStore()
loginUserStore.fetchLoginUser()
const current = ref<string[]>(['home'])
const items = ref<MenuProps['items']>([
{
key: '/',
icon: () => h(HomeOutlined),
label: '主页',
title: '主页',
},
{
key: '/about',
label: '关于',
title: '关于',
meta: { access: ACCESS_ENUM.ADMIN }
},
{
key: '/admin/userManage',
label: '后台管理',
title: '后台管理',
meta: { access: ACCESS_ENUM.ADMIN }
},
{
key: 'others',
label: h('a', { href: 'http://www.zwnsyw.top', target: '_blank' }, '听凭风引'),
title: '听凭风引',
},
])
// 过滤菜单项
const filterMenus = (menus: MenuProps['items']) => {
return menus.filter((menu) => {
if (menu.meta?.access) {
return checkAccess(loginUserStore.loginUser, menu.meta.access)
}
return true
})
}
// 展示在菜单的路由数组
const filteredItems = computed<MenuProps['items']>(() => filterMenus(items.value))
const router = useRouter()
// 路由跳转事件
const doMenuClick = async ({ key }: { key: string }) => {
try {
await router.push({ path: key })
} catch (error) {
console.error('路由跳转失败:', error)
message.error('路由跳转失败,请稍后再试')
}
}
// 监听路由变化,更新当前选中菜单高亮
const routeGuard = router.afterEach((to) => {
current.value = [to.path]
})
// 用户注销
const doLogout = async () => {
const res = await userLogoutUsingPost()
console.log(res)
if (res.data.code === 0) {
loginUserStore.setLoginUser({ userName: '未登录', userRole: ACCESS_ENUM.NOT_LOGIN })
message.success('退出登录成功')
await router.push('/user/login')
} else {
message.error('退出登录失败,' + res.data.description)
}
}
// 导航到登录页面
const navigateToLogin = () => {
router.push({ name: 'login' })
}
// 清除路由监听器
onUnmounted(() => {
routeGuard()
})
.title-bar {
display: flex;
align-items: center;
}
.title {
color: black;
font-size: 18px;
margin-left: 16px;
}
.logo {
height: 48px;
}
项目分区导航:⬅️ 12-用户传图 | 13-用户模块 | ➡️ 01-AI_零代码应用生成平台介绍
💬 评论