前端人保命指南:3招搞定路由权限拦截,让未登录用户彻底没门

前端人保命指南:3招搞定路由权限拦截,让未登录用户彻底没门

前端人保命指南:3招搞定路由权限拦截,让未登录用户彻底没门

前端人保命指南:3招搞定路由权限拦截,让未登录用户彻底没门


先别急着写代码,聊聊咱们踩过的那些"裸奔"坑

说真的,每次看到有人问我"前端路由权限怎么做"的时候,我脑子里第一个浮现的画面,就是三年前那个让我至今想起来都脚趾抠地的下午。

那会儿我刚毕业,进了一家做后台管理系统的公司。带我的老哥跟我说:"你把页面搭起来就行,权限后端会做。"我信了,我真的信了。于是我把所有后台接口地址直接写死在代码里,路由配置得明明白白,哪个页面对应哪个路径,整整齐齐。当时我还觉得自己挺专业的,毕竟代码写得工整啊。

结果呢?测试小姐姐拿着一张截图直接甩到我工位上。截图里,她把浏览器地址栏里的/api/admin/user/list改成了/api/admin/finance/report,回车一按,财务数据出来了。她没登录,她甚至都不是我们公司的人,她就是随手一试。

那一刻我感觉天都塌了。截图里她配的文字是:"这就是你们系统的安全性?"我至今记得那个红色感叹号,刺眼得像是在嘲笑我的发际线。

后来我才明白,所谓的"后端做权限"只是校验你有没有资格调这个接口,但前端要是直接把敏感页面的链接暴露出去,用户虽然拿不到数据( hopefully ),但那个"您无权访问"的报错页面弹出来,体验已经崩了。更惨的是有些系统,前端页面都渲染出来了,只是数据区域显示个"加载失败",这不就是告诉黑客"这里有东西,只是你没拿到钥匙"吗?

还有个更离谱的,是我第二家公司遇到的。他们做了个"退出登录"功能,点完按钮跳转到登录页,看起来一切正常。但是!你点一下浏览器的后退键,奇迹发生了——刚才那个需要登录才能看的仪表盘,又完整地出现了!页面上的数据甚至还保留着!我当时就懵了,这退出登了个寂寞?

后来扒代码才发现,他们用的是Vue Router的hash模式,退出登录只是清空了Vuex里的userInfo,但浏览器的历史记录里,那个路由状态还在。用户点后退,Vue Router从history里恢复状态,组件重新渲染,数据虽然是从接口拿的(这时候Token已经被删了,按理说拿不到数据),但页面骨架已经暴露了,而且有些缓存机制还会让部分数据残留。这逻辑脆弱得跟我日渐稀疏的头顶一样,风一吹就露馅。

所以说啊,光靠后端校验真的不够。你想啊,用户点了个需要权限的页面,前端不管三七二十一先渲染,然后调接口,接口返回401,再弹个框提示"请先登录",这中间的Loading转圈圈能转两秒。两秒听起来不长?错,在互联网时代,两秒足够让用户觉得"这什么破系统,卡死了"。而且万一网络抖动一下,用户看到的是半个页面配一堆报错,这体验简直灾难。

咱们今天不整那些什么"RBAC模型"、"零信任架构"之类听起来很高大上的名词。我就讲最土的办法,怎么把路由大门焊死,让不该进来的人连门把手都摸不着。目标很简单:未登录用户,管你输入什么地址,老老实实给我去登录页,别整那些花里胡哨的。


扒一扒路由保护的底裤,到底是谁在把关

要搞定路由拦截,得先搞清楚家里的保安系统是怎么运作的。不然你对着空气喊"拦住他",保安大爷还在岗亭里打瞌睡呢。

想象一下,你的前端应用就是个小区。Router(路由器)就是那个小区的大门,所有的车辆(URL请求)都要从这里进出。但是大门只管放行,不管查身份。这时候就需要Guard(守卫)——也就是那个坐在门岗亭里、拿着业主名单的大爷。大爷的工作很简单:每辆车进来的时候,摇下车窗,出示证件,对不上名单?不好意思,请回。

在前端路由的世界里,这个"大爷"其实分三种,官大官小不一样,管的事儿也不同。

全局前置守卫(Global Before Guards)是老大,官最大,管得最宽。它在整个应用层面监听所有路由变化,每次URL要变的时候,它第一个跳出来拦路检查。Vue Router里叫router.beforeEach,React Router v6里虽然没有直接等价物,但你可以用useEffect配合location变化来模拟,或者用第三方库。这哥们儿适合干脏活累活,比如检查用户有没有登录Token,没有的话统一踢到登录页。

独享守卫(Per-Route Guard)是老二,只管自己门前那一亩三分地。Vue Router里是在路由配置里写beforeEnter,React Router可以在具体路由元素上包装。比如你的/admin路由,专门配个守卫检查"你是不是管理员",普通用户连这个路由的组件都不会看到,直接拦截在门外。

