parse-api.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. import { Scope } from '../acl';
  2. import { FmodeApi } from './fmode-api';
  3. import {
  4. Agent,
  5. AgentStatus,
  6. AuthSession,
  7. ChannelConfig,
  8. Deliverable,
  9. DeliverableGroup,
  10. MatrixProvision,
  11. Me,
  12. Task,
  13. UploadTicket,
  14. } from './models';
  15. /**
  16. * 真实数据源实现 —— 走 fmode 平台云函数 + REST(《API-契约.md》)
  17. *
  18. * 依据《数据访问层设计.md》§一/§二:本类是 `FmodeApi` 的**真实实现**,
  19. * 由 `api.provider.ts` 在 `environment.useMockData === false` 时装配。
  20. *
  21. * ## 约定
  22. *
  23. * - 云函数统一入口 `POST https://server.fmode.cn/api/functions`
  24. * (头 `X-Parse-Application-Id` / `X-Parse-Session-Token`;体 `{id, token, params}`)
  25. * - 错误处理:HTTP 200 但 `body.code` 为 401/403 → 视为业务错误并抛出,
  26. * 文案取 `msg` → `mess` → `message`
  27. * - 云函数返回的 `host` / `channels` / `balance` 是**字符串形态 JSON**,
  28. * 映射层兼容「已是对象」与「是字符串」两种形态(存量 `parseJsonLoose` 的教训)
  29. *
  30. * ## 前端安全边界(黄金规则 6 / NFR-03)
  31. *
  32. * 前端**不含任何服务端密钥**:无 masterKey、无 OBS AK/SK、无 Matrix 派生密码。
  33. * Matrix 凭据一律由 provision 云函数服务端签发。
  34. *
  35. * ## 一期未实现项
  36. *
  37. * `fetchTasks` / `fetchTaskDetail` 的云函数接收端**尚未实现**(SPEC FR-07b 依赖声明),
  38. * 故**显式抛错**而非静默返回空数组 —— 空数组会把「未接入」伪装成「没有数据」。
  39. */
  40. const FUNCTION_API = 'https://server.fmode.cn/api/functions';
  41. const PARSE_BASE = 'https://server.fmode.cn/parse';
  42. const PARSE_APP_ID = 'ncloudmaster';
  43. /** 云函数 objectId(《API-契约》§二) */
  44. const FN = {
  45. agentStatus: 'xlosi3m46q',
  46. deliverables: 'lfYlgU7SkK',
  47. provision: 'RP5Ht55NeX',
  48. tokenSync: 'zl4yU8PAEB',
  49. uploadUrl: 'AlP56LCKFm',
  50. } as const;
  51. /** 兼容「已是对象」与「是 JSON 字符串」两种形态 */
  52. function parseJsonLoose<T = Record<string, unknown>>(v: unknown): T | undefined {
  53. if (v == null) return undefined;
  54. if (typeof v === 'object') return v as T;
  55. if (typeof v !== 'string' || !v.trim()) return undefined;
  56. try {
  57. return JSON.parse(v) as T;
  58. } catch {
  59. return undefined;
  60. }
  61. }
  62. export class ParseApi extends FmodeApi {
  63. /**
  64. * 会话令牌读取器 + 请求范围判定器。
  65. *
  66. * - **令牌**由 `SessionService` 唯一持有,**每次调用实时读取**(避免登录/退登后持过期副本)
  67. * - **scope** 由 `acl.scopeFor(me)` 判定(超管 all / 公司管理员 company / 普通用户 mine);
  68. * 🔴 **不得硬编码 `'all'`** —— 那会让每个用户都看到全平台别人的容器(越权)
  69. */
  70. constructor(
  71. private readonly readToken: () => string = () => '',
  72. private readonly readScope: () => Scope = () => 'mine',
  73. ) {
  74. super();
  75. }
  76. /** 当前会话令牌(读取器为空时回落到内部缓存,供单测注入) */
  77. private get sessionToken(): string {
  78. return this.readToken() || this.fallbackToken;
  79. }
  80. /** 仅单测/无 DI 场景使用的回落令牌 */
  81. private fallbackToken = '';
  82. setToken(token: string): void {
  83. this.fallbackToken = token;
  84. }
  85. // ---------- 鉴权 ----------
  86. async sendMobileCode(mobile: string): Promise<void> {
  87. // R4:POST /api/apig/message {mobile, company};🔴 code === 1 才算成功
  88. const res = await this.rawFetch(`${PARSE_BASE.replace('/parse', '')}/api/apig/message`, {
  89. method: 'POST',
  90. body: JSON.stringify({ mobile, company: LOGIN_COMPANY }),
  91. });
  92. const d = (await res.json()) as { code?: number; msg?: string; mess?: string };
  93. if (d.code !== 1) throw new Error(friendlySendCodeError(pickMessage(d), res.status));
  94. }
  95. async loginWithMobile(mobile: string, code: string): Promise<AuthSession> {
  96. // R5:GET /api/fmode/mobile?mobile&code&company;🔴 code === 200 && data.token
  97. const url = `${PARSE_BASE.replace('/parse', '')}/api/fmode/mobile?mobile=${encodeURIComponent(
  98. mobile,
  99. )}&code=${encodeURIComponent(code)}&company=${LOGIN_COMPANY}`;
  100. const res = await this.rawFetch(url, { method: 'GET' });
  101. const d = (await res.json()) as {
  102. code?: number;
  103. data?: { token?: string };
  104. msg?: string;
  105. mess?: string;
  106. };
  107. const token = d.data?.token;
  108. if (d.code !== 200 || !token) throw new Error(pickMessage(d) ?? '登录失败');
  109. this.setToken(token);
  110. const me = await this.fetchMe(token);
  111. return { token, me };
  112. }
  113. async loginWithToken(token: string): Promise<AuthSession> {
  114. const me = await this.fetchMe(token);
  115. this.setToken(token);
  116. return { token, me };
  117. }
  118. async fetchMe(token: string): Promise<Me> {
  119. // R1:**GET** /parse/users/me(头 X-Parse-Session-Token)
  120. // 🔴 实测教训:POST 会 404(返回 HTML 错误页);契约文档曾误写为 POST。
  121. const res = await this.rawFetch(`${PARSE_BASE}/users/me`, {
  122. method: 'GET',
  123. headers: { 'X-Parse-Session-Token': token },
  124. });
  125. if (!res.ok) throw new Error(`Token 校验失败(HTTP ${res.status})`);
  126. const u = (await res.json()) as Record<string, unknown>;
  127. const objectId = String(u['objectId'] ?? '');
  128. // R2:_User JSON **不透出 relation**,roles 必须单独查(存量 v0.3.3 著名坑:
  129. // 超管因缺 roles 被误判为 company-member → 列表空)
  130. const roles = objectId
  131. ? await this.fetchRoles(token, objectId).catch(() => [] as string[])
  132. : [];
  133. const companyId = (u['company'] as { objectId?: string } | undefined)?.objectId;
  134. // 所属组织名:实测 Company 表有 name(如 E4KpGvTEto → 飞码AI)。
  135. // 取不到时为 undefined → UI 显示占位,**不编造**。
  136. const companyName = companyId
  137. ? await this.fetchCompanyName(token, companyId).catch(() => undefined)
  138. : undefined;
  139. return {
  140. id: objectId,
  141. name: String(u['nickname'] ?? u['username'] ?? ''),
  142. mobile: u['mobile'] ? String(u['mobile']) : undefined,
  143. avatar: u['avatar'] ? String(u['avatar']) : undefined,
  144. companyId,
  145. companyName,
  146. roles,
  147. };
  148. }
  149. private async fetchRoles(token: string, userId: string): Promise<string[]> {
  150. // R2:GET /parse/roles?where={users:Pointer<_User>}&limit=50
  151. const where = JSON.stringify({
  152. users: { __type: 'Pointer', className: '_User', objectId: userId },
  153. });
  154. const res = await this.rawFetch(
  155. `${PARSE_BASE}/roles?where=${encodeURIComponent(where)}&limit=50`,
  156. { headers: { 'X-Parse-Session-Token': token } },
  157. );
  158. if (!res.ok) return [];
  159. const d = (await res.json()) as { results?: { name?: string }[] };
  160. return (d.results ?? []).map((r) => String(r.name ?? '')).filter(Boolean);
  161. }
  162. /** 取组织名(`Company.name`);失败返回 undefined(不回退假值) */
  163. private async fetchCompanyName(token: string, companyId: string): Promise<string | undefined> {
  164. const res = await this.rawFetch(
  165. `${PARSE_BASE}/classes/Company/${encodeURIComponent(companyId)}?keys=name`,
  166. { headers: { 'X-Parse-Session-Token': token } },
  167. );
  168. if (!res.ok) return undefined;
  169. const d = (await res.json()) as { name?: string };
  170. return d.name ? String(d.name) : undefined;
  171. }
  172. // ---------- 数字生命(实况) ----------
  173. async fetchAgents(): Promise<Agent[]> {
  174. const d = await this.callFunction<{ code: number; agents?: unknown[] }>(FN.agentStatus, {
  175. op: 'query',
  176. // 🔴 按角色判定(acl.scopeFor)—— 超管 all / 公司管理员 company / 普通用户 mine。
  177. // 曾硬编码 'all' → 每个用户都拿到全平台 51 个容器(越权)。
  178. scope: this.readScope(),
  179. });
  180. if (d.code !== 200 || !Array.isArray(d.agents)) {
  181. throw new Error(pickMessage(d) ?? '实况数据查询失败');
  182. }
  183. return d.agents.map((raw) => mapAgent(raw as Record<string, unknown>));
  184. }
  185. async fetchAgentStatus(agentId: string): Promise<Agent | null> {
  186. const d = await this.callFunction<{ code: number; agent?: unknown }>(FN.agentStatus, {
  187. op: 'query',
  188. scope: this.readScope(),
  189. agentId,
  190. });
  191. // 单条查询:code 404/403 → 返回 null(页面自行显示「不存在/无权限」)
  192. if (d.code !== 200 || !d.agent) return null;
  193. return mapAgent(d.agent as Record<string, unknown>);
  194. }
  195. // ---------- Matrix ----------
  196. async provisionMatrix(agentId: string): Promise<MatrixProvision> {
  197. const d = await this.callFunction<MatrixProvision & { code: number }>(FN.provision, {
  198. agentId,
  199. });
  200. // 校验规则(《API-契约》§2.3):必须 code===200 && accessToken && userMxId
  201. if (d.code !== 200 || !d.accessToken || !d.userMxId) {
  202. throw new Error(pickMessage(d) ?? 'FMODE 开通失败');
  203. }
  204. return d;
  205. }
  206. async validateMatrix(agentId: string): Promise<MatrixProvision> {
  207. const d = await this.callFunction<MatrixProvision & { code: number }>(FN.provision, {
  208. agentId,
  209. validate: 1,
  210. });
  211. if (d.code !== 200 || !d.accessToken || !d.userMxId) {
  212. throw new Error(pickMessage(d) ?? 'FMODE 凭据校验失败');
  213. }
  214. return d;
  215. }
  216. // ---------- 交付成果 ----------
  217. async fetchDeliverables(opts?: { agentId?: string; limit?: number }): Promise<Deliverable[]> {
  218. const params: Record<string, unknown> = { action: 'list', limit: opts?.limit ?? 200 };
  219. if (opts?.agentId) params['agentId'] = opts.agentId;
  220. const d = await this.callFunction<{ code: number; deliverables?: unknown[] }>(
  221. FN.deliverables,
  222. params,
  223. );
  224. if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果查询失败');
  225. return (d.deliverables ?? []).map((x) => mapDeliverable(x as Record<string, unknown>));
  226. }
  227. async fetchDeliverablesByProject(limit?: number): Promise<DeliverableGroup[]> {
  228. const d = await this.callFunction<{ code: number; groups?: unknown[] }>(FN.deliverables, {
  229. action: 'byProject',
  230. limit: limit ?? 200,
  231. });
  232. if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果分组查询失败');
  233. return (d.groups ?? []).map((g) => {
  234. const raw = g as Record<string, unknown>;
  235. const items = Array.isArray(raw['items'])
  236. ? raw['items'].map((x) => mapDeliverable(x as Record<string, unknown>))
  237. : [];
  238. return {
  239. project: raw['project'] ? String(raw['project']) : undefined,
  240. projectName: String(raw['projectName'] ?? '未归类'),
  241. count: Number(raw['count']) || items.length,
  242. items,
  243. };
  244. });
  245. }
  246. // ---------- 任务看板(FR-07b:云函数接收端未实现) ----------
  247. async fetchTasks(_agentId: string): Promise<Task[]> {
  248. // 🔴 不得静默返回空数组(那会把「未接入」伪装成「没有数据」)
  249. throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)');
  250. }
  251. async fetchTaskDetail(_taskId: string): Promise<Task | null> {
  252. throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)');
  253. }
  254. // ---------- 上传(OBS / 华为云,统一走 S3 预签名) ----------
  255. async requestUploadUrl(
  256. filename: string,
  257. mimeType: string,
  258. size: number,
  259. ): Promise<UploadTicket> {
  260. const d = await this.callFunction<{
  261. code: number;
  262. uploadUrl?: string;
  263. key?: string;
  264. publicUrl?: string;
  265. headers?: Record<string, string>;
  266. }>(FN.uploadUrl, { filename, mimeType, size });
  267. // 《API-契约》§2.5:缺 uploadUrl 即抛错,不得静默降级为本地 blob
  268. if (d.code !== 200 || !d.uploadUrl || !d.key) {
  269. throw new Error(pickMessage(d) ?? '上传签名获取失败');
  270. }
  271. return {
  272. uploadUrl: d.uploadUrl,
  273. key: d.key,
  274. publicUrl: d.publicUrl ?? `https://s3.fmode.cn/${d.key}`,
  275. headers: d.headers,
  276. };
  277. }
  278. async uploadFile(
  279. file: File,
  280. onProgress?: (pct: number) => void,
  281. ): Promise<{ key: string; publicUrl: string }> {
  282. const ticket = await this.requestUploadUrl(
  283. file.name,
  284. file.type || 'application/octet-stream',
  285. file.size,
  286. );
  287. // S3 直传(华为云 OBS 兼容 S3 协议);前端不经手任何 AK/SK
  288. const headers: Record<string, string> = {
  289. 'Content-Type': file.type || 'application/octet-stream',
  290. 'x-obs-acl': 'public-read',
  291. ...ticket.headers,
  292. };
  293. await this.putWithProgress(ticket.uploadUrl, headers, file, onProgress);
  294. return { key: ticket.key, publicUrl: ticket.publicUrl };
  295. }
  296. /** PUT 直传;浏览器 XMLHttpRequest 才能拿到上传进度 */
  297. private putWithProgress(
  298. url: string,
  299. headers: Record<string, string>,
  300. file: File,
  301. onProgress?: (pct: number) => void,
  302. ): Promise<void> {
  303. return new Promise((resolve, reject) => {
  304. const xhr = new XMLHttpRequest();
  305. xhr.open('PUT', url, true);
  306. for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
  307. xhr.upload.onprogress = (e) => {
  308. if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
  309. };
  310. xhr.onload = () => {
  311. if (xhr.status >= 200 && xhr.status < 300) resolve();
  312. else reject(new Error(`上传失败(HTTP ${xhr.status})`));
  313. };
  314. xhr.onerror = () => reject(new Error('上传失败:网络错误'));
  315. xhr.send(file);
  316. });
  317. }
  318. // ---------- 内部 ----------
  319. /** 云函数统一调用(《API-契约》§1.1) */
  320. private async callFunction<T>(id: string, params: Record<string, unknown>): Promise<T> {
  321. const res = await this.rawFetch(FUNCTION_API, {
  322. method: 'POST',
  323. headers: this.authHeaders(),
  324. body: JSON.stringify({ id, token: this.sessionToken, params }),
  325. });
  326. if (!res.ok) throw new Error(`云函数调用失败(HTTP ${res.status})`);
  327. return (await res.json()) as T;
  328. }
  329. private authHeaders(): Record<string, string> {
  330. const h: Record<string, string> = {
  331. 'Content-Type': 'application/json',
  332. 'X-Parse-Application-Id': PARSE_APP_ID,
  333. };
  334. if (this.sessionToken) h['X-Parse-Session-Token'] = this.sessionToken;
  335. return h;
  336. }
  337. private rawFetch(url: string, init: RequestInit): Promise<Response> {
  338. const headers: Record<string, string> = {
  339. 'X-Parse-Application-Id': PARSE_APP_ID,
  340. ...((init.headers as Record<string, string>) ?? {}),
  341. };
  342. /*
  343. * 🔴 有 body 的方法必须带 `Content-Type: application/json`。
  344. *
  345. * 实测教训:`POST /api/apig/message` 若**不带该头**,服务端**读不到 body**,
  346. * 于是报「缺少参数company」—— 而 body 里其实带了 company。
  347. * 这个误导性报错曾让我误判为「限流」,排查花了很久。
  348. *
  349. * 故此处对带 body 的请求**自动补默认 Content-Type**(调用方显式指定则尊重其值)。
  350. */
  351. if (init.body != null && !headers['Content-Type']) {
  352. headers['Content-Type'] = 'application/json';
  353. }
  354. return fetch(url, { ...init, headers });
  355. }
  356. }
  357. /** 登录公司常量(《API-契约》§1.2) */
  358. const LOGIN_COMPANY = 'E4KpGvTEto';
  359. /** 错误文案取值顺序:msg → mess → message */
  360. function pickMessage(d: unknown): string | undefined {
  361. if (!d || typeof d !== 'object') return undefined;
  362. const o = d as Record<string, unknown>;
  363. for (const k of ['msg', 'mess', 'message']) {
  364. const v = o[k];
  365. if (typeof v === 'string' && v.trim()) return v;
  366. }
  367. return undefined;
  368. }
  369. /**
  370. * 发码错误 → 友好文案。
  371. *
  372. * ## 🔴 为什么要做这一层(实测教训)
  373. *
  374. * 服务端在**限流**状态下会返回**误导性文案**,实测对照:
  375. *
  376. * | 请求 | 服务端返回 |
  377. * |---|---|
  378. * | 带 company(首次) | `200 {"code":1,"data":{"msg":"发送成功"}}` |
  379. * | 带 company(频繁) | `500 {"mess":"该号码发送验证码过于频繁"}` |
  380. * | **不带** company | `500 {"mess":"缺少参数company"}` |
  381. *
  382. * 即:我们的请求**带了** company 且首次能成功,但**短时间重复请求**时
  383. * 服务端可能错报成「缺少参数company」,误导用户以为参数配错了。
  384. *
  385. * 故此处把「缺参数」在**限流上下文**(HTTP 500 而非 400)下转成更可能的真相提示,
  386. * 并在文案中保留原始信息以便排查(不吞掉事实)。
  387. */
  388. function friendlySendCodeError(raw: string | undefined, status: number): string {
  389. const m = raw ?? '';
  390. if (/频繁|过于频繁|too many|rate/i.test(m)) {
  391. return '发送过于频繁,请稍后再试(同一号码通常 60 秒内只能发一次)';
  392. }
  393. // 实测:限流态下服务端会误报「缺少参数company」;保留原始信息供排查
  394. if (/缺少参数\s*company/i.test(m)) {
  395. return '验证码发送失败:服务端返回「缺少参数company」。若已多次点击,通常是发送过于频繁,请稍后再试。(原始:' +
  396. m +
  397. ')';
  398. }
  399. if (m) return m;
  400. return `验证码发送失败(HTTP ${status})`;
  401. }
  402. /**
  403. * `channels` 归一:兼容**数组形态**的非标准容器上报。
  404. *
  405. * 🔴 实测(`agent-node-lj-test`):容器发的是 `["wecom"]`,而平台契约是**对象**
  406. * `{"matrix":true,"matrixConfig":{...},"wecom":true,...}`。
  407. * 不归一 → `channels.matrix` 为 `undefined` → `matrixReady=false` → UI 显示「未接入」,
  408. * 但该容器 Matrix 通道其实**是通的**(实测双向消息可达)。
  409. *
  410. * 数组语义:元素即**已点亮的通道名**,故 `["matrix","wecom"]` → 两者为 true。
  411. * 空数组视为「未上报通道」而非「零通道」(返回 undefined,避免 UI 显示"未接入"误导)。
  412. */
  413. function normalizeChannels(v: unknown): ChannelConfig | undefined {
  414. const parsed = parseJsonLoose<unknown>(v);
  415. if (parsed == null) return undefined;
  416. if (Array.isArray(parsed)) {
  417. const names = parsed.filter((x): x is string => typeof x === 'string');
  418. if (names.length === 0) return undefined;
  419. return {
  420. matrix: names.includes('matrix'),
  421. wecom: names.includes('wecom'),
  422. };
  423. }
  424. // 对象形态:契约标准,原样透传(含 matrixConfig 细节)
  425. return parsed as ChannelConfig;
  426. }
  427. /**
  428. * `host` 归一:兼容 `cpuLoad1`(裸负载)→ `cpuPercent`(百分比)。
  429. *
  430. * 🔴 实测:容器发 `{"cpuLoad1":5.65,"cpuLoad5":5.66,"memPercent":51,...}`,
  431. * 缺 `cpuPercent` 键 → CPU 不显示(memPercent/diskPercent 因键名对上才正常)。
  432. *
  433. * 换算口径 `cpuLoad1 / 核数 * 100`(实测 56 核实测 9.2 → 16.4 与容器自证一致)。
  434. * ⚠️ 核数未知时**不猜**:宁可返回 undefined 让 UI 显示「未上报」,也不编一个假数字。
  435. * 核数来源优先级:`cpuCores`(容器自报)→ 无。
  436. */
  437. function normalizeHost(v: unknown): Agent['host'] {
  438. const h = parseJsonLoose<Record<string, unknown>>(v);
  439. if (!h) return undefined;
  440. const num = (x: unknown): number | undefined =>
  441. typeof x === 'number' && Number.isFinite(x) ? x : undefined;
  442. // 契约标准的 cpuPercent 优先,不被派生值覆盖
  443. let cpuPercent = num(h['cpuPercent']);
  444. if (cpuPercent == null) {
  445. const load = num(h['cpuLoad1']);
  446. const cores = num(h['cpuCores']);
  447. if (load != null && cores != null && cores > 0) {
  448. cpuPercent = Math.min(100, Math.max(0, (load / cores) * 100));
  449. }
  450. }
  451. const memPercent = num(h['memPercent']);
  452. const diskPercent = num(h['diskPercent']);
  453. // 三项指标全无 → 视为未上报(避免 UI 显示 0% 误导为"机器空闲")
  454. if (cpuPercent == null && memPercent == null && diskPercent == null) return undefined;
  455. return { cpuPercent, memPercent, diskPercent };
  456. }
  457. /**
  458. * `heartbeatAt` 归一(三级回落)。
  459. *
  460. * 🔴 实测:容器不发 `heartbeatAt`,心跳时间藏在 `host.probedAt`
  461. * (采集时刻);两者皆无时回落 `updatedAtClient.iso`(云函数写入时刻)。
  462. * 不回落的后果:UI 判「心跳超时」→ 在线容器被显示成离线。
  463. */
  464. function normalizeHeartbeat(raw: Record<string, unknown>, host: unknown): string | undefined {
  465. if (raw['heartbeatAt']) return String(raw['heartbeatAt']);
  466. const h = parseJsonLoose<{ probedAt?: string }>(host);
  467. if (h?.probedAt) return String(h.probedAt);
  468. const uac = raw['updatedAtClient'] as { iso?: string } | undefined;
  469. if (uac?.iso) return String(uac.iso);
  470. return undefined;
  471. }
  472. /** 云函数原始记录 → `Agent` 领域模型(映射只在此处发生) */
  473. function mapAgent(raw: Record<string, unknown>): Agent {
  474. const channels = normalizeChannels(raw['channels']);
  475. const balance = parseJsonLoose<{ remainCny?: number; remainRmb?: number; cny?: number; pageUrl?: string }>(
  476. raw['balance'],
  477. );
  478. const host = normalizeHost(raw['host']);
  479. const heartbeatAt = normalizeHeartbeat(raw, raw['host']);
  480. const user = raw['user'] as Record<string, unknown> | undefined;
  481. return {
  482. id: String(raw['agentId'] ?? raw['objectId'] ?? ''),
  483. name: String(raw['botName'] ?? raw['name'] ?? ''),
  484. avatar: raw['botAvatar'] ? String(raw['botAvatar']) : undefined,
  485. gender: (raw['gender'] as Agent['gender']) ?? 'unknown',
  486. status: (raw['status'] as AgentStatus) ?? 'offline',
  487. currentTask: raw['currentTask'] ? String(raw['currentTask']) : undefined,
  488. currentAction: raw['currentAction'] ? String(raw['currentAction']) : undefined,
  489. progress: typeof raw['progress'] === 'number' ? (raw['progress'] as number) : undefined,
  490. heartbeatAt,
  491. updatedAt: raw['updatedAt'] ? String(raw['updatedAt']) : undefined,
  492. host,
  493. balance: balance
  494. ? {
  495. // 映射层归一:兼容旧键 remainRmb,以及容器复刻脚本发的 cny
  496. remainCny: balance.remainCny ?? balance.remainRmb ?? balance.cny,
  497. pageUrl: balance.pageUrl,
  498. }
  499. : undefined,
  500. channels: channels
  501. ? {
  502. matrix: channels.matrix,
  503. homeserver: channels.homeserver,
  504. matrixConfig: channels.matrixConfig,
  505. wecom: channels.wecom,
  506. wecomBotId: channels.wecomBotId,
  507. }
  508. : undefined,
  509. owner: user
  510. ? {
  511. id: String(user['objectId'] ?? ''),
  512. name: String(user['nickname'] ?? user['username'] ?? ''),
  513. avatar: user['avatar'] ? String(user['avatar']) : undefined,
  514. mobile: user['mobile'] ? String(user['mobile']) : undefined,
  515. }
  516. : undefined,
  517. companyId: (raw['company'] as { objectId?: string } | undefined)?.objectId,
  518. // 「是否已接入 FMODE」= channels.matrix 为真(或已有 matrixConfig.userId)
  519. matrixReady: !!channels?.matrix || !!channels?.matrixConfig?.userId,
  520. // 未读数**不来自服务端**:云函数不返回该字段,真实计数由
  521. // `UnreadService` 依据 Matrix 事件流在客户端维护(见该服务注释)。
  522. // 此处恒为 0,UI 不得消费(消费点一律走 UnreadService.countOf)。
  523. unread: 0,
  524. };
  525. }
  526. /** 成果原始记录 → `Deliverable` 领域模型 */
  527. function mapDeliverable(raw: Record<string, unknown>): Deliverable {
  528. const status = String(raw['status'] ?? '');
  529. const artifacts = Array.isArray(raw['artifacts'])
  530. ? (raw['artifacts'] as Record<string, unknown>[]).map((a) => ({
  531. name: String(a['name'] ?? ''),
  532. url: String(a['url'] ?? ''),
  533. type: String(a['type'] ?? ''),
  534. }))
  535. : [];
  536. return {
  537. id: String(raw['objectId'] ?? raw['id'] ?? ''),
  538. agentId: String(raw['agentId'] ?? ''),
  539. title: String(raw['title'] ?? ''),
  540. summary: raw['summary'] ? String(raw['summary']) : undefined,
  541. projectId: raw['project'] ? String(raw['project']) : undefined,
  542. projectName: raw['projectName'] ? String(raw['projectName']) : undefined,
  543. artifacts,
  544. tags: Array.isArray(raw['tags']) ? (raw['tags'] as string[]).map(String) : [],
  545. deliveredAt: String(raw['deliveredAt'] ?? ''),
  546. // 契约 §2.2:status 取值为推断 —— 含 fail/error 子串即失败
  547. state: /fail|error/i.test(status) ? 'failed' : 'delivered',
  548. };
  549. }