ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3+NestJS后台权限管理系统实战:从RBAC建模到动态路由的源码级教程

Vue3+NestJS后台权限管理系统实战:从RBAC建模到动态路由的源码级教程 1. 从零搭建 Vue3 NestJS 后台权限管理系统RBAC 建模与动态路由到底怎么落地后台权限管理系统这个词听起来很唬人但拆开看无非三件事谁能登录、登录后能看到哪些菜单、进了页面能点哪些按钮。Vue3 NestJS 这套组合之所以适合做中后台是因为前端负责“按权限渲染”后端负责“按权限放行”两边各管一段边界清晰。我这次要交付的就是一条从数据库建模到前端动态路由、再到接口守卫的完整链路角色-菜单-按钮三级权限全部给到可复制的代码片段。适合谁看如果你正在用 Vue3 写管理后台后端选了 NestJS但卡在“菜单怎么根据角色动态出来”“按钮权限怎么控制”“接口怎么防止越权”这几个问题上那这篇就是给你准备的。我会按“先建模、再配权限、然后写守卫、最后验证”的顺序推进每一步都有代码和预期结果你可以边看边敲。核心检索词先明确Vue3 动态路由、NestJS RBAC 权限校验、后台权限管理系统源码教程。这三个词贯穿全文你跟着走完基本能搭出一个可用的权限骨架。先说整体数据模型这是后面所有逻辑的地基。RBAC 的核心是四张表用户表、角色表、菜单/权限表、用户角色关联表。菜单表里用一个 type 字段区分“目录、菜单、按钮”三种类型按钮类型的记录不参与路由渲染只作为权限标识存在。这样一套表结构同时支撑了菜单渲染和按钮级鉴权不用维护两套权限数据。后端 NestJS 侧登录成功后签发 JWTpayload 里带上 userId 和 roleIds。前端拿到 token 存起来每次请求通过拦截器塞进 Authorization 头。后端用一个全局守卫解析 token再用一个 Permissions 装饰器标注每个接口需要的权限码守卫里比对当前用户拥有的权限集合。前端则用自定义指令 v-hasPerm 控制按钮显隐用路由守卫 动态 addRoute 控制菜单。这条链路里最容易出问题的地方有三个一是动态路由添加时机不对导致刷新白屏二是权限码前后端对不上导致按钮该显示却不显示三是守卫里异步获取用户信息没处理好导致接口 401。后面我会逐个给排查方法。整个系统跑起来后你会看到不同角色登录后左侧菜单不一样没权限的按钮直接不渲染手动改 URL 访问越权接口会被后端拦下返回统一错误。这就是我们要的最终效果。下面从环境准备开始一步步来。2. TaoToken 前置准备给 NestJS 接入大模型能力与 API Key 配置在正式写权限代码之前先把一个容易被忽略的前置环节处理掉后台系统里经常需要接入大模型能力比如智能问答、日志分析、代码辅助。这部分如果自己维护一套调用链路会很麻烦用 TaoToken 这类统一入口会省事很多。它的官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 注意 API 地址不带 UTM 参数配置时别写错。为什么放在权限系统里讲这个因为很多中后台项目后期都会加 AI 功能与其到时候临时找方案不如在搭骨架时就把接入层留好。TaoToken 提供的是兼容常见接口规范的调用方式你在 NestJS 里封装一个 service前端通过自己的后端转发权限系统照样能控制“谁能用 AI 功能”。先拿 API Key。打开 https://taotoken.net/api-keys 登录后创建一个新的 Key复制保存好后面配置要用。这个 Key 只显示一次丢了就重新建。拿到 Key 后在 NestJS 项目根目录建一个 .env 文件把配置写进去TAOTOKEN_API_KEYsk-你的key TAOTOKEN_BASE_URLhttps://taotoken.net/api然后在 NestJS 里用 nestjs/config 读取。安装依赖npm i nestjs/config在 app.module.ts 里注册import { ConfigModule } from nestjs/config; Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), // ...其他模块 ], }) export class AppModule {}接着封装一个调用服务用 axios 或内置的 HttpModule 都行。这里用 HttpModulenpm i nestjs/axios axiosimport { Injectable } from nestjs/common; import { HttpService } from nestjs/axios; import { ConfigService } from nestjs/config; import { firstValueFrom } from rxjs; Injectable() export class AiService { constructor( private readonly http: HttpService, private readonly config: ConfigService, ) {} async chat(prompt: string) { const apiKey this.config.getstring(TAOTOKEN_API_KEY); const baseUrl this.config.getstring(TAOTOKEN_BASE_URL); const { data } await firstValueFrom( this.http.post( ${baseUrl}/v1/chat/completions, { model: gpt-4o-mini, messages: [{ role: user, content: prompt }], }, { headers: { Authorization: Bearer ${apiKey}, Content-Type: application/json, }, }, ), ); return data; } }这段代码的关键点Base URL 用 https://taotoken.net/api Key 从环境变量读不要硬编码。模型 ID 按你实际要用的填这里只是示例。封装好之后在 Controller 里加一个接口并用我们后面要讲的 Permissions 装饰器标注权限码这样“谁能调用 AI”就纳入了 RBAC 体系。如果你更习惯用命令行工具做编码辅助可以看下 Coding Plan 相关入口 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 它适合长期编码和 Agent 场景。想先验证模型对话是否通可以用 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 快速试一下。控制台在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。这里要提醒一句API Key 属于敏感信息别提交到 Git。在 .gitignore 里加上 .env。生产环境用环境变量注入不要写死在代码里。这一步做完你的 NestJS 项目就具备了调用大模型的能力而且这个能力是受权限系统管控的不是谁都能调。3. 可复制配置角色-菜单-按钮三级权限的 JSON 与 NestJS 守卫代码这一节是全文的核心直接给可复制的配置和代码。先看数据库建模用 TypeORM 的实体来定义。菜单表设计成自关联树结构type 字段区分类型// menu.entity.ts import { Entity, Column, PrimaryGeneratedColumn, ManyToMany } from typeorm; import { Role } from ./role.entity; export enum MenuType { DIR dir, // 目录 MENU menu, // 菜单 BUTTON button // 按钮 } Entity(sys_menu) export class Menu { PrimaryGeneratedColumn() id: number; Column() parentId: number; Column() name: string; Column({ type: enum, enum: MenuType }) type: MenuType; Column({ nullable: true }) path: string; Column({ nullable: true }) component: string; Column({ nullable: true }) perm: string; // 权限码如 system:menu:add Column({ default: 0 }) sort: number; ManyToMany(() Role, (role) role.menus) roles: Role[]; }角色表用 ManyToMany 关联菜单// role.entity.ts import { Entity, Column, PrimaryGeneratedColumn, ManyToMany, JoinTable } from typeorm; import { Menu } from ./menu.entity; import { User } from ./user.entity; Entity(sys_role) export class Role { PrimaryGeneratedColumn() id: number; Column({ unique: true }) code: string; // 如 admin、editor Column() name: string; ManyToMany(() Menu, (menu) menu.roles) JoinTable({ name: sys_role_menu }) menus: Menu[]; ManyToMany(() User, (user) user.roles) users: User[]; }用户表关联角色// user.entity.ts import { Entity, Column, PrimaryGeneratedColumn, ManyToMany, JoinTable } from typeorm; import { Role } from ./role.entity; Entity(sys_user) export class User { PrimaryGeneratedColumn() id: number; Column({ unique: true }) username: string; Column() password: string; ManyToMany(() Role, (role) role.users) JoinTable({ name: sys_user_role }) roles: Role[]; }建好实体后用一段 seed 数据初始化权限。下面这个 JSON 结构可以直接作为菜单配置的参考前端动态路由和后端权限校验都从这份数据来[ { id: 1, parentId: 0, name: 系统管理, type: dir, path: /system, component: Layout, perm: null, sort: 1 }, { id: 2, parentId: 1, name: 菜单管理, type: menu, path: menu, component: system/menu/index, perm: system:menu:list, sort: 1 }, { id: 3, parentId: 2, name: 新增菜单, type: button, path: null, component: null, perm: system:menu:add, sort: 1 }, { id: 4, parentId: 2, name: 删除菜单, type: button, path: null, component: null, perm: system:menu:delete, sort: 2 } ]注意看目录和菜单有 path、component按钮只有 perm。前端渲染路由时过滤掉 button 类型按钮权限则单独收集成权限码数组。后端守卫部分先写一个 Permissions 装饰器// permissions.decorator.ts import { SetMetadata } from nestjs/common; export const PERMISSIONS_KEY permissions; export const Permissions (...perms: string[]) SetMetadata(PERMISSIONS_KEY, perms);再写全局守卫解析 JWT 并比对权限// permissions.guard.ts import { CanActivate, ExecutionContext, Injectable, ForbiddenException, } from nestjs/common; import { Reflector } from nestjs/core; import { JwtService } from nestjs/jwt; import { PERMISSIONS_KEY } from ./permissions.decorator; Injectable() export class PermissionsGuard implements CanActivate { constructor( private reflector: Reflector, private jwtService: JwtService, ) {} async canActivate(context: ExecutionContext): Promiseboolean { const requiredPerms this.reflector.getAllAndOverridestring[]( PERMISSIONS_KEY, [context.getHandler(), context.getClass()], ); if (!requiredPerms || requiredPerms.length 0) { return true; } const request context.switchToHttp().getRequest(); const token request.headers.authorization?.replace(Bearer , ); if (!token) { throw new ForbiddenException(未登录); } const payload this.jwtService.verify(token); const userPerms: string[] payload.perms || []; const hasPerm requiredPerms.every((p) userPerms.includes(p)); if (!hasPerm) { throw new ForbiddenException(无权限访问); } return true; } }在 Controller 上使用Post(createMenu) Permissions(system:menu:add) async createMenu(Body() dto: CreateMenuDto) { return this.menuService.createMenu(dto); }前端 Vue3 侧自定义指令 v-hasPerm// hasPerm.directive.ts import { Directive, ElementRef, inject } from vue; import { useUserStore } from /store/user; export const hasPerm: Directive { mounted(el: HTMLElement, binding) { const userStore useUserStore(); const perms: string[] binding.value || []; const has perms.every((p) userStore.permissions.includes(p)); if (!has) { el.parentNode?.removeChild(el); } }, };注册到 appimport { createApp } from vue; import { hasPerm } from ./directives/hasPerm.directive; const app createApp(App); app.directive(hasPerm, hasPerm);模板里这样用el-button typeprimary v-hasPerm[system:menu:add] clickhandleAdd 新增 /el-button到这里三级权限的配置和代码就齐了。角色关联菜单菜单里区分类型后端守卫按权限码放行前端指令按权限码渲染。下一节验证这套链路是否真的跑通。4. 验证请求与成功结果登录鉴权、越权访问、菜单渲染三类动作代码写完不验证等于没写。这一节给三个具体的验证动作每个都有操作步骤和预期结果你照着做一遍就知道链路通没通。第一个动作登录鉴权。用 Postman 或 curl 请求登录接口curl -X POST http://localhost:3000/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}预期返回{ code: 0, data: { token: eyJhbGciOiJIUzI1NiIs..., userInfo: { id: 1, username: admin, roles: [admin] } }, message: success }拿到 token 后请求获取用户菜单接口curl http://localhost:3000/auth/menus \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiIs...预期返回一棵菜单树button 类型的节点也在里面但前端渲染路由时会过滤掉。如果这里返回空数组检查角色有没有关联菜单以及 sys_role_menu 表里有没有数据。第二个动作越权访问。用一个只有普通编辑角色的账号登录拿到 token 后去请求需要 system:menu:add 权限的接口curl -X POST http://localhost:3000/menu/createMenu \ -H Authorization: Bearer 普通用户的token \ -H Content-Type: application/json \ -d {name:测试菜单,type:menu,path:test}预期返回 403{ code: 403, message: 无权限访问, data: null }如果这里返回了 200说明守卫没生效。检查 PermissionsGuard 有没有注册为全局守卫或者 Controller 上的 Permissions 装饰器有没有写对权限码。还有一种情况是 JWT payload 里没带 perms 字段守卫拿不到权限集合需要在登录签发 token 时把用户所有权限码塞进 payload。第三个动作菜单渲染。用 admin 登录前端观察左侧菜单。预期看到“系统管理”目录下有“菜单管理”点进去能看到新增、删除按钮。换成普通编辑账号登录预期左侧菜单少了“系统管理”或者进去后新增按钮不显示。如果菜单没出来打开浏览器控制台看动态路由有没有 addRoute 成功常见原因是路由 name 重复或者 component 路径解析失败。动态路由的核心代码在前端路由守卫里router.beforeEach(async (to, from, next) { const userStore useUserStore(); if (!userStore.token) { next(/login); return; } if (userStore.routesLoaded) { next(); return; } const menus await userStore.fetchMenus(); const routes buildRoutes(menus); routes.forEach((r) router.addRoute(r)); userStore.routesLoaded true; next({ ...to, replace: true }); });注意最后的 next({ ...to, replace: true })这是解决刷新白屏的关键。addRoute 之后必须重新触发一次导航否则当前路由匹配不到新加的路由。buildRoutes 函数把后端返回的菜单树转成 Vue Router 的路由配置过滤掉 button 类型component 用动态 import 映射。这里有个坑后端返回的 component 字符串是 system/menu/index前端要用 import.meta.glob 批量导入 views 下的组件然后按路径匹配。如果匹配不到路由会加载失败页面空白。三个动作都通过后你的权限系统基本就通了。下一节处理常见报错。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth 报错对照权限系统跑起来的过程中报错是少不了的。这一节把几个高频错误列出来对照着排查。第一个401 Unauthorized。这个最常见原因通常是 token 没带上、token 过期、或者 JWT 密钥前后端不一致。先看请求头有没有 Authorization格式是不是 Bearer 加空格加 token。再看后端 JwtModule 注册时的 secret 和签发时用的是不是同一个。如果用了刷新 token 机制检查刷新逻辑有没有正确更新本地存储。还有一种情况是守卫执行顺序问题全局守卫在 JWT 守卫之前跑了导致拿不到用户信息。调整守卫注册顺序或者把权限校验合并到 JWT 守卫里。第二个local proxy failed。这个一般出现在前端开发环境配了代理但后端没启动或者端口不对。检查 vite.config.ts 里的 proxy 配置server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ), }, }, }确认 target 的端口和后端实际监听端口一致。如果后端用了全局前缀rewrite 规则要对应调整。另外检查后端有没有开 CORS开发环境可以临时开一下生产环境用 nginx 转发。第三个reading choices。这个报错通常出现在调用大模型接口时返回结构里没有 choices 字段代码却直接去读 data.choices[0]。原因可能是接口返回了错误信息比如 Key 无效、额度不足、模型 ID 写错。排查方法先把完整响应打印出来看不要直接取 choices。在 AiService 里加一层判断if (!data.choices || data.choices.length 0) { throw new Error(模型返回异常: ${JSON.stringify(data)}); }如果返回的是 401检查 API Key 是否正确、有没有多余空格。如果返回模型不存在检查 model 字段拼写。TaoToken 的接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 有详细说明对照着看。第四个OAuth 相关报错。如果你在项目里集成了第三方登录常见错误是 redirect_uri 不匹配、client_id 错误、或者回调地址没在白名单里。检查 OAuth 应用配置里的回调地址和实际请求的是否完全一致包括协议、域名、端口、路径。开发环境用 localhost生产环境换成真实域名两边都要配。如果报 invalid_grant通常是授权码过期或重复使用重新走一遍授权流程。除了这四个还有一个权限系统特有的坑动态路由添加后刷新页面 404。这是因为刷新时路由还没加载完浏览器直接按当前 URL 找路由找不到就 404。解决办法是在路由守卫里等路由加载完再放行或者加一个通配路由兜底加载完后再移除。我试过在 addRoute 之后用 next({ ...to, replace: true }) 重新导航配合 routesLoaded 标志位基本能解决。排查思路总结成一句话先看网络请求的完整响应再看后端日志最后看前端控制台。三层信息一对问题基本定位得到。别一上来就改代码先确认错误发生在哪一层。6. 语义一致 CTA把权限系统接入真实项目后的下一步代码跑通、报错排查完接下来就是把它用到真实项目里。这时候有几个方向可以继续深入。如果你想让后台系统具备 AI 能力比如智能客服、日志摘要、代码审查辅助可以把前面封装的 AiService 扩展一下加上流式输出、多轮对话、上下文管理。API Key 管理入口在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。配置的时候记住三件套Base URL 用 https://taotoken.net/api Key 从环境变量读Model ID 按实际需求填。这三样对齐了调用基本不会出问题。如果你更关注长期编码和 Agent 场景可以了解下 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 它适合把 AI 能力持续集成到开发流程里。想先快速验证模型对话效果用 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 试几句就行。控制台在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 可以查看用量和调用记录。回到权限系统本身下一步可以做的优化还有不少。比如把权限码做成可配置的在菜单管理页面直接增删改不用改代码。比如加上数据权限控制不同角色能看到哪些部门的数据。比如把守卫改成基于 Redis 缓存权限集合减少每次请求查库的开销。这些都是在现有骨架上加东西不用推倒重来。最后说一个实际经验权限系统的难点不在写代码而在权限码的命名和维护。建议从一开始就定好规范比如“模块:资源:操作”三段式system:menu:add、system:user:delete 这样。前后端共用同一套权限码别各写各的。菜单表里的 perm 字段就是唯一来源前端指令和后端装饰器都从这里取。这样后期加功能时只要在菜单表里加一条记录两边自动生效不用改代码。项目部署上线前记得把 .env 里的敏感信息换成生产环境的值JWT secret 用强随机字符串数据库密码别用默认的。Docker 部署的话环境变量通过 docker-compose 注入别打进镜像里。这些细节做好了系统才算真正可用。
返回列表