/** * 驾驶舱接口适配层接缝(票 04):mock/real 切换的唯一出口。 * * 分工(避免和票 05 打架): * · 票 04(本文件):怎么发请求、走 mock 还是真接口、失败怎么归类; * · 票 05:请求回来的字段怎么变成领域对象、mock 样例覆盖哪些情况、分区状态怎么渲染。 * * 硬约束(ADR 0005,2026-09-28 修订): * 1. 驾驶舱 REST(`GET /api/cockpit/v1/**`)已由后端放行**免鉴权**,安全性由部署环境的 * 网络层内网隔离保障 —— 本文件**不再发送 `Authorization`**,配置里的 `api.token` 已废弃; * 2. WebSocket 的 `authenticate` 帧仍带 token(`ws.token`,见 runtimeStore),与本文件无关; * 3. 失败一律收敛成 `{ ok:false, reason }`,**不抛异常、不跳登录页、不整页遮罩**。 */ import { DATA_SOURCE } from '../domain/configStatus.js' import { REQUEST_REASON } from '../domain/requestReason.js' import { getCockpitConfig } from './cockpitConfig.js' // 词汇已提到 domain/requestReason.js(domain 不该反向依赖 service);这里转出,调用点不变。 export { REQUEST_REASON } const mockTransports = new Map() /** 注册一个 mock 处理器(票 05 用它挂各领域接口的契约样例)。 */ export function registerMockTransport(path, handler) { mockTransports.set(normalizePath(path), handler) } /** * 查一个 mock 处理器:先精确命中,再按通配模板命中。 * 模板里的 `*` 匹配任意一段(可出现在中间或末尾),用来覆盖带路径参数的接口: * `/monitoring-points/` + `*` 命中 `/monitoring-points/MP-001` * `/equipments/` + `*` + `/detail-aggregate` 命中 `/equipments/TC-1/detail-aggregate` * 查询串不参与匹配(参数由服务层拼好,处理器按需自己解析)。票 05 加。 */ export function findMockTransport(path) { const normalized = normalizePath(path) if (mockTransports.has(normalized)) return mockTransports.get(normalized) const bare = normalized.split('?')[0] if (mockTransports.has(bare)) return mockTransports.get(bare) for (const [key, handler] of mockTransports) { if (!key.includes('*')) continue // 把模板按 `*` 切成字面段,逐段顺序匹配;末段是 `*` 时允许尾部残留。 const parts = key.split('*') let cursor = 0 let matched = true for (let index = 0; index < parts.length; index += 1) { const part = parts[index] if (part === '') continue if (index === 0) { if (!bare.startsWith(part)) { matched = false break } cursor = part.length continue } const found = bare.indexOf(part, cursor) if (found === -1) { matched = false break } cursor = found + part.length } // 不以 `*` 结尾的模板要求整串匹配到底,避免 `/a/*/b` 命中 `/a/x/b/extra`。 if (matched && !key.endsWith('*') && cursor !== bare.length) matched = false if (matched) return handler } return null } export function getMockTransportPaths() { return [...mockTransports.keys()] } /** 仅测试用。 */ export function resetMockTransportsForTest() { mockTransports.clear() } export function normalizePath(path) { const value = String(path || '') return value.startsWith('/') ? value : `/${value}` } export function joinUrl(baseUrl, path) { return `${String(baseUrl || '').replace(/\/+$/, '')}${normalizePath(path)}` } /** * 纯函数:配置 + 路径 → 这次请求怎么走。 * REST 请求**不带任何凭据**(后端 `GET /api/cockpit/v1/**` 免鉴权,见 ADR 0005)。 * @returns {{mode: string, path: string, url: string, headers: object, error?: string}} */ export function planCockpitRequest(config, path) { const current = config || {} const mode = current.dataSource === DATA_SOURCE.REAL ? DATA_SOURCE.REAL : DATA_SOURCE.MOCK const headers = { Accept: 'application/json' } const plan = { mode, path: normalizePath(path), url: '', headers, } if (mode === DATA_SOURCE.REAL) { const baseUrl = (current.api && current.api.baseUrl) || '' if (!baseUrl) return { ...plan, error: REQUEST_REASON.NO_BASE_URL } plan.url = joinUrl(baseUrl, path) } return plan } async function readJsonPayload(response) { try { if (typeof response.json === 'function') return { ok: true, data: await response.json() } const text = await response.text() return { ok: true, data: JSON.parse(text) } } catch { // dev 的 SPA 回退会把不存在的接口回成 200 text/html,这里必须当成坏载荷。 return { ok: false } } } /** * 从后端错误载荷里取业务失效态(票 08)。 * * 后端把「快照失效」放在 400 响应体 `AjaxResult.data.category`(`CockpitErrorCategory` 的**大写枚举** * `SNAPSHOT_EXPIRED`,见 `CockpitMapSnapshotController` 的异常处理),**不在 HTTP 状态码里**—— * 只看状态码会把它和「分页参数不合法」(`INVALID_REQUEST`)混成同一个原因,界面就只能笼统说 * 「数据不可用」,而值班人员真正需要的是「刷新一下」。 * * 映射:后端大写枚举 → 前端 `REQUEST_REASON` 词汇(两者是不同词汇表,不能直接比字符串)。 * 只认白名单里这一个取值,其它一律回空串——与 mock 分支同一纪律:不让后端塞任意原因进结果。 * @param {object} body 响应体(`AjaxResult`:`{ code, msg, data: { category } }`) */ const BACKEND_ERROR_CATEGORY = { SNAPSHOT_EXPIRED: REQUEST_REASON.SNAPSHOT_EXPIRED, } function readBusinessReason(body) { const outer = body || {} const data = outer.data const category = (data && data.category) || outer.category || '' // 只认白名单的**自有键**:普通对象下标会把 `toString`/`__proto__` 这类继承键也命中, // 于是后端一个随便的 category 就能把非契约值(函数、`[object Object]`)写进失败原因。 if (typeof category !== 'string' || !Object.prototype.hasOwnProperty.call(BACKEND_ERROR_CATEGORY, category)) return '' return BACKEND_ERROR_CATEGORY[category] } /** * 统一出口。任何失败都只回 `{ ok:false, reason }`,由调用方把**受影响分区**标成数据不可用。 * @param {string} path 领域路径,如 `/map/summary` * @param {object} [options] `{ config, fetchImpl, ... }`,便于单测注入 */ export async function requestCockpitJson(path, options = {}) { const config = options.config || getCockpitConfig() const plan = planCockpitRequest(config, path) if (plan.error) { return { ok: false, mode: plan.mode, reason: plan.error, path: plan.path } } if (plan.mode === DATA_SOURCE.MOCK) { const handler = findMockTransport(plan.path) if (!handler) { return { ok: false, mode: plan.mode, reason: REQUEST_REASON.MOCK_MISSING, path: plan.path } } try { const data = await handler({ path: plan.path, config, plan }) return { ok: true, mode: plan.mode, status: 200, path: plan.path, data } } catch (error) { // 处理器可以声明自己的失败原因(票 05 的 mock 用它演示「只这一个分区凭据失败」), // 但只认白名单里的取值——不让 mock 往结果里塞任意原因字符串。 const declared = error && error.reason const known = Object.values(REQUEST_REASON).includes(declared) return { ok: false, mode: plan.mode, reason: known ? declared : REQUEST_REASON.UNAVAILABLE, path: plan.path, error: String((error && error.message) || error), } } } const doFetch = options.fetchImpl || (typeof fetch !== 'undefined' ? fetch : null) if (!doFetch) { return { ok: false, mode: plan.mode, reason: REQUEST_REASON.UNREACHABLE, path: plan.path, error: '当前环境没有 fetch' } } try { const response = await doFetch(plan.url, { headers: plan.headers, cache: 'no-store' }) // 401/403 = 凭据类失败:只回原因,绝不把用户送去登录页。 if (response.status === 401 || response.status === 403) { return { ok: false, mode: plan.mode, status: response.status, reason: REQUEST_REASON.UNAUTHORIZED, path: plan.path } } if (!response.ok) { // 票 08:400 里也藏着可恢复的业务失效态(快照过期),先读出来再判。 const payload = response.status === 400 ? await readJsonPayload(response) : { ok: false } const business = payload.ok ? readBusinessReason(payload.data) : '' return { ok: false, mode: plan.mode, status: response.status, reason: business || REQUEST_REASON.UNAVAILABLE, path: plan.path, } } const payload = await readJsonPayload(response) if (!payload.ok) { return { ok: false, mode: plan.mode, status: response.status, reason: REQUEST_REASON.BAD_PAYLOAD, path: plan.path } } const body = payload.data // 真实后端 AjaxResult 的业务状态不等同于 HTTP 状态;mock/兼容裸载荷不走信封拆解。 if (body && typeof body.code === 'number') { if (body.code !== 200) { return { ok: false, mode: plan.mode, status: response.status, path: plan.path, reason: readBusinessReason(body) || ([401, 403].includes(body.code) ? REQUEST_REASON.UNAUTHORIZED : REQUEST_REASON.UNAVAILABLE), } } if (!Object.prototype.hasOwnProperty.call(body, 'data')) { return { ok: false, mode: plan.mode, status: response.status, path: plan.path, reason: REQUEST_REASON.BAD_PAYLOAD } } return { ok: true, mode: plan.mode, status: response.status, path: plan.path, data: body.data } } return { ok: true, mode: plan.mode, status: response.status, path: plan.path, data: body } } catch (error) { return { ok: false, mode: plan.mode, reason: REQUEST_REASON.UNREACHABLE, path: plan.path, error: String((error && error.message) || error), } } }