组件内守卫(In-Component Guards)是老三,级别最低,但最灵活。它在组件内部,Vue里是beforeRouteEnterbeforeRouteUpdate这些生命周期钩子,React里其实就是组件里的useEffect。这货适合做一些"进了门之后还要再检查"的事儿,比如用户已经登录了,也能进这个页面,但页面里有个"删除"按钮,得再查查他有没有删除权限。

这三兄弟的执行顺序是固定的:全局前置 → 独享 → 组件内。记住这个顺序很重要,不然你会遇到那种"全局守卫说可以进,独享守卫说不行"的混乱场面,到时候调试起来头都要秃。

再说说那个"通行证"——Token。现在主流的无状态认证都是JWT(JSON Web Token),用户登录成功后,后端发个字符串过来,里面包含了用户ID、角色、过期时间这些信息。这个字符串存在哪儿呢?有三个地方可选,每个都有坑。

LocalStorage最方便,存进去之后所有请求都能拿到,页面关了再开还在。但问题是,它怕XSS攻击(跨站脚本攻击)。如果哪个缺德的在你页面里注入了恶意脚本,它能直接localStorage.getItem('token')把你的令牌偷走。而且用户清浏览器缓存的时候,LocalStorage一锅端,Token没了,用户得重新登录,体验不好。

Cookie相对安全一点,可以设HttpOnly属性,这样前端JS都读不到,自然也不怕XSS偷了。但Cookie有大小限制(一般4KB),而且每次请求都会自动带上,如果Token太大,会浪费带宽。还有CSRF(跨站请求伪造)的风险,虽然现代浏览器有SameSite属性可以缓解,但老浏览器不支持。

SessionStorage跟LocalStorage类似,但页面一关就清,适合那种"临时登录"的场景。不过大部分后台系统还是希望用户下次打开浏览器不用重新登录,所以用得少。

我一般推荐的做法是:Access Token放内存(比如React Context或Vuex里),Refresh Token放HttpOnly Cookie。但小项目别搞这么复杂,LocalStorage存个Token,配合路由守卫做校验,够用了。记住,前端的存储都是"防君子不防小人",真要安全还得靠后端。

最后提一嘴角色(Role)和权限(Permission)的区别,这俩经常搞混。Role是"身份",比如"管理员"、“普通用户”、“财务专员”。Permission是"能力",比如"查看订单"、“删除用户”、“导出报表”。一个Role可以有一堆Permission,一个Permission也可以分配给多个Role。路由级别的一般按Role拦,比如"只有管理员能进后台管理页"。按钮级别的一般按Permission拦,比如"这个删除按钮,只有拥有user:delete权限的人才看得见"。别把钥匙搞混了,不然会出现"普通用户进了管理页,但看不到任何操作按钮"的尴尬局面——虽然安全,但体验诡异。


手把手教你三套方案,总有一款适合你的烂摊子

好了,理论说完,上硬菜。我准备了三套方案,从简单粗暴到花里胡哨,你根据自己项目的复杂度挑。

方案一:全局拦截大法——一把锁管所有门

这是我最推荐的,适合90%的项目。思路很简单:在路由跳转之前,统一检查登录状态,没登录?滚去登录页。

Vue Router 3.x 实现:

// router/index.jsimport Vue from'vue'import Router from'vue-router'import routes from'./routes'import{ getToken }from'@/utils/auth'// 你自己封装的获取Token方法 Vue.use(Router)const router =newRouter({mode:'history',routes: routes })// 全局前置守卫,这就是咱们的看门大爷 router.beforeEach((to, from, next)=>{// 先拿到Token,看看用户有没有"通行证"const hasToken =getToken()// 如果用户要去的就是登录页,那还检查个啥,直接放行// 不然会出现"在登录页检查没登录,跳转到登录页"的死循环if(to.path ==='/login'){next()return}// 有Token?说明登录了,放行,想去哪去哪if(hasToken){next()}else{// 没Token?不好意思,记下你想去哪,先去登录页报到// 这里用query参数把目标地址带过去,登录成功后能跳回来next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)}})exportdefault router 
// utils/auth.js 简单的Token管理const TokenKey ='Admin-Token'exportfunctiongetToken(){return localStorage.getItem(TokenKey)}exportfunctionsetToken(token){return localStorage.setItem(TokenKey, token)}exportfunctionremoveToken(){return localStorage.removeItem(TokenKey)}
// views/login/index.vue 登录页逻辑exportdefault{methods:{asynchandleLogin(){try{// 调登录接口const{ data }=awaitlogin({username:this.username,password:this.password })// 存TokensetToken(data.token)// 登录成功,看看有没有之前被拦截时记下的目标地址const redirect =this.$route.query.redirect ||'/'this.$router.push(redirect)this.$message.success('登录成功')}catch(error){this.$message.error(error.message ||'登录失败')}}}}

