feat(user): 实现用户角色权限管理和登录态完善

- 新增异步任务支持,启用@EnableAsync注解
- 添加用户信息响应VO类FindUserInfoRspVO
- 修改MyBatis逆向生成配置,调整映射的表为user_role_rel
- 全局异常处理新增未登录异常处理方法
- Vue头部组件Header.vue完善登录状态显示,显示用户名或登录按钮
- 新增获取用户信息的前端API接口getUserInfo
- 新增UserController,提供获取当前用户信息接口
- UserDOMapper新增selectById方法及对应XML配置
- 设计角色与用户角色关系数据对象及MyBatis映射文件
- 新增RoleDO和UserRoleRelDO数据对象及对应Mapper接口和XML映射
- 实现UserService及其实现类UserServiceImpl,支持推送角色权限到Redis
- 新增定时任务UserRoleTask,定时同步权限数据到Redis
- 配置SaToken权限拦截器,设置登录校验及排除路径
- 实现StpInterface接口,自定义权限与角色列表获取逻辑
- 响应码枚举中添加未登录状态码NOT_LOGIN
This commit is contained in:
lbw
2025-12-22 19:03:02 +08:00
parent f4498e5676
commit bc4c74f881
22 changed files with 435 additions and 8 deletions

View File

@@ -3,11 +3,13 @@ package com.yinlihupo.enlish.service;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@MapperScan("com.yinlihupo.enlish.service.domain.mapper")
@EnableScheduling
@EnableAsync
public class EnlishServiceApplication {
public static void main(String[] args) {

View File

@@ -0,0 +1,46 @@
package com.yinlihupo.enlish.service.config;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@Slf4j
public class SaTokenConfigure implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handler -> {
log.info("Sa-Token 拦截器: {}", handler);
SaRouter.match(SaHttpMethod.OPTIONS)
.free(r -> System.out.println("--------OPTIONS预检请求不做处理"))
.back();
SaRouter.match("/**")
.notMatch("/class/list")
.notMatch("/exam/words/get")
.notMatch("/exam/words/detail")
.notMatch("/exam/words/student/history")
.notMatch("grade/list")
.notMatch("/student/list")
.notMatch("/student/detail")
.notMatch("/studentLessonPlans/list")
.notMatch("/studentLessonPlans/history")
.notMatch("/unit/list")
.notMatch("/vocabulary/list")
.notMatch("/plan/download")
.notMatch("/login/**")
.check(r -> StpUtil.checkLogin());
}))
.addPathPatterns("/**")
.excludePathPatterns("/error");
}
}

View File

