import { Scope } from '../acl'; import { FmodeApi } from './fmode-api'; import { Agent, AgentStatus, AuthSession, ChannelConfig, Deliverable, DeliverableGroup, MatrixProvision, Me, Task, UploadTicket, } 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', } 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; } // ---------- 交付成果 ---------- 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)); } 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)) : []; 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 }; } /** 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) */ private async callFunction(id: string, params: Record): Promise { const res = await this.rawFetch(FUNCTION_API, { method: 'POST', headers: this.authHeaders(), body: JSON.stringify({ id, token: this.sessionToken, params }), }); if (!res.ok) 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 { if (raw['heartbeatAt']) return String(raw['heartbeatAt']); const h = parseJsonLoose<{ probedAt?: string }>(host); if (h?.probedAt) return String(h.probedAt); const uac = raw['updatedAtClient'] as { iso?: string } | undefined; if (uac?.iso) return String(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, 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, 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'] ?? ''); const artifacts = Array.isArray(raw['artifacts']) ? (raw['artifacts'] as Record[]).map((a) => ({ name: String(a['name'] ?? ''), url: String(a['url'] ?? ''), type: String(a['type'] ?? ''), })) : []; 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'] ?? ''), // 契约 §2.2:status 取值为推断 —— 含 fail/error 子串即失败 state: /fail|error/i.test(status) ? 'failed' : 'delivered', }; }