- alova-core 通用头补 yschapirequest:1:后端 CAS 门据此对 SPA 出 200+{code:302},
errorHandle 的 case 302 整页跳登录,跳转前 rememberReturnUrl 记账、返回挂起
Promise 不再触发全局错误弹窗(Notice 同款)
- ssoReturn.ts:回跳目标记 sessionStorage(同页签跨站往返仍在),只认同 origin
同壳页的地址防开放重定向;不照 Notice 拼 hex 进 service——本网关 ctx 为空、
外链前缀被 Caddy 剥离,固定 service 与每次不同的目标不可共存(会票据校验死循环)
- main.ts 挂载前 consumeSsoReturnUrl 换回原页签,避免回跳闪默认页
- 重载注册表按钮改走浏览器面孪生入口 /api/internal/admin/routes/reload
(机器面 /routes/refresh 收口内部令牌后浏览器再打会被 401 拦死)
- 顶栏登出:ssoHost 取自已登录会话 /admin/session(后端无 env-config 端点),
取不到不渲染;mock 同步补 reload/session 两端点
This commit is contained in:
@@ -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 中间件按剥前缀后的真实路径匹配。
|
||||
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { LayoutData } from "@/types/Basics";
|
||||
import Utils from "@/utils";
|
||||
@@ -22,14 +22,15 @@ const isMock = computed(() => !!config.isMock);
|
||||
/** 顶栏操作者占位(网关无用户体系;三态头像取首字「网」) */
|
||||
const opName = "网关治理";
|
||||
|
||||
/* 重载注册表(POST /api/internal/routes/refresh):网关热重载路由+目标快照,无须重启 */
|
||||
/* 重载注册表(POST /api/internal/admin/routes/reload):治理面入口,与机器面
|
||||
/api/internal/routes/refresh 共用后端实现;机器面那条只认内部令牌,浏览器不能再打它 */
|
||||
const reloading = ref(false);
|
||||
const lastReloadTime = ref("");
|
||||
|
||||
async function reloadRegistry() {
|
||||
reloading.value = true;
|
||||
try {
|
||||
const d: any = await Apis.GatewayAdmin.refresh({});
|
||||
const d: any = await Apis.GatewayAdmin.reload({});
|
||||
Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条 / 应用 ${d?.apps ?? "-"} 条`);
|
||||
lastReloadTime.value = Utils.dateFormat(new Date(), "HH:mm:ss");
|
||||
} catch (e: any) {
|
||||
@@ -39,6 +40,25 @@ async function reloadRegistry() {
|
||||
}
|
||||
}
|
||||
|
||||
/* SSO 基址:网关后端无 env-config 端点(conf.ssoHost 恒空),改从已登录会话里取。
|
||||
取不到(未配 CAS / mock)则不渲染登出入口——与 Notice 的 ssoHost 空态一致。 */
|
||||
const ssoHost = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const d: any = await Apis.GatewayAdmin.session({});
|
||||
ssoHost.value = d?.ssoHost || "";
|
||||
} catch (e: any) {
|
||||
// 未登录时这条会被 alova 整页跳去 SSO;其它失败只是没有登出入口,不额外弹
|
||||
}
|
||||
});
|
||||
|
||||
/* 登出(逐字照 Notice 的 layout.vue):打 CAS 平台级注销,service 回当前控制台地址。
|
||||
注意这是平台级动作——会连带注销该浏览器在 CAS 下其他应用的会话(Notice 同此语义)。 */
|
||||
function logout() {
|
||||
window.location.href = ssoHost.value + "/logout?service=" + window.location.href;
|
||||
}
|
||||
|
||||
/* 手动刷新:重建当前页面(App.vue 的 provide("reload")),列表回到服务端真值 */
|
||||
const reloadPage = inject("reload", () => {}) as () => void;
|
||||
</script>
|
||||
@@ -62,6 +82,8 @@ const reloadPage = inject("reload", () => {}) as () => void;
|
||||
<div class="header-aside fr">
|
||||
<UserAvatar class="fl align-middle" :name="opName" :size="28" placement="brand" />
|
||||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);">{{ opName }}</a>
|
||||
<!-- 登出入口:仅在拿到 SSO 基址时出现(未接 CAS/本地 mock 恒不渲染) -->
|
||||
<a v-if="ssoHost" class="logout fl align-middle" href="javascript:void(0);" @click="logout">退出</a>
|
||||
</div>
|
||||
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
|
||||
<div class="theme-cap fr"><ThemeSwitcher /></div>
|
||||
@@ -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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<T>(res: BschResponse<T>, 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* SSO 登录往返的「回到原页签」记账(纯前端)。
|
||||
* <p>
|
||||
* <b>为什么不是 Notice 的 {@code ;redirectToReferer=<hex>} 那套</b>:Notice 把 hex 拼进 service
|
||||
* 参数,再靠校验过滤器从<b>实时请求 URI</b> 重算出同一个带 hex 的 service,两端逐字节一致才过得了
|
||||
* CAS 的 ST 比对。本网关的 ctx 为空、外链前缀 {@code /open-gateway} 由 Caddy 剥掉,容器算不出
|
||||
* 带前缀的地址,只能改用<b>固定 service</b>(见后端 {@code SsoAddressing})——固定值里塞不进每次
|
||||
* 不同的 hex:登录时 service 带 A、校验时 service 是常量,CAS 判定 service 不一致 → 票据校验必失败
|
||||
* → 又是一次 302,死循环。
|
||||
* <p>
|
||||
* 所以回跳目标不下服务端:整页跳去登录前把它记在 {@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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user