👋 哈喽!我是 小奏 , 欢迎关注我的公众号【小奏技术】
从零到一设计一套多租户权限控制
背景
设计一套允许多系统(租户)的权限系统
表结构设计
系统注册表
CREATE TABLE `sys_app` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`app_name` varchar(100) NOT NULL COMMENT '系统名称',
`app_code` varchar(50) NOT NULL COMMENT '系统编码 (如: CRM, ERP)',
`status` tinyint(4) DEFAULT '1' COMMENT '状态 1:正常 0:禁用',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_code` (`app_code`)
) ENGINE=InnoDB COMMENT='应用注册表';
菜单/按钮权限表 (定义逻辑权限,不包含具体URL)
CREATE TABLE `sys_menu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) DEFAULT '0',
`app_code` varchar(50) NOT NULL,
`name` varchar(50) NOT NULL COMMENT '菜单或按钮名称',
`type` tinyint(4) NOT NULL COMMENT '1:菜单 2:按钮',
`perm_code` varchar(100) NOT NULL COMMENT '权限标识 (如: user:add)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_perm` (`perm_code`)
) COMMENT='前端资源(菜单/按钮)';
API 接口表 (定义后端所有接口资源)
CREATE TABLE `sys_api` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`app_code` varchar(50) NOT NULL,
`path` varchar(200) NOT NULL COMMENT '接口路径 (如: /api/user/**)',
`method` varchar(10) DEFAULT 'ALL' COMMENT '请求方式: GET, POST, PUT, DELETE, ALL',
`name` varchar(100) DEFAULT NULL COMMENT '接口描述',
PRIMARY KEY (`id`)
) COMMENT='后端API接口';
菜单-API 绑定表 (一个按钮 -> 多个API)
CREATE TABLE `sys_menu_api` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`menu_id` bigint(20) NOT NULL COMMENT '关联sys_menu.id',
`api_id` bigint(20) NOT NULL COMMENT '关联sys_api.id',
PRIMARY KEY (`id`)
) COMMENT='权限与API的绑定关系';
角色-菜单绑定表
CREATE TABLE `sys_role_menu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`role_id` bigint(20) NOT NULL,
`menu_id` bigint(20) NOT NULL,
PRIMARY KEY (`id`)
);
用户表
- sys_user
CREATE TABLE `sys_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '账号',
`password` varchar(100) NOT NULL COMMENT '加密密码',
`real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名',
`status` tinyint(4) DEFAULT '1' COMMENT '状态 1:正常 0:禁用',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB COMMENT='用户表';
角色表(归属于特定应用)
- sys_role
CREATE TABLE `sys_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`app_code` varchar(50) NOT NULL COMMENT '归属应用编码',
`role_name` varchar(50) NOT NULL COMMENT '角色名称',
`role_code` varchar(50) NOT NULL COMMENT '角色标识 (如: admin, user)',
`status` tinyint(4) DEFAULT '1',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='角色表';
权限/菜单表 (菜单、按钮、API绑定)
CREATE TABLE `sys_permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) DEFAULT '0' COMMENT '父ID',
`app_code` varchar(50) NOT NULL COMMENT '归属应用编码',
`name` varchar(50) NOT NULL COMMENT '名称 (如: 用户管理, 新增用户)',
`type` tinyint(4) NOT NULL COMMENT '类型 1:目录 2:菜单 3:按钮/API',
`perms` varchar(100) DEFAULT NULL COMMENT '权限标识 (如: sys:user:add)',
`api_path` varchar(200) DEFAULT NULL COMMENT '后端接口路径 (可选,用于网关鉴权)',
`route_path` varchar(200) DEFAULT NULL COMMENT '前端路由路径',
`order_num` int(11) DEFAULT '0' COMMENT '排序',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='权限资源表';
用户-角色关联表
CREATE TABLE `sys_user_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL,
`role_id` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_role` (`user_id`,`role_id`)
) ENGINE=InnoDB COMMENT='用户角色关联表';
角色-权限关联表
CREATE TABLE `sys_role_permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`role_id` bigint(20) NOT NULL,
`permission_id` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB COMMENT='角色权限关联表';
java代码实现
框架主要是Spring Boot + Sa-Token + MyBatis Plus
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.37.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
</dependencies>
鉴权核心架构 (动态 URL 鉴权)
@Service
@Slf4j
public class AuthStrategyService {
@Autowired
private SysApiMapper apiMapper; // 假设已有 Mapper
// 缓存:Key="POST:/api/user", Value=["user:add", "user:edit"]
private Map<String, Set<String>> urlPermCache = new ConcurrentHashMap<>();
private AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 初始化:从数据库加载 "API路径 -> 权限标识" 的映射
* SQL逻辑:
* SELECT a.path, a.method, m.perm_code
* FROM sys_api a
* LEFT JOIN sys_menu_api ma ON a.id = ma.api_id
* LEFT JOIN sys_menu m ON ma.menu_id = m.id
* WHERE m.perm_code IS NOT NULL
*/
@PostConstruct
public void loadUrlPermMap() {
List<UrlPermDto> list = apiMapper.selectUrlPermList();
Map<String, Set<String>> temp = new HashMap<>();
for (UrlPermDto dto : list) {
String key = dto.getMethod() + ":" + dto.getPath();
temp.computeIfAbsent(key, k -> new HashSet<>()).add(dto.getPermCode());
}
this.urlPermCache = temp;
log.info("权限规则加载完成,共 {} 条接口规则", temp.size());
}
/**
* 根据请求查找需要的权限
*/
public Set<String> getRequiredPerms(String method, String path) {
// 1. 直接匹配
String key = method + ":" + path;
if (urlPermCache.containsKey(key)) {
return urlPermCache.get(key);
}
// 2. Ant 风格匹配 (如 /api/user/**) - 遍历缓存 Key
for (String ruleKey : urlPermCache.keySet()) {
String[] split = ruleKey.split(":"); // [0]=Method, [1]=Path
if (split[0].equalsIgnoreCase(method) || "ALL".equalsIgnoreCase(split[0])) {
if (pathMatcher.match(split[1], path)) {
return urlPermCache.get(ruleKey);
}
}
}
return null; // 返回 null 代表该接口不需要特定权限(或未配置)
}
}
全局拦截器
@Configuration
public class SaTokenConfigure {
@Autowired
private AuthStrategyService authStrategyService;
@Bean
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()
.addInclude("/**")
.addExclude("/favicon.ico", "/auth/login") // 排除登录接口
.setAuth(obj -> {
SaRequest req = SaHolder.getRequest();
// 1. 跨域预检请求放行
if (req.getMethod().equals("OPTIONS")) return;
// 2. 必须登录校验
StpUtil.checkLogin();
// 3. 动态权限校验
Set<String> neededPerms = authStrategyService.getRequiredPerms(req.getMethod(), req.getRequestPath());
if (neededPerms != null && !neededPerms.isEmpty()) {
// 如果当前用户拥有 neededPerms 中的任意一个,则通过
boolean hasAuth = false;
for (String perm : neededPerms) {
if (StpUtil.hasPermission(perm)) {
hasAuth = true;
break;
}
}
if (!hasAuth) {
throw new NotPermissionException(neededPerms.toString());
}
}
// neededPerms 为 null 时,仅需登录即可访问
})
.setError(e -> {
// 异常处理转交给 GlobalExceptionHandler 或者在这里直接返回 JSON
// 为统一架构,这里只负责设置 Response Header,内容由 JSON 工具生成
SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8");
return JSONUtil.toJsonStr(AjaxJson.getError(e.getMessage()));
});
}
}
权限维护接口
@Data
public class MenuTreeVO {
private Long id;
private Long parentId;
private String title; // 菜单/按钮名称
private String key; // 唯一标识
private Boolean isLeaf; // 是否叶子节点
private Boolean checked; // 当前角色是否已拥有该权限
private List<MenuTreeVO> children;
}
@Data
public class RolePermDto {
private Long roleId;
private List<Long> menuIds; // 勾选的菜单/按钮ID集合
}
@RestController
@RequestMapping("/api/sys")
public class SysManagerController {
@Autowired
private SysUserService userService;
@Autowired
private SysRoleService roleService;
@Autowired
private SysMenuService menuService;
// ================= 用户管理 =================
/**
* 新增用户
* URL: POST /api/sys/user
* 权限绑定: 在数据库 sys_menu_api 绑定给 'user:add'
*/
@PostMapping("/user")
public AjaxJson addUser(@RequestBody SysUser user) {
// 密码加密等逻辑...
user.setPassword(BCrypt.hashpw(user.getPassword()));
userService.save(user);
return AjaxJson.getSuccess("用户创建成功");
}
/**
* 删除用户
*/
@DeleteMapping("/user/{id}")
public AjaxJson deleteUser(@PathVariable Long id) {
userService.removeById(id);
return AjaxJson.getSuccess("删除成功");
}
// ================= 角色管理 =================
/**
* 新增角色
*/
@PostMapping("/role")
public AjaxJson addRole(@RequestBody SysRole role) {
roleService.save(role);
return AjaxJson.getSuccess(role);
}
/**
* 删除角色
* 逻辑: 删除角色的同时,需要删除 sys_role_menu, sys_user_role 中的关联数据
*/
@DeleteMapping("/role/{id}")
public AjaxJson deleteRole(@PathVariable Long id) {
roleService.deleteRoleDeeply(id);
return AjaxJson.getSuccess("角色删除成功");
}
// ================= 权限核心接口 =================
/**
* [核心] 获取角色权限树
* 前端打开“分配权限”弹窗时调用
* @param roleId 当前正在编辑的角色ID (若是新增角色则传 0 或 null)
*/
@GetMapping("/role/permission_tree")
public AjaxJson getRolePermissionTree(@RequestParam(required = false) Long roleId) {
List<MenuTreeVO> tree = menuService.buildMenuTreeForRole(roleId);
return AjaxJson.getSuccess(tree);
}
/**
* [核心] 保存角色-权限关联
*/
@PostMapping("/role/permissions")
public AjaxJson updateRolePermissions(@RequestBody RolePermDto dto) {
roleService.updateRoleMenus(dto.getRoleId(), dto.getMenuIds());
// 重要:更新后,可能需要清除该角色下所有用户的权限缓存,让其重新加载
// StpUtil.getSession().delete("Permission_Cache"); 视缓存策略而定
return AjaxJson.getSuccess("权限分配成功");
}
}
登入登出接口实现
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private SysUserService userService;
@PostMapping("/login")
public SaResult login(@RequestBody LoginDto loginDto) {
// 1. 校验账号密码 (伪代码)
SysUser user = userService.getByUsername(loginDto.getUsername());
if(user == null || !checkPwd(loginDto.getPassword(), user.getPassword())) {
return SaResult.error("账号或密码错误");
}
// 2. 登录 (Sa-Token 会自动生成 Token 并注入 Cookie/Header)
StpUtil.login(user.getId());
// 3. 返回 Token 信息给前端
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
return SaResult.data(tokenInfo);
}
@RequestMapping("/logout")
public SaResult logout() {
StpUtil.logout();
return SaResult.ok();
}
}
@Service
public class SysMenuService extends ServiceImpl<SysMenuMapper, SysMenu> {
@Autowired
private SysRoleMenuMapper roleMenuMapper;
/**
* 构建包含 "checked" 状态的权限树
*/
public List<MenuTreeVO> buildMenuTreeForRole(Long roleId) {
// 1. 查询所有有效菜单/按钮
List<SysMenu> allMenus = this.list(new LambdaQueryWrapper<SysMenu>().orderByAsc(SysMenu::getOrderNum));
// 2. 如果 roleId 存在,查询该角色已拥有的 menu_id 集合
Set<Long> ownedMenuIds = new HashSet<>();
if (roleId != null) {
List<SysRoleMenu> rms = roleMenuMapper.selectList(new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getRoleId, roleId));
rms.forEach(rm -> ownedMenuIds.add(rm.getMenuId()));
}
// 3. 转换 VO 并标记 checked
List<MenuTreeVO> allNodes = allMenus.stream().map(m -> {
MenuTreeVO vo = new MenuTreeVO();
vo.setId(m.getId());
vo.setParentId(m.getParentId());
vo.setTitle(m.getName());
vo.setKey(m.getId().toString());
// 如果集合包含,前端复选框打勾
vo.setChecked(ownedMenuIds.contains(m.getId()));
return vo;
}).collect(Collectors.toList());
// 4. list 转 tree (使用 Hutool TreeUtil 或手写递归)
return buildTreeStructure(allNodes);
}
// 简单的递归构建树
private List<MenuTreeVO> buildTreeStructure(List<MenuTreeVO> nodes) {
List<MenuTreeVO> tree = new ArrayList<>();
for (MenuTreeVO node : nodes) {
if (node.getParentId() == 0) { // 根节点
node.setChildren(getChildren(node.getId(), nodes));
tree.add(node);
}
}
return tree;
}
private List<MenuTreeVO> getChildren(Long pid, List<MenuTreeVO> nodes) {
List<MenuTreeVO> children = new ArrayList<>();
for (MenuTreeVO node : nodes) {
if (node.getParentId().equals(pid)) {
node.setChildren(getChildren(node.getId(), nodes));
children.add(node);
}
}
return children;
}
}
本文为博主原创文章,未经博主允许不得转载