import { tsOf } from '../time'; import { Scope } from '../acl'; import { FmodeApi } from './fmode-api'; import { Agent, AgentStatus, ChatThreadRecord, ChatThreadUpsert, Artifact, ArtifactJob, AuthSession, ChannelConfig, Deliverable, DeliverableGroup, MatrixProvision, Me, Task, UploadTicket, WorkGroup, UsageSession, } from './models'; /** * 真实数据源实现 —— 走 fmode 平台云函数 + REST(《API-契约.md》) * * 依据《数据访问层设计.md》§一/§二:本类是 `FmodeApi` 的**真实实现**, * 由 `api.provider.ts` 在 `environment.useMockData === false` 时装配。 * * ## 约定 * * - 云函数统一入口 `POST https://server.fmode.cn/api/functions` * (头 `X-Parse-Application-Id` / `X-Parse-Session-Token`;体 `{id, token, params}`) * - 错误处理:HTTP 200 但 `body.code` 为 401/403 → 视为业务错误并抛出, * 文案取 `msg` → `mess` → `message` * - 云函数返回的 `host` / `channels` / `balance` 是**字符串形态 JSON**, * 映射层兼容「已是对象」与「是字符串」两种形态(存量 `parseJsonLoose` 的教训) * * ## 前端安全边界(黄金规则 6 / NFR-03) * * 前端**不含任何服务端密钥**:无 masterKey、无 OBS AK/SK、无 Matrix 派生密码。 * Matrix 凭据一律由 provision 云函数服务端签发。 * * ## 一期未实现项 * * `fetchTasks` / `fetchTaskDetail` 的云函数接收端**尚未实现**(SPEC FR-07b 依赖声明), * 故**显式抛错**而非静默返回空数组 —— 空数组会把「未接入」伪装成「没有数据」。 */ const FUNCTION_API = 'https://server.fmode.cn/api/functions'; const PARSE_BASE = 'https://server.fmode.cn/parse'; const PARSE_APP_ID = 'ncloudmaster'; /** 云函数 objectId(《API-契约》§二) */ const FN = { agentStatus: 'xlosi3m46q', deliverables: 'lfYlgU7SkK', provision: 'RP5Ht55NeX', tokenSync: 'zl4yU8PAEB', uploadUrl: 'AlP56LCKFm', workgroup: 'V1EBgm05y2', /** 会话目录(2026-10-31 新建,Function.name = fmodeagent-chatthreads) */ chatThreads: 'oR5O67i1Cm', } as const; /** 兼容「已是对象」与「是 JSON 字符串」两种形态 */ function parseJsonLoose>(v: unknown): T | undefined { if (v == null) return undefined; if (typeof v === 'object') return v as T; if (typeof v !== 'string' || !v.trim()) return undefined; try { return JSON.parse(v) as T; } catch { return undefined; } } export class ParseApi extends FmodeApi { /** * 会话令牌读取器 + 请求范围判定器。 * * - **令牌**由 `SessionService` 唯一持有,**每次调用实时读取**(避免登录/退登后持过期副本) * - **scope** 由 `acl.scopeFor(me)` 判定(超管 all / 公司管理员 company / 普通用户 mine); * 🔴 **不得硬编码 `'all'`** —— 那会让每个用户都看到全平台别人的容器(越权) */ constructor( private readonly readToken: () => string = () => '', private readonly readScope: () => Scope = () => 'mine', ) { super(); } /** 当前会话令牌(读取器为空时回落到内部缓存,供单测注入) */ private get sessionToken(): string { return this.readToken() || this.fallbackToken; } /** 仅单测/无 DI 场景使用的回落令牌 */ private fallbackToken = ''; setToken(token: string): void { this.fallbackToken = token; } // ---------- 鉴权 ---------- async sendMobileCode(mobile: string): Promise { // R4:POST /api/apig/message {mobile, company};🔴 code === 1 才算成功 const res = await this.rawFetch(`${PARSE_BASE.replace('/parse', '')}/api/apig/message`, { method: 'POST', body: JSON.stringify({ mobile, company: LOGIN_COMPANY }), }); const d = (await res.json()) as { code?: number; msg?: string; mess?: string }; if (d.code !== 1) throw new Error(friendlySendCodeError(pickMessage(d), res.status)); } async loginWithMobile(mobile: string, code: string): Promise { // R5:GET /api/fmode/mobile?mobile&code&company;🔴 code === 200 && data.token const url = `${PARSE_BASE.replace('/parse', '')}/api/fmode/mobile?mobile=${encodeURIComponent( mobile, )}&code=${encodeURIComponent(code)}&company=${LOGIN_COMPANY}`; const res = await this.rawFetch(url, { method: 'GET' }); const d = (await res.json()) as { code?: number; data?: { token?: string }; msg?: string; mess?: string; }; const token = d.data?.token; if (d.code !== 200 || !token) throw new Error(pickMessage(d) ?? '登录失败'); this.setToken(token); const me = await this.fetchMe(token); return { token, me }; } async loginWithToken(token: string): Promise { const me = await this.fetchMe(token); this.setToken(token); return { token, me }; } async fetchMe(token: string): Promise { // R1:**GET** /parse/users/me(头 X-Parse-Session-Token) // 🔴 实测教训:POST 会 404(返回 HTML 错误页);契约文档曾误写为 POST。 const res = await this.rawFetch(`${PARSE_BASE}/users/me`, { method: 'GET', headers: { 'X-Parse-Session-Token': token }, }); if (!res.ok) throw new Error(`Token 校验失败(HTTP ${res.status})`); const u = (await res.json()) as Record; const objectId = String(u['objectId'] ?? ''); // R2:_User JSON **不透出 relation**,roles 必须单独查(存量 v0.3.3 著名坑: // 超管因缺 roles 被误判为 company-member → 列表空) const roles = objectId ? await this.fetchRoles(token, objectId).catch(() => [] as string[]) : []; const companyId = (u['company'] as { objectId?: string } | undefined)?.objectId; // 所属组织名:实测 Company 表有 name(如 E4KpGvTEto → 飞码AI)。 // 取不到时为 undefined → UI 显示占位,**不编造**。 const companyName = companyId ? await this.fetchCompanyName(token, companyId).catch(() => undefined) : undefined; return { id: objectId, name: String(u['nickname'] ?? u['username'] ?? ''), mobile: u['mobile'] ? String(u['mobile']) : undefined, avatar: u['avatar'] ? String(u['avatar']) : undefined, companyId, companyName, roles, }; } private async fetchRoles(token: string, userId: string): Promise { // R2:GET /parse/roles?where={users:Pointer<_User>}&limit=50 const where = JSON.stringify({ users: { __type: 'Pointer', className: '_User', objectId: userId }, }); const res = await this.rawFetch( `${PARSE_BASE}/roles?where=${encodeURIComponent(where)}&limit=50`, { headers: { 'X-Parse-Session-Token': token } }, ); if (!res.ok) return []; const d = (await res.json()) as { results?: { name?: string }[] }; return (d.results ?? []).map((r) => String(r.name ?? '')).filter(Boolean); } /** 取组织名(`Company.name`);失败返回 undefined(不回退假值) */ private async fetchCompanyName(token: string, companyId: string): Promise { const res = await this.rawFetch( `${PARSE_BASE}/classes/Company/${encodeURIComponent(companyId)}?keys=name`, { headers: { 'X-Parse-Session-Token': token } }, ); if (!res.ok) return undefined; const d = (await res.json()) as { name?: string }; return d.name ? String(d.name) : undefined; } // ---------- 数字生命(实况) ---------- async fetchAgents(): Promise { const d = await this.callFunction<{ code: number; agents?: unknown[] }>(FN.agentStatus, { op: 'query', // 🔴 按角色判定(acl.scopeFor)—— 超管 all / 公司管理员 company / 普通用户 mine。 // 曾硬编码 'all' → 每个用户都拿到全平台 51 个容器(越权)。 scope: this.readScope(), }); if (d.code !== 200 || !Array.isArray(d.agents)) { throw new Error(pickMessage(d) ?? '实况数据查询失败'); } return d.agents.map((raw) => mapAgent(raw as Record)); } async fetchAgentStatus(agentId: string): Promise { const d = await this.callFunction<{ code: number; agent?: unknown }>(FN.agentStatus, { op: 'query', scope: this.readScope(), agentId, }); // 单条查询:code 404/403 → 返回 null(页面自行显示「不存在/无权限」) if (d.code !== 200 || !d.agent) return null; return mapAgent(d.agent as Record); } // ---------- Matrix ---------- async provisionMatrix(agentId: string): Promise { const d = await this.callFunction(FN.provision, { agentId, }); // 校验规则(《API-契约》§2.3):必须 code===200 && accessToken && userMxId if (d.code !== 200 || !d.accessToken || !d.userMxId) { throw new Error(pickMessage(d) ?? 'FMODE 开通失败'); } return d; } async validateMatrix(agentId: string): Promise { const d = await this.callFunction(FN.provision, { agentId, validate: 1, }); if (d.code !== 200 || !d.accessToken || !d.userMxId) { throw new Error(pickMessage(d) ?? 'FMODE 凭据校验失败'); } return d; } // ---------- 会话目录(云函数 oR5O67i1Cm / fmodeagent-chatthreads) ---------- /** * 拉取当前用户的会话目录。 * * 🔴 云函数按 sessionToken 强制归属当前用户;这里**不传任何用户参数**。 * 返回结构在 function 内已归一(毫秒时间戳、驼峰字段)。 */ async fetchChatThreads(agentId: string, opts?: { includeDeleted?: boolean }): Promise { const d = await this.callFunction<{ code: number; threads?: ChatThreadRecord[]; mess?: string }>( FN.chatThreads, { op: 'list', agentId, ...(opts?.includeDeleted ? { includeDeleted: 1 } : {}) }, ); if (d.code !== 200) throw new Error(d.mess ?? '会话目录读取失败'); return Array.isArray(d.threads) ? d.threads : []; } async upsertChatThreads(items: ChatThreadUpsert[]): Promise { if (items.length === 0) return; const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, { op: 'upsert', items, }); if (d.code !== 200) throw new Error(d.mess ?? '会话目录同步失败'); } async patchChatThread(input: { threadRoot: string; title?: string; archived?: boolean; }): Promise { const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, { op: 'patch', ...input, }); // 404 = 云端还没有这条(本地先建、还没同步过)→ 视为成功,下次 upsert 会补上 if (d.code !== 200 && d.code !== 404) throw new Error(d.mess ?? '会话更新失败'); } async removeChatThread(input: { threadRoot: string; roomId?: string; agentId?: string }): Promise { const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, { op: 'remove', ...input, }); if (d.code !== 200) throw new Error(d.mess ?? '会话删除失败'); } // ---------- 交付成果 ---------- async fetchDeliverables(opts?: { agentId?: string; limit?: number }): Promise { const params: Record = { action: 'list', limit: opts?.limit ?? 200 }; if (opts?.agentId) params['agentId'] = opts.agentId; const d = await this.callFunction<{ code: number; deliverables?: unknown[] }>( FN.deliverables, params, ); if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果查询失败'); return (d.deliverables ?? []) .map((x) => mapDeliverable(x as Record)) /* * 🔴 过滤「非成果」记录(用户实测:右栏出现两条没有意义的文档): * * · `title` 为空/纯符号 → 没有任何信息,点了也看不懂是什么 * · `title` 恰为 `probe` 且无附件 → 上报器的**探针测试**,不是交付物 * * 过滤放在映射层(全项目唯一入口),页面不必各自判断。 */ .filter((x) => { const t = x.title.trim(); if (!t || !/[\p{L}\p{N}]/u.test(t)) return false; if (/^probe$/i.test(t) && x.artifacts.length === 0) return false; return true; }); } async fetchDeliverablesByProject(limit?: number): Promise { const d = await this.callFunction<{ code: number; groups?: unknown[] }>(FN.deliverables, { action: 'byProject', limit: limit ?? 200, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果分组查询失败'); return (d.groups ?? []).map((g) => { const raw = g as Record; const items = Array.isArray(raw['items']) ? raw['items'] .map((x) => mapDeliverable(x as Record)) // 与 fetchDeliverables 同一过滤口径(见上) .filter((x) => { const t = x.title.trim(); if (!t || !/[\p{L}\p{N}]/u.test(t)) return false; if (/^probe$/i.test(t) && x.artifacts.length === 0) return false; return true; }) : []; return { project: raw['project'] ? String(raw['project']) : undefined, projectName: String(raw['projectName'] ?? '未归类'), count: Number(raw['count']) || items.length, items, }; }); } // ---------- 任务看板(FR-07b:云函数接收端未实现) ---------- async fetchTasks(_agentId: string): Promise { // 🔴 不得静默返回空数组(那会把「未接入」伪装成「没有数据」) throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)'); } async fetchTaskDetail(_taskId: string): Promise { throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)'); } // ---------- 上传(OBS / 华为云,统一走 S3 预签名) ---------- async requestUploadUrl( filename: string, mimeType: string, size: number, ): Promise { const d = await this.callFunction<{ code: number; uploadUrl?: string; key?: string; publicUrl?: string; headers?: Record; }>(FN.uploadUrl, { filename, mimeType, size }); // 《API-契约》§2.5:缺 uploadUrl 即抛错,不得静默降级为本地 blob if (d.code !== 200 || !d.uploadUrl || !d.key) { throw new Error(pickMessage(d) ?? '上传签名获取失败'); } return { uploadUrl: d.uploadUrl, key: d.key, publicUrl: d.publicUrl ?? `https://s3.fmode.cn/${d.key}`, headers: d.headers, }; } async uploadFile( file: File, onProgress?: (pct: number) => void, ): Promise<{ key: string; publicUrl: string }> { const ticket = await this.requestUploadUrl( file.name, file.type || 'application/octet-stream', file.size, ); // S3 直传(华为云 OBS 兼容 S3 协议);前端不经手任何 AK/SK const headers: Record = { 'Content-Type': file.type || 'application/octet-stream', 'x-obs-acl': 'public-read', ...ticket.headers, }; await this.putWithProgress(ticket.uploadUrl, headers, file, onProgress); return { key: ticket.key, publicUrl: ticket.publicUrl }; } async requestArtifactSync(input: { deliverableId: string; source: string; filename: string; }): Promise<{ queued: boolean; message?: string }> { const d = await this.callFunction<{ code: number; queued?: boolean; message?: string; msg?: string }>( FN.deliverables, { action: 'requestSync', objectId: input.deliverableId, source: input.source, filename: input.filename, }, ); if (d.code !== 200) { // 契约未实现时清晰报错,不假装成功(前端不得伪造"已上传云盘") throw new Error(pickMessage(d) ?? '云盘同步登记失败(云函数未实现 requestSync)'); } return { queued: d.queued === true, message: d.message }; } // ---------- 成果文件搬运队列 ---------- async enqueueArtifactJob(job: { deliverableId: string; agentId: string; source: string; filename: string; index: number; }): Promise<{ id: string }> { // 只报「传哪个」;地址与头由云函数签好写进队列(客户端不接触密钥) const d = await this.callFunction<{ code: number; id?: string }>(FN.deliverables, { action: 'enqueueArtifact', ...job, }); if (d.code !== 200 || !d.id) { throw new Error(pickMessage(d) ?? '搬运任务创建失败'); } return { id: d.id }; } async fetchArtifactJobs(agentId: string): Promise { const d = await this.callFunction<{ code: number; jobs?: unknown[] }>(FN.deliverables, { action: 'pendingArtifacts', agentId, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '搬运任务查询失败'); return (d.jobs ?? []).map((x) => x as ArtifactJob); } // ---------- 工作组 ---------- async createWorkGroup(input: { name: string; goal?: string; memberAgentIds: string[]; }): Promise<{ id: string; roomId: string }> { const d = await this.callFunction<{ code: number; id?: string; roomId?: string }>(FN.workgroup, { action: 'create', name: input.name, goal: input.goal ?? '', memberAgentIds: input.memberAgentIds, }); if (d.code !== 200 || !d.id || !d.roomId) { throw new Error(pickMessage(d) ?? '创建工作组失败'); } return { id: d.id, roomId: d.roomId }; } async listWorkGroups(): Promise { const d = await this.callFunction<{ code: number; groups?: unknown[] }>(FN.workgroup, { action: 'list', }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '工作组查询失败'); return (d.groups ?? []) as WorkGroup[]; } async inviteToWorkGroup(groupId: string, agentIds: string[]): Promise { const d = await this.callFunction<{ code: number }>(FN.workgroup, { action: 'invite', groupId, agentIds, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '邀请失败'); } async removeFromWorkGroup(groupId: string, agentId: string): Promise { const d = await this.callFunction<{ code: number }>(FN.workgroup, { action: 'remove', groupId, agentId, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '移出失败'); } async archiveWorkGroup(groupId: string): Promise { const d = await this.callFunction<{ code: number }>(FN.workgroup, { action: 'archive', groupId, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '归档失败'); } async ackArtifactJob(input: { id: string; ok: boolean; publicUrl?: string; error?: string; }): Promise { const d = await this.callFunction<{ code: number }>(FN.deliverables, { action: 'ackArtifact', objectId: input.id, ok: input.ok, publicUrl: input.publicUrl, error: input.error, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '搬运任务回执失败'); } /** PUT 直传;浏览器 XMLHttpRequest 才能拿到上传进度 */ private putWithProgress( url: string, headers: Record, file: File, onProgress?: (pct: number) => void, ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url, true); for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v); xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) resolve(); else reject(new Error(`上传失败(HTTP ${xhr.status})`)); }; xhr.onerror = () => reject(new Error('上传失败:网络错误')); xhr.send(file); }); } // ---------- 内部 ---------- /** * 云函数统一调用(《API-契约》§1.1)。 * * ## 🔴 平台抖动时的表现(2026-10-29 实测) * * 平台服务端会出现间歇性故障。实测连续 5 次请求: * `超时 / 超时 / 503 / 200 / 200`。 * * 浏览器此时报的是 **CORS 错误**(`No 'Access-Control-Allow-Origin'`), * 那只是表象 —— 真正原因是服务端 503 或直接断开, * **连响应头都没返回**,浏览器于是判定为跨域失败。 * * 所以: * · 网络层失败(fetch 直接抛)→ 明确告知「连不上平台」,不是用户的操作问题 * · 503 / 502 / 504 → 告知「平台暂时不可用」并带上可重试的意思 * · 其它非 2xx → 保留原状态码 */ private async callFunction(id: string, params: Record): Promise { /* * 平台抖动是**已知会发生**的(实测 5 次里 2 次超时、1 次 503)。 * 瞬时可恢复的错误自动重试一次 —— 用户不该因为一次抖动就看到报错。 * 只重试「网络失败 / 502 / 503 / 504」,业务错误(4xx)不重试。 */ const attempt = async (): Promise => this.rawFetch(FUNCTION_API, { method: 'POST', headers: this.authHeaders(), body: JSON.stringify({ id, token: this.sessionToken, params }), }); let res: Response | null = null; let lastErr: unknown = null; for (let i = 0; i < 2; i++) { try { res = await attempt(); // 可恢复的服务端错误 → 短暂等待后重试一次 if ((res.status === 502 || res.status === 503 || res.status === 504) && i === 0) { await new Promise((r) => setTimeout(r, 800)); continue; } break; } catch (e) { lastErr = e; if (i === 0) { await new Promise((r) => setTimeout(r, 800)); continue; } throw new Error('连不上平台服务(网络或服务端不可用),请稍后重试'); } } if (!res) { void lastErr; throw new Error('连不上平台服务(网络或服务端不可用),请稍后重试'); } if (!res.ok) { if (res.status === 503 || res.status === 502 || res.status === 504) { throw new Error(`平台暂时不可用(HTTP ${res.status}),请稍后重试`); } throw new Error(`云函数调用失败(HTTP ${res.status})`); } return (await res.json()) as T; } private authHeaders(): Record { const h: Record = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': PARSE_APP_ID, }; if (this.sessionToken) h['X-Parse-Session-Token'] = this.sessionToken; return h; } private rawFetch(url: string, init: RequestInit): Promise { const headers: Record = { 'X-Parse-Application-Id': PARSE_APP_ID, ...((init.headers as Record) ?? {}), }; /* * 🔴 有 body 的方法必须带 `Content-Type: application/json`。 * * 实测教训:`POST /api/apig/message` 若**不带该头**,服务端**读不到 body**, * 于是报「缺少参数company」—— 而 body 里其实带了 company。 * 这个误导性报错曾让我误判为「限流」,排查花了很久。 * * 故此处对带 body 的请求**自动补默认 Content-Type**(调用方显式指定则尊重其值)。 */ if (init.body != null && !headers['Content-Type']) { headers['Content-Type'] = 'application/json'; } return fetch(url, { ...init, headers }); } } /** 登录公司常量(《API-契约》§1.2) */ const LOGIN_COMPANY = 'E4KpGvTEto'; /** 错误文案取值顺序:msg → mess → message */ function pickMessage(d: unknown): string | undefined { if (!d || typeof d !== 'object') return undefined; const o = d as Record; for (const k of ['msg', 'mess', 'message']) { const v = o[k]; if (typeof v === 'string' && v.trim()) return v; } return undefined; } /** * 发码错误 → 友好文案。 * * ## 🔴 为什么要做这一层(实测教训) * * 服务端在**限流**状态下会返回**误导性文案**,实测对照: * * | 请求 | 服务端返回 | * |---|---| * | 带 company(首次) | `200 {"code":1,"data":{"msg":"发送成功"}}` | * | 带 company(频繁) | `500 {"mess":"该号码发送验证码过于频繁"}` | * | **不带** company | `500 {"mess":"缺少参数company"}` | * * 即:我们的请求**带了** company 且首次能成功,但**短时间重复请求**时 * 服务端可能错报成「缺少参数company」,误导用户以为参数配错了。 * * 故此处把「缺参数」在**限流上下文**(HTTP 500 而非 400)下转成更可能的真相提示, * 并在文案中保留原始信息以便排查(不吞掉事实)。 */ function friendlySendCodeError(raw: string | undefined, status: number): string { const m = raw ?? ''; if (/频繁|过于频繁|too many|rate/i.test(m)) { return '发送过于频繁,请稍后再试(同一号码通常 60 秒内只能发一次)'; } // 实测:限流态下服务端会误报「缺少参数company」;保留原始信息供排查 if (/缺少参数\s*company/i.test(m)) { return '验证码发送失败:服务端返回「缺少参数company」。若已多次点击,通常是发送过于频繁,请稍后再试。(原始:' + m + ')'; } if (m) return m; return `验证码发送失败(HTTP ${status})`; } /** * `channels` 归一:兼容**数组形态**的非标准容器上报。 * * 🔴 实测(`agent-node-lj-test`):容器发的是 `["wecom"]`,而平台契约是**对象** * `{"matrix":true,"matrixConfig":{...},"wecom":true,...}`。 * 不归一 → `channels.matrix` 为 `undefined` → `matrixReady=false` → UI 显示「未接入」, * 但该容器 Matrix 通道其实**是通的**(实测双向消息可达)。 * * 数组语义:元素即**已点亮的通道名**,故 `["matrix","wecom"]` → 两者为 true。 * 空数组视为「未上报通道」而非「零通道」(返回 undefined,避免 UI 显示"未接入"误导)。 */ function normalizeChannels(v: unknown): ChannelConfig | undefined { const parsed = parseJsonLoose(v); if (parsed == null) return undefined; if (Array.isArray(parsed)) { const names = parsed.filter((x): x is string => typeof x === 'string'); if (names.length === 0) return undefined; return { matrix: names.includes('matrix'), wecom: names.includes('wecom'), }; } // 对象形态:契约标准,原样透传(含 matrixConfig 细节) return parsed as ChannelConfig; } /** * `host` 归一:兼容 `cpuLoad1`(裸负载)→ `cpuPercent`(百分比)。 * * 🔴 实测:容器发 `{"cpuLoad1":5.65,"cpuLoad5":5.66,"memPercent":51,...}`, * 缺 `cpuPercent` 键 → CPU 不显示(memPercent/diskPercent 因键名对上才正常)。 * * 换算口径 `cpuLoad1 / 核数 * 100`(实测 56 核实测 9.2 → 16.4 与容器自证一致)。 * ⚠️ 核数未知时**不猜**:宁可返回 undefined 让 UI 显示「未上报」,也不编一个假数字。 * 核数来源优先级:`cpuCores`(容器自报)→ 无。 */ function normalizeHost(v: unknown): Agent['host'] { const h = parseJsonLoose>(v); if (!h) return undefined; const num = (x: unknown): number | undefined => typeof x === 'number' && Number.isFinite(x) ? x : undefined; // 契约标准的 cpuPercent 优先,不被派生值覆盖 let cpuPercent = num(h['cpuPercent']); if (cpuPercent == null) { const load = num(h['cpuLoad1']); const cores = num(h['cpuCores']); if (load != null && cores != null && cores > 0) { cpuPercent = Math.min(100, Math.max(0, (load / cores) * 100)); } } const memPercent = num(h['memPercent']); const diskPercent = num(h['diskPercent']); // 三项指标全无 → 视为未上报(避免 UI 显示 0% 误导为"机器空闲") if (cpuPercent == null && memPercent == null && diskPercent == null) return undefined; return { cpuPercent, memPercent, diskPercent }; } /** * `heartbeatAt` 归一(三级回落)。 * * 🔴 实测:容器不发 `heartbeatAt`,心跳时间藏在 `host.probedAt` * (采集时刻);两者皆无时回落 `updatedAtClient.iso`(云函数写入时刻)。 * 不回落的后果:UI 判「心跳超时」→ 在线容器被显示成离线。 */ function normalizeHeartbeat(raw: Record, host: unknown): string | undefined { /* * 🔴 必须经 `tsOf` 归一,不能 `String()`。 * 云函数里 heartbeatAt 是 Parse Date → `{__type:'Date', iso:'…'}`, * `String()` 得到 `"[object Object]"` → 下游 `Date.parse()` 得 NaN → * 「心跳超时」判定永远为假(实测:状态永远显示「空闲」,从不报「状态未同步」)。 */ const isoOf = (v: unknown): string | undefined => { const t = tsOf(v); return Number.isFinite(t) ? new Date(t).toISOString() : undefined; }; const direct = isoOf(raw['heartbeatAt']); if (direct) return direct; const h = parseJsonLoose<{ probedAt?: string }>(host); if (h?.probedAt) return isoOf(h.probedAt); const uac = raw['updatedAtClient'] as { iso?: string } | undefined; if (uac?.iso) return isoOf(uac.iso); return undefined; } /** 云函数原始记录 → `Agent` 领域模型(映射只在此处发生) */ function mapAgent(raw: Record): Agent { const channels = normalizeChannels(raw['channels']); const balance = parseJsonLoose<{ remainCny?: number; remainRmb?: number; cny?: number; pageUrl?: string }>( raw['balance'], ); const host = normalizeHost(raw['host']); const heartbeatAt = normalizeHeartbeat(raw, raw['host']); const user = raw['user'] as Record | undefined; return { id: String(raw['agentId'] ?? raw['objectId'] ?? ''), name: String(raw['botName'] ?? raw['name'] ?? ''), avatar: raw['botAvatar'] ? String(raw['botAvatar']) : undefined, gender: (raw['gender'] as Agent['gender']) ?? 'unknown', status: (raw['status'] as AgentStatus) ?? 'offline', currentTask: raw['currentTask'] ? String(raw['currentTask']) : undefined, currentAction: raw['currentAction'] ? String(raw['currentAction']) : undefined, progress: typeof raw['progress'] === 'number' ? (raw['progress'] as number) : undefined, heartbeatAt, updatedAt: raw['updatedAt'] ? String(raw['updatedAt']) : undefined, host, balance: balance ? { // 映射层归一:兼容旧键 remainRmb,以及容器复刻脚本发的 cny remainCny: balance.remainCny ?? balance.remainRmb ?? balance.cny, pageUrl: balance.pageUrl, } : undefined, channels: channels ? { matrix: channels.matrix, homeserver: channels.homeserver, matrixConfig: channels.matrixConfig, wecom: channels.wecom, wecomBotId: channels.wecomBotId, } : undefined, /** * 🔴 `usage` 此前**完全没有映射** —— 云函数返回了、映射层丢掉了, * 造成「Token 用量」永远空白(实测:库里 totalTokens=2238055,页面不显示)。 * * 键名兼容:容器发 `inputTokens`/`outputTokens`, * 契约旧字段是 `promptTokens`/`completionTokens`,两者都收。 */ usage: (() => { const u = parseJsonLoose>(raw['usage']); if (!u) return undefined; const num = (x: unknown): number | undefined => typeof x === 'number' && Number.isFinite(x) ? x : undefined; const usage = { totalTokens: num(u['totalTokens']), promptTokens: num(u['promptTokens']) ?? num(u['inputTokens']), completionTokens: num(u['completionTokens']) ?? num(u['outputTokens']), updatedAt: u['updatedAt'] ? String(u['updatedAt']) : undefined, raw: u, }; // 三项全无 → 视为未上报(避免 UI 显示 0) return usage.totalTokens == null && usage.promptTokens == null && usage.completionTokens == null ? undefined : usage; })(), owner: user ? { id: String(user['objectId'] ?? ''), name: String(user['nickname'] ?? user['username'] ?? ''), avatar: user['avatar'] ? String(user['avatar']) : undefined, mobile: user['mobile'] ? String(user['mobile']) : undefined, } : undefined, // 容器标识(排障反查来源用;云函数读的是 hostname/containerRef) containerRef: raw['containerRef'] ? String(raw['containerRef']) : undefined, // Agent token 健康灯:expired 时余额等依赖 token 的字段会降级 sessionStatus: (() => { const s = String(raw['sessionStatus'] ?? '').toLowerCase(); return s === 'ok' || s === 'expired' ? (s as 'ok' | 'expired') : 'unknown'; })(), // 会话级用量明细(可选,缺失不影响总量展示) usageSessions: (() => { const list = parseJsonLoose(raw['usageSessions']); if (!Array.isArray(list) || list.length === 0) return undefined; return list as UsageSession[]; })(), companyId: (raw['company'] as { objectId?: string } | undefined)?.objectId, // 「是否已接入 FMODE」= channels.matrix 为真(或已有 matrixConfig.userId) matrixReady: !!channels?.matrix || !!channels?.matrixConfig?.userId, // 未读数**不来自服务端**:云函数不返回该字段,真实计数由 // `UnreadService` 依据 Matrix 事件流在客户端维护(见该服务注释)。 // 此处恒为 0,UI 不得消费(消费点一律走 UnreadService.countOf)。 unread: 0, }; } /** 成果原始记录 → `Deliverable` 领域模型 */ function mapDeliverable(raw: Record): Deliverable { const status = String(raw['status'] ?? ''); /* * 🔴 `artifacts` 是**自由数组**,实测两种形态混用(同一张表里都有): * * ``` * ["/opt/data/git-repos/agent-status-reporter/REPORT.md"] ← 字符串数组(容器最常这样写) * [{ name: "report.pdf", url: "https://…", type: "pdf" }] ← 对象数组(契约标准) * ``` * * 此前只处理对象数组,字符串形态进来后 `name` 变成空串 → * 右栏渲染成「仅有 · 的文档」(用户实测反馈)。 * * 字符串形态的分辨口径: * · 以 `http` 开头 → 视作已是可访问地址 * · 其它(容器内路径) → **未同步**,由 `ArtifactSyncService` 交给容器上传到云盘 */ const rawArts = Array.isArray(raw['artifacts']) ? (raw['artifacts'] as unknown[]) : []; const artifacts: Artifact[] = rawArts .map((a): Artifact | null => { if (typeof a === 'string') { const s = a.trim(); if (!s) return null; const name = s.split(/[/\\]/).pop() || s; const isUrl = /^https?:\/\//i.test(s); return { name, url: isUrl ? s : '', type: name.split('.').pop() || '', // 非 URL 形态的原始值(容器内路径),保留给同步服务 source: isUrl ? undefined : s, } as Artifact; } if (a && typeof a === 'object') { const o = a as Record; const name = String(o['name'] ?? o['filename'] ?? ''); const url = String(o['url'] ?? ''); if (!name && !url) return null; return { name: name || url.split('/').pop() || '', url, type: String(o['type'] ?? ''), }; } return null; }) .filter((x): x is Artifact => x != null); const first = artifacts[0]; const filename = first?.name || undefined; const rawType = first?.type || filename?.split('.').pop() || ''; const fileType = rawType ? rawType.replace(/^\./, '').toUpperCase() : undefined; return { id: String(raw['objectId'] ?? raw['id'] ?? ''), agentId: String(raw['agentId'] ?? ''), title: String(raw['title'] ?? ''), summary: raw['summary'] ? String(raw['summary']) : undefined, projectId: raw['project'] ? String(raw['project']) : undefined, projectName: raw['projectName'] ? String(raw['projectName']) : undefined, artifacts, tags: Array.isArray(raw['tags']) ? (raw['tags'] as string[]).map(String) : [], deliveredAt: String(raw['deliveredAt'] ?? ''), filename, fileType, // 契约未返回文件大小 → 保持空(UI 不该显示 0 B) size: raw['size'] ? String(raw['size']) : undefined, // 契约 §2.2:status 取值为推断 —— 含 fail/error 子串即失败 state: /fail|error/i.test(status) ? 'failed' : 'delivered', }; }