/** * 业务模块「用户态接口」的浏览器侧调用工具。 * * 背景(本工程的核心约束):各业务模块的 `/api/*` 只认**模块自己容器里的 CAS 会话** * (`_const_cas_assertion_`),服务端代调必然拿到 `{"code":302,urlToRedirectTo}`。 * 所以凡是"要用用户身份读模块数据",都得由前端带同源 Cookie 去发这个请求。 * * 两个使用场景共用本文件: * 1. 对话里的「浏览器代执行」(模型决定的工具调用,见 `views/Chat.vue`); * 2. 表单下拉的「代取选项」(班级 / 学生名单等,见 `components/FormCard.vue`)。 */ import type { BrowserToolCall, ToolResultItem } from "@/types/agent"; /** 每个模块最近一次「会话预热」的时间戳(避免失败后每条请求都白等一轮) */ const warmTriedAt = new Map(); const WARM_RETRY_MS = 60_000; /** * 判断模块响应是否为「未登录」信号。 * * 平台 CAS 过滤器对 AJAX 不真的 302,而是返回 **HTTP 200 + `{"code":302,urlToRedirectTo}`** * (带 `yschapirequest: 1` 时的约定)。body 以 `<` 开头是「落到网页」的老问题,另有守卫处理。 */ export 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; } } /** * 模块会话预热:用一次真实导航把业务模块的浏览器 CAS 会话建起来。 * * 为什么必须这么做:模块接口只认模块自己容器里的 CAS 会话。同源部署只保证 Cookie 送得到, * 送不到「还没建出来的会话」。而模块 SPA 建会话靠的是整页跳 SSO 签票回跳 —— fetch 永远拿不到 * 这一步(平台对 AJAX 只回 JSON `code=302`,不真跳转),于是每次请求都必然 302。 * * 做法:同源隐藏 iframe 导航到刚失败的那个接口地址(**不带** `yschapirequest`, * 平台即回真 302 → SSO 见已有 TGT 直接签票回跳 → 模块校验票据后落会话),随后重试原请求。 * 整个过程用户无感;只在首次访问某模块时发生一次。 * * @param pathAndQuery 刚失败的接口相对路径(`/homeleave/api/...`),首段即模块上下文 * @returns 是否完成了这轮预热尝试(false = 60s 内已试过,或拿不到模块段) */ export 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); }); } /** 一次模块接口请求的描述(首步用 BrowserToolCall,第二步用它的 followUp) */ interface ModuleRequest { method: string; pathAndQuery: string; body?: string; contentType?: string; } /** * 业务是否成功(模块统一包装 `{code,msg,data}`,`code=0` 才算成功)。 * 无包装(老接口直接回数组/对象)也当成功 —— 与服务端 {@code parseResponse} 口径一致。 */ function isBizSuccess(status: number, body?: string): boolean { if (status < 200 || status >= 300) return false; const s = (body || "").trim(); if (!s) return true; if (s.startsWith("<")) return false; // 拿回网页 = 没落到接口上 try { const j = JSON.parse(s); if (j && typeof j === "object" && "code" in j) return Number((j as any).code) === 0; return true; } catch { return true; } } /** * 从首步响应里取「资料编号」用于第二步路径(`{data}`)。 * * 两种常见形态:直接回 id(作业创建 `data=123`),或回一个对象(`data={id:123,...}`)—— * 对象必须取 `id` 再填路径,直接 JSON 序列化会拼出 `/homeworks/{"id":123}/publish`。 */ function extractDataId(body?: string): string | null { const s = (body || "").trim(); if (!s) return null; try { const j = JSON.parse(s); const d = j && typeof j === "object" ? (j as any).data : undefined; if (d === undefined || d === null) return null; if (typeof d === "object") { const v = (d as any).id; return v === undefined || v === null || String(v) === "" ? null : String(v); } const v = String(d); return v === "" || v === "null" ? null : v; } catch { return null; } } /** * 用用户自己的登录态调用一次模块接口;遇到 `code=302` 会先预热会话再重试一次。 * * 若该调用声明了 `followUp`(两步提交,如作业「先落草稿 → 再发布/定时」), * 首步**业务成功**后自动用返回的资源 id 续跑第二步,回传的是第二步的结果。 * * @param call 接口描述(`pathAndQuery` 相对当前站点根,同站部署 ⇒ 同源) * @param onWarm 识别出「需要建会话」时的回调(用于更新界面文案) */ export async function callModuleApi(call: BrowserToolCall, onWarm?: () => void): Promise { const base = { name: call.name, arguments: call.arguments }; const send = async (req: ModuleRequest) => { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15000); try { const headers: Record = { yschapirequest: "1" }; let body: string | undefined; if (req.method !== "GET") { headers["Content-Type"] = req.contentType || "application/json;charset=UTF-8"; body = req.body; } const res = await fetch(`${location.origin}${req.pathAndQuery}`, { method: req.method === "GET" ? "GET" : "POST", headers, body, credentials: "same-origin", signal: ctrl.signal, }); return { status: res.status, body: await res.text() }; } finally { clearTimeout(timer); } }; /** 发一次请求,遇 `code=302` 先预热模块会话再重试 */ const sendWithWarm = async (req: ModuleRequest) => { let r = await send(req); if (isLoginRedirect(r.status, r.body)) { const warmed = await warmModuleSession(req.pathAndQuery, onWarm); if (warmed) r = await send(req); } return r; }; try { const fu = (call as any).followUp as ModuleRequest | undefined; const r = await sendWithWarm({ method: call.method, pathAndQuery: call.pathAndQuery, body: call.body, contentType: call.contentType, }); if (!fu || !fu.pathAndQuery) { return { ...base, status: r.status, body: r.body }; } // 首步没成功(HTTP 错 / code≠0 / 拿回网页)就不发第二步,如实回传首步结果 if (!isBizSuccess(r.status, r.body)) { return { ...base, status: r.status, body: r.body }; } const id = extractDataId(r.body); if (!id) { return { ...base, status: r.status, body: r.body, error: "第一步已提交,但没拿回资料编号,无法继续第二步(发布 / 定时发布)", }; } const r2 = await sendWithWarm({ method: fu.method || "POST", pathAndQuery: fu.pathAndQuery.replace("{data}", encodeURIComponent(id)), body: fu.body, contentType: fu.contentType, }); return { ...base, status: r2.status, body: r2.body }; } catch (e: any) { const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败"; return { ...base, error: reason }; } } /** * 把「字典接口原始响应」解析成表单下拉项。 * * 解析是**确定性**的:由后端在字段定义里显式给出 {@code valueKey / labelKey} * (如 `classId/className`),前端只按这两个键取值,不猜结构。 */ export function parseOptionItems( body?: string, valueKey?: string, labelKey?: string ): { value: string; label: string }[] { if (!body) return []; let payload: any; try { payload = JSON.parse(body); } catch { return []; } if (payload == null) return []; // 平台统一包装 {code,msg,data}:非 0 即业务失败 if (typeof payload === "object" && "code" in payload) { if (Number(payload.code) !== 0) return []; payload = payload.data; } const arr = findArray(payload, 0); if (!arr) return []; const vk = valueKey || "id"; const lk = labelKey || "name"; const out: { value: string; label: string }[] = []; // 组织树(年级→班级 / 学部→年级→班级)要拍平成叶子,否则下拉里只会出现「年级」这一层的 id。 // 判定:任一项带非空 children 数组即视为树;否则按平铺字典处理(多数字典接口是平铺的)。 const flat = arr.some(it => it && Array.isArray((it as any).children) && (it as any).children.length) ? flattenLeaves(arr) : arr; for (const item of flat) { if (item == null || typeof item !== "object") continue; const v = (item as any)[vk]; if (v === undefined || v === null || String(v) === "") continue; // labelKey 支持用「+」拼多个字段(如房间的 buildingName+roomNo →「1号楼 305」), // 因为这类 VO 里没有现成的展示名,靠单一字段会出现「同名房间分不清楼栋」。 const l = lk.includes("+") ? lk.split("+").map(k => (item as any)[k.trim()]).filter(V => V !== undefined && V !== null && String(V) !== "").join(" ") : (item as any)[lk]; out.push({ value: String(v), label: l === undefined || l === null || String(l) === "" ? String(v) : String(l), }); } return out; } /** 把「组→子」的树拍平成叶子列表(叶子 = 没有非空 children 的节点) */ function flattenLeaves(nodes: any[]): any[] { const out: any[] = []; for (const n of nodes || []) { const kids = n && Array.isArray(n.children) ? n.children : null; if (kids && kids.length) out.push(...flattenLeaves(kids)); else out.push(n); } return out; } /** 递归找第一个数组(限深,容忍 data 外面再包一层 list/records/rows) */ function findArray(node: any, depth: number): any[] | null { if (node == null || depth > 3) return null; if (Array.isArray(node)) return node; if (typeof node === "object") { for (const k of Object.keys(node)) { const a = findArray(node[k], depth + 1); if (a) return a; } } return null; }