@@ -0,0 +1,46 @@
package com.yinlihupo.enlish.service.config;
import cn.dev33.satoken.stp.StpInterface;
import com.google.common.cache.Cache;
import com.yinlihupo.enlish.service.constant.RoleConstants;
import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Slf4j
public class StpInterfaceImpl implements StpInterface {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return List.of();
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
return userToRole((Long) loginId) ;
}
private List<String> userToRole(Long userId) {
String keys = stringRedisTemplate.opsForValue().get(RoleConstants.buildUserRoleKey(userId));
if (keys != null) {
try {
return JsonUtils.parseList(keys, String.class);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
return List.of();
}
}

View File

@@ -0,0 +1,11 @@
package com.yinlihupo.enlish.service.constant;
public class RoleConstants {
public final static String USER_ROLE = "user:role";
public final static String ROLE = "role";
public static String buildUserRoleKey(Long userId) {
return USER_ROLE + ":" + userId;
}
}

View File

@@ -0,0 +1,30 @@
package com.yinlihupo.enlish.service.controller;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
import com.yinlihupo.enlish.service.model.vo.user.FindUserInfoRspVO;
import com.yinlihupo.enlish.service.service.UserService;
import com.yinlihupo.framework.common.response.Response;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user/")
public class UserController {
@Resource
private UserService userService;
@PostMapping("info")
public Response<FindUserInfoRspVO> info() {
UserDO user = userService.findUser();
FindUserInfoRspVO findUserInfoRspVO = FindUserInfoRspVO.builder()
.id(user.getId())
.name(user.getName())
.build();
return Response.success(findUserInfoRspVO);
}
}

View File

@@ -0,0 +1,28 @@
package com.yinlihupo.enlish.service.domain.dataobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class RoleDO {
private Long id;
private String roleName;
private String roleKey;
private Integer status;
private Date createTime;
private Integer isDeleted;
}

View File

@@ -0,0 +1,55 @@
package com.yinlihupo.enlish.service.domain.dataobject;
import java.util.Date;
public class UserRoleRelDO {
private Long id;
private Long userId;
private Long roleId;
private Date createTime;
private Boolean isDeleted;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getRoleId() {
return roleId;
}
public void setRoleId(Long roleId) {
this.roleId = roleId;
}
public Date getCreateTime() {
return createTime;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
public Boolean getIsDeleted() {
return isDeleted;
}
public void setIsDeleted(Boolean isDeleted) {
this.isDeleted = isDeleted;
}
}

View File

@@ -0,0 +1,10 @@
package com.yinlihupo.enlish.service.domain.mapper;
import com.yinlihupo.enlish.service.domain.dataobject.RoleDO;
import java.util.List;
public interface RoleDOMapper {
List<RoleDO> selectAll();
}

View File

@@ -7,4 +7,6 @@ public interface UserDOMapper {
UserDO selectByPhone(String phone);
void insert(UserDO userDO);
UserDO selectById(Long id);
}

View File

@@ -0,0 +1,10 @@
package com.yinlihupo.enlish.service.domain.mapper;
import com.yinlihupo.enlish.service.domain.dataobject.UserRoleRelDO;
import java.util.List;
public interface UserRoleRelDOMapper {
List<UserRoleRelDO> selectAll();
}

View File

@@ -13,7 +13,7 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 通用异常状态码 -----------
SYSTEM_ERROR("AUTH-10000", "出错啦,后台小哥正在努力修复中..."),
PARAM_NOT_VALID("AUTH-10001", "参数错误"),
NOT_LOGIN("AUTH-10002", "请先登录")
// ----------- 业务异常状态码 -----------
;

View File

@@ -1,6 +1,7 @@
package com.yinlihupo.enlish.service.exception;
import cn.dev33.satoken.exception.NotLoginException;
import com.yinlihupo.enlish.service.enums.ResponseCodeEnum;
import com.yinlihupo.framework.common.exception.BizException;
import com.yinlihupo.framework.common.response.Response;
@@ -96,4 +97,11 @@ public class GlobalExceptionHandler {
log.error("{} request error, ", request.getRequestURI(), e);
return Response.fail(ResponseCodeEnum.SYSTEM_ERROR);
}
@ExceptionHandler({ NotLoginException.class })
@ResponseBody
public Response<Object> handleNotLoginException(HttpServletRequest request, NotLoginException e) {
log.warn("{} request error, ", request.getRequestURI(), e);
return Response.fail(ResponseCodeEnum.NOT_LOGIN);
}
}

View File

@@ -0,0 +1,16 @@
package com.yinlihupo.enlish.service.model.vo.user;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class FindUserInfoRspVO {
private Long id;
private String name;
}

View File

@@ -0,0 +1,10 @@
package com.yinlihupo.enlish.service.service;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
public interface UserService {
void pushRolePermission2Redis();
UserDO findUser();
}

View File

@@ -0,0 +1,63 @@
package com.yinlihupo.enlish.service.service.user;
import cn.dev33.satoken.stp.StpUtil;
import com.yinlihupo.enlish.service.constant.RoleConstants;
import com.yinlihupo.enlish.service.domain.dataobject.RoleDO;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
import com.yinlihupo.enlish.service.domain.dataobject.UserRoleRelDO;
import com.yinlihupo.enlish.service.domain.mapper.RoleDOMapper;
import com.yinlihupo.enlish.service.domain.mapper.UserDOMapper;
import com.yinlihupo.enlish.service.domain.mapper.UserRoleRelDOMapper;
import com.yinlihupo.enlish.service.service.UserService;
import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@Slf4j
public class UserServiceImpl implements UserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RoleDOMapper roleDOMapper;
@Resource
private UserRoleRelDOMapper userRoleRelDOMapper;
@Resource
private UserDOMapper userDOMapper;
@Override
public void pushRolePermission2Redis() {
List<RoleDO> roleDOS = roleDOMapper.selectAll();
List<String> roleKeys = roleDOS.stream().map(RoleDO::getRoleKey).toList();
log.info("将角色同步到 redis 中, {}", roleKeys);
stringRedisTemplate.opsForValue().set(RoleConstants.ROLE, JsonUtils.toJsonString(roleKeys), 60 * 60 * 24);
Map<Long, RoleDO> roleId2RoleDO = roleDOS.stream().collect(Collectors.toMap(RoleDO::getId, roleDO -> roleDO));
List<UserRoleRelDO> userRoleRelDOS = userRoleRelDOMapper.selectAll();
Map<Long, List<UserRoleRelDO>> userId2UserRoleRelDOs = userRoleRelDOS.stream().collect(Collectors.groupingBy(UserRoleRelDO::getUserId));
userId2UserRoleRelDOs.forEach((userId, userRoleRelDOs) -> {
List<Long> roleIds = userRoleRelDOs.stream().map(UserRoleRelDO::getRoleId).toList();
List<RoleDO> roleDOs = roleIds.stream().map(roleId2RoleDO::get).toList();
List<String> user2RoleKeys = roleDOs.stream().map(RoleDO::getRoleKey).toList();
log.info("将用户 {} 的角色同步到 redis 中, {}", userId, roleKeys);
stringRedisTemplate.opsForValue().set(RoleConstants.buildUserRoleKey(userId), JsonUtils.toJsonString(user2RoleKeys), 60 * 60 * 24);
});
}
@Override
public UserDO findUser() {
String loginIdStr =(String) StpUtil.getLoginId();
Long loginId = Long.parseLong(loginIdStr);
return userDOMapper.selectById(loginId);
}
}