这套代码的核心就是router.beforeEach,每次路由变化它都会执行。注意那个判断to.path === '/login'的代码,千万别漏,不然就会出现"检查没登录→跳登录页→检查没登录→跳登录页"的无限循环,浏览器直接卡死。

React Router v6 实现:

React Router v6之后,API变了很多,没有直接的全局守卫了,但思路一样,用自定义组件包装。

// components/AuthRoute.jsx import { Navigate, useLocation } from 'react-router-dom' import { getToken } from '../utils/auth' // 这是一个高阶组件,包装了权限检查逻辑 const AuthRoute = ({ children }) => { const location = useLocation() const hasToken = getToken() // 没登录?重定向到登录页,并记下当前位置 if (!hasToken) { return <Navigate to="/login" state={{ from: location }} replace /> } // 登录了?正常渲染子组件 return children } export default AuthRoute 
// App.jsx 路由配置 import { BrowserRouter, Routes, Route } from 'react-router-dom' import AuthRoute from './components/AuthRoute' import Login from './pages/Login' import Dashboard from './pages/Dashboard' import UserList from './pages/UserList' function App() { return ( <BrowserRouter> <Routes> {/* 登录页不需要权限检查 */} <Route path="/login" element={<Login />} /> {/* 其他页面都用AuthRoute包一层 */} <Route path="/dashboard" element={ <AuthRoute> <Dashboard /> </AuthRoute> } /> <Route path="/users" element={ <AuthRoute> <UserList /> </AuthRoute> } /> {/* 首页重定向 */} <Route path="/" element={<Navigate to="/dashboard" />} /> </Routes> </BrowserRouter> ) } export default App 
// pages/Login.jsx import { useNavigate, useLocation } from 'react-router-dom' import { setToken } from '../utils/auth' const Login = () => { const navigate = useNavigate() const location = useLocation() const handleSubmit = async (values) => { try { const { data } = await loginApi(values) setToken(data.token) // 登录成功后,跳转到之前被拦截的页面,或者默认跳首页 const from = location.state?.from?.pathname || '/dashboard' navigate(from, { replace: true }) } catch (error) { message.error('登录失败') } } return ( // ... 登录表单 ) } 

React这边稍微麻烦点,每个路由都要手动包一层AuthRoute。但好处是更灵活,你可以给不同的路由传不同的参数,比如<AuthRoute requiredRole="admin">,实现更细粒度的控制。

方案二:路由元信息(Meta)配置流——VIP室需要特殊通行证

全局拦截太粗暴了,有时候我们希望"大部分页面随便看,但某些敏感页面必须登录"。这时候可以用路由的meta字段,给每个路由贴标签。

Vue Router 实现:

// router/routes.jsexportconst routes =[{path:'/',name:'Home',component:()=>import('@/views/home/index.vue'),meta:{title:'首页',requiresAuth:false// 不需要登录}},{path:'/products',name:'Products',component:()=>import('@/views/products/index.vue'),meta:{title:'产品列表',requiresAuth:false// 公开页面}},{path:'/cart',name:'Cart',component:()=>import('@/views/cart/index.vue'),meta:{title:'购物车',requiresAuth:true// 必须登录}},{path:'/admin',name:'Admin',component:()=>import('@/views/admin/index.vue'),meta:{title:'管理后台',requiresAuth:true,requiredRoles:['admin','super_admin']// 还需要特定角色}},{path:'/login',name:'Login',component:()=>import('@/views/login/index.vue'),meta:{title:'登录',requiresAuth:false,hideInMenu:true// 不显示在导航菜单}}]
// router/index.js 升级版的守卫import router from'./router'import{ getToken, getUserInfo }from'@/utils/auth' router.beforeEach(async(to, from, next)=>{// 设置页面标题 document.title = to.meta.title ||'默认标题'const hasToken =getToken()// 如果页面不需要认证,直接放行if(!to.meta.requiresAuth){next()return}// 需要认证但没Token,去登录if(!hasToken){next(`/login?redirect=${to.path}`)return}// 有Token,但还要检查角色权限(如果有配置的话)if(to.meta.requiredRoles){// 这里假设用户信息存在Vuex或已经通过接口获取const userInfo =getUserInfo()// { id: 1, name: '张三', roles: ['admin'] }const hasRole = to.meta.requiredRoles.some(role=> userInfo.roles.includes(role))if(!hasRole){// 没权限?去403页面,或者弹窗提示next('/403')return}}// 所有检查都通过了,放行next()})
// utils/auth.js 升级版,带用户信息const TokenKey ='Admin-Token'const UserKey ='User-Info'exportfunctiongetToken(){return localStorage.getItem(TokenKey)}exportfunctionsetToken(token){ localStorage.setItem(TokenKey, token)}exportfunctiongetUserInfo(){const userStr = localStorage.getItem(UserKey)return userStr ?JSON.parse(userStr):null}exportfunctionsetUserInfo(user){ localStorage.setItem(UserKey,JSON.stringify(user))}

