权限控制体系设计

本文梳理当前项目的权限控制体系,覆盖后端鉴权、权限模型、菜单路由、按钮控制、数据归属校验和权限变更生效链路。结论先行:系统采用以 RBAC 为主的权限模型,后端通过 Spring Security、JWT、@PreAuthorize 和账号归属校验负责最终准入,前端通过用户信息和菜单路由做可见性控制。

总体模型

权限体系分为三层:

层级 负责内容 主要位置 是否作为最终安全边界
登录态鉴权 校验 Token、恢复当前登录用户、刷新登录态 wt-framework
功能权限 用户、角色、菜单、权限标识和接口注解 wt-systemwt-adminwt-client
前端可见性 动态菜单、路由注册、按钮隐藏 admin/src/routeradmin/src/directives/permission.tsadmin/src/composables/useAuth.ts

整体链路如下:

sequenceDiagram participant U as 用户 participant FE as 前端 participant API as 后端接口 participant SEC as Spring Security participant SYS as wt-system participant REDIS as Redis U->>FE: 登录 FE->>API: POST /api/v1/login API->>SEC: 用户名、密码、验证码校验 SEC->>SYS: 查询用户、角色、权限、账户关系 API->>REDIS: 写入 LoginUser API-->>FE: 返回 Token FE->>API: GET /api/v1/info API-->>FE: 返回 user、roles、permissions、账户关系 FE->>API: GET /api/v1/routers API-->>FE: 返回当前用户菜单路由 FE->>API: 业务请求 + Authorization SEC->>REDIS: 根据 Token 读取 LoginUser API->>SEC: @PreAuthorize 校验权限标识 API-->>FE: 允许或拒绝

核心数据模型

当前权限模型的核心表和实体如下:

实体 作用
SysUser t_sys_user 系统登录用户。
SysRole t_sys_role 角色,roleKey 用于角色标识,例如 admin
SysMenu t_sys_menu 菜单、目录、按钮节点和路由配置,menuType 分为 MCF
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-adminwt-clientSysLoginController,路径都是 /api/v1/login

登录成功后:

  1. SysLoginService 校验验证码、用户名、密码和登录前置规则。
  2. UserDetailsServiceImpl 加载用户、角色、权限和账户关系。
  3. TokenService.createToken 生成 JWT,JWT 中只保存登录用户唯一标识。
  4. 完整 LoginUser 写入 Redis,缓存键前缀为 login_tokens:
  5. 前端保存返回的 token,后续请求通过 Authorization: Bearer <token> 传递。

请求进入后:

  1. JwtAuthenticationTokenFilter 从请求头读取 Token。
  2. TokenService 解析 JWT,取出 Redis 中的 LoginUser
  3. 若 Token 快到期,verifyToken 自动刷新 Redis 里的登录态有效期。
  4. 当前用户写入 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 返回当前用户可访问菜单:

  1. SysMenuServiceImpl.listByUserId 查询当前用户角色。
  2. 每个启用角色通过 t_sys_role_menu 找到菜单。
  3. 过滤启用菜单,超级管理员直接拥有全部启用菜单。
  4. buildMenus 将菜单转换为前端路由结构。

SysMenu 主要字段含义:

字段 作用
menuName 菜单显示名称。
parentId 父级菜单。
path 前端路由路径。
component 前端组件路径。
routeName 路由名称。
menuType M 为目录,C 为菜单,F 为按钮。
visible 是否在菜单中隐藏。
isFrame 是否外链。
isCache 是否缓存。
icon 菜单图标。

前端 admin 在后端控制模式下:

  1. 路由守卫检查登录态。
  2. 调用 UserService.getRouters() 获取菜单。
  3. normalizeBackendRoutes 标准化后端路由。
  4. registerDynamicRoutes 动态注册路由。
  5. 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

  1. 根据用户 ID 重新构建 LoginUser
  2. 重新查询用户、角色、权限和账户关系。
  3. 保留原 Token、登录时间、过期时间和客户端信息。
  4. 写回 Redis。

这意味着权限变更后,后端接口校验可以尽快生效;但前端侧边栏和按钮仍依赖本地用户信息与路由数据,必要时需要刷新页面或重新拉取 /api/v1/info/api/v1/routers

多端权限边界

系统存在平台管理端、客户端和运营小程序等入口,权限标识必须按端别隔离。

端别 入口 权限前缀 典型身份
平台管理端 adminwt-admin admin: 平台管理员、运营人员。
客户端 clientwt-client client:personal:client:merchant:client:employee: 消费者、商户、员工。
运营小程序 ops 当前复用部分 clientadmin 接口 仓库操作员、配送员、商户发货人员。

运营小程序的接口归属需要持续收敛:仓配、配送、商户发货类能力应避免长期泛用平台管理员权限,建议逐步落到明确的 client:employee:client:merchant: 或独立运营前缀。

新增权限的标准流程

新增一个受控功能时,按以下顺序处理:

  1. 定义权限标识,例如 admin:marketing:coupon:add
  2. 在 Controller 方法上添加 @PreAuthorize("@ss.hasPermi('admin:marketing:coupon:add')")
  3. 在权限管理中新增 SysPermission,当前需要把权限标识写入 permissionNamepermissionCode 可保持一致。
  4. 新增或确认菜单,菜单页面要能映射到前端组件。
  5. 绑定角色和菜单,决定用户是否能看到入口。
  6. 绑定角色权限或菜单权限,决定用户是否拥有接口和按钮能力。
  7. 前端按钮使用 v-authhasAuth 控制可见性。
  8. 使用目标角色账号验证 /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 和数据归属校验。