权限控制体系设计
本文梳理当前项目的权限控制体系,覆盖后端鉴权、权限模型、菜单路由、按钮控制、数据归属校验和权限变更生效链路。结论先行:系统采用以 RBAC 为主的权限模型,后端通过 Spring Security、JWT、@PreAuthorize 和账号归属校验负责最终准入,前端通过用户信息和菜单路由做可见性控制。
总体模型
权限体系分为三层:
| 层级 | 负责内容 | 主要位置 | 是否作为最终安全边界 |
|---|---|---|---|
| 登录态鉴权 | 校验 Token、恢复当前登录用户、刷新登录态 | wt-framework |
是 |
| 功能权限 | 用户、角色、菜单、权限标识和接口注解 | wt-system、wt-admin、wt-client |
是 |
| 前端可见性 | 动态菜单、路由注册、按钮隐藏 | admin/src/router、admin/src/directives/permission.ts、admin/src/composables/useAuth.ts |
否 |
整体链路如下:
核心数据模型
当前权限模型的核心表和实体如下:
| 实体 | 表 | 作用 |
|---|---|---|
SysUser |
t_sys_user |
系统登录用户。 |
SysRole |
t_sys_role |
角色,roleKey 用于角色标识,例如 admin。 |
SysMenu |
t_sys_menu |
菜单、目录、按钮节点和路由配置,menuType 分为 M、C、F。 |
SysPermission |
t_sys_permission |
权限标识配置。当前代码以 permissionName 作为接口和按钮权限标识,permissionCode 是备用编码字段。 |
SysUserRole |
t_sys_user_role |
用户和角色关联。 |
SysRoleMenu |
t_sys_role_menu |
角色和菜单关联。 |
SysRolePermission |
t_sys_role_permission |
角色和权限关联。 |
SysMenuPermission |
t_sys_menu_permission |
菜单和权限关联。 |
权限集合的来源有两类:
- 角色直接绑定的权限:
t_sys_role_permission。 - 角色绑定菜单后,菜单再绑定的权限:
t_sys_role_menu+t_sys_menu_permission。
SysPermissionServiceImpl.listByUserId 会合并这两类权限,再过滤已启用权限,最终形成用户的权限集合。
登录态和 Token
登录入口在 wt-admin 和 wt-client 的 SysLoginController,路径都是 /api/v1/login。
登录成功后:
SysLoginService校验验证码、用户名、密码和登录前置规则。UserDetailsServiceImpl加载用户、角色、权限和账户关系。TokenService.createToken生成 JWT,JWT 中只保存登录用户唯一标识。- 完整
LoginUser写入 Redis,缓存键前缀为login_tokens:。 - 前端保存返回的
token,后续请求通过Authorization: Bearer <token>传递。
请求进入后:
JwtAuthenticationTokenFilter从请求头读取 Token。TokenService解析 JWT,取出 Redis 中的LoginUser。- 若 Token 快到期,
verifyToken自动刷新 Redis 里的登录态有效期。 - 当前用户写入
SecurityContextHolder,供SecurityUtils和@PreAuthorize使用。
SecurityConfig 当前放行登录、注册、验证码、静态资源、Swagger、Druid 和部分文件授权访问路径,其他请求默认要求认证。
接口权限校验
接口权限由 Spring Security 方法级注解控制,统一写法如下:
@PreAuthorize("@ss.hasPermi('admin:system:role:list')")
@ss 对应 PermissionService,它会从当前 LoginUser.permissions 中判断是否包含目标权限。超级管理员使用 *:*:* 作为全权限标识。
常用能力如下:
| 方法 | 作用 |
|---|---|
@ss.hasPermi('xxx') |
必须拥有指定权限。 |
@ss.hasAnyPermi('a,b') |
拥有任一权限即可。 |
@ss.hasRole('admin') |
必须拥有指定角色。 |
@ss.hasAnyRoles('admin,merchant') |
拥有任一角色即可。 |
权限标识建议按以下格式组织:
端别:领域:资源:动作
示例:
| 端别 | 示例 | 说明 |
|---|---|---|
admin |
admin:system:role:list |
平台管理端角色列表。 |
admin |
admin:product:review:edit |
平台管理端评价审核操作。 |
client |
client:merchant:inventory:query |
商户侧库存查询。 |
client |
client:personal:order:query |
消费者侧订单查询。 |
动作建议保持稳定:
| 动作 | 含义 |
|---|---|
list |
列表查询。 |
query |
详情、条件查询或只读查询。 |
add |
新增。 |
edit |
修改、审核、状态变更。 |
remove |
删除。 |
tran |
交易、状态流转、业务动作。 |
export |
导出。 |
菜单和路由权限
菜单权限负责“用户能看到哪些页面入口”。后端通过 /api/v1/routers 返回当前用户可访问菜单:
SysMenuServiceImpl.listByUserId查询当前用户角色。- 每个启用角色通过
t_sys_role_menu找到菜单。 - 过滤启用菜单,超级管理员直接拥有全部启用菜单。
buildMenus将菜单转换为前端路由结构。
SysMenu 主要字段含义:
| 字段 | 作用 |
|---|---|
menuName |
菜单显示名称。 |
parentId |
父级菜单。 |
path |
前端路由路径。 |
component |
前端组件路径。 |
routeName |
路由名称。 |
menuType |
M 为目录,C 为菜单,F 为按钮。 |
visible |
是否在菜单中隐藏。 |
isFrame |
是否外链。 |
isCache |
是否缓存。 |
icon |
菜单图标。 |
前端 admin 在后端控制模式下:
- 路由守卫检查登录态。
- 调用
UserService.getRouters()获取菜单。 normalizeBackendRoutes标准化后端路由。registerDynamicRoutes动态注册路由。menuStore.setMenuList保存侧边栏菜单。
因此页面入口以菜单配置为准,新增页面时需要同步新增菜单,否则路由不会出现在侧边栏中。
按钮和页面能力控制
前端按钮权限只负责可见性,不是安全边界。
当前有两种使用方式:
<ElButton v-auth="'admin:system:permission:add'">新增权限</ElButton>
const { hasAuth } = useAuth()
if (hasAuth('admin:product:review:edit')) {
// 显示或执行前端交互
}
判断逻辑来自用户信息中的 permissions:
- 如果
user.admin为真,直接通过。 - 如果角色包含
admin,直接通过。 - 如果权限集合包含
*:*:*,直接通过。 - 否则必须包含传入的权限标识。
前端隐藏按钮不能阻止绕过前端直接调用接口,所以所有写接口和敏感读接口都必须有后端 @PreAuthorize。
账户和数据归属控制
RBAC 解决“能不能调用某类功能”,数据归属解决“能不能操作这条数据”。
LoginUser 中保存 UserAccountRelation,用于识别当前用户关联的个人、企业、政府、商户和员工账号。SecurityUtils 提供以下常用方法:
| 方法 | 作用 |
|---|---|
getPersonalAccountId() |
获取个人账号 ID,不存在则抛业务异常。 |
getMerchantAccountId() |
获取商户账号 ID,不存在则抛业务异常。 |
getEmployeeAccountId() |
获取员工账号 ID,不存在则抛业务异常。 |
checkUserId(userId) |
校验用户 ID 是否为本人,管理员放行。 |
checkAccountRel(accountId) |
校验账号 ID 是否属于当前用户账户关系,管理员放行。 |
商户和个人接口必须在业务层或 Controller 中追加数据归属条件。例如商户库存查询会把 merchantAccountId 设置为当前登录商户账号,详情接口也会校验数据是否属于当前商户。
权限变更生效机制
登录后的权限和账户关系缓存在 Redis 的 LoginUser 中。为了避免用户重新登录才能生效,系统在部分权限变更后会刷新在线用户缓存。
当前已覆盖的刷新场景:
| 操作 | 刷新范围 |
|---|---|
| 修改角色、角色状态、角色绑定菜单、角色绑定权限 | 绑定该角色的在线用户。 |
| 修改菜单、菜单绑定权限 | 绑定该菜单相关角色的在线用户。 |
| 新增、修改、删除权限 | 全部在线用户。 |
| 修改用户状态、用户授权角色 | 指定用户。 |
刷新逻辑在 UserRefreshComponent:
- 根据用户 ID 重新构建
LoginUser。 - 重新查询用户、角色、权限和账户关系。
- 保留原 Token、登录时间、过期时间和客户端信息。
- 写回 Redis。
这意味着权限变更后,后端接口校验可以尽快生效;但前端侧边栏和按钮仍依赖本地用户信息与路由数据,必要时需要刷新页面或重新拉取 /api/v1/info 与 /api/v1/routers。
多端权限边界
系统存在平台管理端、客户端和运营小程序等入口,权限标识必须按端别隔离。
| 端别 | 入口 | 权限前缀 | 典型身份 |
|---|---|---|---|
| 平台管理端 | admin、wt-admin |
admin: |
平台管理员、运营人员。 |
| 客户端 | client、wt-client |
client:personal:、client:merchant:、client:employee: |
消费者、商户、员工。 |
| 运营小程序 | ops |
当前复用部分 client 或 admin 接口 |
仓库操作员、配送员、商户发货人员。 |
运营小程序的接口归属需要持续收敛:仓配、配送、商户发货类能力应避免长期泛用平台管理员权限,建议逐步落到明确的 client:employee:、client:merchant: 或独立运营前缀。
新增权限的标准流程
新增一个受控功能时,按以下顺序处理:
- 定义权限标识,例如
admin:marketing:coupon:add。 - 在 Controller 方法上添加
@PreAuthorize("@ss.hasPermi('admin:marketing:coupon:add')")。 - 在权限管理中新增
SysPermission,当前需要把权限标识写入permissionName,permissionCode可保持一致。 - 新增或确认菜单,菜单页面要能映射到前端组件。
- 绑定角色和菜单,决定用户是否能看到入口。
- 绑定角色权限或菜单权限,决定用户是否拥有接口和按钮能力。
- 前端按钮使用
v-auth或hasAuth控制可见性。 - 使用目标角色账号验证
/api/v1/info、/api/v1/routers、页面入口、按钮显示和接口返回。
排查清单
| 现象 | 优先检查 |
|---|---|
| 登录后没有菜单 | 用户是否绑定角色,角色是否启用,角色是否绑定菜单,菜单是否启用。 |
| 页面有入口但接口返回 403 | Controller 的 @PreAuthorize 标识是否和用户权限集合一致。 |
| 按钮不显示 | /api/v1/info 返回的 permissions 是否包含按钮权限,前端 v-auth 是否写错。 |
| 菜单显示但页面空白 | 菜单 component 是否能匹配 admin/src/views 下的组件。 |
| 权限变更后仍无效 | Redis 中在线用户是否刷新,前端是否重新拉取用户信息和路由。 |
| 商户看到其他商户数据 | 接口是否追加 SecurityUtils.getMerchantAccountId() 或账号归属校验。 |
| 超级管理员权限异常 | 用户 ID 是否为 1,是否返回 *:*:* 和 admin 角色。 |
当前注意点
- 当前代码以
SysPermission.permissionName作为权限标识参与校验,permissionCode仅作为备用编码字段;录入权限时不要只填中文名称。 PermissionService.hasPermi是精确匹配permissions.contains(permission),不支持admin:system:*这类通配模式;SecurityUtils.hasPermi支持简单通配,但接口注解当前走的是PermissionService。- 前端菜单、按钮和后端接口必须使用同一个权限标识,否则会出现“能看不能点”或“看不到但接口可调”的不一致。
- 前端控制模式仍支持按路由
roles过滤菜单,但当前项目主路径应以后端菜单模式为准。 - 新增敏感接口时不能只做前端隐藏,必须补
@PreAuthorize和数据归属校验。