这套方案的好处是配置化,每个路由的权限要求一目了然。而且你可以扩展meta字段,加requiredPermissions(需要的权限点)、keepAlive(是否缓存)等等,很灵活。

React Router v6 实现:

// router/config.js export const routes = [ { path: '/', element: <Home />, meta: { requiresAuth: false, title: '首页' } }, { path: '/profile', element: <Profile />, meta: { requiresAuth: true, title: '个人中心' } }, { path: '/admin', element: <Admin />, meta: { requiresAuth: true, requiredRoles: ['admin'], title: '管理后台' } } ] 
// components/ProtectedRoute.jsx import { Navigate, useLocation } from 'react-router-dom' import { getToken, getUserInfo } from '../utils/auth' const ProtectedRoute = ({ route, children }) => { const location = useLocation() const hasToken = getToken() const meta = route.meta || {} // 不需要认证,直接过 if (!meta.requiresAuth) { return children } // 需要认证但没登录 if (!hasToken) { return <Navigate to="/login" state={{ from: location }} replace /> } // 需要特定角色 if (meta.requiredRoles) { const userInfo = getUserInfo() const hasRole = meta.requiredRoles.some(role => userInfo?.roles?.includes(role) ) if (!hasRole) { return <Navigate to="/403" replace /> } } return children } // 在App.jsx中使用 <Route path="/admin" element={ <ProtectedRoute route={adminRouteConfig}> <Admin /> </ProtectedRoute> } /> 

方案三:高阶组件(HOC)包装术——React老哥的优雅之选

如果你用React,而且不喜欢在每个路由配置里写一堆逻辑,可以用HOC(Higher-Order Component)把权限逻辑抽离出来,组件本身只管渲染,清爽。

// hocs/withAuth.jsx import { Component } from 'react' import { Navigate } from 'react-router-dom' import { getToken, getUserInfo } from '../utils/auth' // 这是一个高阶组件工厂,接收配置参数,返回一个包装函数 const withAuth = (options = {}) => (WrappedComponent) => { // options可以配置:requiredRoles, requiredPermissions等 return class AuthComponent extends Component { render() { const hasToken = getToken() // 没登录,去登录页 if (!hasToken) { return <Navigate to="/login" replace /> } // 需要角色检查 if (options.requiredRoles) { const userInfo = getUserInfo() const hasRole = options.requiredRoles.some(role => userInfo?.roles?.includes(role) ) if (!hasRole) { return <div>403 Forbidden - 你没有权限访问此页面</div> } } // 所有检查通过,渲染被包装的组件,并注入用户信息 const userInfo = getUserInfo() return <WrappedComponent {...this.props} userInfo={userInfo} /> } } } export default withAuth 
// pages/AdminDashboard.jsx import withAuth from '../hocs/withAuth' const AdminDashboard = ({ userInfo }) => { // 组件里可以直接拿到用户信息,不用自己再调接口 return ( <div> <h1>欢迎回来,{userInfo.name}</h1> <p>你的角色:{userInfo.roles.join(', ')}</p> {/* 管理后台内容 */} </div> ) } // 导出的时候用HOC包一下,配置权限要求 export default withAuth({ requiredRoles: ['admin', 'super_admin'] })(AdminDashboard) 
// 更高级的用法:组合多个HOC import withAuth from '../hocs/withAuth' import withLayout from '../hocs/withLayout' import withLoading from '../hocs/withLoading' // 先检查权限,再套布局,最后加Loading效果 export default withAuth({ requiredRoles: ['admin'] })( withLayout('admin')( withLoading(AdminDashboard) ) ) 

HOC的好处是复用性极强,而且符合React"组合优于继承"的理念。但缺点也很明显:嵌套太深的时候,调试起来像剥洋葱,一层一层又一层,眼泪都要出来了。而且函数组件时代,Hooks其实能替代大部分HOC的场景,所以现在很多React项目更倾向于用自定义Hook。

对比Vue Router和React Router的"小心机":

Vue Router是"配置驱动",路由表写好了,守卫逻辑也写好了,剩下的它帮你调度,很省心。但有时候太自动了,你想在某个特定场景跳过守卫?得用next()的各种骚操作,容易搞晕。

React Router是"组件驱动",一切都是组件,路由是组件,守卫也是组件,灵活是灵活,但 boilerplate 代码多,每个路由都要手动包一层。而且v5到v6的API变化巨大,很多老项目还在用v5,新教程都是v6,照着文档抄都能抄出Bug。

