模块接口只认模块自己容器里的 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:
+101
-20
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user