| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947 |
- 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<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;
- }
- // ---------- 会话目录(云函数 oR5O67i1Cm / fmodeagent-chatthreads) ----------
- /**
- * 拉取当前用户的会话目录。
- *
- * 🔴 云函数按 sessionToken 强制归属当前用户;这里**不传任何用户参数**。
- * 返回结构在 function 内已归一(毫秒时间戳、驼峰字段)。
- */
- async fetchChatThreads(agentId: string, opts?: { includeDeleted?: boolean }): Promise<ChatThreadRecord[]> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<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>))
- /*
- * 🔴 过滤「非成果」记录(用户实测:右栏出现两条没有意义的文档):
- *
- * · `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<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>))
- // 与 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<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 };
- }
- 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<ArtifactJob[]> {
- 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<WorkGroup[]> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<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)。
- *
- * ## 🔴 平台抖动时的表现(2026-10-29 实测)
- *
- * 平台服务端会出现间歇性故障。实测连续 5 次请求:
- * `超时 / 超时 / 503 / 200 / 200`。
- *
- * 浏览器此时报的是 **CORS 错误**(`No 'Access-Control-Allow-Origin'`),
- * 那只是表象 —— 真正原因是服务端 503 或直接断开,
- * **连响应头都没返回**,浏览器于是判定为跨域失败。
- *
- * 所以:
- * · 网络层失败(fetch 直接抛)→ 明确告知「连不上平台」,不是用户的操作问题
- * · 503 / 502 / 504 → 告知「平台暂时不可用」并带上可重试的意思
- * · 其它非 2xx → 保留原状态码
- */
- private async callFunction<T>(id: string, params: Record<string, unknown>): Promise<T> {
- /*
- * 平台抖动是**已知会发生**的(实测 5 次里 2 次超时、1 次 503)。
- * 瞬时可恢复的错误自动重试一次 —— 用户不该因为一次抖动就看到报错。
- * 只重试「网络失败 / 502 / 503 / 504」,业务错误(4xx)不重试。
- */
- const attempt = async (): Promise<Response> =>
- 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<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})`;
- }
- /**
- * `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<unknown>(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<Record<string, unknown>>(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<string, unknown>, 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<string, unknown>): 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<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,
- 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<Record<string, unknown>>(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<unknown[]>(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<string, unknown>): 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<string, unknown>;
- 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',
- };
- }
|