
一、引言
做后台管理系统,最头疼的就是权限。
不同角色能看到不同的菜单、操作不同的功能、查询不同的数据——如果设计得不够灵活,每加一个页面就要改一套判断逻辑,代码会越来越臃肿。
我们这个项目有四个角色:平台运营、合作商户、代理、运维人员,外加两套独立前端(OPS 运营后台 + Web 商户后台)。但后端只有一套。
这一篇就讲权限体系怎么设计的,以及两套前端怎么复用同一套后端。
二、整体设计:五层权限模型
从粗到细,一共分了五层:
第一层:应用隔离 → AppId(OPS / Web 两个应用)
第二层:菜单导航 → 模块列表(不同角色看到不同菜单)
第三层:功能按钮 → API 级别的按钮权限
第四层:数据范围 → 同一个接口,返回不同的数据
第五层:路由注册 → 前端根据权限动态生成路由
每个用户登录后,系统根据其角色计算出可访问的菜单、可调用的 API、可见的数据范围。
三、数据模型
权限体系的数据库表结构:
SysUser 用户表
├── SysRoleUser 用户-角色关联
│ └── SysRole 角色表
│ └── SysRolePermission 角色-权限关联
│ ├── PermissionType=1 → SysModule 模块(菜单)
│ └── PermissionType=2 → SysApi 接口(按钮)
└── SysDeptUser 用户-部门关联
└── SysDept 部门表(数据权限维度)
核心关联表 SysRolePermission 只有四个字段:
public class SysRolePermissionEntity : BaseEntity
{
public string Id { get; set; }
public string RoleId { get; set; } // 角色 ID
public int PermissionType { get; set; } // 1=模块, 2=API
public string PermissionId { get; set; } // 模块 ID 或 API ID
}
设计很简洁——用 PermissionType 区分授权的是「导航菜单」还是「功能接口」。
四、后端权限判定:接口级别的授权拦截
整个后端的 API 分两层控制:
第一层:JWT 认证
所有业务接口都加了 [Authorize] 标签,未登录直接返回 401。
JWT Token 中包含用户 ID 和安全级别两个关键字段:
var jwt = JWTExtension.CreateJWT(
userId,
userName,
securityLevel, // 0=超级用户, 1=普通用户
expires
);
Token 验证在 AddSecurity 中配置。同时 SignalR 连接也通过 URL 参数携带 Token 做认证:
OnMessageReceived = context =>
{
if (context.Request.Query.TryGetValue("access_token", out var accessToken)
&& context.HttpContext.Request.Path.StartsWithSegments("/messageHub"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
第二层:API 授权
对于非超级用户,每个请求都会被 PermissionHandler 拦截,检查当前 URL 是否在该用户的可访问 API 列表中:
protected override async Task<Task> HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
if (level == 0)
{
// 超级用户直接通过
context.Succeed(requirement);
}
else
{
// 查询用户的所有 API 权限
apiList = await requirement.GetApiListByPermission(apiService, userId);
bool result = apiList.Any(x =>
string.Equals(x, questUrl, StringComparison.CurrentCultureIgnoreCase));
if (result)
context.Succeed(requirement);
else
context.Fail();
}
}
权限数据的查询链路:
public async Task<List<string>> GetListByPermissionAsync(string userId)
{
// 普通用户:角色 → 权限 → API
var roleIds = roleUserRepository.GetQuery(x => x.UserId == userId)
.Select(x => x.RoleId).ToList();
var apiIds = rolePermissionRepository.GetQueryAsNoTracking(
x => roleIds.Contains(x.RoleId) && x.PermissionType == 2)
.Select(m => m.PermissionId).ToList();
var apis = allApis.Where(x => apiIds.Contains(x.ApiId)).ToList();
// 超级用户:直接返回所有
if (securityLevel == 0)
apis = allApis.ToList();
return apis.Select(x => x.ApiUrl).ToList();
}
两个查询都用 [Caching] 做了 5 分钟缓存,避免每次请求都去查数据库。
五、前端权限控制:菜单 + 按钮 + 路由
菜单权限:按角色动态加载
用户登录后,前端先调 getMenu 获取菜单树。后端根据用户的角色过滤菜单,只返回有权限的模块。每个菜单项包含层级、路由、图标等信息:
await appStore.getMenu(); // 获取菜单树
await appStore.getApi(); // 获取接口权限列表
await appStore.getRouter(); // 获取路由配置
菜单组件递归渲染,支持无限级嵌套:
<MenuItem v-for="childItem in item.children" :key="childItem.moduleId"
:item="childItem" :openModuleIds="openModuleIds" @onSlideToggle="..." />
有 moduleRoute 的节点渲染为 <router-link>,没有的渲染为分组标题,点击展开/折叠子菜单。
按钮权限:v-has 指令
前端获取 API 权限列表后,通过一个自定义指令控制按钮的显隐:
export default {
mounted(el, binding, vnode) {
const { value } = binding;
const appStore = useAppStore();
if (value) {
const hasPermission = appStore.apiRes.includes(value);
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el);
}
}
},
};
使用时直接在按钮上加上 API 路径即可:
<el-button v-has="'/api/sys/module/add'" @click="cmdAdd">新增</el-button>
<el-button v-has="'/api/sys/module/delete'" @click="cmdDelete">删除</el-button>
如果没有权限,这个 DOM 元素根本不会出现在页面上。简单粗暴,但非常有效。
路由权限:动态注册
路由不是写死的,而是在登录后根据后端返回的模块树动态生成并注册:
const res = await appStore.getRouter();
const accessRoutes = await appStore.generateRoutes(res);
// 递归注册 Vue Router 路由
accessRoutes.forEach(route => {
router.addRoute(route);
});
动态路由的组件路径存在数据库中,前端通过字符串解析自动加载:
export function filterAsyncRouter(routerRes, isDeepLoop) {
return routerRes.filter((route) => {
if (route.component) {
let str = route.component;
route.component = () => import(`@/pages${str}`);
}
// 递归处理子路由
if (isDeepLoop && route.children && route.children.length) {
route.children = filterAsyncRouter(route.children);
}
return true;
});
}
这意味着新增一个页面只需要在后台配置模块 + 写 Vue 组件,前端的路由配置不需要改一行代码。
六、数据权限:同一个接口,不同数据
菜单和按钮权限解决了「能不能看到这个页面/按钮」的问题。但还有一个更细粒度的控制——数据权限:同一个订单查询接口,商户只能看自己店铺的,运营可以看全部的。
我们在各个 Service 的查询方法中实现了数据权限过滤。以订单查询为例:
SysUserEntity user = await userRepository
.FirstOrDefaultAsNoTrackingAsync(x => x.UserId == aspNetUser.UserId);
if (user?.SecurityLevel != 0 && user?.IsSystem == 0)
{
var shopIds = new List<string>();
if (user?.IsBiz == 1) // 商户:自己店铺的订单
shopIds.AddRange(shopRepository.GetQuery(
x => x.ZnShopUser.Any(n => n.UserId == userId))
.Select(x => x.ShopId));
if (user?.IsBroker == 1) // 代理:名下店铺的订单
shopIds.AddRange(shopRepository.GetQuery(
x => x.SysUser.UserId == userId)
.Select(x => x.ShopId));
if (user?.IsOps == 1) // 运维:负责区域的订单
shopIds.AddRange(shopRepository.GetQuery(
x => codeList.Contains(x.AreaCode))
.Select(x => x.ShopId));
strWhere = strWhere.And(x => shopIds.Contains(x.ShopId));
}
设备管理的数据权限也是类似的思路——运维人员只能看到自己负责区域的设备:
if (user?.IsOps == 1)
{
var areaCode = user?.AreaCode;
if (!string.IsNullOrEmpty(areaCode))
{
// areaCode 格式:[["湖南省","长沙市"],["湖南省","株洲市"]]
var codes = JsonConvert.DeserializeObject<List<List<string>>>(areaCode);
var codeList = codes.Select(item => item.ToJson()).ToList();
shopIds.AddRange(shopRepository.GetQuery(
x => codeList.Contains(x.AreaCode)).Select(x => x.ShopId));
}
strWhere = strWhere.And(x => shopIds.Contains(x.ShopId));
}
运维的区域编码支持多维数组,比如一个运维人员可以同时负责「长沙市」和「株洲市」。
七、两套前端,一个后端
OPS 运营后台和 Web 商户后台虽然面向不同用户,但代码结构几乎一样:
两个前端的共同点:
- 同一个后端 API
- 同一套权限体系(菜单 + API + 数据权限)
- 同一套登录流程(账号密码 + JWT)
- 复用大部分组件(Popbox、Search、WangEditor)
两个前端的差异:
- OPS 侧重系统管理(角色、部门、字典、定时任务)
- Web 侧重业务管理(设备、订单、店铺、收益)
- 页面和路由不同
实现方式也很简单:两个前端项目各自独立构建,打包后部署在不同域名下,访问同一个后端接口。
ops.cssbzd.com → OPS 运营后台(Vue 3 + Element Plus)
↓
api.cssbzd.com → 后端 API
↑
cssbzd.com → Web 商户后台(Vue 3 + Element Plus)
登录时通过 appId 参数区分当前是哪个前端,后端据此返回不同的菜单和路由配置。
八、权限配置的运营操作界面
在 OPS 后台的角色管理页面,运营人员可以直观地为角色分配权限:

