fix(chat): 模块会话缺失时自动预热一次 CAS 会话再重试
build / build (push) Successful in 2m50s

模块接口只认模块自己容器里的 CAS 会话(_const_cas_assertion_)。同源部署只能
保证 Cookie 送得到,送不到「还没建出来的会话」:模块 SPA 建会话靠整页跳 SSO
签票回跳,而代执行走 fetch —— 平台对 AJAX 只回 JSON code=302、不真跳转,
于是首次查询某模块必然失败,且每次重问都同样失败。

现在首次收到 code=302 时:用同源隐藏 iframe 对刚失败的接口地址做一次真实导航
(不带 yschapirequest),走完「模块 302 → SSO 凭已有 TGT 签票回跳 → 模块落会话」,
随后重试原请求;每模块 60s 内只尝试一次,用户无感。会话建好后同浏览器后续查询
直接命中,无额外开销。
This commit is contained in:
lijing
2026-10-12 14:57:30 +08:00
parent 37d292c5d2
commit ab87a4b560
+101 -20
View File
@@ -165,6 +165,71 @@ function fill(placeholder: ChatItem, resp: any) {
}
}
/**
* 判断模块响应是否为「未登录」信号。
*
* <p>平台 CAS 过滤器对 AJAX 不真的 302,而是返回 <b>HTTP 200 + `{"code":302,urlToRedirectTo}`</b>
* (带 `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<string, number>();
const WARM_RETRY_MS = 60_000;
/**
* 模块会话预热:用一次<b>真实导航</b>把业务模块的浏览器 CAS 会话建起来。
*
* <p>为什么必须这么做:模块接口只认<b>模块自己容器里的</b> CAS 会话(`_const_cas_assertion_`)。
* 同源部署只保证 Cookie 送得到,送不到「还没建出来的会话」。而模块 SPA 建会话靠的是
* `window.location.href = urlToRedirectTo` 整页跳 SSO 签票回跳 —— fetch 永远拿不到这一步
* (平台对 AJAX 只回 JSON `code=302`,不真跳转),于是每次查询都必然 302。
*
* <p>做法:同源隐藏 iframe 导航到刚失败的那个接口地址(<b>不带</b> `yschapirequest`,
* 平台即回真 302 → SSO 见已有 TGT 直接签票回跳 → 模块校验票据后落会话),随后重试原请求。
* 整个过程用户无感;只在首次查询某模块时发生一次。
*
* @param pathAndQuery 刚失败的接口相对路径(`/campusnotice/api/...`),首段即模块上下文
* @param onStart 确认要预热时回调(用于更新界面文案)
* @returns 是否完成了这轮预热尝试(false = 60s 内已试过,或拿不到模块段)
*/
function warmModuleSession(pathAndQuery: string, onStart?: () => void, timeoutMs = 6000): Promise<boolean> {
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<boolean>(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<ToolResultItem> {
async function execBrowserCall(call: BrowserToolCall, onWarm?: () => void): Promise<ToolResultItem> {
const base = { name: call.name, arguments: call.arguments };
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
try {
const headers: Record<string, string> = { 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<string, string> = { 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();