diff --git a/MOCK.md b/MOCK.md index 88f3502..5d7d04d 100644 --- a/MOCK.md +++ b/MOCK.md @@ -59,7 +59,8 @@ npm run dev:mock | `GET /api/internal/admin/targets` | 同信封分页 | 一致 | | `PUT /api/internal/admin/targets/{id}` | body{baseUrl 非空, enabled 真布尔};成功 `data={targets:N}` | 一致 | | `GET /api/internal/admin/logs?pageIndex&pageSize&targetCode&callerAppKey` | pageSize 默认 50 上限 500;筛选**精确等值**、空串=不过滤;排序 id desc;`ts` ISO 字符串;backendCode 恒 null | 一致(先过滤再分页) | -| `POST /api/internal/routes/refresh` | 无入参 → `data={loaded,targets}` | 一致(返回内存实时条数:28/7) | +| `POST /api/internal/admin/routes/reload` | 无入参 → `data={loaded,targets}` | 一致(返回内存实时条数:28/7;`apps` 键 mock 无数据源,前端按 `-` 渲染) | +| `GET /api/internal/admin/session` | `data={ssoHost,principal}`,值来自已登录 CAS 会话 | `ssoHost` 返空串 → 顶栏「退出」不渲染(同 Notice 的 ssoHost 空态) | 前端请求带 `/open-gateway` 前缀(serviceHost),mock 中间件按剥前缀后的真实路径匹配。 diff --git a/src/api/apiDefinitions.ts b/src/api/apiDefinitions.ts index bbe937b..e17a9ef 100644 --- a/src/api/apiDefinitions.ts +++ b/src/api/apiDefinitions.ts @@ -15,6 +15,9 @@ export default { 'GatewayAdmin.createApp': ['POST', '/api/internal/admin/apps'], 'GatewayAdmin.updateApp': ['PUT', '/api/internal/admin/apps/{id}'], 'GatewayAdmin.logs': ['GET', '/api/internal/admin/logs'], - 'GatewayAdmin.refresh': ['POST', '/api/internal/routes/refresh'], + // 重载注册表:走浏览器面孪生入口。机器面 /api/internal/routes/refresh 只认内部令牌, + // 浏览器带 CAS 会话去打它会被 401 拦死(两口共用 RegistryReloader,返回体同形) + 'GatewayAdmin.reload': ['POST', '/api/internal/admin/routes/reload'], + 'GatewayAdmin.session': ['GET', '/api/internal/admin/session'], 'GatewayAdmin.proxyTest': ['POST', '/api/internal/admin/proxy-test'], }; diff --git a/src/assets/mock/gatewayApi.ts b/src/assets/mock/gatewayApi.ts index bb351b4..73d7fe3 100644 --- a/src/assets/mock/gatewayApi.ts +++ b/src/assets/mock/gatewayApi.ts @@ -498,10 +498,15 @@ export async function handleGatewayMockRequest(req: IncomingMessage, res: Server send(res, ok(listLogs(q))); return true; } - if (api === "/api/internal/routes/refresh" && method === "POST") { + if (api === "/api/internal/admin/routes/reload" && method === "POST") { send(res, ok({ loaded: routes.length, targets: targets.length })); return true; } + if (api === "/api/internal/admin/session" && method === "GET") { + // ssoHost 留空:mock 没有真 SSO,前端据此隐藏「退出登录」(同 Notice 的 ssoHost 空态行为) + send(res, ok({ ssoHost: "", principal: "mock 治理" })); + return true; + } if (api === "/api/internal/admin/proxy-test" && method === "POST") { const body = await readJsonBody(req); if (body === undefined) { diff --git a/src/components/layout.vue b/src/components/layout.vue index 59fb02e..7bfe07e 100644 --- a/src/components/layout.vue +++ b/src/components/layout.vue @@ -1,5 +1,5 @@ @@ -62,6 +82,8 @@ const reloadPage = inject("reload", () => {}) as () => void;
{{ opName }} + + 退出
@@ -134,4 +156,10 @@ const reloadPage = inject("reload", () => {}) as () => void; border: 1px solid rgba(255, 153, 0, 0.65); color: #ffd49a; } +/* 登出入口:贴用户胶囊右侧,沿用顶栏次要文字色(不做按钮,避免与「重载注册表」抢视觉权重) */ +.header-aside .logout { + margin-left: 10px; + font-size: 12px; + color: rgba(255, 255, 255, 0.75); +} diff --git a/src/main.ts b/src/main.ts index 4095b97..f655d97 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import "virtual:uno.css"; import "virtual:svg-icons-register"; import config from "@/assets/config/host.config"; +import { consumeSsoReturnUrl } from "@/utils/ssoReturn"; const app = createApp(App); @@ -32,4 +33,9 @@ app.use(ViewUIPlus); app.config.globalProperties.$conf = config; app.config.globalProperties.$utils = utils; +// SSO 登录往返只能把浏览器送回控制台默认页(后端用固定 CAS service,见网关 SsoAddressing); +// 挂载前先换回跳转前记下的页签,避免首帧闪一下默认页。 +const ssoReturn = consumeSsoReturnUrl(); +if (ssoReturn) router.replace(ssoReturn); + app.mount("#app"); diff --git a/src/utils/alova/alova-core.ts b/src/utils/alova/alova-core.ts index 1e712d5..3d9fbf7 100644 --- a/src/utils/alova/alova-core.ts +++ b/src/utils/alova/alova-core.ts @@ -1,6 +1,7 @@ import { createAlova, Method, StatesHook } from "alova"; import fetchAdapter from "alova/fetch"; import VueHook from "alova/vue"; +import { rememberReturnUrl } from "@/utils/ssoReturn"; /* ============= 类型定义 ============= */ export interface RequestHooks { /** 前置票据/通用头注入 */ @@ -56,8 +57,14 @@ function errorHandle(res: BschResponse, status: number): T { return res.data ?? (null as T); case 302: console.log(res); + // 后端 CAS 门已拼好登录地址(含 service=会话建立端点),整页跳过去即可。 + // 跳转前记下当前地址:固定 service 的回跳只能落默认页,hash 页签靠前端记账换回 + //(原因见 utils/ssoReturn.ts——不能照 Notice 把 hex 拼进固定 service)。 + // 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,全局 onBizError 与 + // 各页面 catch 都不再触发——否则会弹一个"redirect"错误提示(Notice 同款处理)。 + rememberReturnUrl(); window.location.href = res.urlToRedirectTo!; - throw new Error("redirect"); + return new Promise(() => {}) as T; case 4: throw new Error(res.msg || "参数错误!"); default: @@ -90,8 +97,11 @@ export function createBschAlova(baseURL: string, hooks: RequestHooks = {}, state injectHeaders(method); method.config.credentials = "include"; - // 通用头(网关管理面不识别 yschapirequest/yschreferer/currentRole,故不注入) + // 通用头。yschapirequest 是治理面 CAS 门识别 SPA 的标记头(与平台 + // CasSsoApiRedirectFilter 同名同值):带它,未登录才返回 200+{code:302,urlToRedirectTo} + // 让本页去整页跳 SSO;不带,网关按裸调用方出 401 json。 method.config.headers = Object.assign(method.config.headers || {}, { + yschapirequest: "1", timeout: 20000, }); } diff --git a/src/utils/ssoReturn.ts b/src/utils/ssoReturn.ts new file mode 100644 index 0000000..a2c7349 --- /dev/null +++ b/src/utils/ssoReturn.ts @@ -0,0 +1,48 @@ +/** + * SSO 登录往返的「回到原页签」记账(纯前端)。 + *

+ * 为什么不是 Notice 的 {@code ;redirectToReferer=} 那套:Notice 把 hex 拼进 service + * 参数,再靠校验过滤器从实时请求 URI 重算出同一个带 hex 的 service,两端逐字节一致才过得了 + * CAS 的 ST 比对。本网关的 ctx 为空、外链前缀 {@code /open-gateway} 由 Caddy 剥掉,容器算不出 + * 带前缀的地址,只能改用固定 service(见后端 {@code SsoAddressing})——固定值里塞不进每次 + * 不同的 hex:登录时 service 带 A、校验时 service 是常量,CAS 判定 service 不一致 → 票据校验必失败 + * → 又是一次 302,死循环。 + *

+ * 所以回跳目标不下服务端:整页跳去登录前把它记在 {@code sessionStorage}(同标签页、跨站点往返仍在), + * 回到控制台壳页时读出来换回原页签。副作用为零,且不引入任何服务端开放重定向面。 + */ + +const RETURN_KEY = "ysch-return-url"; + +/** 整页跳去 SSO 之前调用:记下当前控制台地址(含 hash 路由) */ +export function rememberReturnUrl(href: string = window.location.href): void { + try { + sessionStorage.setItem(RETURN_KEY, href); + } catch (e: any) { + // 隐私模式/配额满:最坏只是回到默认页,不影响登录本身 + } +} + +/** + * 取出并清掉回跳目标,返回 vue-router 的 hash 路由段(如 {@code /ledger?pageIndex=2})。 + * 只认「同 origin + 同一壳页」的地址,其余一律丢弃——防把用户导向站外或别的应用。 + */ +export function consumeSsoReturnUrl(): string | null { + let raw: string | null = null; + try { + raw = sessionStorage.getItem(RETURN_KEY); + if (raw) sessionStorage.removeItem(RETURN_KEY); + } catch (e: any) { + return null; + } + if (!raw) return null; + try { + const u = new URL(raw); + if (u.origin !== window.location.origin) return null; + if (u.pathname !== window.location.pathname) return null; + const route = u.hash.replace(/^#\/?/, "/"); + return route && route !== "/" ? route : null; + } catch (e: any) { + return null; + } +}