Vue的beforeEach是同步执行的(除非你显式返回Promise),逻辑顺序很清晰。React的守卫逻辑写在组件里,涉及到异步获取用户信息的时候,很容易出现"组件已经mount了,权限检查还没完成"的闪屏问题,得用useState配合useEffect小心处理Loading状态。


这玩意儿真香吗?也不全是,有些坑你得提前绕

说了这么多好处,咱也得客观聊聊这些方案的阴暗面,别到时候上线了才发现"当时为啥没人告诉我这个"。

优点确实很明显:

用户体验好是真的。不该看的页面直接拦在门外,用户不会看到那种"页面加载了一半,突然弹个框说没权限"的尴尬场面。而且前端拦截是瞬间完成的,不用等后端接口返回401,那个Loading转圈圈的时间省了,感觉系统响应很快。

安全性也有提升。虽然前端拦截防不了真正的高手(人家直接调你接口),但能防住90%的普通用户和测试人员的误操作。而且把路由结构藏起来,也增加了攻击者的信息收集成本。

但缺点也很头疼:

动态菜单怎么搞? 这是个大坑。很多后台系统的左侧菜单是根据用户权限动态生成的,管理员看到10个菜单,普通用户只看到5个。如果你在前端硬编码路由表,那没权限的用户虽然进不去页面,但菜单上还能看到那个入口,点进去提示无权限,这体验就很诡异。

解决方案是后端返回菜单配置,前端动态生成路由。但这就涉及到"异步获取权限信息"的问题——用户刷新页面,Token还在,但用户信息(包括权限列表)需要调接口获取,这个异步过程还没完成的时候,路由守卫已经执行了,怎么破?

// Vue Router解决"异步权限"问题// store/modules/user.jsimport{ getToken, getUserInfo }from'@/utils/auth'import{ fetchUserProfile }from'@/api/user'const state ={token:getToken(),userInfo:getUserInfo(),// 可能为null,因为只存了Token没存详细信息roles:[],// 权限角色permissions:[],// 权限点hasFetchedInfo:false// 标记是否已经获取过用户信息}const actions ={// 获取用户信息的ActionasyncgetInfo({ commit, state }){// 如果已经有信息了,直接返回,避免重复请求if(state.hasFetchedInfo){return{roles: state.roles,permissions: state.permissions }}// 调接口获取const{ data }=awaitfetchUserProfile()commit('SET_ROLES', data.roles)commit('SET_PERMISSIONS', data.permissions)commit('SET_USER_INFO', data)commit('SET_HAS_FETCHED_INFO',true)return data }}// router/index.js 改造守卫import store from'@/store' router.beforeEach(async(to, from, next)=>{const hasToken =getToken()if(to.path ==='/login'){next()return}if(!hasToken){next('/login')return}// 有Token,但还没获取用户信息(刷新页面后的情况)if(!store.state.user.hasFetchedInfo){try{// 等待获取用户信息await store.dispatch('user/getInfo')// 获取成功后,重新走一遍路由守卫(因为动态路由可能刚添加)next({...to,replace:true})}catch(error){// 获取失败(Token过期等),清除登录状态await store.dispatch('user/logout')next(`/login?redirect=${to.path}`)}return}// 已经有用户信息了,正常检查权限if(to.meta.requiredRoles){const hasRole = to.meta.requiredRoles.some(role=> store.state.user.roles.includes(role))if(!hasRole){next('/403')return}}next()})

异步获取用户信息时页面闪白的问题,上面代码用await store.dispatch解决了,但代价是路由跳转会阻塞,用户体验是"点击菜单,卡一下,然后页面出来"。如果你希望先显示Loading,可以用路由懒加载配合组件内的Loading状态,但代码复杂度就上去了。

"死循环"陷阱是新手最常踩的坑。我前面提过,守卫里跳转到登录页,但登录页本身也被守卫拦截了,就会无限循环。还有一种情况是,获取用户信息失败(Token过期),守卫里执行logout然后跳登录页,但logout是异步的,还没清完Token就执行了跳转,结果跳转过去还有Token(清了一半),又触发守卫,又获取信息,又失败,又跳转…

// 错误的写法,可能导致死循环 router.beforeEach((to, from, next)=>{if(!getToken()&& to.path !=='/login'){next('/login')}// 缺少else分支,当to.path === '/login'时,没有调用next(),路由不会继续})

记住,守卫里必须有且只有一次next()调用,而且逻辑分支要覆盖所有情况。

微前端场景简直就是修罗场。主子应用都有各自的路由器,权限校验怎么协调?主应用登录了,子应用怎么知道?子应用的路由被拦截了,是子应用自己跳登录页,还是通知主应用统一处理?URL怎么同步?这些问题没有标准答案,得根据你的微前端方案(qiankun、Module Federation等)具体设计。一般建议是权限校验统一在主应用做,子应用只负责渲染,但这样子应用单独运行的时候就废了。或者子应用也保留一套简单的校验,主应用传Token下来。总之很头大,非必要别上微前端。