View File

@@ -0,0 +1,23 @@
package com.yinlihupo.enlish.service.task;
import com.yinlihupo.enlish.service.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class UserRoleTask {
@Resource
private UserService userService;
@Scheduled(cron = "0 0 1 * * ?")
public void PushRolePermissions2Redis() {
log.info("定时任务,将系统权限推送到 redis 中");
userService.pushRolePermission2Redis();
}
}

View File

@@ -45,7 +45,7 @@
targetProject="src/main/java"/>
<!-- 需要生成的表-实体类 -->
<table tableName="user" domainObjectName="UserDO"
<table tableName="user_role_rel" domainObjectName="UserRoleRelDO"
enableCountByExample="false"
enableUpdateByExample="false"
enableDeleteByExample="false"

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yinlihupo.enlish.service.domain.mapper.RoleDOMapper">
<resultMap id="BaseResultMap" type="com.yinlihupo.enlish.service.domain.dataobject.RoleDO">
<id column="id" jdbcType="BIGINT" property="id" />
<result column="role_name" jdbcType="VARCHAR" property="roleName" />
<result column="role_key" jdbcType="VARCHAR" property="roleKey" />
<result column="status" jdbcType="INTEGER" property="status" />
<result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
<result column="is_deleted" jdbcType="INTEGER" property="isDeleted" />
</resultMap>
<select id="selectAll" resultMap="BaseResultMap">
select * from role
</select>
</mapper>

View File

@@ -24,4 +24,11 @@
and is_deleted = 0
</select>
<select id="selectById">
select *
from user
where id = #{id}
and is_deleted = 0
</select>
</mapper>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yinlihupo.enlish.service.domain.mapper.UserRoleRelDOMapper">
<resultMap id="BaseResultMap" type="com.yinlihupo.enlish.service.domain.dataobject.UserRoleRelDO">
<id column="id" jdbcType="BIGINT" property="id" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="role_id" jdbcType="BIGINT" property="roleId" />
<result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
<result column="is_deleted" jdbcType="BIT" property="isDeleted" />
</resultMap>
<select id="selectAll" resultMap="BaseResultMap">
select *
from user_role_rel
where is_deleted = 0
</select>
</mapper>

View File

@@ -7,3 +7,7 @@ export function login(data) {
export function getVerificationCode(data) {
return axios.post("/login/sendVerificationCode", data)
}
export function getUserInfo() {
return axios.post("/user/info")
}

View File

@@ -7,10 +7,18 @@
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">Flowbite</span>
</a>
<div class="flex items-center lg:order-2">
<template v-if="userName">
<span
class="text-gray-800 dark:text-white font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2">
{{ userName }}
</span>
</template>
<template v-else>
<a href="#" @click.prevent="showLogin = true"
class="text-gray-800 dark:text-white hover:bg-gray-50 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2 dark:hover:bg-gray-700 focus:outline-none dark:focus:ring-gray-800">
Login
</a>
</template>
<a href="#"
class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800">
Get started
@@ -61,12 +69,26 @@
</div>
</div>
</nav>
<LoginDialog v-model="showLogin" />
<LoginDialog v-model="showLogin" @success="refreshUser" />
</header>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import LoginDialog from '@/layouts/components/LoginDialog.vue'
import { getUserInfo } from '@/api/user'
const showLogin = ref(false)
const userName = ref('')
async function refreshUser() {
try {
const r = await getUserInfo()
const d = r?.data
userName.value = d?.success ? (d?.data?.name || '') : ''
} catch {
userName.value = ''
}
}
onMounted(() => {
refreshUser()
})
</script>