From fb1a79a3e940606521bef2f23d221e22f5d48634 Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 22 Sep 2026 15:59:05 +0800 Subject: [PATCH] V001 --- .../DmsFileService.auto.cs | 8 +- .../AuthService.cs | 25 +- .../CloudBuilder.Web.Entry.csproj.user | 2 +- .../FolderProfile3.pubxml.user | 2 +- .../FolderProfile4.pubxml.user | 2 +- .../CloudBuilder.Web.Entry/appsettings.json | 9 +- WebVue/src/components/basic/ErrorPanel.vue | 305 ++++++++++++++++++ WebVue/src/router/index.ts | 118 ++++++- WebVue/src/router/insideLayout.ts | 28 +- WebVue/src/store/modules/loginProfile.ts | 7 + WebVue/src/utils/request.ts | 85 +++-- WebVue/src/views/error/page401.vue | 53 ++- WebVue/src/views/error/page403.vue | 51 ++- WebVue/src/views/error/page404.vue | 51 ++- WebVue/src/views/login/index.vue | 13 +- .../src/views/master/FamilyTreeOperation.vue | 3 +- .../views/master/PublishAudioOperation.vue | 2 +- .../views/master/PublishPhotoOperation.vue | 1 + .../views/master/PublishVideoOperation.vue | 2 +- WebVue/src/views/security/ActorOperation.vue | 2 +- .../src/views/security/FunctionOperation.vue | 2 +- WebVue/src/views/security/GroupOperation.vue | 2 +- WebVue/src/views/security/MasterOperation.vue | 2 +- .../views/security/PermissionOperation.vue | 2 +- .../security/SecurityHelpFeatureOperation.vue | 2 +- .../SecurityHelpFunctionOperation.vue | 2 +- WebVue/src/views/security/UserOperation.vue | 2 +- 27 files changed, 708 insertions(+), 75 deletions(-) create mode 100644 WebVue/src/components/basic/ErrorPanel.vue diff --git a/CloudBuilder/CloudBuilder.Master.Service/DmsFileService.auto.cs b/CloudBuilder/CloudBuilder.Master.Service/DmsFileService.auto.cs index 358cab5..e3d8beb 100644 --- a/CloudBuilder/CloudBuilder.Master.Service/DmsFileService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master.Service/DmsFileService.auto.cs @@ -1435,19 +1435,21 @@ namespace CloudBuilder.Master.Service Directory.CreateDirectory(targetDir); } - para = string.Format(service.Configuration["Ffmpeg:Arguments"], mp4Url, fullName); + para = string.Format(service.Configuration[ServiceSettingsPolicy.FfmpegArguments], mp4Url, "", fullName); IRepository logRepository = repository.GetRepository>(); SecurityScheduleTaskQueueEntity task = new SecurityScheduleTaskQueueEntity(); task.DocumentId = documentId; - task.TaskId = service.Configuration["Ffmpeg:Path"]; + task.TaskId = service.Configuration[ServiceSettingsPolicy.FfmpegPath]; task.TaskParam = para; logRepository.InsertNow(task); + para = string.Format(service.Configuration[ServiceSettingsPolicy.FfmpegArguments], mp4Url, " -t 10", fullName); + //直接转码视频文件10秒,影响服务器性能,改为在闭时自动生成全部 - RunMyProcess(task.TaskId, task.TaskParam); + RunMyProcess(task.TaskId, para); } public void ImageToScare(string fullName, DmsFileEntity dmsFile) diff --git a/CloudBuilder/CloudBuilder.Security.Service/AuthService.cs b/CloudBuilder/CloudBuilder.Security.Service/AuthService.cs index 38fb024..986080d 100644 --- a/CloudBuilder/CloudBuilder.Security.Service/AuthService.cs +++ b/CloudBuilder/CloudBuilder.Security.Service/AuthService.cs @@ -447,14 +447,23 @@ namespace CloudBuilder.Security.Service IRepository repositoryLog = service.GetRepository>(); - SecurityUserWebLoginEntity log = repositoryLog.DetachedEntities.Where(x => x.Username == username && x.SessionId == clainmName).FirstOrDefault()!; + SecurityUserWebLoginEntity log = repositoryLog.DetachedEntities.Where(x => x.Username == username && x.SessionId == clainmName).FirstOrDefault(); - //不是最后登录的令牌失效 - DateTime? LoginTime = repositoryLog.DetachedEntities.Where(x => x.Username == username).Max(x => x.LoginTime); - if (LoginTime.HasValue && log.LoginTime < LoginTime.Value) throw new ServiceErrorException("无效的令牌,请重新登录."); + // 先判空:原来判空写在后面,却已经先用了 log.LoginTime, + // 会话记录查不到时(日志被清理、拿了旧 token 来刷新)会抛 NullReference, + // 而不是返回友好的"无效的令牌,请重新登录."。 + if (log == null) throw new ServiceErrorException("无效的令牌,请重新登录."); - if (log == null || log.RefreshToken != jwtInfo.RefreshToken || log.LogoutTime.HasValue || - log.RefreshToken != jwtInfo.RefreshToken || log.RefreshTokenRefreshedTime.AddMinutes(Convert.ToInt32(service.CloudBuilder.CloudBuilderOptions.Jwt.RefreshExpireMins)) < DateTime.Now) + // 不是最后一次登录的令牌失效。 + // 用 Any(存在更晚的登录) 代替 Max(LoginTime) 聚合:语义完全一致 + // (只失效"登录时间更晚"的会话,同一时刻的并列登录依旧有效), + // 而且 Any 命中索引即可短路返回,比每次刷新都算一遍 Max 便宜。 + bool hasLaterLogin = repositoryLog.DetachedEntities.Any(x => x.Username == username && x.LoginTime > log.LoginTime); + if (hasLaterLogin) throw new ServiceErrorException("无效的令牌,请重新登录."); + + // 原来 RefreshToken 的比较写了两遍,去掉重复 + if (log.RefreshToken != jwtInfo.RefreshToken || log.LogoutTime.HasValue || + log.RefreshTokenRefreshedTime.AddMinutes(Convert.ToInt32(service.CloudBuilder.CloudBuilderOptions.Jwt.RefreshExpireMins)) < DateTime.Now) throw new ServiceErrorException("无效的令牌,请重新登录."); JwtGenerate jwtGenerate = service.ServiceProvider.GetService()!; @@ -470,10 +479,6 @@ namespace CloudBuilder.Security.Service log.RefreshToken = jwtGenerate.GenerateRefreshToken(); log.RefreshTokenRefreshedTime = DateTime.Now; - if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization")) - httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization"); - httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken); - repositoryLog.UpdateNow(log); jwtInfo.AccessToken = accessToken; diff --git a/CloudBuilder/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj.user b/CloudBuilder/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj.user index 7b23cd1..d91626f 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj.user +++ b/CloudBuilder/CloudBuilder.Web.Entry/CloudBuilder.Web.Entry.csproj.user @@ -1,7 +1,7 @@  - D:\Docker\CloudBuilder\CloudBuilder.Web.Entry\Properties\PublishProfiles\FolderProfile3.pubxml + D:\Docker\CloudBuilder\CloudBuilder.Web.Entry\Properties\PublishProfiles\FolderProfile4.pubxml http diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile3.pubxml.user b/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile3.pubxml.user index eab1af1..4e020af 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile3.pubxml.user +++ b/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile3.pubxml.user @@ -3,7 +3,7 @@ <_PublishTargetUrl>C:\inetpub\wwwroot\dockwebapi2 - True|2026-09-21T14:40:07.1232044Z||;True|2026-09-21T22:35:06.9446571+08:00||;True|2026-09-21T22:27:42.1146825+08:00||;False|2026-09-21T22:27:17.6066573+08:00||;True|2026-09-21T21:01:22.8888913+08:00||;True|2026-09-21T19:15:03.4753747+08:00||;True|2026-09-21T18:08:30.0866986+08:00||;True|2026-09-21T16:51:19.8137872+08:00||;True|2026-09-21T16:30:01.0950896+08:00||;True|2026-09-21T15:09:00.2326737+08:00||;True|2026-09-21T14:47:09.6120256+08:00||;True|2026-09-21T14:16:35.3194409+08:00||;True|2026-09-21T13:54:58.2829712+08:00||;True|2026-09-21T13:36:45.3134499+08:00||;True|2026-09-19T09:48:52.0085130+08:00||;False|2026-09-19T09:47:56.1658214+08:00||;True|2026-09-17T18:50:15.9493380+08:00||;True|2026-09-17T18:11:11.5364547+08:00||;True|2026-09-17T18:07:27.7830758+08:00||;True|2026-09-17T17:58:16.5798560+08:00||;True|2026-09-17T15:37:30.9025614+08:00||;True|2026-09-17T15:25:46.9116292+08:00||;True|2026-09-17T15:05:18.6592403+08:00||;True|2026-09-17T14:57:28.7997437+08:00||;True|2026-09-17T14:36:00.6339386+08:00||;True|2026-09-17T13:56:43.2376873+08:00||;True|2026-09-16T13:56:51.8724546+08:00||;True|2026-09-15T20:44:46.6559899+08:00||;True|2026-09-13T19:02:59.5654245+08:00||;True|2026-09-10T09:14:56.2914193+08:00||;True|2026-09-09T16:40:16.6461154+08:00||;True|2026-09-08T13:55:23.1502435+08:00||;True|2026-09-08T08:04:53.4279263+08:00||;True|2026-09-07T08:48:17.4682304+08:00||;True|2026-09-04T14:39:08.7878670+08:00||;True|2026-09-04T14:35:48.2937877+08:00||;True|2026-09-04T11:45:16.1372876+08:00||;True|2026-09-04T11:42:59.9356436+08:00||;True|2026-09-03T16:05:52.5270232+08:00||;True|2026-09-03T14:52:56.8559144+08:00||;True|2026-09-02T11:08:23.8720038+08:00||;True|2026-09-02T10:58:16.8081797+08:00||;True|2026-08-26T13:34:12.3805892+08:00||;True|2026-08-25T08:31:30.7787234+08:00||;True|2026-08-20T13:10:46.4725468+08:00||;True|2026-08-20T13:10:28.3142576+08:00||;True|2026-08-19T16:10:27.3362887+08:00||;True|2026-08-19T16:01:12.6234971+08:00||;True|2026-08-19T15:42:46.6576265+08:00||;True|2026-08-19T13:39:59.9363522+08:00||;True|2026-08-19T13:36:53.0379313+08:00||;True|2026-08-19T13:31:36.0841398+08:00||;True|2026-08-19T11:44:37.5319626+08:00||;True|2026-08-19T09:36:45.6000047+08:00||;True|2026-08-19T09:34:36.8468631+08:00||;True|2026-08-19T08:35:41.2202300+08:00||;True|2026-08-18T16:20:59.0170916+08:00||; + True|2026-09-22T03:19:19.6087109Z||;True|2026-09-22T11:09:24.9165065+08:00||;True|2026-09-21T22:40:07.1232044+08:00||;True|2026-09-21T22:35:06.9446571+08:00||;True|2026-09-21T22:27:42.1146825+08:00||;False|2026-09-21T22:27:17.6066573+08:00||;True|2026-09-21T21:01:22.8888913+08:00||;True|2026-09-21T19:15:03.4753747+08:00||;True|2026-09-21T18:08:30.0866986+08:00||;True|2026-09-21T16:51:19.8137872+08:00||;True|2026-09-21T16:30:01.0950896+08:00||;True|2026-09-21T15:09:00.2326737+08:00||;True|2026-09-21T14:47:09.6120256+08:00||;True|2026-09-21T14:16:35.3194409+08:00||;True|2026-09-21T13:54:58.2829712+08:00||;True|2026-09-21T13:36:45.3134499+08:00||;True|2026-09-19T09:48:52.0085130+08:00||;False|2026-09-19T09:47:56.1658214+08:00||;True|2026-09-17T18:50:15.9493380+08:00||;True|2026-09-17T18:11:11.5364547+08:00||;True|2026-09-17T18:07:27.7830758+08:00||;True|2026-09-17T17:58:16.5798560+08:00||;True|2026-09-17T15:37:30.9025614+08:00||;True|2026-09-17T15:25:46.9116292+08:00||;True|2026-09-17T15:05:18.6592403+08:00||;True|2026-09-17T14:57:28.7997437+08:00||;True|2026-09-17T14:36:00.6339386+08:00||;True|2026-09-17T13:56:43.2376873+08:00||;True|2026-09-16T13:56:51.8724546+08:00||;True|2026-09-15T20:44:46.6559899+08:00||;True|2026-09-13T19:02:59.5654245+08:00||;True|2026-09-10T09:14:56.2914193+08:00||;True|2026-09-09T16:40:16.6461154+08:00||;True|2026-09-08T13:55:23.1502435+08:00||;True|2026-09-08T08:04:53.4279263+08:00||;True|2026-09-07T08:48:17.4682304+08:00||;True|2026-09-04T14:39:08.7878670+08:00||;True|2026-09-04T14:35:48.2937877+08:00||;True|2026-09-04T11:45:16.1372876+08:00||;True|2026-09-04T11:42:59.9356436+08:00||;True|2026-09-03T16:05:52.5270232+08:00||;True|2026-09-03T14:52:56.8559144+08:00||;True|2026-09-02T11:08:23.8720038+08:00||;True|2026-09-02T10:58:16.8081797+08:00||;True|2026-08-26T13:34:12.3805892+08:00||;True|2026-08-25T08:31:30.7787234+08:00||;True|2026-08-20T13:10:46.4725468+08:00||;True|2026-08-20T13:10:28.3142576+08:00||;True|2026-08-19T16:10:27.3362887+08:00||;True|2026-08-19T16:01:12.6234971+08:00||;True|2026-08-19T15:42:46.6576265+08:00||;True|2026-08-19T13:39:59.9363522+08:00||;True|2026-08-19T13:36:53.0379313+08:00||;True|2026-08-19T13:31:36.0841398+08:00||;True|2026-08-19T11:44:37.5319626+08:00||;True|2026-08-19T09:36:45.6000047+08:00||;True|2026-08-19T09:34:36.8468631+08:00||;True|2026-08-19T08:35:41.2202300+08:00||;True|2026-08-18T16:20:59.0170916+08:00||; \ No newline at end of file diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile4.pubxml.user b/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile4.pubxml.user index 8a31a4a..9c073e2 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile4.pubxml.user +++ b/CloudBuilder/CloudBuilder.Web.Entry/Properties/PublishProfiles/FolderProfile4.pubxml.user @@ -3,7 +3,7 @@ <_PublishTargetUrl>C:\inetpub\wwwroot\windows - True|2026-09-19T01:55:09.0653660Z||;True|2026-09-19T09:53:13.8933944+08:00||; + True|2026-09-22T07:18:53.4935889Z||;True|2026-09-22T11:24:43.8593406+08:00||;True|2026-09-19T09:55:09.0653660+08:00||;True|2026-09-19T09:53:13.8933944+08:00||; \ No newline at end of file diff --git a/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json b/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json index 131c224..88e9fc1 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json +++ b/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json @@ -38,8 +38,8 @@ "Issuer": "Owen Chen", "Audience": "Cloud Builder", "Secret": "{8619F7C3-B53C-4B85-99F0-983D351ECD82}", - "AccessExpireMins": 480, //8小时过期 - "RefreshExpireMins": 1440, // 1天 + "AccessExpireMins": 120, //2小时过期 + "RefreshExpireMins": 86400, // 60天重刷新过期时间 "IsRefreshAbsoluteExpired": false }, "Redis": { @@ -61,8 +61,7 @@ "ScaleFileDirectory": "D:\\Net8\\FileServer\\Scale", "RequestPath": "/FileServer", //如果是IIS反向代理来的,那么要在本地给这个文件添加IUSR,IIS_IUSRS权限 "DmsFileDirectory": "DmsFile", - "ExcelOutputDirectory": "D:\\Net8\\FileServer\\Output", - "MaxFileSize": 1000//M + "ExcelOutputDirectory": "D:\\Net8\\FileServer\\Output" }, "ScheduleTask": { "Ffmpeg": "RunTask" @@ -70,7 +69,7 @@ "Ffmpeg": { "Path": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe", //视频转码 "Pictrue": " -i {0} -ss 00:00:2.000 -frames:v 1 -vf \"scale=1280:-2\" -q:v 4 {1}", //" -i {0} -ss 00:00:2.000 -frames:v 1 {1}", - "Arguments": "-i {0} -t 10 -c:v libx264 -preset veryfast -crf 26 -profile:v main -level 3.1 -vf \"scale='min(1280,iw)':-2\" -maxrate 1500k -bufsize 2400k -c:a aac -b:a 96k -ac 2 -ar 44100 -start_number 0 -hls_time 3 -hls_list_size 0 -hls_flags independent_segments -f hls {1}" //0-源路径,1-目标路径 + "Arguments": "-i {0}{1} -c:v libx264 -preset veryfast -crf 26 -profile:v main -level 3.1 -vf \"scale='min(1280,iw)':-2\" -maxrate 1500k -bufsize 2400k -c:a aac -b:a 96k -ac 2 -ar 44100 -start_number 0 -hls_time 3 -hls_list_size 0 -hls_flags independent_segments -f hls {2}" //0-源路径,1-切片秒数,2-目标路径 }, "CloudBuilder.Topshelf": { "Path": "D:\\MAFAI\\CloudBuilder.Topshelf\\bin\\Debug\\net8.0\\CloudBuilder.Topshelf.exe" //图片压缩 diff --git a/WebVue/src/components/basic/ErrorPanel.vue b/WebVue/src/components/basic/ErrorPanel.vue new file mode 100644 index 0000000..b2357c6 --- /dev/null +++ b/WebVue/src/components/basic/ErrorPanel.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/WebVue/src/router/index.ts b/WebVue/src/router/index.ts index 95bc792..c00d039 100644 --- a/WebVue/src/router/index.ts +++ b/WebVue/src/router/index.ts @@ -2,8 +2,82 @@ import { createRouter, createWebHistory } from "vue-router"; import type { RouteRecordRaw } from "vue-router"; import type { App } from "vue"; import outsideRoute from "./outsideLayout"; -import packChildren from "./insideLayout"; +import packChildren, { isMenuAuthFailed } from "./insideLayout"; import { bsInfo } from "@/utils/browser"; +import { useLoginProfileStoreWithOut } from "@/store/modules/loginProfile"; +import { PermissionContext } from "@/hooks/PermissionContext"; + +/** + * 不需要登录就能访问的页面。 + * 错误页也在白名单里:401 页就是给"没登录/登录失效"的用户看的, + * 拦掉它反而只能看到登录框,看不到出错原因。 + */ +const WHITE_LIST = [ + "/login", + "/loginAgent", + "/register", + "/error/page401", + "/error/page403", + "/error/page404", +]; + +/** + * 全局登录守卫。 + * + * 页面里的接口不一定会返回 401(比如首页用到的 FindMenus / FindMenuByUsername + * 都是 AllowAnonymous,无 token 时返回 200 + null), + * 只靠 utils/request.ts 的 401 拦截器跳登录页是不够的—— + * 本地没有令牌时就必须在路由层拦住,否则用户会卡在内页看到空白内容。 + */ +function setupLoginGuard() { + router.beforeEach((to) => { + const token = useLoginProfileStoreWithOut().getToken(); + if (!token && !WHITE_LIST.includes(to.path)) { + return { + path: "/login", + query: to.fullPath === "/" ? undefined : { redirect: to.fullPath }, + }; + } + // 已经登录过:不用再看首页动画,直接进工作台。 + // 首页按屏幕尺寸二选一加载 index.vue / indexSmall.vue,在路由层拦掉就不用改两个组件。 + if (token && to.name === "home") { + return { path: "/dashboard" }; + } + return true; + }); +} + +/** + * 免检路由(按路由名)。 + * 这几个是从别的页面跳进来的预览页,不在菜单里单独作为功能入口: + * 能打开来源页面就能预览,路由层不再按 meta.permission 拦, + * 否则没被单独授权的用户会连预览都打不开(接口层的 [PermissionOperate] 仍会校验)。 + */ +const PERMISSION_FREE_ROUTE_NAMES = ["Preview", "PreviewAudio", "PreviewVideo"]; + +/** + * 权限守卫。 + * + * 路由表是按功能全量注册的(/SecurityFunction/FindMenus 不区分用户), + * 任何人从地址栏都能输到任意页面,所以导航时再按 meta.permission 校验一次: + * 没有该功能的查看权限就跳 403,并把原地址带过去,用户能一眼看出是哪个页面被拦了。 + */ +function setupPermissionGuard() { + router.beforeEach((to) => { + const permission = to.meta.permission as string | undefined; + // 没绑定权限名的页面(欢迎页、错误页等)不拦 + if (!permission) return true; + if (PERMISSION_FREE_ROUTE_NAMES.includes(to.name as string)) return true; + + const permissionInfo = useLoginProfileStoreWithOut().getPermissionInfo(); + // 本地拿不到权限信息时放行:宁可少拦,也不要因为本地缓存异常把所有人挡在门外。 + // 真正的安全边界在后端——每个接口都有 [PermissionOperate] 校验。 + if (!permissionInfo) return true; + if (new PermissionContext(permissionInfo).canView(permission)) return true; + + return { path: "/error/page403", query: { from: to.fullPath } }; + }); +} export const routes: Array = [...outsideRoute]; @@ -14,6 +88,11 @@ export const router = createRouter({ }); export async function setupRouter(app: App) { + // 守卫要在装 router 之前注册好:应用启动时的第一次导航也要受它约束 + setupLoginGuard(); + // 登录态之后:按 meta.permission 拦「没有该功能查看权限」的页面(跳 403) + setupPermissionGuard(); + let chs: Array = await packChildren(); if (chs && chs.length > 0) { @@ -75,22 +154,49 @@ export async function setupRouter(app: App) { }); } - // 错误页路由:地址不存在(拼错 / 菜单里没有)时统一跳到 404,不再白屏 + // 三个错误页:401 未登录 / 403 无权限 / 404 地址不存在 + router.addRoute({ + path: "/error/page401", + name: "page401", + component: () => import("@/views/error/page401.vue"), + meta: { + title: "需要登录401", + }, + }); router.addRoute({ path: "/error/page403", name: "page403", component: () => import("@/views/error/page403.vue"), - props: true, meta: { - title: "页面不存在403", + title: "无权限403", }, }); - // catch-all 必须最后注册,匹配所有未定义路径 + router.addRoute({ + path: "/error/page404", + name: "page404", + component: () => import("@/views/error/page404.vue"), + meta: { + title: "页面不存在404", + }, + }); + // catch-all 必须最后注册,匹配所有未定义路径。 + // 原来跳的是 403(无权限),语义不对:地址不存在应该是 404; + // 同时把原地址通过 ?from= 带过去,用户能一眼看出是哪个地址出错了。 router.addRoute({ path: "/:pathMatch(.*)*", - redirect: "/error/page403", + redirect: (to) => ({ + path: "/error/page404", + query: { from: to.fullPath }, + }), }); app.use(router); + + // 菜单都没能加载出来(加载菜单时拿到 401/403,说明登录态已失效): + // 清掉本地登录态回登录页,避免停在 404/空白页上 + if (isMenuAuthFailed()) { + useLoginProfileStoreWithOut().clearStorage(); + router.replace({ path: "/login" }); + } } export default router; diff --git a/WebVue/src/router/insideLayout.ts b/WebVue/src/router/insideLayout.ts index a98a773..d769ec3 100644 --- a/WebVue/src/router/insideLayout.ts +++ b/WebVue/src/router/insideLayout.ts @@ -3,6 +3,7 @@ import { SecurityFunctionDisplayData } from "@/data/auto/securityFunctionDisplay import type { RouteRecordRaw } from "vue-router"; import axios from "axios"; import { getRoutes, setRoutes } from "@/store/modules/loadRoutes"; +import { useLoginProfileStoreWithOut } from "@/store/modules/loginProfile"; import { notification } from "ant-design-vue"; import { WebSitePolicy } from "@/policy/custom/webSitePolicy"; @@ -14,6 +15,16 @@ const errorNotificationWithIcon = (message: string) => { }); }; +/** + * 加载菜单时是否发现登录态失效(401/403)。 + * 这里走的是独立 axios,绕开了 utils/request.ts 的 401 拦截器, + * 所以把结果暴露给 router:由它清登录态并跳登录页, + * 否则登录态失效时会静默退化成 404 页面(一条路由都注册不上)。 + */ +let authFailed = false; + +export const isMenuAuthFailed = () => authFailed; + async function packChildren() { let data: SecurityFunctionDisplayData[] = []; let routes: Array = []; @@ -30,19 +41,28 @@ async function packChildren() { axios.defaults.baseURL = WebSitePolicy.WebApiRoot; } try { + // 带上令牌:以前这个请求是裸的,只是因为接口恰好是 AllowAnonymous 才没事 + const token = useLoginProfileStoreWithOut().getToken(); let response = await axios.create().request({ url: "/SecurityFunction/FindMenus", method: "post", + headers: token ? { Authorization: token } : {}, }); if (response.status == 200) { data = response.data; setRoutes(data); } - } catch (error) { - errorNotificationWithIcon( - "服务器器已停止工作,请联系管理员." - ); + } catch (error: any) { + const status = error?.response?.status; + if (status === 401 || status === 403) { + // 登录态失效:交给 router 清态跳登录,不再提示"服务器停止工作" + authFailed = true; + } else { + errorNotificationWithIcon( + "服务器器已停止工作,请联系管理员." + ); + } } } diff --git a/WebVue/src/store/modules/loginProfile.ts b/WebVue/src/store/modules/loginProfile.ts index 0ef3a93..461ef15 100644 --- a/WebVue/src/store/modules/loginProfile.ts +++ b/WebVue/src/store/modules/loginProfile.ts @@ -34,6 +34,13 @@ export const useLoginProfileStore = defineStore("loginProfile", { updateJwtInfo(jwtInfo: JwtInfo) { storage.set(JWT_STORAGE_ID, "Bearer " + jwtInfo.AccessToken); storage.set(JWT_REFRESHKEY_STORAGE_ID, jwtInfo.RefreshToken); + // 同时同步内存里的登录信息:否则别处读 loginProfile.Token 拿到的还是旧令牌 + if (this.loginProfile?.Token) { + this.loginProfile.Token.AccessToken = jwtInfo.AccessToken; + this.loginProfile.Token.RefreshToken = jwtInfo.RefreshToken; + this.loginProfile.Token.CreatedTime = jwtInfo.CreatedTime; + storage.set(LOGIN_PROFILE_STORAGE_ID, JSON.stringify(this.loginProfile)); + } }, getToken() { return storage.get(JWT_STORAGE_ID); diff --git a/WebVue/src/utils/request.ts b/WebVue/src/utils/request.ts index b43c821..05aedde 100644 --- a/WebVue/src/utils/request.ts +++ b/WebVue/src/utils/request.ts @@ -54,6 +54,48 @@ service.interceptors.request.use( } ); +/** + * 正在进行的刷新请求。 + * 多个请求同时 401 时共用同一次刷新:后端每次刷新都会换新 RefreshToken + * (AuthService.RefreshToken 里 log.RefreshToken = GenerateRefreshToken()), + * 各自刷新会让先完成的那个把其它请求携带的旧 RefreshToken 作废, + * 结果被判定为「无效的令牌,请重新登录.」而把人踢下线。 + */ +let refreshPromise: Promise | null = null; + +/** 认证失败统一收尾:必要时清登录态,并跳登录页(已在登录页就不再跳,避免死循环) */ +const goLogin = (clearLoginState: boolean) => { + if (clearLoginState) useLoginProfileStore().clearStorage(); + if (router.currentRoute.value.path !== "/login") { + router.push({ path: "/login" }); + } +}; + +/** 真正发起刷新(调用方保证同一时刻只有一次) */ +const doRefreshToken = async (): Promise => { + const data = useLoginProfileStore().getJwtInfo()!; + // _retry 标记:刷新请求自己 401 时不要再进刷新分支,避免无限递归 + const config: any = { + url: "/Auth/RefreshToken", + method: "post", + data, + _retry: true, + }; + const jwt = await request(config); + useLoginProfileStore().updateJwtInfo(jwt); + return jwt; +}; + +/** 取得「共用的刷新动作」:并发 401 只刷新一次 */ +const refreshTokenOnce = (): Promise => { + if (!refreshPromise) { + refreshPromise = doRefreshToken().finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +}; + service.interceptors.response.use( (response) => { // if the custom code is not 200, it is judged as an error. @@ -69,31 +111,25 @@ service.interceptors.response.use( //第一次出现401,可以重新刷新令牌一次 if (error.response.status == 401 && !originalConfig._retry) { originalConfig._retry = true; - let data: JwtInfo = new JwtInfo(); - data = useLoginProfileStore().getJwtInfo()!; - if ( - data == null || - data.AccessToken == null || - data.AccessToken.length == 0 - ) { - // errorNotificationWithIcon( - // t("request.notification.identityAuthenticationFailure") - // ); - router.push({ path: "/login" }); - return Promise.resolve(); + + const jwtInfo: JwtInfo = useLoginProfileStore().getJwtInfo()!; + if (!jwtInfo || !jwtInfo.AccessToken || jwtInfo.AccessToken.length == 0) { + // 本来就没有登录态,直接去登录页 + goLogin(true); + return Promise.reject(error); } + try { - let jwt: JwtInfo = await request({ - url: "/Auth/RefreshToken", - method: "post", - data, - }); - useLoginProfileStore().updateJwtInfo(jwt); - //令牌成功刷新后,再次调用之前请求 - const res = await service.request(originalConfig); - return res; - } catch (error) { - return Promise.resolve(); + await refreshTokenOnce(); + //令牌成功刷新后,再次调用之前请求(请求拦截器会带上新令牌) + return await service.request(originalConfig); + } catch (refreshError) { + // 刷新失败必须 reject:原来 return Promise.resolve() 会让业务拿到 undefined + // 当成功处理;这里清登录态跳登录页,并把错误抛给调用方。 + // 网络类异常(没有响应)保留登录态,避免一次抖动就把用户踢出去。 + const e: any = refreshError; + goLogin(!!(e && (e.status || e.response))); + return Promise.reject(refreshError ?? error); } } return Promise.reject(error); @@ -135,7 +171,8 @@ export const request = async ( } - return isGetDataDirectly ? res.data : res; + // res 是 AxiosResponse,取 data 才是业务数据;这里断言成 T 消除类型报错(原代码就有) + return (isGetDataDirectly ? res.data : res) as T; } else { return res; } diff --git a/WebVue/src/views/error/page401.vue b/WebVue/src/views/error/page401.vue index 5d102dd..6bfea94 100644 --- a/WebVue/src/views/error/page401.vue +++ b/WebVue/src/views/error/page401.vue @@ -1,2 +1,51 @@ - - + + + diff --git a/WebVue/src/views/error/page403.vue b/WebVue/src/views/error/page403.vue index 54efa13..5684bd5 100644 --- a/WebVue/src/views/error/page403.vue +++ b/WebVue/src/views/error/page403.vue @@ -1,2 +1,49 @@ - - \ No newline at end of file + + + diff --git a/WebVue/src/views/error/page404.vue b/WebVue/src/views/error/page404.vue index 7773a83..a272985 100644 --- a/WebVue/src/views/error/page404.vue +++ b/WebVue/src/views/error/page404.vue @@ -1,2 +1,49 @@ - - \ No newline at end of file + + + diff --git a/WebVue/src/views/login/index.vue b/WebVue/src/views/login/index.vue index 5d1c650..2e75634 100644 --- a/WebVue/src/views/login/index.vue +++ b/WebVue/src/views/login/index.vue @@ -75,7 +75,7 @@