by tcdos 2026-07-04

运营中台与商户后台 — 一个后端,两套 Vue 3 前端

一、引言

做后台管理系统,最头疼的就是权限。

不同角色能看到不同的菜单、操作不同的功能、查询不同的数据——如果设计得不够灵活,每加一个页面就要改一套判断逻辑,代码会越来越臃肿。

我们这个项目有四个角色:平台运营、合作商户、代理、运维人员,外加两套独立前端(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;
}

九、小结

这套权限体系用下来,感受最深的是几点:

  1. 五层权限模型覆盖了所有场景——从应用隔离到数据行级过滤,每一层都有自己的作用,互不重叠
  2. 按钮级权限用自定义指令实现——v-has 简洁直观,新增功能时只需要在按钮上加一个属性
  3. 数据权限在 Service 层控制——不污染 SQL,不侵入前端,逻辑集中可维护
  4. 动态路由减少维护成本——新增页面只需要写组件 + 后台配置,前端路由不用改

下一篇会讲设备管理、远程控制与监控,看看运维人员在后台怎么管理几百台设备、远程操控机柜、监控实时数据。

有疑问或者想讨论的点,留言区见。

天草工坊公众号

欢迎关注 天草工坊 公众号

好的设计被看见,好的开发被落地。

围观:16| 收获点赞:0

评论 0

暂无评论,来说点什么吧

?
内容目录
快捷操作
点赞 评论 返回顶部