勾选导航后,对应的功能权限才会展示。提交保存时后端先清空该角色的所有旧权限,再写入新权限:
public async Task<bool> Submit(RolePermissionReqDto forms, string userId)
{
// 先清空该角色的所有授权
await currentRepository.RemoveRangeAsync(t => t.RoleId == roleId);
// 重新授权
foreach (var moduleId in moduleIds)
{
entities.Add(new SysRolePermissionEntity {
RoleId = roleId, PermissionType = 1, PermissionId = moduleId
});
}
foreach (var apiId in apiIds)
{
entities.Add(new SysRolePermissionEntity {
RoleId = roleId, PermissionType = 2, PermissionId = apiId
});
}
return await currentRepository.AddRangeAsync(entities) > 0;
}
九、小结
这套权限体系用下来,感受最深的是几点:
- 五层权限模型覆盖了所有场景——从应用隔离到数据行级过滤,每一层都有自己的作用,互不重叠
- 按钮级权限用自定义指令实现——
v-has简洁直观,新增功能时只需要在按钮上加一个属性 - 数据权限在 Service 层控制——不污染 SQL,不侵入前端,逻辑集中可维护
- 动态路由减少维护成本——新增页面只需要写组件 + 后台配置,前端路由不用改
下一篇会讲设备管理、远程控制与监控,看看运维人员在后台怎么管理几百台设备、远程操控机柜、监控实时数据。
有疑问或者想讨论的点,留言区见。