真实项目里的那些"骚操作"和血泪史

理论讲完,分享几个我在生产环境踩过的坑,以及怎么爬出来的。

Token过期无感刷新

用户正在愉快地操作,突然Token过期了,调接口返回401,这时候如果直接踢去登录页,用户刚才填的表单数据就全丢了,他会杀了你。

解决方案是双Token机制:Access Token(短期,比如2小时)+ Refresh Token(长期,比如7天)。Access Token过期了,用Refresh Token换一个新的,用户无感知。

// utils/request.js Axios拦截器实现import axios from'axios'import{ getToken, setToken, removeToken, getRefreshToken }from'./auth'import router from'@/router'let isRefreshing =false// 标记是否正在刷新Tokenlet requestsQueue =[]// 刷新期间被阻塞的请求队列const service = axios.create({baseURL: process.env.VUE_APP_BASE_API,timeout:5000})// 请求拦截器,统一加Token service.interceptors.request.use(config=>{const token =getToken()if(token){ config.headers['Authorization']='Bearer '+ token }return config },error=>{return Promise.reject(error)})// 响应拦截器,处理401和刷新Token service.interceptors.response.use(response=> response.data,asyncerror=>{const{ response, config }= error // 如果是401且不是因为刷新Token接口本身报的错if(response?.status ===401&&!config._retry){const refreshToken =getRefreshToken()if(!refreshToken){// 没有Refresh Token,直接踢去登录removeToken() router.push('/login')return Promise.reject(error)}// 如果正在刷新,把请求加入队列,等刷新完再重试if(isRefreshing){returnnewPromise(resolve=>{ requestsQueue.push(()=>{ config.headers['Authorization']='Bearer '+getToken()resolve(service(config))})})}// 开始刷新 isRefreshing =true config._retry =truetry{// 调刷新接口const{ data }=await axios.post('/auth/refresh',{refreshToken: refreshToken })// 存新TokensetToken(data.accessToken)// Refresh Token也可能更新if(data.refreshToken){setRefreshToken(data.refreshToken)}// 重试刚才失败的请求 config.headers['Authorization']='Bearer '+ data.accessToken // 执行队列里等待的请求 requestsQueue.forEach(callback=>callback()) requestsQueue =[]returnservice(config)}catch(refreshError){// 刷新也失败了,彻底凉凉removeToken()removeRefreshToken() router.push('/login')return Promise.reject(refreshError)}finally{ isRefreshing =false}}return Promise.reject(error)})exportdefault service 

这段代码的关键是isRefreshing标记和requestsQueue队列。当Token过期时,如果同时有多个请求发出,第一个请求会触发刷新,其他请求不会重复刷新,而是加入队列等刷新完成后一起重试。这样用户正在填的表单数据不会丢,操作也不会中断。

动态路由表生成

硬编码路由表太low了,高级玩法是从后端拉取配置,动态挂载路由。

// 后端返回的路由配置示例const serverRoutes =[{path:'/user',component:'Layout',// 对应前端的布局组件children:[{path:'list',component:'user/list',// 对应views/user/list.vuemeta:{title:'用户列表',permission:'user:view'}},{path:'detail/:id',component:'user/detail',meta:{title:'用户详情',permission:'user:detail'}}]}]// 前端处理函数functiongenerateRoutes(serverRoutes){const routes =[] serverRoutes.forEach(item=>{const route ={path: item.path,component: item.component ==='Layout'?()=>import('@/layout/index.vue'):()=>import(`@/views/${item.component}.vue`),// 动态导入meta: item.meta,children: item.children ?generateRoutes(item.children):[]} routes.push(route)})return routes }// 在获取用户信息后,动态添加路由const accessRoutes =generateRoutes(serverRoutes) accessRoutes.forEach(route=>{ router.addRoute(route)// Vue Router 4.x的API,动态添加路由})

注意router.addRoute是Vue 3.x的API,Vue 2.x是router.addRoutes(复数)。动态添加的路由会立即生效,但刷新页面后会丢失,所以需要在beforeEach里重新添加,或者存到Vuex/Pinia里持久化。

按钮级别的权限控制

拦住页面不够,页面上的按钮也得控制。比如"删除"按钮,只有拥有user:delete权限的人才看得见。

