From ab87a4b560c19b721f68db214d5ebae67c083e85 Mon Sep 17 00:00:00 2001 From: lijing Date: Mon, 12 Oct 2026 14:57:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E6=A8=A1=E5=9D=97=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=BC=BA=E5=A4=B1=E6=97=B6=E8=87=AA=E5=8A=A8=E9=A2=84?= =?UTF-8?q?=E7=83=AD=E4=B8=80=E6=AC=A1=20CAS=20=E4=BC=9A=E8=AF=9D=E5=86=8D?= =?UTF-8?q?=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模块接口只认模块自己容器里的 CAS 会话(_const_cas_assertion_)。同源部署只能 保证 Cookie 送得到,送不到「还没建出来的会话」:模块 SPA 建会话靠整页跳 SSO 签票回跳,而代执行走 fetch —— 平台对 AJAX 只回 JSON code=302、不真跳转, 于是首次查询某模块必然失败,且每次重问都同样失败。 现在首次收到 code=302 时:用同源隐藏 iframe 对刚失败的接口地址做一次真实导航 (不带 yschapirequest),走完「模块 302 → SSO 凭已有 TGT 签票回跳 → 模块落会话」, 随后重试原请求;每模块 60s 内只尝试一次,用户无感。会话建好后同浏览器后续查询 直接命中,无额外开销。 --- src/views/Chat.vue | 121 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 101 insertions(+), 20 deletions(-) diff --git a/src/views/Chat.vue b/src/views/Chat.vue index fd92150..287ee10 100644 --- a/src/views/Chat.vue +++ b/src/views/Chat.vue @@ -165,6 +165,71 @@ function fill(placeholder: ChatItem, resp: any) { } } +/** + * 判断模块响应是否为「未登录」信号。 + * + *

平台 CAS 过滤器对 AJAX 不真的 302,而是返回 HTTP 200 + `{"code":302,urlToRedirectTo}` + * (带 `yschapirequest: 1` 时的约定)。body 以 `<` 开头是「落到网页」的老问题,另有守卫处理。 + */ +function isLoginRedirect(status: number, body?: string): boolean { + if (status < 200 || status >= 300 || !body || !body.includes("302")) return false; + try { + const j = JSON.parse(body); + return j != null && String(j.code) === "302" && !!j.urlToRedirectTo; + } catch { + return false; + } +} + +/** 每个模块最近一次「会话预热」的时间戳(避免失败后每条查询都白等一轮) */ +const warmTriedAt = new Map(); +const WARM_RETRY_MS = 60_000; + +/** + * 模块会话预热:用一次真实导航把业务模块的浏览器 CAS 会话建起来。 + * + *

为什么必须这么做:模块接口只认模块自己容器里的 CAS 会话(`_const_cas_assertion_`)。 + * 同源部署只保证 Cookie 送得到,送不到「还没建出来的会话」。而模块 SPA 建会话靠的是 + * `window.location.href = urlToRedirectTo` 整页跳 SSO 签票回跳 —— fetch 永远拿不到这一步 + * (平台对 AJAX 只回 JSON `code=302`,不真跳转),于是每次查询都必然 302。 + * + *

做法:同源隐藏 iframe 导航到刚失败的那个接口地址(不带 `yschapirequest`, + * 平台即回真 302 → SSO 见已有 TGT 直接签票回跳 → 模块校验票据后落会话),随后重试原请求。 + * 整个过程用户无感;只在首次查询某模块时发生一次。 + * + * @param pathAndQuery 刚失败的接口相对路径(`/campusnotice/api/...`),首段即模块上下文 + * @param onStart 确认要预热时回调(用于更新界面文案) + * @returns 是否完成了这轮预热尝试(false = 60s 内已试过,或拿不到模块段) + */ +function warmModuleSession(pathAndQuery: string, onStart?: () => void, timeoutMs = 6000): Promise { + const ctx = pathAndQuery.split("/").filter(Boolean)[0] || ""; + if (!ctx) return Promise.resolve(false); + const now = Date.now(); + if (now - (warmTriedAt.get(ctx) || 0) < WARM_RETRY_MS) return Promise.resolve(false); + warmTriedAt.set(ctx, now); + onStart?.(); + + const url = `${location.origin}${pathAndQuery}`; + return new Promise(resolve => { + const frame = document.createElement("iframe"); + frame.setAttribute("aria-hidden", "true"); + frame.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px;border:0;"; + let settled = false; + const done = (ok: boolean) => { + if (settled) return; + settled = true; + window.clearTimeout(cap); + window.setTimeout(() => frame.remove(), 0); + resolve(ok); + }; + const cap = window.setTimeout(() => done(false), timeoutMs); + // 导航链(模块 302 → SSO 签票 → 回跳模块)走完才会触发 load,再留一点沉降时间 + frame.onload = () => window.setTimeout(() => done(true), 700); + frame.src = url; + document.body.appendChild(frame); + }); +} + /** * 浏览器代执行(方案 A):模块接口只认浏览器 CAS 会话,服务端代调必然 302。 * 后端挂起后把接口描述下发到这里,用「用户自己的登录态」调完模块接口再把结果回传续跑。 @@ -172,31 +237,44 @@ function fill(placeholder: ChatItem, resp: any) { * 前提:本应用与模块同站部署(同源 Cookie 自动携带)。本地 dev(localhost:8081) * 跨域取不到 base 域会话,fetch 会失败或 404 → 结果回传后由服务端判为失败, * 自动落回「去系统里查看」的深链兜底,UI 不会卡死。 + * + * 首次遇到 `code=302`(该浏览器还没建过这个模块的会话)会先预热一次再重试; + * 会话建好后同一浏览器后续查询直接命中,无额外开销。 */ -async function execBrowserCall(call: BrowserToolCall): Promise { +async function execBrowserCall(call: BrowserToolCall, onWarm?: () => void): Promise { const base = { name: call.name, arguments: call.arguments }; - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 15000); - try { - const headers: Record = { yschapirequest: "1" }; - let body: string | undefined; - if (call.method !== "GET") { - headers["Content-Type"] = call.contentType || "application/json;charset=UTF-8"; - body = call.body; + const send = async () => { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 15000); + try { + const headers: Record = { yschapirequest: "1" }; + let body: string | undefined; + if (call.method !== "GET") { + headers["Content-Type"] = call.contentType || "application/json;charset=UTF-8"; + body = call.body; + } + const res = await fetch(`${location.origin}${call.pathAndQuery}`, { + method: call.method === "GET" ? "GET" : "POST", + headers, + body, + credentials: "same-origin", + signal: ctrl.signal, + }); + return { status: res.status, body: await res.text() }; + } finally { + clearTimeout(timer); } - const res = await fetch(`${location.origin}${call.pathAndQuery}`, { - method: call.method === "GET" ? "GET" : "POST", - headers, - body, - credentials: "same-origin", - signal: ctrl.signal, - }); - return { ...base, status: res.status, body: await res.text() }; + }; + try { + let r = await send(); + if (isLoginRedirect(r.status, r.body)) { + const warmed = await warmModuleSession(call.pathAndQuery, onWarm); + if (warmed) r = await send(); + } + return { ...base, status: r.status, body: r.body }; } catch (e: any) { const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败"; return { ...base, error: reason }; - } finally { - clearTimeout(timer); } } @@ -210,7 +288,10 @@ async function runBrowserTool(placeholder: ChatItem, pending: any) { const results: ToolResultItem[] = []; for (const call of calls) { placeholder.stage = `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`; - results.push(await execBrowserCall(call)); + results.push(await execBrowserCall(call, () => { + const ctx = call.pathAndQuery.split("/").filter(Boolean)[0] || "该模块"; + placeholder.stage = `「${ctx}」模块还没建立登录会话,正在为你自动登录…`; + })); } placeholder.stage = "正在整理结果…"; const resp: any = await agentApi.submitToolResults(sessionId.value, results).send();