cockpitApi.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. /**
  2. * 驾驶舱接口适配层接缝(票 04):mock/real 切换的唯一出口。
  3. *
  4. * 分工(避免和票 05 打架):
  5. * · 票 04(本文件):怎么发请求、走 mock 还是真接口、失败怎么归类;
  6. * · 票 05:请求回来的字段怎么变成领域对象、mock 样例覆盖哪些情况、分区状态怎么渲染。
  7. *
  8. * 硬约束(ADR 0005,2026-09-28 修订):
  9. * 1. 驾驶舱 REST(`GET /api/cockpit/v1/**`)已由后端放行**免鉴权**,安全性由部署环境的
  10. * 网络层内网隔离保障 —— 本文件**不再发送 `Authorization`**,配置里的 `api.token` 已废弃;
  11. * 2. WebSocket 的 `authenticate` 帧仍带 token(`ws.token`,见 runtimeStore),与本文件无关;
  12. * 3. 失败一律收敛成 `{ ok:false, reason }`,**不抛异常、不跳登录页、不整页遮罩**。
  13. */
  14. import { DATA_SOURCE } from '../domain/configStatus.js'
  15. import { REQUEST_REASON } from '../domain/requestReason.js'
  16. import { getCockpitConfig } from './cockpitConfig.js'
  17. // 词汇已提到 domain/requestReason.js(domain 不该反向依赖 service);这里转出,调用点不变。
  18. export { REQUEST_REASON }
  19. const mockTransports = new Map()
  20. /** 注册一个 mock 处理器(票 05 用它挂各领域接口的契约样例)。 */
  21. export function registerMockTransport(path, handler) {
  22. mockTransports.set(normalizePath(path), handler)
  23. }
  24. /**
  25. * 查一个 mock 处理器:先精确命中,再按通配模板命中。
  26. * 模板里的 `*` 匹配任意一段(可出现在中间或末尾),用来覆盖带路径参数的接口:
  27. * `/monitoring-points/` + `*` 命中 `/monitoring-points/MP-001`
  28. * `/equipments/` + `*` + `/detail-aggregate` 命中 `/equipments/TC-1/detail-aggregate`
  29. * 查询串不参与匹配(参数由服务层拼好,处理器按需自己解析)。票 05 加。
  30. */
  31. export function findMockTransport(path) {
  32. const normalized = normalizePath(path)
  33. if (mockTransports.has(normalized)) return mockTransports.get(normalized)
  34. const bare = normalized.split('?')[0]
  35. if (mockTransports.has(bare)) return mockTransports.get(bare)
  36. for (const [key, handler] of mockTransports) {
  37. if (!key.includes('*')) continue
  38. // 把模板按 `*` 切成字面段,逐段顺序匹配;末段是 `*` 时允许尾部残留。
  39. const parts = key.split('*')
  40. let cursor = 0
  41. let matched = true
  42. for (let index = 0; index < parts.length; index += 1) {
  43. const part = parts[index]
  44. if (part === '') continue
  45. if (index === 0) {
  46. if (!bare.startsWith(part)) {
  47. matched = false
  48. break
  49. }
  50. cursor = part.length
  51. continue
  52. }
  53. const found = bare.indexOf(part, cursor)
  54. if (found === -1) {
  55. matched = false
  56. break
  57. }
  58. cursor = found + part.length
  59. }
  60. // 不以 `*` 结尾的模板要求整串匹配到底,避免 `/a/*/b` 命中 `/a/x/b/extra`。
  61. if (matched && !key.endsWith('*') && cursor !== bare.length) matched = false
  62. if (matched) return handler
  63. }
  64. return null
  65. }
  66. export function getMockTransportPaths() {
  67. return [...mockTransports.keys()]
  68. }
  69. /** 仅测试用。 */
  70. export function resetMockTransportsForTest() {
  71. mockTransports.clear()
  72. }
  73. export function normalizePath(path) {
  74. const value = String(path || '')
  75. return value.startsWith('/') ? value : `/${value}`
  76. }
  77. export function joinUrl(baseUrl, path) {
  78. return `${String(baseUrl || '').replace(/\/+$/, '')}${normalizePath(path)}`
  79. }
  80. /**
  81. * 纯函数:配置 + 路径 → 这次请求怎么走。
  82. * REST 请求**不带任何凭据**(后端 `GET /api/cockpit/v1/**` 免鉴权,见 ADR 0005)。
  83. * @returns {{mode: string, path: string, url: string, headers: object, error?: string}}
  84. */
  85. export function planCockpitRequest(config, path) {
  86. const current = config || {}
  87. const mode = current.dataSource === DATA_SOURCE.REAL ? DATA_SOURCE.REAL : DATA_SOURCE.MOCK
  88. const headers = { Accept: 'application/json' }
  89. const plan = {
  90. mode,
  91. path: normalizePath(path),
  92. url: '',
  93. headers,
  94. }
  95. if (mode === DATA_SOURCE.REAL) {
  96. const baseUrl = (current.api && current.api.baseUrl) || ''
  97. if (!baseUrl) return { ...plan, error: REQUEST_REASON.NO_BASE_URL }
  98. plan.url = joinUrl(baseUrl, path)
  99. }
  100. return plan
  101. }
  102. async function readJsonPayload(response) {
  103. try {
  104. if (typeof response.json === 'function') return { ok: true, data: await response.json() }
  105. const text = await response.text()
  106. return { ok: true, data: JSON.parse(text) }
  107. } catch {
  108. // dev 的 SPA 回退会把不存在的接口回成 200 text/html,这里必须当成坏载荷。
  109. return { ok: false }
  110. }
  111. }
  112. /**
  113. * 从后端错误载荷里取业务失效态(票 08)。
  114. *
  115. * 后端把「快照失效」放在 400 响应体 `AjaxResult.data.category`(`CockpitErrorCategory` 的**大写枚举**
  116. * `SNAPSHOT_EXPIRED`,见 `CockpitMapSnapshotController` 的异常处理),**不在 HTTP 状态码里**——
  117. * 只看状态码会把它和「分页参数不合法」(`INVALID_REQUEST`)混成同一个原因,界面就只能笼统说
  118. * 「数据不可用」,而值班人员真正需要的是「刷新一下」。
  119. *
  120. * 映射:后端大写枚举 → 前端 `REQUEST_REASON` 词汇(两者是不同词汇表,不能直接比字符串)。
  121. * 只认白名单里这一个取值,其它一律回空串——与 mock 分支同一纪律:不让后端塞任意原因进结果。
  122. * @param {object} body 响应体(`AjaxResult`:`{ code, msg, data: { category } }`)
  123. */
  124. const BACKEND_ERROR_CATEGORY = {
  125. SNAPSHOT_EXPIRED: REQUEST_REASON.SNAPSHOT_EXPIRED,
  126. }
  127. function readBusinessReason(body) {
  128. const outer = body || {}
  129. const data = outer.data
  130. const category = (data && data.category) || outer.category || ''
  131. // 只认白名单的**自有键**:普通对象下标会把 `toString`/`__proto__` 这类继承键也命中,
  132. // 于是后端一个随便的 category 就能把非契约值(函数、`[object Object]`)写进失败原因。
  133. if (typeof category !== 'string' || !Object.prototype.hasOwnProperty.call(BACKEND_ERROR_CATEGORY, category)) return ''
  134. return BACKEND_ERROR_CATEGORY[category]
  135. }
  136. /**
  137. * 统一出口。任何失败都只回 `{ ok:false, reason }`,由调用方把**受影响分区**标成数据不可用。
  138. * @param {string} path 领域路径,如 `/map/summary`
  139. * @param {object} [options] `{ config, fetchImpl, ... }`,便于单测注入
  140. */
  141. export async function requestCockpitJson(path, options = {}) {
  142. const config = options.config || getCockpitConfig()
  143. const plan = planCockpitRequest(config, path)
  144. if (plan.error) {
  145. return { ok: false, mode: plan.mode, reason: plan.error, path: plan.path }
  146. }
  147. if (plan.mode === DATA_SOURCE.MOCK) {
  148. const handler = findMockTransport(plan.path)
  149. if (!handler) {
  150. return { ok: false, mode: plan.mode, reason: REQUEST_REASON.MOCK_MISSING, path: plan.path }
  151. }
  152. try {
  153. const data = await handler({ path: plan.path, config, plan })
  154. return { ok: true, mode: plan.mode, status: 200, path: plan.path, data }
  155. } catch (error) {
  156. // 处理器可以声明自己的失败原因(票 05 的 mock 用它演示「只这一个分区凭据失败」),
  157. // 但只认白名单里的取值——不让 mock 往结果里塞任意原因字符串。
  158. const declared = error && error.reason
  159. const known = Object.values(REQUEST_REASON).includes(declared)
  160. return {
  161. ok: false,
  162. mode: plan.mode,
  163. reason: known ? declared : REQUEST_REASON.UNAVAILABLE,
  164. path: plan.path,
  165. error: String((error && error.message) || error),
  166. }
  167. }
  168. }
  169. const doFetch = options.fetchImpl || (typeof fetch !== 'undefined' ? fetch : null)
  170. if (!doFetch) {
  171. return { ok: false, mode: plan.mode, reason: REQUEST_REASON.UNREACHABLE, path: plan.path, error: '当前环境没有 fetch' }
  172. }
  173. try {
  174. const response = await doFetch(plan.url, { headers: plan.headers, cache: 'no-store' })
  175. // 401/403 = 凭据类失败:只回原因,绝不把用户送去登录页。
  176. if (response.status === 401 || response.status === 403) {
  177. return { ok: false, mode: plan.mode, status: response.status, reason: REQUEST_REASON.UNAUTHORIZED, path: plan.path }
  178. }
  179. if (!response.ok) {
  180. // 票 08:400 里也藏着可恢复的业务失效态(快照过期),先读出来再判。
  181. const payload = response.status === 400 ? await readJsonPayload(response) : { ok: false }
  182. const business = payload.ok ? readBusinessReason(payload.data) : ''
  183. return {
  184. ok: false,
  185. mode: plan.mode,
  186. status: response.status,
  187. reason: business || REQUEST_REASON.UNAVAILABLE,
  188. path: plan.path,
  189. }
  190. }
  191. const payload = await readJsonPayload(response)
  192. if (!payload.ok) {
  193. return { ok: false, mode: plan.mode, status: response.status, reason: REQUEST_REASON.BAD_PAYLOAD, path: plan.path }
  194. }
  195. const body = payload.data
  196. // 真实后端 AjaxResult 的业务状态不等同于 HTTP 状态;mock/兼容裸载荷不走信封拆解。
  197. if (body && typeof body.code === 'number') {
  198. if (body.code !== 200) {
  199. return {
  200. ok: false, mode: plan.mode, status: response.status, path: plan.path,
  201. reason: readBusinessReason(body) || ([401, 403].includes(body.code)
  202. ? REQUEST_REASON.UNAUTHORIZED : REQUEST_REASON.UNAVAILABLE),
  203. }
  204. }
  205. if (!Object.prototype.hasOwnProperty.call(body, 'data')) {
  206. return { ok: false, mode: plan.mode, status: response.status, path: plan.path, reason: REQUEST_REASON.BAD_PAYLOAD }
  207. }
  208. return { ok: true, mode: plan.mode, status: response.status, path: plan.path, data: body.data }
  209. }
  210. return { ok: true, mode: plan.mode, status: response.status, path: plan.path, data: body }
  211. } catch (error) {
  212. return {
  213. ok: false,
  214. mode: plan.mode,
  215. reason: REQUEST_REASON.UNREACHABLE,
  216. path: plan.path,
  217. error: String((error && error.message) || error),
  218. }
  219. }
  220. }