<!-- Vue指令实现 --> <template> <div> <button v-permission="'user:create'">新增用户</button> <button v-permission="'user:delete'">删除用户</button> <button v-permission="'user:export'">导出Excel</button> </div> </template> <script> // directives/permission.js import { getUserPermissions } from '@/utils/auth' export default { mounted(el, binding) { const { value } = binding const permissions = getUserPermissions() // ['user:view', 'user:create'] if (!permissions.includes(value)) { // 没权限,移除按钮(或者disabled) el.parentNode && el.parentNode.removeChild(el) // 或者 el.style.display = 'none' // 或者 el.disabled = true } } } // 注册全局指令 // main.js import permission from './directives/permission' Vue.directive('permission', permission) </script> 
// React实现,用Hook import { usePermission } from '../hooks/usePermission' const UserList = () => { const { hasPermission } = usePermission() return ( <div> {hasPermission('user:create') && ( <button onClick={handleCreate}>新增用户</button> )} {hasPermission('user:delete') && ( <button onClick={handleDelete}>删除用户</button> )} </div> ) } // hooks/usePermission.js import { getUserPermissions } from '../utils/auth' export const usePermission = () => { const permissions = getUserPermissions() || [] const hasPermission = (permission) => { return permissions.includes(permission) } const hasAnyPermission = (permissionList) => { return permissionList.some(p => permissions.includes(p)) } const hasAllPermissions = (permissionList) => { return permissionList.every(p => permissions.includes(p)) } return { hasPermission, hasAnyPermission, hasAllPermissions } } 

多租户系统的数据隔离

之前做过一个SaaS系统,多个公司共用一套代码,但数据要完全隔离。路由层面要做到:A公司的人输入B公司的URL,要么404,要么提示无权限。

// 路由守卫里加租户检查 router.beforeEach(async(to, from, next)=>{const hasToken =getToken()const currentTenant =getCurrentTenant()// 从Token解析或从URL参数获取// 检查URL里的租户ID是否和当前用户一致const routeTenantId = to.params.tenantId if(routeTenantId && routeTenantId !== currentTenant.id){// 尝试访问其他租户的数据,直接403next('/403')return}// 或者更严格:所有路由都必须带租户ID前缀// /:tenantId/dashboard,不匹配当前租户的,直接拦截})

报错了别慌,这套排查思路能救你的狗命

线上出问题了,用户说"进不去页面",别急着改代码,按这个顺序排查:

第一步:看Network,确认Token带没带出去

打开浏览器控制台,找到那个报401的请求,看Request Headers里有没有Authorization: Bearer xxx。如果没有,说明前端没拿到Token,检查localStorage里存了没,或者存的时候key名写错了(比如token写成了Token)。如果有Token但还是401,说明Token过期了或后端不认,找后端对一下Token生成逻辑。

第二步:检查路由配置的meta字段

是不是meta: { requiresAuth: true }手抖写成了false?或者写成了requireAuth(少了s)?这种拼写错误我犯过不止一次,守卫里判断的是requiresAuth,配置里写的是requireAuth,结果守卫判断undefined为假,直接放行了。

第三步:留意异步加载的时机

守卫里调了await store.dispatch('user/getInfo'),但组件里又await fetchData(),两个异步并行,结果fetchData先执行了,这时候Token还没被拦截器加上,接口报了401。解决办法是在组件里等用户信息获取完成再调接口,或者在路由守卫里等所有前置条件完成再next()

第四步:清理缓存!清理缓存!清理缓存!

很多时候不是代码错了,是浏览器在耍流氓。特别是用了PWA或Service Worker的,缓存策略太激进,新版本代码没生效。Ctrl+F5强制刷新,或者Application标签页里Clear Storage,再试。我有一次折腾了两个小时,最后发现是Chrome Extension拦截了请求,关了扩展就好了,气得我差点把键盘砸了。


老鸟私藏的防脱发小技巧,一般人我不告诉他

拦截器配合守卫,一套代码走天下

把HTTP拦截器和路由守卫打通,拦截器负责自动加Token和处理401,守卫负责路由层面的拦截,两者各司其职但共享认证状态。

// 拦截器里处理401,通知守卫 service.interceptors.response.use(response=> response,error=>{if(error.response?.status ===401){// 触发全局事件或修改全局状态 window.dispatchEvent(newCustomEvent('auth:expired'))}return Promise.reject(error)})// 守卫里监听这个事件 window.addEventListener('auth:expired',()=>{ router.push('/login')})

SPA的SEO问题(虽然有点矛盾)

路由保护了,但搜索引擎爬虫爬不到内容,怎么办?说实话,后台管理系统不需要SEO,但如果是前台页面需要登录才能看(比如付费内容),可以用预渲染(Prerendering)或服务端渲染(SSR)时根据User-Agent判断,是爬虫就返回简化版内容,是用户就正常走权限校验。

降级处理:权限服务挂了怎么办

万一后端获取权限配置的接口挂了,是让所有人进不去(安全优先),还是默认放行(可用性优先)?这得看老板心情和业务场景。金融系统肯定选前者,内部工具可以选后者。建议加个配置开关:

// 守卫里的降级逻辑try{awaitfetchUserPermissions()}catch(error){if(process.env.VUE_APP_AUTH_FAIL_SAFE==='true'){// 降级:使用本地缓存的权限,或默认放行基础页面next()}else{// 严格:拒绝访问next('/system-error')}}

