| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 |
- /**
- * 驾驶舱接口适配层接缝(票 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),
- }
- }
- }
|