|
|
@@ -0,0 +1,524 @@
|
|
|
+import { Scope } from '../acl';
|
|
|
+import { FmodeApi } from './fmode-api';
|
|
|
+import {
|
|
|
+ Agent,
|
|
|
+ AgentStatus,
|
|
|
+ AuthSession,
|
|
|
+ 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<T = Record<string, unknown>>(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<void> {
|
|
|
+ // 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<AuthSession> {
|
|
|
+ // 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<AuthSession> {
|
|
|
+ const me = await this.fetchMe(token);
|
|
|
+ this.setToken(token);
|
|
|
+ return { token, me };
|
|
|
+ }
|
|
|
+
|
|
|
+ async fetchMe(token: string): Promise<Me> {
|
|
|
+ // 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<string, unknown>;
|
|
|
+ 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<string[]> {
|
|
|
+ // 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<string | undefined> {
|
|
|
+ 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<Agent[]> {
|
|
|
+ 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<string, unknown>));
|
|
|
+ }
|
|
|
+
|
|
|
+ async fetchAgentStatus(agentId: string): Promise<Agent | null> {
|
|
|
+ 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<string, unknown>);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- Matrix ----------
|
|
|
+
|
|
|
+ async provisionMatrix(agentId: string): Promise<MatrixProvision> {
|
|
|
+ const d = await this.callFunction<MatrixProvision & { code: number }>(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<MatrixProvision> {
|
|
|
+ const d = await this.callFunction<MatrixProvision & { code: number }>(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<Deliverable[]> {
|
|
|
+ const params: Record<string, unknown> = { 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<string, unknown>));
|
|
|
+ }
|
|
|
+
|
|
|
+ async fetchDeliverablesByProject(limit?: number): Promise<DeliverableGroup[]> {
|
|
|
+ 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<string, unknown>;
|
|
|
+ const items = Array.isArray(raw['items'])
|
|
|
+ ? raw['items'].map((x) => mapDeliverable(x as Record<string, unknown>))
|
|
|
+ : [];
|
|
|
+ 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<Task[]> {
|
|
|
+ // 🔴 不得静默返回空数组(那会把「未接入」伪装成「没有数据」)
|
|
|
+ throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)');
|
|
|
+ }
|
|
|
+
|
|
|
+ async fetchTaskDetail(_taskId: string): Promise<Task | null> {
|
|
|
+ throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)');
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 上传(OBS / 华为云,统一走 S3 预签名) ----------
|
|
|
+
|
|
|
+ async requestUploadUrl(
|
|
|
+ filename: string,
|
|
|
+ mimeType: string,
|
|
|
+ size: number,
|
|
|
+ ): Promise<UploadTicket> {
|
|
|
+ const d = await this.callFunction<{
|
|
|
+ code: number;
|
|
|
+ uploadUrl?: string;
|
|
|
+ key?: string;
|
|
|
+ publicUrl?: string;
|
|
|
+ headers?: Record<string, string>;
|
|
|
+ }>(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<string, string> = {
|
|
|
+ '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<string, string>,
|
|
|
+ file: File,
|
|
|
+ onProgress?: (pct: number) => void,
|
|
|
+ ): Promise<void> {
|
|
|
+ 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<T>(id: string, params: Record<string, unknown>): Promise<T> {
|
|
|
+ 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<string, string> {
|
|
|
+ const h: Record<string, string> = {
|
|
|
+ '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<Response> {
|
|
|
+ const headers: Record<string, string> = {
|
|
|
+ 'X-Parse-Application-Id': PARSE_APP_ID,
|
|
|
+ ...((init.headers as Record<string, string>) ?? {}),
|
|
|
+ };
|
|
|
+ /*
|
|
|
+ * 🔴 有 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<string, unknown>;
|
|
|
+ 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})`;
|
|
|
+}
|
|
|
+
|
|
|
+/** 云函数原始记录 → `Agent` 领域模型(映射只在此处发生) */
|
|
|
+function mapAgent(raw: Record<string, unknown>): Agent {
|
|
|
+ const channels = parseJsonLoose<{
|
|
|
+ matrix?: boolean;
|
|
|
+ homeserver?: string;
|
|
|
+ matrixConfig?: { userId?: string; homeserver?: string; serverName?: string };
|
|
|
+ }>(raw['channels']);
|
|
|
+ const balance = parseJsonLoose<{ remainCny?: number; remainRmb?: number; pageUrl?: string }>(
|
|
|
+ raw['balance'],
|
|
|
+ );
|
|
|
+ const host = parseJsonLoose<{
|
|
|
+ cpuPercent?: number;
|
|
|
+ memPercent?: number;
|
|
|
+ diskPercent?: number;
|
|
|
+ }>(raw['host']);
|
|
|
+ const user = raw['user'] as Record<string, unknown> | 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: raw['heartbeatAt'] ? String(raw['heartbeatAt']) : undefined,
|
|
|
+ updatedAt: raw['updatedAt'] ? String(raw['updatedAt']) : undefined,
|
|
|
+ host,
|
|
|
+ balance: balance
|
|
|
+ ? {
|
|
|
+ // 映射层归一:兼容旧键 remainRmb
|
|
|
+ remainCny: balance.remainCny ?? balance.remainRmb,
|
|
|
+ pageUrl: balance.pageUrl,
|
|
|
+ }
|
|
|
+ : undefined,
|
|
|
+ channels: channels
|
|
|
+ ? {
|
|
|
+ matrix: channels.matrix,
|
|
|
+ homeserver: channels.homeserver,
|
|
|
+ matrixConfig: channels.matrixConfig,
|
|
|
+ }
|
|
|
+ : 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<string, unknown>): Deliverable {
|
|
|
+ const status = String(raw['status'] ?? '');
|
|
|
+ const artifacts = Array.isArray(raw['artifacts'])
|
|
|
+ ? (raw['artifacts'] as Record<string, unknown>[]).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',
|
|
|
+ };
|
|
|
+}
|