别过度设计

小项目就几个页面,别搞什么RBAC(基于角色的访问控制)模型了,几个if-else能解决的事,别整花活。我见过一个只有3个页面的后台系统,作者硬是搞了一套完整的权限配置平台,可以动态配置角色、权限、菜单,结果配置的时间比写页面还长,而且那系统上线两年,权限配置从来没变过,纯纯的过度设计。


行了,今天就唠到这,愿你的线上永远没403

记住,权限保护不是为了炫技,是为了让半夜报警电话少响几次。没有什么比凌晨3点被运维电话吵醒,说"系统被爬虫爬崩了"或"数据泄露了"更可怕的了。

下次再有人问你怎么做路由保护,直接把这篇文章甩过去,深藏功与名。如果看完还是不会,那可能是咱俩缘分未到,或者你该换个框架试试了(开玩笑的,别打我)。

赶紧去修Bug吧,毕竟产品经理又在群里催新需求了。祝大家好运,头发茂密,线上稳定,永无Bug。

在这里插入图片描述

Read more

GitHub Copilot 调用第三方模型API

GitHub Copilot 调用第三方模型API

一、说明 OAI Compatible Provider for Copilot 的作用是:把 Copilot/Copilot Chat 发出的“类似 OpenAI API 的请求”,转发到指定的 OpenAI-Compatible 服务端(例如 ModelScope 推理网关、自建的兼容网关等)。 ⚠️ Warning 登录 GitHub Copilot 的账号一定要是非组织方式开通 pro 会员的,不然无法管理模型。 推荐直接用免费的free账号登录即可。 二、插件安装 在 VS Code 扩展市场安装并启用: * GitHub Copilot * GitHub Copilot Chat * OAI Compatible Provider for Copilot (johnny-zhao.

llama.cpp最新版Windows编译全记录:从源码下载到模型测试(含w64devkit配置)

llama.cpp Windows编译实战:从工具链配置到模型部署全解析 在本地运行大型语言模型正成为开发者探索AI能力的新趋势,而llama.cpp以其高效的C++实现和跨平台特性脱颖而出。本文将深入探讨Windows平台下llama.cpp的完整编译流程,特别针对开发者常遇到的环境配置、API兼容性和性能优化问题进行系统化梳理。 1. 开发环境准备与工具链配置 Windows平台编译C++项目需要精心配置工具链,而w64devkit提供了一个轻量级但功能完整的解决方案。与常见的Visual Studio或MinGW-w64不同,w64devkit将所有必要工具集成在单个便携包中,特别适合需要干净编译环境的开发者。 核心组件获取步骤: 1. 访问w64devkit官方GitHub仓库,下载最新稳定版本(当前推荐1.23.0) 2. 解压至不含中文和空格的路径,例如D:\dev\w64devkit-1.23.0 3. 验证基础功能:运行w64devkit.exe后执行gcc --version 注意:Windows 7用户需确保系统已安装KB2533623补丁,否则

深度解析 GitHub Copilot Agent Skills:如何打造可跨项目的 AI 专属“工具箱”

前言 随着 GitHub Copilot 从单纯的“代码补全”工具向 Copilot Agent(AI 代理) 进化,开发者们迎来了更高的定制化需求。我们不仅希望 AI 能写代码,更希望它能理解团队的特殊规范、掌握内部工具的使用方法,甚至在不同的项目中复用这些经验。 Agent Skills(代理技能) 正是解决这一痛点的核心机制。本文将深入解析 Copilot Skills 的工作原理,并分享如何通过软链接(Symbolic Link)与自动化工作流,构建一套高效的个人及团队知识库。 一、 什么是 Agent Skills? 如果说 Copilot 是一个通用的“AI 程序员”,那么 Skill(技能) 就是你为它配备的专用工具箱。 它不仅仅是一段简单的提示词(Prompt),而是一个包含元数据、指令和执行资源的标准文件夹结构。当

彻底解决 Codex / Copilot 修改中文乱码【含自动化解决方案】

引言 在使用 GitHub Copilot 或 OpenAI Codex 自动重构代码时,你是否遇到过这样的尴尬:AI 生成的代码逻辑完美,但原本注释里的中文却变成了 我爱中文 这样的乱码?有时候这种字符甚至会污染正确的代码,带来巨大的稳定性隐患。 一、 问题核心:被忽视的“终端中转” 乱码的根源不在于 AI 的大脑,也不在于编辑器的显示,而在于执行链路的编码不一致。 Copilot/Codex 在执行某些修改任务(如:重构整个文件或批量替换)时,往往会通过终端调用系统指令。由于 Windows 终端(PowerShell/CMD)默认使用 GBK 编码,它在处理 AI 传来的 UTF-8 字节时会发生“误读”,导致写入文件的内容从源头上就损坏了。